diff options
208 files changed, 33008 insertions, 16486 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 02b48a0..70d64d9 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -1,3 +1,100 @@ +# `@dispatch/transport-contract` — in-repo reference (read THIS, not node_modules) + +> MIRRORS the backend's `@dispatch/transport-contract` package source so headless FE agents can read +> the transport types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a +> permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — this file is for +> READING only. +> +> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers + provider concurrency + cancel queued message). Regenerate whenever +> it changes. +> +> **2026-06-29 update (cancel-queued-message — ADDITIVE, 0.23.0 → 0.24.0):** a per-message +> **cancel** for the steering message queue ships. While a turn is GENERATING and a user +> message is queued (awaiting steering delivery), the client can cancel a single queued +> message by id so it never runs. New `WsClientMessage` member `ChatQueueCancelMessage` +> (`{ type: "chat.queue.cancel"; conversationId; messageId }` — fire-and-forget, idempotent; +> success confirmed by the `message-queue` surface updating, failure as `chat.error`). New +> HTTP path `DELETE /conversations/:id/queue/:messageId` → `QueueCancelResponse` +> (`{ conversationId; cancelled: boolean; queue }`). The cancel is scoped per conversation; +> cancelling a drained/already-cancelled/unknown message is a silent no-op. `@dispatch/wire` +> is unchanged (`QueuedMessage.id` is the cancel target). +> +> **2026-06-27 update (concurrency-fixes — ADDITIVE, NO version bump):** the provider concurrency surface gains +> (a) a configurable + persisted per-provider release COOLDOWN, and (b) adaptive headroom. `ConcurrencyStatusEntry` +> gains FOUR new fields: `cooldownMs: number` (REQUIRED — per-slot release cooldown in ms, default 350; a recycled slot is +> held this long before the next waiter is admitted), `autoReduced: boolean` (REQUIRED — true when the limit was auto-reduced +> by 1 after a 429, one-way + persisted; the FE renders a visible banner), `autoReducedFrom?: number` (present only when +> `autoReduced===true` — the original limit before reduction), and `notice?: string` (present only when `autoReduced===true` — +> a human-readable banner message). The banner is DISMISSIBLE / persists while `autoReduced===true`; it clears when the user +> restores the limit via `PUT /concurrency/limits/:providerId` (a manual PUT clears `autoReduced` server-side). NEW cooldown +> endpoints: `GET /concurrency/cooldown/:providerId` → `ConcurrencyCooldownResponse` (`{ providerId, cooldownMs }`) — 404 when +> the provider has no concurrency config at all (no limit, no cooldown), 503 when the extension isn't loaded; +> `PUT /concurrency/cooldown/:providerId` ← `SetConcurrencyCooldownRequest` (`{ cooldownMs }` — must be a non-negative integer, +> 0 = no cooldown / instant re-admission) → `ConcurrencyCooldownResponse` — 400 on an invalid body, 503 when not loaded. +> Persists + applies immediately to subsequently recycled slots. Also (backend-only, no FE surface): a usage gate polls upstream +> `concurrent_sessions` before admitting a queued agent. See `backend-handoff.md` §2j-update-3. +> +> **2026-06-26 delta (provider concurrency — `[email protected]` bump):** adds the +> per-provider concurrency-limits API types: `ConcurrencyLimitsResponse` (`GET /concurrency/limits`), +> `SetConcurrencyLimitRequest` + `ConcurrencyLimitResponse` (`GET`/`PUT /concurrency/limits/:providerId`), +> and `ConcurrencyStatusEntry` + `ConcurrencyStatusResponse` (`GET /concurrency/status`). The +> `concurrency` extension tracks/limits in-flight token-generating requests per provider with +> oldest-agent-first queueing; when it isn't loaded the list + status endpoints return empty arrays and +> the single/PUT/DELETE return `503`. See `backend-handoff.md` §2j. +> **2026-06-26 delta (vision handoff — ADDITIVE, NO version bump):** adds the vision/image surface. +> `ChatRequest` (+ `ChatSendMessage`/`QueueRequest`) gains an optional `images?: readonly ImageInput[]` +> (each entry: `{ url, mimeType? }` — a base64 data URL or `http(s)://` URL; validated non-array/no-url/ +> empty-url → 400, empty array treated as absent). `ModelMetadata` gains `vision?: boolean` (true when the +> model natively accepts images; absent → the server's vision handoff transcribes images to text before the +> model sees them). `ImageChunk`/`ImageInput` are `@dispatch/wire` types (re-exported here). +> +> **2026-06-26 update (consult_vision + vision settings — ADDITIVE, NO version bump):** the `read_image` +> tool is REPLACED by `consult_vision` (`{ question: string, imageIds?: number[], path?: string }`) — it +> opens a NEW conversation tab with a vision-capable model, attaches the image + question, and returns the +> vision model's answer (rendered like any tool call/result). Non-vision models now get NUMBERED +> PLACEHOLDERS (`[Image N attached — call consult_vision with imageIds=[N] and a specific question to +> analyze it]`) instead of auto-transcriptions — these are regular `text` chunks (render as-is). Image +> compaction transcribes the oldest images past `imageLimit` to `[Compacted image]: <description>` text +> chunks (also regular `text` — render as-is; the persisted `image` chunk stays for rendering). NEW global +> vision settings API: `GET /settings/vision` → `VisionSettingsResponse` (`{ imageLimit, compactionModel }`), +> `PUT /settings/vision` ← `SetVisionSettingsRequest` (partial: `imageLimit?` non-negative int, 0 = disable +> compaction; `compactionModel?` `<key>/<model>` or null = auto). See `backend-handoff.md` §2j. +> +> **2026-06-26 update (image storage — NO type change, behavior only):** persisted `ImageChunk.url`s are now +> compact relative HTTP paths (`/images/<conversationId>/<uuid>.png`) served by the new +> `GET /images/:conversationId/:imageId` endpoint (raw image bytes + correct Content-Type) — NOT base64 data +> URLs (images are stored on disk under tmp, not in the SQLite store). `ChatRequest.images` (`ImageInput.url`) +> is UNCHANGED — clients still send data URLs; the backend saves them to tmp and returns compact paths in +> the persisted chunks. A client resolves a relative `url` against its API base (`resolveImageUrl`); the +> optimistic echo's data URL and any absolute URL pass through. See `backend-handoff.md` §2j. +> +> **2026-06-25 delta (SSH handoff #2 — ADDITIVE to `[email protected]`, NO version bump):** adds the +> computer HTTP API types: `ComputerListResponse` (`GET /computers`), `ComputerResponse` (`GET /computers/:alias`), +> `ComputerStatusResponse` (`GET /computers/:alias/status`), `TestComputerResponse` (`POST /computers/:alias/test`), +> `SetConversationComputerRequest` + `ConversationComputerResponse` +> (`GET`/`PUT`/`DELETE /conversations/:id/computer`), `SetWorkspaceDefaultComputerRequest` +> (`PUT /workspaces/:id/default-computer`). Also `computerId?: string` on `ChatRequest`/`ChatSendMessage`/ +> `QueueRequest` (per-turn override; resolved server-side from the persisted per-conversation value in the MVP, so +> `chat.send` need not send it). `Computer`/`ComputerEntry` themselves are `@dispatch/wire` types. See +> `backend-handoff.md` §2e. (The `ssh` extension that provides the ComputerService is the last backend wave — +> until it lands, `GET /computers` returns `[]` and statuses return `disconnected`.) +> +> **2026-06-24 delta (MCP status handoff — package bumped `0.18.0` → `0.22.0`, ADDITIVE):** adds +> `McpServerState`, `McpServerInfo`, and `McpStatusResponse`; endpoint +> `GET /conversations/:id/mcp`. Mirrors the existing `GET /conversations/:id/lsp` shape (returns +> `{cwd, servers}`, empty `servers` when no cwd is set). Each `McpServerInfo` reports an `id`, +> `state` (`connecting` | `connected` | `error` | `disconnected`), optional `error`, `toolCount`, +> and optional `configSource`. Also adds the previously-missing `configSource` field to +> `LspServerInfo`. See `frontend-mcp-status-handoff.md`. +> +> **2026-06-24 delta (system prompt handoff — package bumped `0.17.0` → `0.18.0`, ADDITIVE):** adds +> `SystemPromptTemplateResponse`, `SetSystemPromptTemplateRequest`, `SystemPromptVariable`, and +> `SystemPromptVariablesResponse`; endpoints `GET /system-prompt`, `PUT /system-prompt`, and +> `GET /system-prompt/variables`. The system prompt template is global (resolved once per conversation +> at construction time, persisted for cache safety). Variables include `system:*`, `prompt:*`, `git:*`, +> and dynamic `file:<path>`; conditional blocks use `[if]`, `[else]`, `[endif]`. See +> `frontend-system-prompt-handoff.md`. + /** * Transport contract — the typed description of Dispatch's client–server API * (HTTP + WebSocket). @@ -22,24 +119,35 @@ import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; import type { AgentEvent, + Computer, + ComputerEntry, ConversationMeta, ConversationStatus, + ImageInput, QueuedMessage, ReasoningEffort, StoredChunk, TurnMetrics, + Workspace, + WorkspaceEntry, } from "@dispatch/wire"; export type { AgentEvent, CompactionResult, + Computer, + ComputerEntry, ConversationMeta, ConversationStatus, + ImageChunk, + ImageInput, QueuedMessage, ReasoningEffort, StepMetrics, StoredChunk, TurnMetrics, + Workspace, + WorkspaceEntry, } from "@dispatch/wire"; /** @@ -60,6 +168,21 @@ export interface ChatRequest { readonly message: string; /** + * Images attached to this turn (e.g. a user-pasted screenshot). Each entry's + * `url` is a base64 data URL (`data:image/…;base64,…`) or an `http(s)://` + * URL. The server converts these to `image` chunks on the persisted user + * message. For a VISION-capable model (e.g. kimi), the images are passed + * through to the provider natively. For a NON-vision model (e.g. glm-5.2), + * the server's vision handoff transcribes each image to a text description + * (via a vision-capable model) and feeds that text instead — so a text-only + * model can still reason about the image's contents. Optional — omit for a + * text-only turn (backward compatible). Validation: non-array `images` → + * 400; an image without `url` → 400; empty `url` → 400. An empty array is + * accepted and treated as absent. + */ + readonly images?: readonly ImageInput[]; + + /** * The model to use, as a model name in `<credentialName>/<model>` form — one * of the exact strings returned by `GET /models`. Omit to use the server's * default credential + model. @@ -80,6 +203,23 @@ export interface ChatRequest { * unrecognized value → HTTP 400 `{ error }`. */ readonly reasoningEffort?: ReasoningEffort; + + /** + * The workspace to assign this conversation to. Omit for `"default"`. + * If the workspace doesn't exist yet, it is auto-created (title = id, + * defaultCwd = null). + */ + readonly workspaceId?: string; + + /** + * The computer to run this turn's tools on — an SSH config `Host` alias + * (one of the `alias` values returned by `GET /computers`). Omit to inherit + * the resolved chain: per-conversation `computerId` → the workspace's + * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is + * a per-turn tool-execution target forwarded to tools and never part of the + * model prompt (so it does not affect prompt caching). Mirrors `cwd`. + */ + readonly computerId?: string; } /** @@ -99,6 +239,14 @@ export interface ModelsResponse { /** Per-model metadata returned alongside the model catalog. */ export interface ModelMetadata { readonly contextWindow?: number; + /** + * Whether this model can natively accept image input (vision/multimodal). + * When `true`, image chunks in a user message are passed through to the + * provider. When `false`/absent, the server's vision handoff transcribes + * images to text before the model sees them. A client may use this to show + * a vision badge in the model picker. Optional — absent when unknown. + */ + readonly vision?: boolean; } /** @@ -170,6 +318,14 @@ export interface ConversationMetricsResponse { readonly turns: readonly TurnMetrics[]; } +export interface ConversationStatusResponse { + readonly conversationId: string; + /** True if the orchestrator has an in-memory active turn for this conversation. */ + readonly isActive: boolean; + /** The persisted lifecycle status from the conversation store. */ + readonly status: ConversationStatus; +} + /** The aggregation window for `GET /metrics/throughput`. */ export type ThroughputPeriod = "day" | "week" | "month"; @@ -221,9 +377,19 @@ export interface CwdResponse { readonly cwd: string | null; } -/** Body of `PUT /conversations/:id/cwd`. */ +/** + * Body of `PUT /conversations/:id/cwd`. + * + * When `workspaceId` is provided, the conversation is assigned to that + * workspace BEFORE the cwd is persisted — so a subsequent + * `GET /conversations/:id/lsp` resolves a relative cwd against the + * workspace's `defaultCwd` (not the server default). Omit for unchanged + * workspace assignment (the conversation keeps its current workspace, or + * `"default"` if none). + */ export interface SetCwdRequest { readonly cwd: string; + readonly workspaceId?: string; } // ─── Per-conversation reasoning effort ──────────────────────────────────────── @@ -248,6 +414,29 @@ export interface SetReasoningEffortRequest { readonly reasoningEffort: ReasoningEffort; } +// ─── Per-conversation model persistence ─────────────────────────────────────── + +/** + * Response of `GET /conversations/:id/model`. `model` is the persisted model + * name in `<credentialName>/<model>` form, or null when never set (the server + * then resolves turns using the default provider + model). + */ +export interface ModelResponse { + readonly conversationId: string; + readonly model: string | null; +} + +/** + * Body of `PUT /conversations/:id/model` — persists the conversation's sticky + * model selection (used for every later turn that does not carry a per-turn + * `ChatRequest.model` override). Pass `null` to clear the persisted selection. + * An unrecognized model name is not validated here (the provider resolves it + * at turn time; an unknown model → turn error, not a 400). + */ +export interface SetModelRequest { + readonly model: string | null; +} + // ─── Conversation close (explicit tab close) ────────────────────────────────── /** @@ -270,6 +459,78 @@ export interface CloseConversationResponse { readonly abortedTurn: boolean; } +// ─── System prompt template ─────────────────────────────────────────────────── + +/** + * Response of `GET /system-prompt` — the current global system prompt template. + * + * The template is a text string with variable placeholders (`[type:name]`) and + * conditional blocks (`[if]`/`[else]`/`[endif]`). At construction time (first + * turn or compaction), variables are resolved against the conversation's cwd + * and system state. The resolved system prompt is persisted per conversation + * and reused on all subsequent turns (cache-safe — no per-turn reconstruction). + */ +export interface SystemPromptTemplateResponse { + /** The template text (may be empty — then no system prompt is sent). */ + readonly template: string; +} + +/** + * Body of `PUT /system-prompt` — set the global system prompt template. + * + * Changing the template does NOT affect existing conversations until they are + * compacted (the persisted resolved system prompt is stable). New + * conversations use the new template on their first turn. + */ +export interface SetSystemPromptTemplateRequest { + readonly template: string; +} + +/** + * One available variable for the system prompt template, as reported by + * `GET /system-prompt/variables` so the frontend can render the variable + * selector buttons. + */ +export interface SystemPromptVariable { + /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */ + readonly type: string; + /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */ + readonly name: string; + /** Human-readable description of what the variable resolves to. */ + readonly description: string; + /** + * When `true`, any name is valid for this type (e.g. `file:<path>` accepts + * any file path). The frontend should allow free-text input for the name. + */ + readonly dynamic?: boolean; +} + +/** Response of `GET /system-prompt/variables`. */ +export interface SystemPromptVariablesResponse { + readonly variables: readonly SystemPromptVariable[]; +} + +// ─── Vision settings (global) ─────────────────────────────────────────────── + +/** + * Response of `GET /settings/vision` — the global vision configuration shared + * across all conversations and vision models. + */ +export interface VisionSettingsResponse { + /** Max native images per turn (default 10); 0 disables image compaction. */ + readonly imageLimit: number; + /** Which model transcribes old images (null = auto-select a vision model). */ + readonly compactionModel: string | null; +} + +/** Body of `PUT /settings/vision` — a partial update. */ +export interface SetVisionSettingsRequest { + /** Non-negative integer (0 = disable compaction). */ + readonly imageLimit?: number; + /** A model name (`<key>/<model>`) or null (auto). */ + readonly compactionModel?: string | null; +} + // ─── Message queue (steering) ───────────────────────────────────────────────── /** @@ -289,6 +550,11 @@ export interface CloseConversationResponse { */ export interface QueueRequest { readonly text: string; + /** + * The workspace to assign the conversation to (if a new conversation is + * started). Omit for `"default"`. Auto-creates if missing. + */ + readonly workspaceId?: string; } /** @@ -305,6 +571,27 @@ export interface QueueResponse { readonly queue: readonly QueuedMessage[]; } +/** + * Response body for + * `DELETE /conversations/:id/queue/:messageId` — cancel (remove) a single + * queued steering message by id so it never runs. + * + * `cancelled` is `true` when a message with the given id was found in the + * conversation's queue and removed (it will never be delivered as steering nor + * carried into a new turn). `cancelled` is `false` when the message was not in + * the queue (already drained/delivered, never existed, unknown conversation) + * OR when the message-queue extension isn't loaded (degraded — feature off). + * `queue` is the post-cancel snapshot (empty when no queue extension is + * loaded). Idempotent — cancelling a message that is no longer queued returns + * `cancelled: false` with HTTP 200 (not an error), so a client may optimistically + * fire-and-forget a cancel and reconcile from the surface. + */ +export interface QueueCancelResponse { + readonly conversationId: string; + readonly cancelled: boolean; + readonly queue: readonly QueuedMessage[]; +} + // ─── Per-conversation LSP status ────────────────────────────────────────────── /** The connection state of a single language server for a workspace. */ @@ -324,17 +611,60 @@ export interface LspServerInfo { readonly state: LspServerState; /** Present only when `state === "error"`: a short human-readable reason. */ readonly error?: string; + /** + * Which config source this server was resolved from: `".dispatch/lsp.json"`, + * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). Omitted + * when not yet resolved. Surfaces config-shadow debugging to the status caller + * (a broken `.dispatch/lsp.json` silently shadowing `opencode.json`). + */ + readonly configSource?: string; } /** Response of `GET /conversations/:id/lsp`. */ export interface LspStatusResponse { readonly conversationId: string; - /** The conversation's persisted cwd, or null if unset (then `servers` is empty). */ + /** + * The resolved working directory the LSP connects on, or `null` when no + * cwd has been set for the conversation (then `servers` is empty). When + * non-null, this is the effective cwd — a relative persisted cwd resolved + * against the conversation's workspace `defaultCwd`. + */ readonly cwd: string | null; /** The language servers configured for `cwd` and their live state. */ readonly servers: readonly LspServerInfo[]; } +// ─── MCP status ────────────────────────────────────────────────────── + +export type McpServerState = "connecting" | "connected" | "error" | "disconnected"; + +/** One MCP server's status as reported to the frontend. */ +export interface McpServerInfo { + /** Stable server id (the config key from `.dispatch/mcp.json`), e.g. "freecad". */ + readonly id: string; + /** Current connection state. */ + readonly state: McpServerState; + /** Present only when `state === "error"`: a short human-readable reason. */ + readonly error?: string; + /** Number of tools discovered from this server. */ + readonly toolCount: number; + /** Which config source this server was resolved from. */ + readonly configSource?: string; +} + +/** Response of `GET /conversations/:id/mcp`. */ +export interface McpStatusResponse { + readonly conversationId: string; + /** + * The resolved working directory the MCP servers are configured for, or + * `null` when no cwd has been set for the conversation (then `servers` is + * empty). Mirrors the LSP status endpoint behavior. + */ + readonly cwd: string | null; + /** The MCP servers configured for `cwd` and their live state. */ + readonly servers: readonly McpServerInfo[]; +} + /** * Request body for `POST /chat/warm` — manually trigger a prompt-cache WARMING * request for a conversation (e.g. a frontend "warm now" button, or fast tests @@ -474,6 +804,29 @@ export interface ChatQueueMessage { readonly type: "chat.queue"; readonly conversationId: string; readonly text: string; + /** + * The workspace to assign the conversation to (if a new conversation is + * started). Omit for `"default"`. Auto-creates if missing. + */ + readonly workspaceId?: string; +} + +/** + * Client → server: cancel (remove) a SINGLE queued steering message by id so + * it never runs. The WebSocket counterpart of the HTTP + * `DELETE /conversations/:id/queue/:messageId` (`QueueCancelResponse`). + * Fire-and-forget: success is confirmed by the message-queue SURFACE updating + * (the cancelled message leaves the snapshot); a failure (missing/empty + * `conversationId` or `messageId`) arrives as a `chat.error`. Idempotent — + * cancelling a message that is no longer queued (already drained/delivered) is + * a silent no-op (no surface update, no error). `messageId` is the stable + * client-visible `QueuedMessage.id` (obtained from the queue surface snapshot + * or the enqueue response). + */ +export interface ChatQueueCancelMessage { + readonly type: "chat.queue.cancel"; + readonly conversationId: string; + readonly messageId: string; } /** @@ -485,7 +838,8 @@ export type WsClientMessage = | ChatSendMessage | ChatSubscribeMessage | ChatUnsubscribeMessage - | ChatQueueMessage; + | ChatQueueMessage + | ChatQueueCancelMessage; /** * Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat @@ -509,6 +863,12 @@ export type WsServerMessage = export interface ConversationOpenMessage { readonly type: "conversation.open"; readonly conversationId: string; + /** + * The conversation's actual workspace id, so a frontend can open/focus it + * in the correct workspace instead of stamping it with the viewer's current + * workspace. + */ + readonly workspaceId: string; } /** @@ -520,6 +880,12 @@ export interface ConversationStatusChangedMessage { readonly type: "conversation.statusChanged"; readonly conversationId: string; readonly status: ConversationStatus; + /** + * The conversation's actual workspace id, so a frontend can open/focus it + * in the correct workspace instead of stamping it with the viewer's current + * workspace. + */ + readonly workspaceId: string; } /** @@ -606,3 +972,215 @@ export interface CompactPercentResponse { export interface SetCompactPercentRequest { readonly threshold: number; } + +// ─── Workspaces ─────────────────────────────────────────────────────────────── + +/** + * Body of `PUT /workspaces/:id` — the idempotent create-on-miss call. All + * fields are optional and only applied when the workspace is first created; + * an existing workspace is returned as-is. + */ +export interface EnsureWorkspaceRequest { + /** Display title. Default: the workspace id. Only used on create. */ + readonly title?: string; + /** Default cwd. Default: null (inherit server default). Only used on create. */ + readonly defaultCwd?: string | null; +} + +/** Response of `GET`/`PUT /workspaces/:id` — the workspace itself. */ +export interface WorkspaceResponse extends Workspace {} + +/** Response of `GET /workspaces` — all workspaces sorted by `lastActivityAt` desc. */ +export interface WorkspaceListResponse { + readonly workspaces: readonly WorkspaceEntry[]; +} + +/** Body of `PUT /workspaces/:id/title` — rename (display only; id unchanged). */ +export interface SetWorkspaceTitleRequest { + readonly title: string; +} + +/** Body of `PUT /workspaces/:id/default-cwd` — set or clear the default cwd. */ +export interface SetWorkspaceDefaultCwdRequest { + readonly defaultCwd: string | null; +} + +/** + * Response of `DELETE /workspaces/:id`. All conversations in the workspace + * are closed (status → "closed") and reassigned to "default", then the + * workspace entity is deleted. `"default"` is non-deletable (HTTP 409). + */ +export interface DeleteWorkspaceResponse { + readonly workspaceId: string; + /** Conversations that were closed (status → "closed") by this delete. */ + readonly closedCount: number; +} + +// ─── Computers (SSH handoff #2) ───────────────────────────────────────────── + +/** + * Response of `GET /computers` — every remote computer discovered from the + * system's `~/.ssh/config`, sorted by `alias`. Parallel to + * `WorkspaceListResponse`: each entry is a `ComputerEntry` (a `Computer` plus a + * usage count). There is no Computer CRUD — to add one, the user adds a `Host` + * block to `~/.ssh/config` and Dispatch discovers it on the next read. + */ +export interface ComputerListResponse { + readonly computers: readonly ComputerEntry[]; +} + +/** + * Response of `GET /computers/:alias` — a single computer. Parallel to + * `WorkspaceResponse` (the entity itself). `alias` is the `computerId` users + * select; the remaining fields are resolved from the SSH config. + */ +export interface ComputerResponse extends Computer {} + +/** + * Response of `GET /computers/:alias/status` — the live connection state of a + * computer (whether Dispatch currently holds an open SSH session to it). Drives + * the frontend connection indicator. `error` is present only when + * `state === "error"`; `knownHost` mirrors the read-only `Computer` field. + */ +export interface ComputerStatusResponse { + readonly alias: string; + readonly state: "disconnected" | "connecting" | "connected" | "error"; + readonly error?: string; + readonly knownHost: boolean; +} + +/** + * Body of `PUT /conversations/:id/computer` — set or clear the conversation's + * persisted computer selection (the computer analog of `SetCwdRequest`). Pass + * `null` to clear → the conversation inherits the workspace's + * `defaultComputerId`, then `null`/local. An unknown alias is not validated here + * (the connection resolves at turn time; an unreachable host → turn error, not + * a 400). Mirrors the cwd/model PUT clear semantics. + */ +export interface SetConversationComputerRequest { + readonly computerId: string | null; +} + +/** + * Response of `GET /conversations/:id/computer`. `computerId` is the persisted + * SSH `Host` alias, or `null` when never set (the conversation then inherits + * the workspace default → local). Parallel to `CwdResponse`. + */ +export interface ConversationComputerResponse { + readonly conversationId: string; + readonly computerId: string | null; +} + +/** + * Body of `PUT /workspaces/:id/default-computer` — set or clear the workspace's + * default computer (the computer analog of `SetWorkspaceDefaultCwdRequest`). + * `null` means local (no SSH). Conversations in the workspace with no + * `computerId` of their own inherit this. + */ +export interface SetWorkspaceDefaultComputerRequest { + readonly computerId: string | null; +} + +/** + * Response of `POST /computers/:alias/test` — the result of a one-shot + * connectivity probe (Dispatch opens an SSH connection to the alias, runs a + * trivial command, then closes). `ok` is true on success; `error` carries the + * failure reason (e.g. auth refused, host unreachable) when `ok` is false. + */ +export interface TestComputerResponse { + readonly alias: string; + readonly ok: boolean; + readonly error?: string; +} + +// ── Provider concurrency limits ([email protected]) ─────────────────── + +/** + * Response of `GET /concurrency/limits` — all providers with configured + * concurrency limits. Each entry pairs a provider id (e.g. "umans", + * "openai-compat") with its maximum concurrent in-flight requests. Providers + * not listed here have no limit (unlimited). + */ +export interface ConcurrencyLimitsResponse { + readonly limits: readonly { + readonly providerId: string; + readonly limit: number; + }[]; +} +/** + * Body of `PUT /concurrency/limits/:providerId` — set or update the concurrency + * limit for a provider. `limit` must be a positive integer. When a limit is + * set, requests beyond the limit queue (oldest-agent-first) rather than being + * sent immediately. + */ +export interface SetConcurrencyLimitRequest { + readonly limit: number; +} +/** Response of `GET/PUT /concurrency/limits/:providerId` — the configured limit. */ +export interface ConcurrencyLimitResponse { + readonly providerId: string; + readonly limit: number; +} +/** + * One provider's live concurrency status. + * + * - `inFlight`: how many slots are currently held (tokens being generated). + * - `queued`: how many agents are waiting for a slot. + * - `paused`: whether the queue is paused due to a 429 backoff. + * - `pausedUntil`: when the pause expires (epoch-ms), present only when paused. + * - `cooldownMs`: the per-slot release cooldown (ms). A recycled slot is held + * this long before the next waiter is admitted — covers the upstream + * provider's accounting lag. Configurable + persisted per provider. + * - `autoReduced`: whether the limit was auto-reduced by 1 after a 429 + * (adaptive headroom, one-way, persisted). The user restores the limit + * manually via `PUT /concurrency/limits/:providerId`, which clears the flag. + * When `true`, the frontend renders a visible notice/banner. + * - `autoReducedFrom`: the original limit before auto-reduction (present only + * when `autoReduced` is true). + * - `notice`: a human-readable notice string for the frontend to render as a + * banner when the limit was auto-reduced (present only when `autoReduced`). + */ +export interface ConcurrencyStatusEntry { + readonly providerId: string; + readonly limit: number; + readonly inFlight: number; + readonly queued: number; + readonly paused: boolean; + readonly pausedUntil?: number; + readonly cooldownMs: number; + readonly autoReduced: boolean; + readonly autoReducedFrom?: number; + readonly notice?: string; +} +/** + * Response of `GET /concurrency/status` — live status for every provider with a + * configured limit. Providers without a limit are absent (they are unlimited). + */ +export interface ConcurrencyStatusResponse { + readonly providers: readonly ConcurrencyStatusEntry[]; +} + +// ─── Provider concurrency cooldown ──────────────────────────────────────────── + +/** + * Response of `GET /concurrency/cooldown/:providerId` — the per-slot release + * cooldown (ms) for a provider. A recycled slot is held this long before the + * next waiter is admitted, covering the upstream provider's accounting lag. + * When no cooldown was explicitly set, the server default (350ms) is returned. + */ +export interface ConcurrencyCooldownResponse { + readonly providerId: string; + readonly cooldownMs: number; +} + +/** + * Body of `PUT /concurrency/cooldown/:providerId` — set the release cooldown + * (ms) for a provider. `cooldownMs` must be a non-negative integer (0 = no + * cooldown, instant re-admission). The value is persisted and applied to + * subsequently recycled slots. + */ +export interface SetConcurrencyCooldownRequest { + readonly cooldownMs: number; +} +``` + diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index 44b0fe7..b430a45 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -4,98 +4,59 @@ > types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission > prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only. > -> **Orchestrator:** SNAPSHOT of `[email protected]` (compaction). Regenerate -> whenever `@dispatch/wire` changes. +> **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces + computers + provider-retry + concurrency-`queued` status). Regenerate whenever `@dispatch/wire` changes. > -> **2026-06-22 delta (compaction handoff — package bumped `0.10.0` → `0.11.0`, ADDITIVE):** -> adds `CompactionResult` — the result of a compaction operation (`summary`, `messagesSummarized`, -> `messagesKept`). The summary text is the model's output; the FE doesn't render it directly (it -> becomes the conversation's first system message after compaction). +> **2026-06-27 delta (workspace starring — ADDITIVE to `[email protected]`, NO version bump):** `Workspace` gains a +> required `starred: boolean` (defaults to `false` on creation). A starred workspace's agents receive +> PRIORITY in the concurrency limiter queue — they jump ahead of agents from non-starred workspaces +> (oldest-agent-first within each group). Toggled via dedicated `PUT`/`DELETE /workspaces/:id/star` endpoints +> (no body; both create-on-miss and return the updated `Workspace`). `PUT /workspaces/:id` does NOT accept a +> `starred` field. See `backend-handoff.md`. > -> **2026-06-22 delta (conversation lifecycle handoff — package bumped `0.9.0` → `0.10.0`, ADDITIVE):** -> adds `ConversationStatus` (`"active" | "idle" | "closed"`) — the per-conversation lifecycle -> status. `ConversationMeta` gains a `status` field. `active` = a turn is generating; `idle` = -> exists, not generating; `closed` = dismissed (hidden from the tab bar). Transitions are -> backend-owned: `idle → active` on turn start, `active → idle` on turn settle, `→ closed` on -> `POST /conversations/:id/close`. Pushed to all WS clients via `conversation.statusChanged` -> (see `[email protected]`). +> **2026-06-26 delta (provider concurrency — ADDITIVE to `[email protected]`, NO version bump):** `ConversationStatus` +> widened to `"active" | "queued" | "idle" | "closed"`. `queued` = the turn is in flight but waiting for a +> per-provider concurrency slot (broadcast-only via `conversation.statusChanged`, never persisted); the FE shows +> a loading ring (vs the dots of `active`). See `backend-handoff.md` CR-13. > -> **2026-06-21 delta (conversation.open handoff — package bumped `0.8.0` → `0.9.0`, ADDITIVE):** -> adds `ConversationMeta` — metadata for a conversation (id, title, createdAt, lastActivityAt), -> returned by `GET /conversations` (the list endpoint, see `[email protected]`). +> **2026-06-26 delta (vision handoff — ADDITIVE to `[email protected]`, NO version bump):** adds a new +> `ImageChunk` variant to the `Chunk` union (`{ type: "image", url, mimeType? }` — `url` is a base64 data +> URL or an `http(s)://` URL) and a transport-facing `ImageInput` (`{ url, mimeType? }`, what a client +> sends on `ChatRequest.images`; the orchestrator converts each into an `ImageChunk` on the persisted user +> message). Vision-capable models receive image chunks natively; non-vision models never see them directly +> — the orchestrator's vision handoff transcribes each to a text description (persisted as a separate +> `text` chunk in the SAME user message). See `backend-handoff.md` §2j. > -> **2026-06-21 delta (message-queue + steering handoff — package bumped `0.7.0` → `0.8.0`, ADDITIVE):** -> adds the per-conversation **message queue** + **steering** feature. While a turn is GENERATING, -> a client enqueues a user message (via the `chat.queue` WS op or `POST /conversations/:id/queue`, -> see `[email protected]`); it is delivered mid-turn as **steering** — injected at the next -> tool-result boundary so the model sees it alongside the tool results and can adjust course. If the -> turn ends with a non-empty queue (no tool call fired), the queue is carried into a NEW turn as its -> opening prompt (no `steering` event — the new turn's `user-message` covers it). +> **2026-06-26 update (image storage — NO type change, behavior only):** `ImageChunk.url` for PERSISTED +> chunks is now a compact relative HTTP path (`/images/<conversationId>/<uuid>.png`) served by the backend's +> new `GET /images/:conversationId/:imageId` endpoint (raw bytes + correct Content-Type), NOT a base64 data +> URL — images are stored on disk under tmp, not in the SQLite conversation store (keeps payloads small). +> `ImageInput.url` (what a client SENDS on `ChatRequest.images`) is UNCHANGED — still a data URL or +> `http(s)://` URL; the backend saves it to tmp and returns the compact path in the persisted chunk. A client +> resolves a relative `url` against its API base (`resolveImageUrl`); a data URL (the optimistic echo) or an +> absolute URL passes through unchanged. See `backend-handoff.md` §2j. > -> Adds: -> - **`QueuedMessage`** (`{ id, text, queuedAt }`) — a message held in the queue (stable id for UI -> keying + dedup). -> - **`QueuePayload`** (`{ messages: QueuedMessage[] }`) — the payload of the message-queue -> extension's per-conversation `custom` surface field (`rendererId: "message-queue"`). Carried on -> the SURFACE channel (NOT the chat stream) — the queue is control/state. Empty `messages` = empty -> queue. See `transport-contract.reference.md` for the surface + the enqueue op. -> - **`TurnSteeringEvent`** (`{ type: "steering"; conversationId; turnId; text }`) — a NEW -> `AgentEvent` union member, emitted on the chat stream when the kernel drains a non-empty queue -> at a tool-result boundary. Render `text` as a USER bubble in the transcript (positioned after -> the tool-result it followed); the queue surface separately clears on drain. One event per drain; -> `text` is the combined text of all drained messages. Late-join safe (buffered into the in-flight -> turn's event buffer, mirroring `user-message`). Carry-to-new-turn does NOT emit `steering`. -> ADDITIVE to the union — if you have an exhaustive `AgentEvent` switch, add a `steering` case. +> **2026-06-23 delta (workspaces handoff — package bumped `0.11.0` → `0.12.0`, ADDITIVE):** adds +> `Workspace` + `WorkspaceEntry` (a list entry with a conversation count) and a required +> `workspaceId: string` on `ConversationMeta` (`"default"` for legacy/unspecified conversations). A +> workspace is a URL-driven grouping of conversations that owns a default cwd; conversations that +> haven't set their own cwd inherit `workspace.defaultCwd`. See `backend-handoff-workspaces-reply.md`. > -> **2026-06-12 delta (reasoning-effort handoff — package bumped `0.6.1` → `0.7.0`, ADDITIVE):** -> adds the **`ReasoningEffort`** type — the per-request thinking-depth ladder -> `"low" | "medium" | "high" | "xhigh" | "max"`. Provider-agnostic; the Anthropic provider maps -> levels to extended-thinking token budgets (low 4096 · medium 10240 · high 16384 · xhigh 32768 · -> max 65536); providers without a thinking knob ignore it. Resolution is SERVER-owned (do not -> re-implement): per-turn `ChatRequest.reasoningEffort` override → persisted per-conversation value -> (`GET`/`PUT /conversations/:id/reasoning-effort`, see `[email protected]`) → default -> `"high"`. Higher levels mean longer runs of `reasoning-delta` events before the first text delta. -> See the `ReasoningEffort` definition below. +> **2026-06-25 delta (SSH handoff #1 — ADDITIVE to `[email protected]`, NO version bump):** adds a REQUIRED +> `defaultComputerId: string | null` on `Workspace` (null = local / no SSH; the computer analog of +> `defaultCwd`) and two new read-only view types: `Computer` (a discovered `~/.ssh/config` `Host` alias) +> and `ComputerEntry extends Computer` (a list entry with a `usageCount`). `alias` IS the `computerId` +> users select (persisted per-conversation/per-workspace like cwd). The full HTTP API surface +> (`GET /computers`, `PUT /conversations/:id/computer`, `PUT /workspaces/:id/default-computer`, +> `GET /computers/:alias/status`, `chat.send computerId`) comes in a LATER handoff — NOT consumed yet. > -> **2026-06-12 delta (CR-5 history windowing — package bumped `0.6.0` → `0.6.1`, DOC-ONLY):** the -> per-conversation `seq` numbering is now a WRITTEN CONTRACTUAL GUARANTEE on `StoredChunk`: -> **1-based, monotonic, gap-free** — a conversation's first chunk is always `seq === 1` and -> numbering never skips. A client holding only a windowed suffix of the log derives "older chunks -> exist server-side" purely from `oldestLoaded.seq > 1` (no `earliestSeq`/`hasOlder` field exists). -> -> **2026-06-12 delta (CR-3 user-message handoff — package bumped `0.5.0` → `0.6.0`, ADDITIVE):** adds a -> new `AgentEvent` union member `TurnInputEvent` (`{ type: "user-message"; conversationId; turnId; text }`) -> that surfaces the turn's USER prompt INTO the outward event stream. Emitted ONCE as the FIRST event of -> every turn (before `turn-start`), so it is buffered + replayed to every subscriber — live AND late-join -> — and rides `chat.delta`/NDJSON like any other event. Fixes CR-3 (a pure watcher couldn't see the prompt -> until seal). The sender still echoes its own prompt optimistically, so consumers DE-DUP against that -> (by text); a pure watcher renders it directly. Persistence/metrics unchanged. See `TurnInputEvent` below. -> -> **2026-06-12 delta (context-size handoff — package bumped `0.4.0` → `0.5.0`):** adds an OPTIONAL -> `contextSize?: number` to BOTH `TurnDoneEvent` (live `done`) and `TurnMetrics` (persisted) — the -> turn's FINAL step `inputTokens + outputTokens` (current context occupancy), NOT the aggregate -> `usage` (which overcounts multi-step turns). The two carriers are equal for the same turn. Current -> value = the LATEST turn's `contextSize`; `undefined` ⇒ render "unknown", never `0`. See the field -> doc-comments on `TurnMetrics`/`TurnDoneEvent` below. -> -> **0.3.0 changes (token + timing metrics):** -> - **Live per-step/per-turn telemetry on the event stream** (transient — NOT persisted): -> `TurnUsageEvent` gained an OPTIONAL `stepId?` (attribute tokens per step). A NEW -> `TurnStepCompleteEvent` (`type: "step-complete"`, REQUIRED `stepId`) carries the per-step -> generation timing `ttftMs?` / `decodeMs?` / `genTotalMs?` (all optional — present only when the -> runtime had a clock; `ttftMs`/`decodeMs` additionally require a first content token). `TurnDoneEvent` -> gained an OPTIONAL `durationMs?` (total turn wall-clock) + OPTIONAL `usage?` (aggregate across -> steps). `TurnToolResultEvent` gained an OPTIONAL `durationMs?` (tool execution time). -> - **Durable, replayable metrics** (persisted, keyed per turn): NEW `StepMetrics` + `TurnMetrics` -> — the persisted counterparts of the live `usage` + `step-complete` + `done` packets. Served by -> `GET /conversations/:id/metrics` (see `transport-contract.reference.md`). Build the SAME -> `TurnMetrics` shape from the live events for the in-flight turn; the durable endpoint supplies it -> for sealed turns. TPS is derived (`usage.outputTokens / (genTotalMs / 1000)`), not on the wire. -> - **0.2.0 (still current — step grouping):** `ToolCallChunk`/`ToolResultChunk` carry an OPTIONAL -> `stepId?: StepId`; `TurnToolCallEvent`/`TurnToolResultEvent` carry a REQUIRED `stepId: StepId`. -> Group batched/parallel tool calls by `stepId` equality. Live: read `event.stepId`. Replay: read -> `storedChunk.chunk.stepId` (NOT the envelope; tolerate absence). `StoredChunk` envelope is -> UNCHANGED (`{ seq, role, chunk }` — carries NO `turnId`). +> **⚠️ CROSS-REPO DIVERGENCE (2026-06-25, BLOCKING FE typecheck):** the backend `feature/ssh-support` +> branch (where the SSH types landed) was cut from `8a74335` and is MISSING the `TurnProviderRetryEvent` / +> `provider-retry` `AgentEvent` addition that is on `dev` (and which the FE already consumes — see §2c of +> `backend-handoff.md`). The mirror below KEEPS `TurnProviderRetryEvent` (it is the FE's expected contract +> and matches `dev`); it is marked where it appears. Until the backend merges `dev` into +> `feature/ssh-support`, the FE pinned to the `feature/ssh-support` wire will NOT typecheck (11 errors, +> all the missing `provider-retry` seam). The SSH `Computer`/`defaultComputerId` types ARE present on +> `feature/ssh-support` and are consumed below. ```ts /** @@ -136,7 +97,8 @@ export type Chunk = | ToolCallChunk | ToolResultChunk | ErrorChunk - | SystemChunk; + | SystemChunk + | ImageChunk; /** A piece of plain text content from the assistant or user. */ export interface TextChunk { @@ -213,6 +175,51 @@ export interface SystemChunk { } /** + * An image attached to a message (e.g. a user-pasted screenshot or pasted + * photo). Carries a `url` that is EITHER a base64 data URL + * (`data:image/png;base64,…`) OR an `http(s)://` URL OR — for PERSISTED chunks + * (history/replay) — a compact relative HTTP path (`/images/<conversationId>/ + * <uuid>.png`) served by the backend's `GET /images/:conversationId/:imageId` + * endpoint (images are stored on disk under tmp, NOT in the conversation store, + * to keep SQLite payloads small). A client resolves a relative path against its + * API base URL; a data URL (the optimistic echo / a pasted image) or an + * absolute URL is rendered as-is. Vision-capable models receive it natively + * (the provider serializes it to its image-content format); non-vision models + * never see it directly — the orchestrator's **vision handoff** transcribes it + * to a text description (via a vision-capable model) and feeds that text + * instead, so a text-only model can still reason about the image's contents. + * + * When a transcription was performed, it is persisted as a separate `text` + * chunk alongside the `image` chunk in the SAME user message, so the + * description is reused on every later turn (no re-transcription) and a + * client renders both the original image and its textual analysis. + */ +export interface ImageChunk { + readonly type: "image"; + /** Image source: a base64 data URL (`data:image/…;base64,…`), an `http(s)://` URL, or a compact relative path (`/images/<conv>/<uuid>.png`) for persisted chunks. */ + readonly url: string; + /** + * Optional MIME type of the image (e.g. `"image/png"`). Inferred from the + * data URL when absent; present so a client can render an icon/label without + * parsing the URL. Optional — callers that only have a URL omit it. + */ + readonly mimeType?: string; +} + +/** + * An image a client attaches to a chat message (`ChatRequest.images`). The + * transport-facing input shape; the orchestrator converts each `ImageInput` + * into an `ImageChunk` on the persisted user message. Carries the same `url` + * semantics as `ImageChunk.url`. + */ +export interface ImageInput { + /** Image source: a base64 data URL (`data:image/…;base64,…`) or an `http(s)://` URL. */ + readonly url: string; + /** Optional MIME type (e.g. `"image/png"`). Optional — inferred from the data URL when absent. */ + readonly mimeType?: string; +} + +/** * A chat message: a role plus an ordered sequence of chunks. Messages are the * unit passed to and from the provider; chunks are the unit persisted and * rendered. @@ -299,8 +306,8 @@ export interface StepMetrics { * Durable per-turn metrics for a completed (sealed) turn — the persisted, * replayable counterpart of the live `done` event's aggregate `usage` + * `durationMs`, plus the per-step breakdown. `usage` is the aggregate across all - * steps; `steps` carries each step's `StepMetrics` in step order. Stored by - * `conversation-store` keyed by `turnId` and served by + * steps; `steps` carries each step's `StepMetrics` in step order. Persisted per + * turn by `conversation-store` (returned in turn-append order) and served by * `GET /conversations/:id/metrics`. (`turnId` is the plain wire string carried * on every `AgentEvent`, the join key to the live stream.) */ @@ -373,6 +380,7 @@ export type AgentEvent = | TurnUsageEvent | TurnStepCompleteEvent | TurnErrorEvent + | TurnProviderRetryEvent // ⚠️ divergent: present on `dev`, MISSING on the pinned `feature/ssh-support` wire (see header + backend-handoff.md §2c) | TurnDoneEvent | TurnSealedEvent | TurnSteeringEvent; @@ -393,13 +401,15 @@ export interface TurnStartEvent { /** * The user prompt that opened this turn, surfaced INTO the turn's outward event - * stream so a WATCHER (subscribed but not the sender) can render the prompt - * mid-turn — the user message is otherwise persisted only at seal. Emitted ONCE - * as the FIRST event of the turn (before `turn-start`); buffered + replayed to - * every subscriber (live + late-join). The sender echoes its own prompt - * optimistically, so DE-DUP against that (by text); a pure watcher renders it - * directly. Carries the raw `text` passed to the provider. (Turn-scoped: it - * carries `turnId`, so a multi-turn transcript attributes each prompt to its turn.) + * stream. The user message is persisted only when the turn seals (atomically with + * the assistant reply), so without this event a client that is merely WATCHING a + * conversation (subscribed but not the sender) has no source for the prompt text + * mid-turn — it would see the streaming reply with no preceding user bubble until + * seal. Emitted once, as the FIRST event of the turn (before `turn-start`), so it + * is buffered and replayed to every subscriber — live and late-join — exactly like + * the rest of the turn. The sender already echoes its own prompt optimistically, so + * a consumer should de-dup against that (e.g. by text); a pure watcher renders it + * directly. Carries the raw prompt `text` (the same text passed to the provider). */ export interface TurnInputEvent { readonly type: "user-message"; @@ -527,6 +537,31 @@ export interface TurnErrorEvent { readonly code?: string; } +/** + * A retryable provider error is being retried with backoff. Emitted once per + * scheduled retry, BEFORE the sleep, so the UI can show "⚠ Server overloaded — + * retrying in 5s…" immediately. TRANSIENT: emitted to the frontend but NOT + * persisted into the model's message history (it never pollutes the prompt). + * + * When the retry budget is exhausted, the existing `error` event is emitted and + * the turn seals — so the final failure is still a persisted error. `attempt` is + * 0-based (the Nth retry about to happen); `delayMs` is the scheduled sleep + * before that retry fires. + */ +export interface TurnProviderRetryEvent { + readonly type: "provider-retry"; + readonly conversationId: string; + readonly turnId: string; + /** 0-based: this is the Nth retry about to happen. */ + readonly attempt: number; + /** ms the client should expect to wait before the retry fires. */ + readonly delayMs: number; + /** The endpoint's error verbatim (e.g. "HTTP 429: {…overloaded_error…}"). */ + readonly message: string; + /** The HTTP code when known (e.g. "429"). */ + readonly code?: string; +} + /** The turn has completed (model finished generating). */ export interface TurnDoneEvent { readonly type: "done"; @@ -545,11 +580,11 @@ export interface TurnDoneEvent { */ readonly usage?: Usage; /** - * **Context size** — tokens the conversation occupies right now: the turn's - * FINAL step `inputTokens + outputTokens` (the prompt sent into the last LLM - * round-trip plus that round-trip's output). This is the "tokens in context" - * figure a client renders as the chat's current context usage, and a client - * treats the LATEST turn's value as the live total. + * **Context size** — the number of tokens the conversation now occupies: this + * (the most recent) turn's FINAL step `inputTokens + outputTokens` (the full + * prompt sent into the last LLM round-trip plus that round-trip's output). This + * is the "tokens in context" figure a client renders as the chat's current + * context usage, and a client treats the LATEST turn's value as the live total. * * Deliberately NOT the aggregate `usage` above: `usage` SUMS each step's * `inputTokens`, which overcounts a multi-step / tool-calling turn because every @@ -598,18 +633,24 @@ export interface TurnSteeringEvent { // ─── Conversation metadata ─────────────────────────────────────────────────── /** - * The per-conversation lifecycle status. `active` = a turn is generating; - * `idle` = exists, not generating; `closed` = dismissed (hidden from the tab - * bar, not deleted). Transitions are backend-owned and pushed via the - * `conversation.statusChanged` WS message (see `transport-contract`). + * The lifecycle status of a conversation, used for tab persistence across + * devices. `active` = an agent is currently generating; `queued` = the turn is + * in flight but waiting for a per-provider concurrency slot (broadcast-only, + * never persisted — CR-13; the tab shows a ring vs the dots of `active`); + * `idle` = exists but not generating; `closed` = user dismissed the tab + * (hidden from the tab bar, not deleted). New conversations start as `idle`; + * transitions to `active` on turn-start (or `queued` when the request blocks on + * a concurrency slot before generation begins), back to `idle` on turn + * done/error, and to `closed` on user close. */ -export type ConversationStatus = "active" | "idle" | "closed"; +export type ConversationStatus = "active" | "queued" | "idle" | "closed"; /** * Metadata for a conversation, returned by `GET /conversations` (the list * endpoint). The title defaults to the first user message (truncated) and can * be set via `PUT /conversations/:id/title`. `createdAt` is set on first write; - * `lastActivityAt` is updated on every append. + * `lastActivityAt` is updated on every append. `status` tracks the tab lifecycle + * for cross-device persistence. */ export interface ConversationMeta { readonly id: string; @@ -617,7 +658,17 @@ export interface ConversationMeta { readonly lastActivityAt: number; readonly title: string; readonly status: ConversationStatus; - /** Points to the archive conversation with full pre-compaction history. */ + /** + * The workspace this conversation belongs to. Always present; reads as + * `"default"` for legacy conversations that were never explicitly assigned. + * Conversations created with no `workspaceId` default to `"default"`. + */ + readonly workspaceId: string; + /** + * Set on a compacted conversation: points to the archive conversation ID + * that holds the full pre-compaction history. Absent on conversations + * that have never been compacted. + */ readonly compactedFrom?: string; } @@ -627,6 +678,8 @@ export interface ConversationMeta { * Result of a compaction operation. `summary` is the text the model produced; * `messagesKept` is how many recent messages were retained after the summary; * `messagesSummarized` is how many old messages were replaced by the summary. + * `newConversationId` is the ID of the new conversation that holds the full + * pre-compaction history (non-destructive — the original history is preserved). */ export interface CompactionResult { readonly summary: string; @@ -634,4 +687,94 @@ export interface CompactionResult { readonly messagesSummarized: number; readonly messagesKept: number; } + +// ─── Workspaces ────────────────────────────────────────────────────────────── + +/** + * A named, URL-driven grouping of conversations that owns a default cwd. + * Every conversation belongs to exactly one workspace; conversations that + * haven't set their own per-conversation cwd inherit `defaultCwd`. + * + * Workspaces are backend-owned (so cross-device just works): the workspace + * entity and each conversation's `workspaceId` live server-side. The + * `"default"` workspace is always present and non-deletable; conversations + * created with no `workspaceId` are assigned to `"default"`. + */ +export interface Workspace { + /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */ + readonly id: string; + /** Display title (editable). Defaults to `id` on creation. */ + readonly title: string; + /** The workspace's default cwd, or `null` (fall through to server default). */ + readonly defaultCwd: string | null; + /** + * The workspace's default computer — an SSH config `Host` alias that + * conversations in this workspace inherit when they set no `computerId` of + * their own. `null` means local (no SSH; today's behavior). The computer + * analog of `defaultCwd`. Resolved per-conversation by `getEffectiveComputer` + * (per-conv `computerId` → this → `null`/local). + */ + readonly defaultComputerId: string | null; + /** + * Whether the workspace is starred by the user. Starred workspaces receive + * PRIORITY in the concurrency limiter queue — their agents jump ahead + * of agents from non-starred workspaces (oldest-agent-first within each group). + * Defaults to `false` on creation. + */ + readonly starred: boolean; + /** Epoch-ms when the workspace was first created. */ + readonly createdAt: number; + /** Epoch-ms of the most recent conversation activity in this workspace. */ + readonly lastActivityAt: number; +} + +/** + * A workspace entry in the list response (`GET /workspaces`) — a `Workspace` + * plus a conversation count. + */ +export interface WorkspaceEntry extends Workspace { + /** Number of conversations assigned to this workspace. */ + readonly conversationCount: number; +} + +// ─── Computers ─────────────────────────────────────────────────────────────── + +/** + * A read-only view of a remote computer discovered from the system's + * `~/.ssh/config` — a "computer" is a `Host` alias, NOT an editable entity + * (there is no Computer CRUD store). To add a computer, the user adds a `Host` + * block to `~/.ssh/config`; Dispatch discovers it on the next `listComputers()` + * read. Every field below is resolved from the config (first-match-wins for + * `HostName`/`User`/`Port`/`IdentityFile`). + * + * `alias` is the `computerId` users select — the string persisted per + * conversation and per workspace (the computer analog of `cwd`). `knownHost` + * drives the frontend "known/new" indicator and is read-only. + */ +export interface Computer { + /** The SSH config `Host` alias — also the `computerId` users select. */ + readonly alias: string; + /** Resolved `HostName`/IP from the config (falls back to the alias itself). */ + readonly hostName: string; + /** Resolved port (config `Port`, default 22). */ + readonly port: number; + /** Resolved user (config `User`, default the current user). */ + readonly user: string; + /** Resolved `IdentityFile` path (from the config, or `null` = default `~/.ssh/id_*`). */ + readonly identityFile: string | null; + /** + * Whether the host's key is already in `~/.ssh/known_hosts` (i.e. previously + * connected). Drives the frontend "known/new" indicator. Read-only. + */ + readonly knownHost: boolean; +} + +/** + * A computer entry in the list response (`GET /computers`) — a `Computer` plus + * a usage count. Parallel to `WorkspaceEntry`. + */ +export interface ComputerEntry extends Computer { + /** Number of conversations/workspaces whose `computerId` resolves to this alias. */ + readonly usageCount: number; +} ``` diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..aacc6cf --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,15 @@ +{ + "plugins": ["prettier-plugin-svelte"], + "useTabs": false, + "tabWidth": 2, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "overrides": [ + { + "files": "*.svelte", + "options": { "parser": "svelte" } + } + ] +} @@ -3,7 +3,7 @@ > Loaded every session — the single source of truth for working in this repo: the > build constitution (code rules) + the workflow. Non-obvious, project-specific rules > only — if a fresh frontier model could infer it from the code, it is NOT here (P6). -> Full design + rationale: `../arch-rewrite/notes/frontend-design.md` (and the +> Full design + rationale: `../backend/notes/frontend-design.md` (and the > backend's `notes/restructure-plan.md` §1 for P1–P8). > > **You are the single agent for this repo.** You plan, author the cross-unit @@ -13,7 +13,7 @@ ## What this is The **web frontend** for Dispatch — a SEPARATE repo from the backend -(`../arch-rewrite`). It is a **thin shell + pure feature libraries + a surface +(`../backend`). It is a **thin shell + pure feature libraries + a surface host**, NOT a default-SvelteKit ball of mud. It consumes the backend's typed contracts (`@dispatch/ui-contract` + the wire types) over HTTP + a WebSocket. The app is a COMPOSITION of feature modules + surfaces, assembled at the composition root @@ -23,7 +23,7 @@ effects / no ambient state / typed contracts / asymmetric testing. ## Stack Bun + Vite + Svelte 5 (runes) + TypeScript (strict). Biome for lint/format -(tabs, double quotes, semicolons, width 100) — **biome covers `.ts`/`.js` ONLY; +(2-space indent, double quotes, semicolons, width 100) — **biome covers `.ts`/`.js` ONLY; `.svelte` correctness is `svelte-check`'s job** (biome can't read Svelte template semantics — it flags template-used vars as unused). Vitest + `@testing-library/ svelte` for tests. @@ -37,7 +37,7 @@ src/adapters/ injected browser effects: WS client, fetch, IndexedDB, history .dispatch/ mirrored backend contracts (*.reference.md) + rules/ reports/ (gitignored) ``` -Backend (SEPARATE repo, contracts only): `../arch-rewrite` — consume +Backend (SEPARATE repo, contracts only): `../backend` — consume `@dispatch/ui-contract` (`file:` dep) + the wire types. Do NOT edit it. ## The non-negotiable rules @@ -91,7 +91,7 @@ effects backed by a shared global (`fake-indexeddb`, `localStorage`) to catch cr pollution. After a slice that touches the wire or browser effects, run a LIVE probe (below). ## Backend seam (cross-repo) -The backend is `../arch-rewrite` (separate repo; `lsp references` does NOT span the +The backend is `../backend` (separate repo; `lsp references` does NOT span the boundary). You consume `@dispatch/ui-contract` + the wire/transport types as pinned `file:` deps. **Read the in-repo mirrors `.dispatch/*.reference.md`, never `node_modules/@dispatch/*`** (they symlink out of the repo); regenerate the relevant mirror whenever a contract changes. @@ -143,7 +143,7 @@ live view (subscribe/reconnect + the user prompt on the event stream), and the c show-earlier server backfill; `hasOlder` from the 1-based gap-free seq contract), and the reasoning-effort selector (Model view, under the provider/model dropdowns; sticky per-conversation `GET`/`PUT /reasoning-effort`, `null` ⇒ "high (default)"). Plan in -`../arch-rewrite/notes/frontend-design.md` §10. +`../backend/notes/frontend-design.md` §10. ## Reports Optionally record a finished milestone in `reports/<name>.md` (gitignored): what you built, diff --git a/GLOSSARY.md b/GLOSSARY.md index e350ffa..4ca083c 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -1,4 +1,4 @@ -# Glossary — canonical vocabulary (dispatch-web) +# Glossary — canonical vocabulary (frontend) > One name per concept. Shared backend terms are adopted VERBATIM (no drift). > New term? The orchestrator proposes the standard name and the user confirms @@ -24,6 +24,12 @@ | **context window** | The model's MAXIMUM token capacity (the limit a **context size** is measured against). A FUTURE backend field — not on the wire yet. **Placeholder:** the composer status bar currently HARDCODES a `1,000,000`-token window for the `size / limit · pct%` readout + fill bar; swap to the real per-model value when the backend ships it (see `backend-handoff.md` §3). | max context, token limit (distinct from **context size**, the current usage) | | **message queue** | A per-conversation buffer of user messages awaiting mid-turn **steering** delivery (owned by the `message-queue` backend extension). Transient + in-memory; exposed to the FE as a per-conversation **surface** (`message-queue`, scope `conversation`; one `custom` field, `rendererId: "message-queue"`, `payload: QueuePayload`). NOT on the chat stream — it is control/state. Enqueued via `chat.queue` (WS) or `POST /conversations/:id/queue` (HTTP); auto-starts a turn if idle. `[email protected]`. | pending messages, steering queue | | **steering** | A user message injected into an in-flight turn at the tool-result boundary (drawn from the **message queue**): the model sees it alongside the tool results and may adjust course. Emitted on the chat stream as a `steering` `AgentEvent` (`TurnSteeringEvent`); the queue surface clears on drain (move, don't duplicate). If the turn ends with a non-empty queue (no tool call fired), the queue carries into a NEW turn as its opening prompt (no `steering` event). `[email protected]`. | mid-turn injection, course correction, interruption | +| **computer** | A remote SSH target discovered from the system's `~/.ssh/config` — a read-only VIEW, NOT an editable entity (no CRUD store; to add one the user edits `~/.ssh/config`). Backend-canonical (`[email protected]`, additive). On the wire as `Computer` (`{ alias, hostName, port, user, identityFile, knownHost }`) + `ComputerEntry extends Computer` (adds `usageCount`, for `GET /computers`). `alias` IS the **computerId** — the string persisted per-conversation / per-workspace (the computer analog of `cwd`). Resolution is SERVER-owned (never re-implement): per-conversation `computerId` → `workspace.defaultComputerId` → `null`/local. USER-facing only: a tool-execution target forwarded to tools, NEVER part of the model prompt (does not affect prompt caching); the agent never sees it. HTTP API (`GET /computers`, `GET`/`PUT /conversations/:id/computer`, `PUT /workspaces/:id/default-computer`, `GET /computers/:alias/status`, `POST /computers/:alias/test`) consumed in handoff #2. | ssh host, remote host, server, connection target | +| **computerId** | The string id of a **computer** — an SSH config `Host` alias users select. Persisted per-conversation and per-workspace (the computer analog of `cwd`/`workspaceId`). `null` means local (no SSH; today's behavior). On `Workspace` as the REQUIRED `defaultComputerId: string \| null` (null = local / no SSH; the computer analog of `defaultCwd`); per-conversation persistence via `GET`/`PUT`/`DELETE /conversations/:id/computer`. `chat.send` need not send it (resolved server-side from the persisted per-conversation value in the MVP). | ssh alias, host id, remote id | +| **image chunk** | An `ImageChunk` (`{ type: "image", url, mimeType? }`) — a `Chunk` variant for an image attached to a message (a user-pasted screenshot/photo). `url` is a base64 data URL (`data:image/…;base64,…`), an `http(s)://` URL, or — for PERSISTED chunks — a compact relative HTTP path (`/images/<conversationId>/<uuid>.png`) served by `GET /images/:conversationId/:imageId` (images are stored on disk under tmp, not in the SQLite store). Backend-canonical (`[email protected]`, additive). A user message may be multi-chunk (`[text, image, image, …]` in order). On the wire as `ImageChunk` (persisted) + `ImageInput` (what `ChatRequest.images` carries — still a data URL; the orchestrator saves it to tmp and returns the compact path). A client resolves a relative `url` against its API base (`resolveImageUrl`); a data URL (the optimistic echo) or absolute URL passes through. | picture, photo attachment, screenshot chunk | +| **vision** (capability) | Whether a model can natively accept image input (multimodal). On the wire as `ModelMetadata.vision?: boolean` (`GET /models` `modelInfo[name].vision`). `true` (e.g. any `kimi/*` model) → image chunks are passed through to the provider natively. Absent/`false` (e.g. `umans/glm-5.2`) → the server's **vision handoff** gives the model a numbered placeholder + the `consult_vision` tool. The FE shows a vision badge in the model picker; it does NOT decide handoff (server-owned). | multimodal, image support | +| **vision handoff** | The server-owned mechanism by which a NON-vision model still reasons about a pasted image. A non-vision model gets a NUMBERED PLACEHOLDER text chunk alongside the persisted `image` chunk, then calls the `consult_vision` tool (which opens a NEW conversation tab with a vision-capable model, attaches the image + question, and returns the vision model's answer). The persisted user message keeps the original `image` chunk (the FE renders it) AND the placeholder text. When a vision-capable model has more than `imageLimit` images in history, image **compaction** transcribes the oldest to `[Compacted image]: <description>` text chunks (the `image` chunk stays for rendering). All these are regular `text` chunks — the FE renders them as-is. Distinct from a vision-capable model, which receives the image natively. | image transcription, vision relay | +| **consult_vision** | A tool available to all models that defers image analysis to a vision-capable model: it opens a NEW conversation tab (with a vision model, e.g. Kimi), attaches the image (by `imageIds` from a pasted placeholder, or by `path` from disk) + a `question`, and returns the conversation id + the vision model's answer (suggesting the dispatch CLI for follow-ups). Replaces the former `read_image` tool. Rendered generically like any tool call/result (by `toolName`). | read_image (former), vision tool | ## Frontend-specific | Term | Meaning | Aliases to avoid | @@ -43,3 +49,5 @@ | **chat limit** | The max LOADED chunks per conversation (default 256; persisted at localStorage `dispatch.chatLimit`, settable via the sidebar's Settings view) before the oldest quarter is unloaded. Counts **chunks** (committed + provisional + accumulating). Policy in `core/chunks/trim.ts`. | chunk limit, message limit, history limit | | **unload** | Drop the oldest COMMITTED chunks from the in-memory transcript (and DOM) past the **chat limit** — in BULK (`ceil(limit/4)` per pass, deferred while the reader is scrolled up), never one-per-delta (old Dispatch's scroll-jump bug). Purely local: the IndexedDB cache and the server keep everything; `TranscriptState.hiddenBeforeSeq` is the watermark. Distinct from the conversation-cache's cross-conversation **eviction**. | evict (reserved for the cross-conversation cache), prune, drop | | **show earlier** | The affordance at the top of a transcript with unloaded history ("Show earlier messages"): pages one unload-unit back in — local cache first, then the server (CR-5 `?beforeSeq=&limit=`) when the cache doesn't reach far enough back — preserving the reader's scroll position. Offered whenever the loaded window starts above seq 1 (the [email protected] 1-based gap-free seq contract). | load more, pagination | +| **workspaces** | A URL-driven grouping of conversations that owns a **default cwd**. The root path `/` lists workspaces; `/<id>` opens one (visiting a nonexistent id creates it — create-on-miss). Each conversation belongs to exactly one workspace; the always-present `"default"` workspace is the fallback for unassigned/legacy conversations. Tabs are scoped per workspace (filtered client-side). Backend-owned (`[email protected]` `Workspace`); a conversation's cwd inherits `workspace.defaultCwd` when its own is unset. Distinct from the per-conversation cwd+LSP module (`features/cwd-lsp`, formerly `features/workspace`). | project, space | +| **starred** | A workspace flag (`Workspace.starred: boolean`, defaults `false`) that grants its agents PRIORITY in the concurrency limiter queue — they jump ahead of agents from non-starred workspaces, oldest-agent-first within each group. Takes effect immediately for already-queued agents (backend-owned). Toggled via dedicated `PUT`/`DELETE /workspaces/:id/star` endpoints (no body; create-on-miss; `PUT /workspaces/:id` does NOT accept `starred`). The FE sorts starred workspaces to the top of the home list (the display echo of their concurrency priority) and toggles optimistically with error revert. | favorited, pinned (and do NOT call it "priority" — priority is the EFFECT, starred is the FLAG) | @@ -1,6 +1,6 @@ # Dispatch Web -The **web frontend** for [Dispatch](../arch-rewrite) — a separate repo built to the same +The **web frontend** for [Dispatch](../backend) — a separate repo built to the same methodology (thin shell + pure feature libraries + a backend-driven *surface* host). It consumes the backend's typed contracts over HTTP + a WebSocket and ships no business logic the backend doesn't expose. @@ -17,17 +17,17 @@ doesn't expose. - [Bun](https://bun.sh) (v1.3+). - **The backend repo as a sibling directory** — this repo links `@dispatch/ui-contract` from - `../arch-rewrite` via a `file:` dependency: + `../backend` via a `file:` dependency: ``` dispatch/ - arch-rewrite/ # the backend (Dispatch server) - dispatch-web/ # this repo + backend/ # the backend (Dispatch server) + frontend/ # this repo ``` -- The **backend server running** for surfaces to appear (see `../arch-rewrite/README.md`). +- The **backend server running** for surfaces to appear (see `../backend/README.md`). ```sh -cd dispatch-web -bun install # links @dispatch/ui-contract from ../arch-rewrite +cd frontend +bun install # links @dispatch/ui-contract from ../backend ``` --- @@ -36,17 +36,17 @@ bun install # links @dispatch/ui-contract from ../arch-rewrite ```sh # 1) start the backend (sibling repo) — HTTP :24203 + surface WS :24205 -cd ../arch-rewrite && bun run dev +cd ../backend && bun run dev # 2) start this dev server — Vite on :24204 -cd ../dispatch-web && bun run dev +cd ../frontend && bun run dev ``` Open **http://localhost:24204**. You'll see the surface catalog (e.g. "Loaded Extensions"); the frontend connects to the backend's surface WebSocket at `ws://localhost:24205` (override with `VITE_WS_URL`). -> **Tip — run both at once with live reload:** the backend repo ships `../arch-rewrite/bin/up` +> **Tip — run both at once with live reload:** the backend repo ships `../backend/bin/up` > (also `bun run dev:all` there) which starts the backend (`bun --watch`) + this dev server > (Vite HMR) together; **Ctrl-C stops both**. @@ -64,7 +64,7 @@ When browsing from a **different device than the one running the backend**, set 1. **Reach the dev server:** open `http://<this-machine-tailscale-name>:24204`. (The backend's Bun servers already bind all interfaces, so `:24203`/`:24205` are reachable over Tailscale too.) 2. **Point the frontend at the backend's WebSocket.** The WS URL runs in *your browser*, so - `localhost` would mean *your* device — set it to the backend host. Create `dispatch-web/.env`: + `localhost` would mean *your* device — set it to the backend host. Create `frontend/.env`: ```sh VITE_WS_URL=ws://<backend-machine-tailscale-name>:24205 ``` @@ -99,5 +99,5 @@ bun run check # biome (.ts/.js; .svelte correctness is svelte-check's job ## Documentation -- **Design + plan:** `../arch-rewrite/notes/frontend-design.md` +- **Design + plan:** `../backend/notes/frontend-design.md` - **Build rules + workflow:** `AGENTS.md` · **Vocabulary:** `GLOSSARY.md` @@ -1,4 +1,4 @@ -# Roadmap — dispatch-web +# Roadmap — frontend > Living document of shipped + planned FE work. Updated at each milestone. > Source of truth for "what's done" + "what's next". Cross-repo handoffs land here @@ -19,6 +19,7 @@ - **Conversation.open broadcast** — `conversation.open` WS message handler, opens a tab (without auto-switching) from CLI `--open` flag. - **Conversation lifecycle (cross-device tab sync)** — `GET /conversations?status=active,idle` on connect restores tabs across devices; `conversation.statusChanged` WS handler updates tab status + removes closed tabs; TabBar shows a spinner on `active` conversations. - **Conversation compaction** — "Compaction" sidebar view with manual "Compact now" button (`POST /conversations/:id/compact`) + auto-compact threshold input (`GET`/`PUT /conversations/:id/compact-threshold`); `conversation.compacted` WS handler reloads history. +- **Workspaces** — URL-driven conversation grouping with a backend-owned default cwd (`[email protected]`/`[email protected]`). Routing: `/` lists workspaces (create-on-visit + delete); `/<id>` opens one (tabs scoped to it, existing tabs migrate to `"default"`). New conversations are stamped with the active workspace on `chat.send`/`chat.queue`. Workspace CRUD via `GET`/`PUT`/`DELETE /workspaces`. *Pending: CwdField explicit-vs-inherited display + clear-to-inherit (`DELETE /conversations/:id/cwd`).* ## Next up diff --git a/backend-handoff-cache-warming.md b/backend-handoff-cache-warming.md deleted file mode 100644 index a0019f9..0000000 --- a/backend-handoff-cache-warming.md +++ /dev/null @@ -1,102 +0,0 @@ -# Cache-warming lifecycle handoff (FE → backend) — CR-4 — **RESOLVED ✅ 2026-06-12** - -> **Closed.** Backend reply: `../arch-rewrite/frontend-cache-warming-lifecycle-handoff.md` -> (`[email protected]` + `[email protected]`). All asks shipped; FE consumed + live-probed -> 17/17 (`scripts/probe-cache-warming.ts` against `bin/up`). CR-4d turned out to be an FE bug (our -> WS parser dropped the `conversationId` echo on the initial `surface` message) — fixed FE-side. -> Current status lives in `backend-handoff.md` §2. Original report kept below for history. - -> **From:** dispatch-web · **To:** arch-rewrite · **Courier:** the user. -> User-reported symptoms, investigated FE-side with a live probe against a running backend -> (`bin/up2` stack, HTTP :25203 / surface WS :25205, 2026-06-12). Repro tool: -> `dispatch-web/scripts/probe-cache-warming.ts` (drives the FE's real WS adapter + the -> `cache-warming` surface; safe to re-run to verify fixes). -> -> **Verdict up front:** the FE renders the surface data faithfully — symptoms 1 and 2 are -> backend data/behavior; symptom 3 needs a new backend affordance (FE will wire it on arrival). - -## User-reported symptoms - -1. Warming is **ON by default** for a new conversation — the user has to manually turn it off. - Wanted: default OFF, opt-in per conversation. -2. With warming enabled, **no usable countdown** to the next refresh — the user can't tell - whether refreshes are happening at all. -3. Wanted lifecycle: refreshes **keep running when the browser window closes** (✅ already true, - verified — see below), but **closing the conversation's tab in the app should stop the - refreshes AND abort any in-flight generation** (closing the tab = "done with this chat for now"). - -## Probe evidence (verbatim observations) - -Fresh conversation (first turn sealed), then `subscribe {surfaceId:"cache-warming", conversationId}`: - -- **Initial spec:** `toggle value: true`, `number value: 240` (s), timer payload - `{ nextWarmAt: <now+240s>, lastWarmAt: null }` → **enabled by default, warm already scheduled**. - Confirms symptom 1 is backend default state. -- `invoke cache-warming/set-interval payload:20` → update with a FUTURE `nextWarmAt` (+20s). ✅ -- **Automatic warms DO repeat and DO push updates** — 3 warms observed at ~21s spacing - (interval 20s), each pushing an `update` with fresh `Last Cache %` / `Cache retention` stats. - So the engine itself works. -- **BUG (symptom 2 root cause): every post-warm `update` carries a STALE `nextWarmAt` — the fire - time of the warm that JUST completed (i.e. in the past), never the next scheduled one.** - Observed sequence (epoch ms): - - | update after | nextWarmAt | lastWarmAt | note | - |---|---|---|---| - | warm #1 | 1781246273405 | 1781246274299 | nextWarmAt < lastWarmAt (past) | - | warm #2 | 1781246294299 | 1781246295269 | = warm#1.lastWarmAt + 20 000 → still past | - | warm #3 | 1781246315269 | 1781246315998 | = warm#2.lastWarmAt + 20 000 → still past | - - The pattern shows the reschedule math exists (`next = lastWarm + interval`) but the surface - update is emitted with the PRE-warm snapshot; the post-reschedule (future) `nextWarmAt` is - never pushed. The FE countdown is authoritative off `nextWarmAt` (per the cache-warming - handoff design), so after the FIRST automatic warm the UI shows "Next warm in 0s" forever — - exactly the user's "I can't tell if it's working". -- Same staleness after a real chat turn while subscribed: last update after `turn-sealed` still - carried a past `nextWarmAt` (−10s and counting), even though a warm was presumably scheduled. -- **Browser-closed continuity ✅:** the schedule is fully server-side — warms fired with no - browser attached (only the headless probe socket). Symptom 3's "keep running when the window - closes" half already works; do not regress it. -- **Contract deviation (minor):** the initial `surface` reply to a conversation-scoped subscribe - does NOT echo `conversationId` (updates do). `ui-contract` says the echo should be present - ("echoes the subscribe's conversation … so the client routes it"). The FE currently tolerates - the missing echo (treats no-echo as current), but that weakens stale-scope filtering on fast - conversation switches — please echo it. - -## Asks - -### CR-4a — default warming to OFF for a new conversation -New conversations currently start `enabled: true`, interval 240s, first warm scheduled -immediately. Make the default `enabled: false` (no warm scheduled until the user opts in). -No contract change — it's the initial state of the existing surface. - -### CR-4b — push the refreshed (future) `nextWarmAt` after each automatic warm -After a warm completes + the next one is scheduled, the emitted surface `update`'s -`cache-warming-timer` payload must carry the NEW future `nextWarmAt` (and the new `lastWarmAt`). -Either emit the update after rescheduling or emit a second update — FE is indifferent; it just -renders the authoritative timestamp. (Same applies to the post-`turn-sealed` reschedule path.) -No contract change — it's the payload of the existing custom field. - -### CR-4c — a "conversation closed" affordance (stop warming + abort generation) -The FE needs to tell the backend "the user closed this conversation's tab": that should -(1) disable/stop cache-warming for the conversation and (2) abort any in-flight turn. -Today there is no path: -- `chat.unsubscribe` / socket close explicitly never stops the turn (by design — keep that); -- surface `unsubscribe` doesn't touch the warming schedule (correct for mere disconnects); -- `POST /conversations/:id/cancel` is DEFERRED in `transport-contract`; -- programmatically invoking `cache-warming/toggle` is unsuitable: it FLIPS with no payload, so - it's racy as an explicit "disable" (and doesn't abort generation). - -Preferred shape (backend's call): a single explicit `POST /conversations/:id/close` (or WS -message) that does both, OR un-defer `/cancel` + accept an optional explicit boolean payload on -`cache-warming/toggle`. Whatever ships, the FE wires it into its tab-close path. Note the -asymmetry the user wants: browser/socket disconnect ⇒ warming continues; explicit tab close ⇒ -warming + generation stop. - -### CR-4d (minor) — echo `conversationId` on the initial `surface` message -Per the `ui-contract` doc comment on `SurfaceMessage` (see deviation above). - -## FE-side follow-ups (ours, queued behind the above) -- Harden the countdown display: a past `nextWarmAt` renders as "waiting…" instead of a stuck - "0s" (cosmetic guard; CR-4b is the real fix). -- On CR-4c shipping: call the close affordance from `store.closeTab()`; re-pin + re-mirror the - contract; extend `scripts/probe-cache-warming.ts` to verify default-off + post-warm countdown. diff --git a/backend-handoff-chat-limit.md b/backend-handoff-chat-limit.md deleted file mode 100644 index da20583..0000000 --- a/backend-handoff-chat-limit.md +++ /dev/null @@ -1,66 +0,0 @@ -# Backend handoff — CR-5: history windowing for the FE chat limit (courier doc) - -> **From:** dispatch-web · **To:** arch-rewrite · **Courier:** the user. -> Companion to the living `backend-handoff.md` (§2 CR-5). 2026-06-12. - -## Context — what the FE is building (no backend blocker) - -The FE is adding a **chat limit**: in very long conversations the transcript unloads old -chunks from memory/DOM so the browser stays fast. Policy (already decided with the user): - -- Limit `L` counts **chunks** (default 256, localStorage-configurable). -- When the loaded count exceeds `L`, the FE unloads the oldest `ceil(L/4)` chunks in ONE - bulk pass (e.g. `L=100`: at 101 chunks it unloads 25 → 76 remain). Bulk-on-threshold — - NOT one-per-delta like old Dispatch — to kill the scroll-jump-per-step failure mode. -- A fresh page load shows only the newest `floor(0.75 × L)` chunks (192 for the default). -- A "Show earlier messages" affordance pages older history back in (today: from the FE's - IndexedDB cache, which still holds it). - -**This works TODAY with no backend change** — the FE fetches everything and windows in -memory. The ask below makes the *fresh-browser* case cheap: with an empty IndexedDB cache, -`GET /conversations/:id?sinceSeq=0` currently returns the ENTIRE conversation, so a -10k-chunk chat downloads + parses megabytes only for the FE to display 192 chunks. - -## The ask (additive, `transport-contract` bump) - -Extend `GET /conversations/:id` with two OPTIONAL query params: - -1. **`limit=<n>`** — return only the **newest** `n` chunks of the selection (still - ascending seq order in the response). Selection semantics otherwise unchanged - (`seq > sinceSeq`). - - **If the selection has ≤ `n` chunks, return everything** — the FE will routinely send - a largish number (e.g. `limit=192`) against short conversations and expects the - normal full response (that flow must stay cheap and exact). - - `limit` absent → exactly today's behavior (full selection). Existing FE versions keep - working unchanged. -2. **`beforeSeq=<s>`** — restrict the selection to `seq < s` (combined with `limit`: the - newest `n` chunks below `s`, ascending). This is the "Show earlier messages" page-in - path for history the FE's local cache doesn't have (e.g. a fresh browser that - initial-loaded with `limit`). `beforeSeq` + `sinceSeq` together = `sinceSeq < seq < s` - (we only ever send one of them, but defined semantics beat undefined). - -And one additive response field on `ConversationHistoryResponse`: - -3. **`earliestSeq?: number`** (or `hasOlder: boolean` — your pick, flag your choice in the - reply) — the conversation's overall lowest seq (or whether chunks exist below the - returned window). The FE needs to know whether to OFFER "Show earlier messages" when - its local cache is exhausted. Without it the FE can only guess (seq 1 = start works if - seqs are guaranteed to start at 1 and be gap-free — if you'd rather just CONFIRM that - invariant in writing, the FE can derive `hasOlder` from `chunks[0].seq > 1` and we skip - the new field entirely; cheapest option, totally fine). - -## How the FE will consume it - -- Fresh load (empty cache): `GET /conversations/:id?sinceSeq=0&limit=<floor(0.75×L)>`. -- Incremental tail sync (cache warm): unchanged `?sinceSeq=<maxCachedSeq>` (no limit — the - tail since last sync is small by construction). -- Show-earlier beyond local cache: `GET /conversations/:id?beforeSeq=<oldestLoadedSeq>&limit=<ceil(L/4)>`. -- The FE's IndexedDB cache is seq-keyed + dedup-by-seq and already tolerates a - non-contiguous prefix (a windowed suffix), so no cache-format change is needed FE-side. - -## Priority / sequencing - -Not a blocker — the FE ships the limit feature against the current contract (full fetch + -in-memory windowing) and lights up the `limit`/`beforeSeq` params when you ship. Ship -whenever convenient; please bump `transport-contract` and note the params in the reply -handoff so the FE re-pins + re-mirrors. diff --git a/backend-handoff-cwd-lsp.md b/backend-handoff-cwd-lsp.md deleted file mode 100644 index d896deb..0000000 --- a/backend-handoff-cwd-lsp.md +++ /dev/null @@ -1,61 +0,0 @@ -# FE handoff — cwd + LSP consumed; please VERIFY these backend behaviors - -> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user. -> Focused courier doc (the living seam is `backend-handoff.md`). `lsp references` does not span the -> two repos, so this is the cross-repo channel. Re: your `frontend-lsp-cwd-handoff.md` -> (`[email protected]`). - -## What the FE built (so you know what's now exercising your endpoints) - -A new `workspace` feature consumes the cwd + LSP endpoints: -- **cwd field** in the Model sidebar panel — `GET /conversations/:id/cwd` to seed, `PUT` to set. -- **"Language Servers" sidebar view** — `GET /conversations/:id/lsp`, rendering each `LspServerInfo` - as a `connected`/`starting`/`error`/`not-started` badge (spinner while transient, `error` text shown - inline), with a manual Refresh. Loaded on mount and whenever the cwd changes. -- The FE **normalizes the untyped LSP body** at the network seam (a missing/partial `servers` ⇒ `[]`), - so a malformed response can't crash the UI. - -**Key design point that drives the asks below:** the FE lets the user set the cwd / view LSP **for a -DRAFT conversation that has not sent any message yet.** A draft already has a stable, client-minted -`conversationId` (the FE mints ids and sends them on `chat.send`); that same id is reused when the -draft is promoted on first send. So a cwd set on a draft must carry into its first real turn. - -## Please CONFIRM / ensure correct - -1. **Unseen-id graceful reads (CRITICAL).** For a `conversationId` the backend has **never seen** - (a fresh draft id — no `/chat`, no prior write): - - `GET /conversations/:id/cwd` ⇒ **`200 { conversationId, cwd: null }`** (not 404/500). - - `GET /conversations/:id/lsp` ⇒ **`200 { conversationId, cwd: null, servers: [] }`** (not 404/500). - The FE polls both for drafts on app load / panel mount. If an unseen id errors, the draft - Language-Servers panel shows a spurious error and the cwd field can't seed. Your handoff says - "cwd is null until set," which implies this — please confirm it holds for a **brand-new** id. - -2. **`PUT /conversations/:id/cwd` on an unseen/draft id persists it.** A `PUT` with a client-minted id - that has had no `/chat` yet should `200` and persist, keyed purely by id (the conversation need not - "exist" yet). Confirm the cwd store doesn't require a prior turn / row. - -3. **cwd defaulting carries the draft cwd into turn 1.** Sequence: FE `PUT /conversations/D/cwd {cwd}` - → then `chat.send`/`POST /chat` with `conversationId: D` and **no `cwd` field**. Per your handoff's - "cwd defaulting," that turn must run in the persisted `D` cwd. Confirm this works when the cwd PUT is - the FIRST thing that ever touched conversation `D`. - -4. **CORS preflight for `PUT`.** The handoff says CORS now allows `PUT`; please confirm the browser - **preflight** (`OPTIONS /conversations/:id/cwd` with `Access-Control-Request-Method: PUT`) is - answered, not just the `PUT` itself — otherwise the browser blocks the request before it's sent. - -5. **No spawn when cwd is null.** `GET /lsp` with `cwd: null` returns `servers: []` **without** spawning - any language server (so draft polling never spawns). Confirm the lazy spawn only happens once a cwd - is set. - -6. **Error body shape.** On a 4xx/5xx the FE reads `{ error: string }` (e.g. the `400` from an - empty-cwd `PUT`). Confirm error responses use that shape so the FE surfaces the reason. - -## FE behavior notes (no action needed — FYI) -- LSP status is **HTTP-polled** (panel mount / cwd change / manual Refresh). A WS/surface push for LSP - status would let the FE drop the manual refresh and reflect live state flips — listed as a future ask - in `backend-handoff.md` §3, NOT requested now. -- The FE shows the `LspServerInfo.error` text verbatim (e.g. `ENOENT ... posix_spawn`), per your - operational note about binaries needing to be on the daemon PATH. - -**None of these are blocking** — they are correctness confirmations for the draft path the FE now -exercises. If (1) or (3) don't hold as assumed, that's the one thing that would need a backend change. diff --git a/backend-handoff.md b/backend-handoff.md deleted file mode 100644 index 2768493..0000000 --- a/backend-handoff.md +++ /dev/null @@ -1,106 +0,0 @@ -# Backend handoff — LIVING doc (FE ⇄ backend, couriered by the user) - -> **Purpose:** the single rolling document the FE orchestrator keeps current so the user can hand off -> the whole FE↔backend seam at any time — on completion OR at a roadblock. Updated continuously. -> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user. -> `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here. - -_Last updated: 2026-06-22 (context window + percentage-based compact consumed). -**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** 686 tests green. -**Open asks: NONE.** All CRs resolved (CR-1 through CR-6) + context-window + compact-percent -handoff consumed._ - ---- - -## 1. Pinned backend contracts (consumed by the FE) - - -| Package | Used for | -|---|---| -| `@dispatch/ui-contract` | surfaces + surface WS protocol | -| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs`, `done.contextSize`/`TurnMetrics.contextSize`, `ReasoningEffort`, `QueuedMessage`/`QueuePayload`/`TurnSteeringEvent`, `ConversationMeta`/`ConversationStatus` | -| `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + `QueueRequest`/`QueueResponse`/`ChatQueueMessage` + `ConversationOpenMessage`/`ConversationStatusChangedMessage`/`ConversationListResponse`/`LastMessageResponse`/`OpenConversationResponse`/`SetTitleRequest`/`TitleResponse` + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + WS chat ops + `WsClientMessage`/`WsServerMessage` | - -Endpoints in use (HTTP **24203**, WS **24205**, CORS `*` incl. `PUT`): -`POST /chat` (NDJSON) · `GET /models` · -`GET /conversations/:id?sinceSeq=<n>&beforeSeq=<s>&limit=<k>` (CR-5 windowing) · -`GET /conversations/:id/metrics` · `GET`/`PUT /conversations/:id/cwd` · -`GET`/`PUT /conversations/:id/reasoning-effort` (sticky thinking-depth; `null` ⇒ default `high`) · -`GET /conversations/:id/lsp` · `POST /chat/warm` · `POST /conversations/:id/close` (explicit -tab-close: abort turn + stop/disable warming) · `POST /conversations/:id/queue` (enqueue -steering message; auto-starts a turn if idle) · WS `chat.send`→`chat.delta` · -WS `chat.subscribe`/`chat.unsubscribe` (watch a conversation's turns without sending; replay + live) · -WS `chat.queue` (enqueue steering; fire-and-forget — surface updates on success) · -WS `conversation.open` (broadcast: CLI `--open` flag signals the FE to open/focus a tab) · -WS `conversation.statusChanged` (broadcast: lifecycle status change — `active`/`idle`/`closed`). - -Mirrored in-repo for headless agents: `.dispatch/{ui-contract,wire,transport-contract}.reference.md` -(regenerate on any contract bump; all current as of `[email protected]` / -`[email protected]` / `[email protected]`). - -### FE invariants to keep (don't regress) - -- **`chat.send` must omit `cwd`** (send `undefined`), never `cwd:""`/`cwd:null`. The `/chat` `cwd` - field treats any non-`undefined` value as "provided". Verified safe: `chat/store.svelte.ts` builds - `chat.send` with only `type`/`conversationId`/`message`/`model` — no `cwd` field. -- **Per-conversation seqs are 1-based, monotonic, gap-free** (CR-5 contractual guarantee on - `StoredChunk`). The FE derives `hasOlder = oldestLoaded.seq > 1`. -- **Warming opt-in is NOT re-hydrated across a backend restart** — a conversation reads disabled - until toggled again (fail-safe). Backend offered boot hydration if it becomes a product need. - ---- - -## 2. Open asks FOR THE BACKEND - -### CR-6 — Assign seq during generation → **RESOLVED ✅** (backend shipped; FE adoption pending) - -The backend now persists chunks **incrementally at step boundaries** during generation: -1. Turn starts → user message is `append`ed immediately (gets seq). -2. Each step completes → step's messages are `append`ed immediately (get seq). -3. Turn seals → `turn-sealed` emitted (no batch append needed — already persisted). - -`GET /conversations/:id?sinceSeq=N` returns committed, seq'd chunks **during generation**. The -FE's existing `syncTail` already polls this — it will find new chunks as each step completes. No -wire/transport-contract change needed (`StoredChunk` already has `seq`; `AgentEvent` types unchanged). - -**FE adoption: NOT pursuing syncTail-during-generation.** Investigation revealed -the kernel emits `step-complete` (line 360 of `run-turn.ts`) BEFORE calling -`onStepComplete` (line 542) — the step's chunks are persisted only AFTER tool -results come back, not when `step-complete` fires. So `syncTail` triggered by -`step-complete` finds nothing. Moving the emission after `onStepComplete` would -be a kernel change. - -Instead, the FE now trims provisional chunks directly in `trimTranscript` when -committed is exhausted — no `syncTail` needed. Dropped provisional chunks are -lost temporarily (no "Show earlier" for them) but come back as committed when -the turn seals and `syncTail` fetches everything. - ---- - -### Resolved CRs (for reference) - -| CR | Summary | Status | -|---|---|---| -| CR-1 | Loaded Extensions as a true table (`rendererId: "table"`) | ✅ shipped + consumed | -| CR-2 | catalog `scope` flag (`"global"` / `"conversation"`) | ✅ `[email protected]` | -| CR-3 | `user-message` event (watcher sees user prompt mid-turn) | ✅ `[email protected]` | -| CR-4 | cache-warming lifecycle (default OFF, future `nextWarmAt`, `POST /close`) | ✅ `[email protected]` | -| CR-5 | history windowing (`?limit=`, `?beforeSeq=`, 1-based gap-free seqs) | ✅ `[email protected]` / `[email protected]` | -| CR-6 | Assign seq during generation (incremental persist at step boundaries) | ✅ shipped; FE adoption pending | - ---- - -## 3. Likely NEXT backend asks (heads-up, not yet requested) - -- **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns - `modelInfo[model].contextWindow`. The Composer uses the real value (falls back to - 1,000,000 when absent). The hardcoded `MAX_CONTEXT` is gone. -- **Percentage-based auto-compact** → **CONSUMED ✅** — `compact-threshold` endpoint - renamed to `compact-percent`; field is now `percent` (0-100, default 85, 0 = manual). - CompactionView UI updated from token count to percent input (0-100). -- **`GET /conversations`** — conversation list / sidebar (history explorer / switcher); could also - expose a per-conversation "last model" so a reopened tab seeds its model from the server. -- **LSP status over WS** (push) — today the FE HTTP-polls `GET /conversations/:id/lsp` on panel mount - / cwd change + a manual refresh; a live surface/WS push would remove the manual refresh and reflect - a server flipping to `error`/`connected` without a reload. @@ -1,18 +1,18 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", - "assist": { "actions": { "source": { "organizeImports": "on" } } }, - "linter": { "enabled": true, "rules": { "recommended": true } }, - "formatter": { "enabled": true, "indentStyle": "tab", "lineWidth": 100 }, - "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always" } }, - "css": { "parser": { "tailwindDirectives": true } }, - "files": { - "includes": [ - "**", - "!**/node_modules", - "!**/dist", - "!**/build", - "!**/*.svelte", - "!**/src/themes" - ] - } + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "linter": { "enabled": true, "rules": { "recommended": true } }, + "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, + "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always" } }, + "css": { "parser": { "tailwindDirectives": true } }, + "files": { + "includes": [ + "**", + "!**/node_modules", + "!**/dist", + "!**/build", + "!**/*.svelte", + "!**/src/themes" + ] + } } @@ -5,9 +5,9 @@ "": { "name": "dispatch-web", "dependencies": { - "@dispatch/transport-contract": "file:../arch-rewrite/packages/transport-contract", - "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract", - "@dispatch/wire": "file:../arch-rewrite/packages/wire", + "@dispatch/transport-contract": "file:../backend/packages/transport-contract", + "@dispatch/ui-contract": "file:../backend/packages/ui-contract", + "@dispatch/wire": "file:../backend/packages/wire", "dompurify": "^3.4.5", "highlight.js": "^11.11.1", "marked": "^18.0.4", @@ -24,6 +24,8 @@ "daisyui": "^5.5.20", "fake-indexeddb": "^6.0.0", "jsdom": "^25.0.0", + "prettier": "^3.8.5", + "prettier-plugin-svelte": "^4.1.1", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "tailwindcss": "^4.3.0", @@ -34,8 +36,8 @@ }, }, "overrides": { - "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract", - "@dispatch/wire": "file:../arch-rewrite/packages/wire", + "@dispatch/ui-contract": "file:../backend/packages/ui-contract", + "@dispatch/wire": "file:../backend/packages/wire", }, "packages": { "@adobe/css-tools": ["@adobe/[email protected]", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], @@ -48,23 +50,23 @@ "@babel/runtime": ["@babel/[email protected]", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], - "@biomejs/biome": ["@biomejs/[email protected]", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], + "@biomejs/biome": ["@biomejs/[email protected]", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.1", "@biomejs/cli-darwin-x64": "2.5.1", "@biomejs/cli-linux-arm64": "2.5.1", "@biomejs/cli-linux-arm64-musl": "2.5.1", "@biomejs/cli-linux-x64": "2.5.1", "@biomejs/cli-linux-x64-musl": "2.5.1", "@biomejs/cli-win32-arm64": "2.5.1", "@biomejs/cli-win32-x64": "2.5.1" }, "bin": { "biome": "bin/biome" } }, "sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w=="], - "@biomejs/cli-darwin-x64": ["@biomejs/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw=="], - "@biomejs/cli-linux-x64": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A=="], - "@biomejs/cli-win32-arm64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], + "@biomejs/cli-win32-arm64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ=="], - "@biomejs/cli-win32-x64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + "@biomejs/cli-win32-x64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg=="], "@csstools/color-helpers": ["@csstools/[email protected]", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], @@ -76,11 +78,11 @@ "@csstools/css-tokenizer": ["@csstools/[email protected]", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], - "@dispatch/transport-contract": ["@dispatch/transport-contract@file:../arch-rewrite/packages/transport-contract", { "dependencies": { "@dispatch/ui-contract": "workspace:*", "@dispatch/wire": "workspace:*" } }], + "@dispatch/transport-contract": ["@dispatch/transport-contract@file:../backend/packages/transport-contract", { "dependencies": { "@dispatch/ui-contract": "workspace:*", "@dispatch/wire": "workspace:*" } }], - "@dispatch/ui-contract": ["@dispatch/ui-contract@file:../arch-rewrite/packages/ui-contract", {}], + "@dispatch/ui-contract": ["@dispatch/ui-contract@file:../backend/packages/ui-contract", {}], - "@dispatch/wire": ["@dispatch/wire@file:../arch-rewrite/packages/wire", {}], + "@dispatch/wire": ["@dispatch/wire@file:../backend/packages/wire", {}], "@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], @@ -144,101 +146,101 @@ "@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], - "@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw=="], + "@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], - "@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA=="], + "@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], - "@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ=="], + "@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], - "@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw=="], + "@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], - "@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg=="], + "@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], "@sveltejs/acorn-typescript": ["@sveltejs/[email protected]", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], - "@sveltejs/load-config": ["@sveltejs/[email protected]", "", {}, "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA=="], + "@sveltejs/load-config": ["@sveltejs/[email protected]", "", {}, "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/[email protected]", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="], "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/[email protected]", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="], - "@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], + "@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="], - "@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], + "@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.1", "@tailwindcss/oxide-darwin-arm64": "4.3.1", "@tailwindcss/oxide-darwin-x64": "4.3.1", "@tailwindcss/oxide-freebsd-x64": "4.3.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", "@tailwindcss/oxide-linux-x64-musl": "4.3.1", "@tailwindcss/oxide-wasm32-wasi": "4.3.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/[email protected]", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/[email protected]", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA=="], - "@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="], + "@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "dependencies": { "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ=="], "@testing-library/dom": ["@testing-library/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/jest-dom": ["@testing-library/[email protected]", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], - "@testing-library/svelte": ["@testing-library/[email protected]", "", { "dependencies": { "@testing-library/dom": "9.x.x || 10.x.x", "@testing-library/svelte-core": "1.0.0" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", "vite": "*", "vitest": "*" }, "optionalPeers": ["vite", "vitest"] }, "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w=="], + "@testing-library/svelte": ["@testing-library/[email protected]", "", { "dependencies": { "@testing-library/dom": "9.x.x || 10.x.x", "@testing-library/svelte-core": "1.1.3" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", "vite": "*", "vitest": "*" }, "optionalPeers": ["vite", "vitest"] }, "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ=="], - "@testing-library/svelte-core": ["@testing-library/[email protected]", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ=="], + "@testing-library/svelte-core": ["@testing-library/[email protected]", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g=="], "@testing-library/user-event": ["@testing-library/[email protected]", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], @@ -268,7 +270,7 @@ "@vitest/utils": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg=="], - "acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], "agent-base": ["[email protected]", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -276,7 +278,7 @@ "ansi-styles": ["[email protected]", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "aria-query": ["[email protected]", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + "aria-query": ["[email protected]", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], "assertion-error": ["[email protected]", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -302,7 +304,7 @@ "cssstyle": ["[email protected]", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], - "daisyui": ["[email protected]", "", {}, "sha512-HemJcjl0Gk9rQ8BcgofN6p+EURrqftQG9wK1Hkxs98i49xe68+QxpNvry+PyxwkIUgrbMpNmZ5ZWjmtffAjfhQ=="], + "daisyui": ["[email protected]", "", {}, "sha512-QvdtXnQ/tD5a18Y/+NJkJ1+ggwRtMF84GHTHCCAto5SNqYwZj8SUQQviKMpe1cKR7vMo/evTBDExkYd19KloBQ=="], "data-urls": ["[email protected]", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], @@ -324,11 +326,11 @@ "dom-accessibility-api": ["[email protected]", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], - "dompurify": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="], + "dompurify": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], "dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="], + "enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], "entities": ["[email protected]", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -346,17 +348,17 @@ "esm-env": ["[email protected]", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], - "esrap": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ=="], + "esrap": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="], "estree-walker": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "expect-type": ["[email protected]", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "expect-type": ["[email protected]", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], "fake-indexeddb": ["[email protected]", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], "fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "form-data": ["[email protected]", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "form-data": ["[email protected]", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], "fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -450,9 +452,9 @@ "ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], - "nwsapi": ["[email protected]", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], + "nwsapi": ["[email protected]", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="], "parse5": ["[email protected]", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -464,7 +466,11 @@ "picomatch": ["[email protected]", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + + "prettier": ["[email protected]", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-ppiDo2CSwexck1eyZUwJHg/N3nf1+6IRCv7W/VJ5vaLnVCmB7+3CdRfMwoCHBBX6xTrREDTksZ4OZl5SSf4zXA=="], + + "prettier-plugin-svelte": ["[email protected]", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^5.0.0" } }, "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA=="], "pretty-format": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], @@ -476,7 +482,7 @@ "redent": ["[email protected]", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], - "rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.61.1", "@rollup/rollup-android-arm64": "4.61.1", "@rollup/rollup-darwin-arm64": "4.61.1", "@rollup/rollup-darwin-x64": "4.61.1", "@rollup/rollup-freebsd-arm64": "4.61.1", "@rollup/rollup-freebsd-x64": "4.61.1", "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", "@rollup/rollup-linux-arm-musleabihf": "4.61.1", "@rollup/rollup-linux-arm64-gnu": "4.61.1", "@rollup/rollup-linux-arm64-musl": "4.61.1", "@rollup/rollup-linux-loong64-gnu": "4.61.1", "@rollup/rollup-linux-loong64-musl": "4.61.1", "@rollup/rollup-linux-ppc64-gnu": "4.61.1", "@rollup/rollup-linux-ppc64-musl": "4.61.1", "@rollup/rollup-linux-riscv64-gnu": "4.61.1", "@rollup/rollup-linux-riscv64-musl": "4.61.1", "@rollup/rollup-linux-s390x-gnu": "4.61.1", "@rollup/rollup-linux-x64-gnu": "4.61.1", "@rollup/rollup-linux-x64-musl": "4.61.1", "@rollup/rollup-openbsd-x64": "4.61.1", "@rollup/rollup-openharmony-arm64": "4.61.1", "@rollup/rollup-win32-arm64-msvc": "4.61.1", "@rollup/rollup-win32-ia32-msvc": "4.61.1", "@rollup/rollup-win32-x64-gnu": "4.61.1", "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA=="], + "rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], "rrweb-cssom": ["[email protected]", "", {}, "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg=="], @@ -498,13 +504,13 @@ "strip-literal": ["[email protected]", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], - "svelte": ["[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.11", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-1lDf8TLqpxyAt3xgybfytWPJQbaUD6TiDgpiCLH0BKrKEwzecB9pjuNVnEJMpzH018xUzo6oxheK2HT0oa2RoQ=="], + "svelte": ["[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="], - "svelte-check": ["[email protected]", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "0.1.1", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ=="], + "svelte-check": ["[email protected]", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.0", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg=="], "symbol-tree": ["[email protected]", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], - "tailwindcss": ["[email protected]", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], + "tailwindcss": ["[email protected]", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], "tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], @@ -558,19 +564,19 @@ "zimmerframe": ["[email protected]", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], - "@dispatch/transport-contract/@dispatch/ui-contract": ["@dispatch/ui-contract@file:../arch-rewrite/packages/ui-contract", {}], + "@dispatch/transport-contract/@dispatch/ui-contract": ["@dispatch/ui-contract@file:../backend/packages/ui-contract", {}], - "@dispatch/transport-contract/@dispatch/wire": ["@dispatch/wire@file:../arch-rewrite/packages/wire", {}], + "@dispatch/transport-contract/@dispatch/wire": ["@dispatch/wire@file:../backend/packages/wire", {}], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -581,7 +587,5 @@ "cssstyle/rrweb-cssom": ["[email protected]", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], "strip-literal/js-tokens": ["[email protected]", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], - - "svelte/aria-query": ["[email protected]", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], } } diff --git a/notes/assumptions-log.md b/notes/assumptions-log.md new file mode 100644 index 0000000..026cde2 --- /dev/null +++ b/notes/assumptions-log.md @@ -0,0 +1,58 @@ +# Assumptions log (frontend) + +> Recorded while working autonomously (user away). Raise these when the user returns. + +## 2026-06-24 — CR-10: workspaceId on conversation.open / statusChanged + +1. **Contract version already at 0.19.0 in the backend repo.** The `file:` dep in `package.json` + points to `../arch-rewrite/packages/transport-contract`, which was already at `0.19.0` when the + handoff arrived. No `package.json` version string needed changing — `bun install` re-synced the + symlink. If the backend repo is later rolled back, the FE will fail to compile (the new required + `workspaceId` field won't exist on the type). + +2. **The `dist/` directory is owned by root** (likely from a previous root-run build). `bun run build` + fails with `EACCES` when Vite tries to empty `dist/assets`. This is an environment issue, not a + code issue — I verified the build succeeds with `--outDir dist-tmp` (a fresh directory I own, then + removed). The user may want to `sudo chown -R tradam:tradam dist/` or remove it. + +3. **Parser now rejects broadcasts missing `workspaceId`.** The WS parser (`logic.ts`) returns `null` + for `conversation.open` / `conversation.statusChanged` messages that lack a string `workspaceId`. + This is a breaking change for a mixed-version setup (old backend + new FE) — those broadcasts + would be silently dropped. Assumed acceptable because the backend contract is updated in lockstep. + +4. **Tab is opened but not focused when the conversation's workspace differs from the active one.** + The FE creates the tab (stamped with the correct `workspaceId`) but does NOT navigate to that + workspace or switch the active tab. The tab is hidden by the `tabs` getter filter + (`t.workspaceId === activeWorkspaceId`) until the user navigates to the correct workspace. This + matches the original "open without switching" behavior of the `conversation.open` handler. If the + product wants auto-navigation to the conversation's workspace on `--open`, that's a separate FE + product decision. + +5. **CR-10 numbering.** The prior open ask was CR-9 (still open). I assigned this fix CR-10 in the + handoff doc for tracking. If the backend used a different CR number, adjust. + +## 2026-06-24 — CR-11: Per-conversation model persistence + +1. **`refreshModel()` is called on every focus change** (boot, tab switch, workspace switch, + reconnect) mirroring `refreshCwd` / `refreshReasoningEffort` / `refreshCompactPercent`. For a + draft conversation the `GET /conversations/:id/model` request will 404 (the draft id isn't + persisted yet); `res.ok` is false so it's a silent no-op. This matches how the other refresh + functions behave for drafts. + +2. **`refreshModel` only applies a non-empty string model.** The backend returns `model: null` when + never set; the FE leaves `activeModel` unchanged in that case (falls back to the boot default or + the last-known tab model). This avoids resetting the selector to `null`/`undefined` and crashing + `ModelSelector`'s `splitModelName`. An empty string is also ignored defensively. + +3. **`selectModel` persists only for real conversation tabs, not drafts.** A draft has no persisted + conversation id yet; the first `chat.send` will carry `model` (the chat store still sends it), + and the backend persists it on turn start. Drafts update session-local state only — same as the + pre-change behavior. + +4. **No "clear model" UI affordance added.** The backend supports `PUT /model` with `{ model: null }`, + but the FE model selector has no "reset to default" button. This is a future product decision; the + persistence path is ready if/when the UI adds it. + +5. **No `model` field on `ConversationMeta`.** Following the precedent of `cwd` and `reasoningEffort` + (fetched via dedicated endpoints, not on the list response). The FE fetches model on focus, not + from `fetchOpenConversations`. diff --git a/package.json b/package.json index acef44b..f772728 100644 --- a/package.json +++ b/package.json @@ -1,47 +1,49 @@ { - "name": "dispatch-web", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "typecheck": "svelte-check --tsconfig ./tsconfig.json", - "test": "vitest run --passWithNoTests", - "test:watch": "vitest", - "check": "biome check .", - "check:fix": "biome check --write ." - }, - "dependencies": { - "@dispatch/transport-contract": "file:../arch-rewrite/packages/transport-contract", - "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract", - "@dispatch/wire": "file:../arch-rewrite/packages/wire", - "dompurify": "^3.4.5", - "highlight.js": "^11.11.1", - "marked": "^18.0.4", - "marked-highlight": "^2.2.4" - }, - "overrides": { - "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract", - "@dispatch/wire": "file:../arch-rewrite/packages/wire" - }, - "devDependencies": { - "@biomejs/biome": "^2.4.16", - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "@tailwindcss/vite": "^4.3.0", - "@testing-library/jest-dom": "^6.6.0", - "@testing-library/svelte": "^5.2.0", - "@testing-library/user-event": "^14.6.1", - "@tsconfig/svelte": "^5.0.0", - "daisyui": "^5.5.20", - "fake-indexeddb": "^6.0.0", - "jsdom": "^25.0.0", - "svelte": "^5.0.0", - "svelte-check": "^4.0.0", - "tailwindcss": "^4.3.0", - "typescript": "^5.7.0", - "vite": "^6.0.0", - "vitest": "^3.0.0" - } + "name": "dispatch-web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest", + "check": "biome check .", + "check:fix": "biome check --write ." + }, + "dependencies": { + "@dispatch/transport-contract": "file:../backend/packages/transport-contract", + "@dispatch/ui-contract": "file:../backend/packages/ui-contract", + "@dispatch/wire": "file:../backend/packages/wire", + "dompurify": "^3.4.5", + "highlight.js": "^11.11.1", + "marked": "^18.0.4", + "marked-highlight": "^2.2.4" + }, + "overrides": { + "@dispatch/ui-contract": "file:../backend/packages/ui-contract", + "@dispatch/wire": "file:../backend/packages/wire" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.16", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.3.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/svelte": "^5.2.0", + "@testing-library/user-event": "^14.6.1", + "@tsconfig/svelte": "^5.0.0", + "daisyui": "^5.5.20", + "fake-indexeddb": "^6.0.0", + "jsdom": "^25.0.0", + "prettier": "^3.8.5", + "prettier-plugin-svelte": "^4.1.1", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.3.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + } } diff --git a/scripts/fix-dist-perms.sh b/scripts/fix-dist-perms.sh new file mode 100755 index 0000000..471cbdf --- /dev/null +++ b/scripts/fix-dist-perms.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Fix ownership of dist/ so Vite can clean + rebuild it. +# The dist/assets/ dir was created as root (likely a Docker build) and Vite +# can't rmSync it as a non-root user → EACCES on `bun run build`. +# +# Usage: sudo ./scripts/fix-dist-perms.sh +set -euo pipefail + +DIST_DIR="$(cd "$(dirname "$0")/.." && pwd)/dist" + +if [ ! -d "$DIST_DIR" ]; then + echo "No dist/ directory found — nothing to fix." + exit 0 +fi + +OWNER="$(stat -c '%U:%G' "$DIST_DIR")" +echo "dist/ is currently owned by: $OWNER" + +if [ "$OWNER" = "root:root" ]; then + echo "Fixing ownership to $(stat -c '%U:%G' "$(dirname "$DIST_DIR")") ..." +fi + +# chown the whole dist/ tree to the same owner as the repo root +chown -R --reference="$(dirname "$DIST_DIR")" "$DIST_DIR" +echo "Done. dist/ is now owned by: $(stat -c '%U:%G' "$DIST_DIR")" diff --git a/scripts/live-probe-provider-retry.ts b/scripts/live-probe-provider-retry.ts new file mode 100644 index 0000000..952163c --- /dev/null +++ b/scripts/live-probe-provider-retry.ts @@ -0,0 +1,188 @@ +/** + * scripts/live-probe-provider-retry.ts — FOCUSED live probe of the transient + * `provider-retry` AgentEvent seam, run against a RUNNING backend (bin/up). + * NOT part of `bun run test`. + * + * A real `provider-retry` only fires on an upstream 429/5xx, which we can't + * force from here. So this probe verifies the two things unit tests CAN'T: + * + * 1. REGRESSION (real wire): a normal text turn through the REAL WS socket + + * the updated `foldEvent` (provider-retry case + the reduceEvent wrapper) + * seals cleanly and `providerRetry` stays NULL throughout — no spurious + * banner, and the wrapper's re-spread didn't break streaming. + * 2. PARSER + REDUCER SEAM (the new event's effectful boundary): feed a + * synthetic `provider-retry` `chat.delta` JSON string through the REAL + * `parseServerMessage` wire parser (the function that runs on every inbound + * WS frame) → confirm it is ACCEPTED (not rejected as an unknown event) → + * `foldEvent` SETS `providerRetry` (coalesces on a 2nd) and adds NO chunk → + * a subsequent `text-delta` CLEARS it. This proves the new variant survives + * the JSON-parse boundary the unit tests skip (they pass constructed events). + * + * bun scripts/live-probe-provider-retry.ts + * PROBE_MODEL=opencode/glm-5.2 bun scripts/live-probe-provider-retry.ts + */ +import type { ChatDeltaMessage, ChatErrorMessage } from "@dispatch/transport-contract"; +import type { SurfaceServerMessage } from "@dispatch/ui-contract"; +import { createSurfaceSocket } from "../src/adapters/ws/index.ts"; +import { parseServerMessage } from "../src/adapters/ws/logic.ts"; +import { + foldEvent, + initialState, + selectChunks, + selectProviderRetry, +} from "../src/core/chunks/index.ts"; + +const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205"; +const MODEL = process.env.PROBE_MODEL ?? "opencode/deepseek-v4-flash"; +const PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: ok"; + +type ChatMsg = ChatDeltaMessage | ChatErrorMessage; + +const checks: { name: string; ok: boolean; detail?: string }[] = []; +const record = (name: string, ok: boolean, detail?: string) => { + checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) }); + console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`); +}; +const fail = (msg: string): never => { + console.error(`\n[probe] FATAL: ${msg}`); + process.exit(1); +}; + +/** A chat.delta JSON frame carrying the given AgentEvent, exactly as the backend sends. */ +function deltaFrame(event: ChatDeltaMessage["event"]): string { + return JSON.stringify({ type: "chat.delta", event } satisfies ChatDeltaMessage); +} + +async function main() { + console.log(`[probe] provider-retry seam · model=${MODEL} · WS=${WS_URL}\n`); + + // ─── 1. REGRESSION: a real text turn through the updated foldEvent ────────── + // Routed by conversationId via a per-conv handler map (same pattern as live-probe.ts). + const handlers = new Map<string, (msg: ChatMsg) => void>(); + const socket = createSurfaceSocket({ + url: WS_URL, + onMessage: (_m: SurfaceServerMessage) => {}, + onChat: (msg: ChatMsg) => { + const id = msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId; + const h = id !== undefined ? handlers.get(id) : undefined; + h?.(msg); + }, + }); + await new Promise((r) => setTimeout(r, 500)); + + const conversationId = crypto.randomUUID(); + let state = initialState(); + let deltas = 0; + let sealed = false; + let error: string | null = null; + const done = Promise.withResolvers<void>(); + handlers.set(conversationId, (msg) => { + if (msg.type === "chat.error") { + error = msg.message; + done.resolve(); + return; + } + deltas++; + state = foldEvent(state, msg.event); + if (msg.event.type === "turn-sealed") { + sealed = true; + done.resolve(); + } + }); + + socket.send({ type: "chat.send", conversationId, message: PROMPT, model: MODEL }); + const timeout = setTimeout(() => done.resolve(), 90_000); + await done.promise; + clearTimeout(timeout); + handlers.delete(conversationId); + + record( + "regression: a real text turn sealed cleanly", + sealed && error === null, + `${deltas} deltas${error ? ` err=${error}` : ""}`, + ); + record( + "regression: providerRetry stayed NULL through a normal turn (no spurious banner)", + selectProviderRetry(state) === null, + ); + + // ─── 2. PARSER + REDUCER SEAM: synthetic provider-retry through the REAL parser ─ + console.log("\n[probe] parser+reducer seam (synthetic provider-retry)"); + let s = initialState(); + s = foldEvent(s, { type: "turn-start", conversationId, turnId: "t1" }); + + const retryJson = deltaFrame({ + type: "provider-retry", + conversationId, + turnId: "t1", + attempt: 0, + delayMs: 5000, + message: 'HTTP 429: {"error":{"type":"overloaded_error","message":"overloaded"}}', + code: "429", + }); + const parsed1 = parseServerMessage(retryJson); + record( + "REAL parseServerMessage ACCEPTS a provider-retry chat.delta (not rejected as unknown)", + parsed1 !== null && parsed1.type === "chat.delta" && parsed1.event.type === "provider-retry", + parsed1 ? `event.type=${(parsed1 as { event: { type: string } }).event.type}` : "parsed=null", + ); + + if (parsed1 !== null && parsed1.type === "chat.delta") s = foldEvent(s, parsed1.event); + const retry1 = selectProviderRetry(s); + record( + "foldEvent SETS providerRetry from the PARSED event", + retry1 !== null && retry1.attempt === 0 && retry1.delayMs === 5000 && retry1.code === "429", + retry1 ? `attempt=${retry1.attempt} delay=${retry1.delayMs}ms code=${retry1.code}` : "null", + ); + record( + "provider-retry adds NO chunk (never persisted — never pollutes the prompt)", + selectChunks(s).length === 0, + `${selectChunks(s).length} chunk(s)`, + ); + + const retry2Json = deltaFrame({ + type: "provider-retry", + conversationId, + turnId: "t1", + attempt: 1, + delayMs: 10000, + message: "HTTP 429: still overloaded", + code: "429", + }); + const parsed2 = parseServerMessage(retry2Json); + if (parsed2 !== null && parsed2.type === "chat.delta") s = foldEvent(s, parsed2.event); + const retry2 = selectProviderRetry(s); + record( + "a 2nd provider-retry COALESCES (latest attempt + delay replaces previous)", + retry2 !== null && + retry2.attempt === 1 && + retry2.delayMs === 10000 && + retry2.message === "HTTP 429: still overloaded", + retry2 ? `attempt=${retry2.attempt} delay=${retry2.delayMs}ms` : "null", + ); + + const textJson = deltaFrame({ + type: "text-delta", + conversationId, + turnId: "t1", + delta: "here is the reply", + }); + const parsedText = parseServerMessage(textJson); + if (parsedText !== null && parsedText.type === "chat.delta") s = foldEvent(s, parsedText.event); + record( + "a subsequent text-delta CLEARS the banner (retry succeeded → live reply)", + selectProviderRetry(s) === null, + ); + record( + "…and the text-delta content DID land as a chunk (the reply streams normally after retries)", + selectChunks(s).some((c) => c.chunk.type === "text"), + ); + + socket.close(); + const passed = checks.filter((c) => c.ok).length; + const total = checks.length; + console.log(`\n[probe] ${passed}/${total} checks passed`); + process.exit(passed === total ? 0 : 1); +} + +main().catch((e) => fail(String(e))); diff --git a/scripts/live-probe.ts b/scripts/live-probe.ts index f44a136..6121eac 100644 --- a/scripts/live-probe.ts +++ b/scripts/live-probe.ts @@ -27,29 +27,29 @@ // browser has these natively; Bun does not). The product code is unchanged. import "fake-indexeddb/auto"; import type { - ChatDeltaMessage, - ChatErrorMessage, - ConversationHistoryResponse, - ConversationMetricsResponse, + ChatDeltaMessage, + ChatErrorMessage, + ConversationHistoryResponse, + ConversationMetricsResponse, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; import { createIdbChunkStore } from "../src/adapters/idb/index.ts"; import { createSurfaceSocket } from "../src/adapters/ws/index.ts"; import { - applyHistory, - foldEvent, - groupRenderedChunks, - initialState, - selectChunks, - selectMessages, - type TranscriptState, + applyHistory, + foldEvent, + groupRenderedChunks, + initialState, + selectChunks, + selectMessages, + type TranscriptState, } from "../src/core/chunks/index.ts"; import { - applyDurableMetrics, - foldMetricsEvent, - initialMetricsState, - type MetricsState, - selectOrderedTurnMetrics, + applyDurableMetrics, + foldMetricsEvent, + initialMetricsState, + type MetricsState, + selectOrderedTurnMetrics, } from "../src/core/metrics/index.ts"; import { createConversationCache } from "../src/features/conversation-cache/index.ts"; @@ -58,51 +58,51 @@ const HTTP_BASE = process.env.PROBE_HTTP ?? "http://localhost:24203"; const MODEL = process.env.PROBE_MODEL ?? "opencode/deepseek-v4-flash"; const TEXT_PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: hello from dispatch"; const TOOL_PROMPT = - process.env.PROBE_TOOL_PROMPT ?? - "Make two tool calls AT THE SAME TIME in a single step (parallel tool calls). " + - "For example, run two independent shell commands together: `echo alpha` and `echo beta`. " + - "If you have no shell tool, invoke any two of your available read-only tools simultaneously."; + process.env.PROBE_TOOL_PROMPT ?? + "Make two tool calls AT THE SAME TIME in a single step (parallel tool calls). " + + "For example, run two independent shell commands together: `echo alpha` and `echo beta`. " + + "If you have no shell tool, invoke any two of your available read-only tools simultaneously."; const checks: { name: string; ok: boolean; detail?: string }[] = []; const record = (name: string, ok: boolean, detail?: string) => { - checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) }); - console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`); + checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) }); + console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`); }; const note = (msg: string) => console.log(` ℹ️ ${msg}`); function fail(msg: string): never { - console.error(`\n[live-probe] FATAL: ${msg}`); - process.exit(1); + console.error(`\n[live-probe] FATAL: ${msg}`); + process.exit(1); } async function historySync( - id: string, - sinceSeq: number, - window?: { limit?: number; beforeSeq?: number }, + id: string, + sinceSeq: number, + window?: { limit?: number; beforeSeq?: number }, ): Promise<ConversationHistoryResponse> { - let url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?sinceSeq=${sinceSeq}`; - if (window?.limit !== undefined) url += `&limit=${window.limit}`; - if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`; - const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } }); - if (!res.ok) fail(`history fetch ${res.status} for ${url}`); - return (await res.json()) as ConversationHistoryResponse; + let url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?sinceSeq=${sinceSeq}`; + if (window?.limit !== undefined) url += `&limit=${window.limit}`; + if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`; + const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } }); + if (!res.ok) fail(`history fetch ${res.status} for ${url}`); + return (await res.json()) as ConversationHistoryResponse; } /** Raw history GET that returns the status (for the CR-5 validation checks). */ async function historyStatus(id: string, query: string): Promise<number> { - const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?${query}`; - const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } }); - await res.arrayBuffer(); // drain - return res.status; + const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?${query}`; + const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } }); + await res.arrayBuffer(); // drain + return res.status; } /** Durable metrics fetch — returns the response, or the HTTP status when not OK * (the endpoint is being implemented backend-side; the FE tolerates a 404). */ async function metricsSync(id: string): Promise<ConversationMetricsResponse | { status: number }> { - const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}/metrics`; - const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } }); - if (!res.ok) return { status: res.status }; - return (await res.json()) as ConversationMetricsResponse; + const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}/metrics`; + const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } }); + if (!res.ok) return { status: res.status }; + return (await res.json()) as ConversationMetricsResponse; } type ChatMsg = ChatDeltaMessage | ChatErrorMessage; @@ -110,295 +110,295 @@ type Socket = ReturnType<typeof createSurfaceSocket>; const handlers = new Map<string, (msg: ChatMsg) => void>(); function convOf(msg: ChatMsg): string | undefined { - return msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId; + return msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId; } /** Drive one turn to turn-sealed (or error), folding events into a fresh state. */ async function runTurn( - socket: Socket, - conversationId: string, - prompt: string, + socket: Socket, + conversationId: string, + prompt: string, ): Promise<{ - state: TranscriptState; - metrics: MetricsState; - deltas: number; - sealed: boolean; - error: string | null; + state: TranscriptState; + metrics: MetricsState; + deltas: number; + sealed: boolean; + error: string | null; }> { - let state = initialState(); - let metrics = initialMetricsState(); - let deltas = 0; - let sealed = false; - let error: string | null = null; - const done = Promise.withResolvers<void>(); + let state = initialState(); + let metrics = initialMetricsState(); + let deltas = 0; + let sealed = false; + let error: string | null = null; + const done = Promise.withResolvers<void>(); - handlers.set(conversationId, (msg) => { - if (msg.type === "chat.error") { - error = msg.message; - done.resolve(); - return; - } - deltas++; - state = foldEvent(state, msg.event); - metrics = foldMetricsEvent(metrics, msg.event); - if (msg.event.type === "turn-sealed") { - sealed = true; - done.resolve(); - } - }); + handlers.set(conversationId, (msg) => { + if (msg.type === "chat.error") { + error = msg.message; + done.resolve(); + return; + } + deltas++; + state = foldEvent(state, msg.event); + metrics = foldMetricsEvent(metrics, msg.event); + if (msg.event.type === "turn-sealed") { + sealed = true; + done.resolve(); + } + }); - socket.send({ type: "chat.send", conversationId, message: prompt, model: MODEL }); - const timeout = setTimeout(() => done.resolve(), 90_000); - await done.promise; - clearTimeout(timeout); - handlers.delete(conversationId); - return { state, metrics, deltas, sealed, error }; + socket.send({ type: "chat.send", conversationId, message: prompt, model: MODEL }); + const timeout = setTimeout(() => done.resolve(), 90_000); + await done.promise; + clearTimeout(timeout); + handlers.delete(conversationId); + return { state, metrics, deltas, sealed, error }; } function toolChunksOf(state: TranscriptState) { - return selectChunks(state).filter( - (c) => c.chunk.type === "tool-call" || c.chunk.type === "tool-result", - ); + return selectChunks(state).filter( + (c) => c.chunk.type === "tool-call" || c.chunk.type === "tool-result", + ); } async function main() { - console.log(`[live-probe] model=${MODEL}`); - console.log(`[live-probe] WS=${WS_URL} HTTP=${HTTP_BASE}\n`); + console.log(`[live-probe] model=${MODEL}`); + console.log(`[live-probe] WS=${WS_URL} HTTP=${HTTP_BASE}\n`); - const cache = createConversationCache(createIdbChunkStore()); + const cache = createConversationCache(createIdbChunkStore()); - let gotCatalog = false; - const socket = createSurfaceSocket({ - url: WS_URL, - onMessage: (m: SurfaceServerMessage) => { - if (m.type === "catalog") { - gotCatalog = true; - console.log(` ↳ surface catalog: ${m.catalog.length} surface(s)`); - } - }, - onChat: (msg: ChatMsg) => { - const id = convOf(msg); - const h = id !== undefined ? handlers.get(id) : undefined; - if (h) h(msg); - }, - }); + let gotCatalog = false; + const socket = createSurfaceSocket({ + url: WS_URL, + onMessage: (m: SurfaceServerMessage) => { + if (m.type === "catalog") { + gotCatalog = true; + console.log(` ↳ surface catalog: ${m.catalog.length} surface(s)`); + } + }, + onChat: (msg: ChatMsg) => { + const id = convOf(msg); + const h = id !== undefined ? handlers.get(id) : undefined; + if (h) h(msg); + }, + }); - await new Promise((r) => setTimeout(r, 500)); - record("WS connected + surface catalog received", gotCatalog); + await new Promise((r) => setTimeout(r, 500)); + record("WS connected + surface catalog received", gotCatalog); - // ─── Turn 1: text streaming + cache + replay ──────────────────────────────── - console.log(`\n[live-probe] TURN 1 (text): "${TEXT_PROMPT}"`); - const textConv = crypto.randomUUID(); - const t1 = await runTurn(socket, textConv, TEXT_PROMPT); - if (t1.error !== null) record("turn 1 had no chat.error", false, t1.error); - record("turn 1 received chat.delta events", t1.deltas > 0, `${t1.deltas} deltas`); - record("turn 1 reached turn-sealed", t1.sealed); + // ─── Turn 1: text streaming + cache + replay ──────────────────────────────── + console.log(`\n[live-probe] TURN 1 (text): "${TEXT_PROMPT}"`); + const textConv = crypto.randomUUID(); + const t1 = await runTurn(socket, textConv, TEXT_PROMPT); + if (t1.error !== null) record("turn 1 had no chat.error", false, t1.error); + record("turn 1 received chat.delta events", t1.deltas > 0, `${t1.deltas} deltas`); + record("turn 1 reached turn-sealed", t1.sealed); - let state = t1.state; - const sinceSeq = await cache.sinceSeq(textConv); - const hist = await historySync(textConv, sinceSeq); - record( - "turn 1 history endpoint returned chunks", - hist.chunks.length > 0, - `${hist.chunks.length} chunks, latestSeq=${hist.latestSeq}`, - ); - const monotonic = hist.chunks.every((c, i) => i === 0 || c.seq > (hist.chunks[i - 1]?.seq ?? -1)); - record("turn 1 history chunks are seq-monotonic", monotonic); - const merged = await cache.commit(textConv, hist.chunks); - state = applyHistory(state, merged); - record("turn 1 provisional superseded (sealedTurnId cleared)", state.sealedTurnId === null); - const cached = await cache.load(textConv); - record("turn 1 IndexedDB cache persisted the turn", cached.length === hist.chunks.length); - const committedText = selectMessages(state) - .filter((m) => m.role === "assistant") - .flatMap((m) => m.chunks) - .filter((c) => c.type === "text") - .map((c) => (c as { text: string }).text) - .join(""); - record("turn 1 committed transcript has assistant text", committedText.length > 0); + let state = t1.state; + const sinceSeq = await cache.sinceSeq(textConv); + const hist = await historySync(textConv, sinceSeq); + record( + "turn 1 history endpoint returned chunks", + hist.chunks.length > 0, + `${hist.chunks.length} chunks, latestSeq=${hist.latestSeq}`, + ); + const monotonic = hist.chunks.every((c, i) => i === 0 || c.seq > (hist.chunks[i - 1]?.seq ?? -1)); + record("turn 1 history chunks are seq-monotonic", monotonic); + const merged = await cache.commit(textConv, hist.chunks); + state = applyHistory(state, merged); + record("turn 1 provisional superseded (sealedTurnId cleared)", state.sealedTurnId === null); + const cached = await cache.load(textConv); + record("turn 1 IndexedDB cache persisted the turn", cached.length === hist.chunks.length); + const committedText = selectMessages(state) + .filter((m) => m.role === "assistant") + .flatMap((m) => m.chunks) + .filter((c) => c.type === "text") + .map((c) => (c as { text: string }).text) + .join(""); + record("turn 1 committed transcript has assistant text", committedText.length > 0); - // ─── CR-5: history windowing (?limit= / ?beforeSeq=, [email protected]) ─────── - const logLen = hist.chunks.length; - record( - "CR-5 seq origin: first chunk is seq 1 (1-based gap-free contract)", - hist.chunks[0]?.seq === 1, - `first seq=${hist.chunks[0]?.seq}`, - ); - const win = await historySync(textConv, 0, { limit: 2 }); - record( - "CR-5 ?limit=2 returns the NEWEST 2, ascending, latestSeq = window tail", - win.chunks.length === Math.min(2, logLen) && - win.chunks[0]?.seq === Math.max(1, logLen - 1) && - win.chunks[win.chunks.length - 1]?.seq === logLen && - win.latestSeq === logLen, - `seqs=[${win.chunks.map((c) => c.seq).join(",")}] latestSeq=${win.latestSeq}`, - ); - const whole = await historySync(textConv, 0, { limit: 200 }); - record( - "CR-5 ?limit= larger than the log returns everything (short-chat flow exact)", - whole.chunks.length === logLen, - `${whole.chunks.length}/${logLen} chunks`, - ); - const oldestLoaded = win.chunks[0]?.seq ?? 0; - if (oldestLoaded > 1) { - const back = await historySync(textConv, 0, { beforeSeq: oldestLoaded, limit: 50 }); - record( - "CR-5 ?beforeSeq= pages the older run (seq < bound, ascending from 1)", - back.chunks.length === oldestLoaded - 1 && - back.chunks[0]?.seq === 1 && - back.chunks.every((c) => c.seq < oldestLoaded), - `seqs=[${back.chunks.map((c) => c.seq).join(",")}]`, - ); - } - record("CR-5 limit=0 rejected with 400", (await historyStatus(textConv, "limit=0")) === 400); - record( - "CR-5 beforeSeq=-1 rejected with 400", - (await historyStatus(textConv, "beforeSeq=-1")) === 400, - ); + // ─── CR-5: history windowing (?limit= / ?beforeSeq=, [email protected]) ─────── + const logLen = hist.chunks.length; + record( + "CR-5 seq origin: first chunk is seq 1 (1-based gap-free contract)", + hist.chunks[0]?.seq === 1, + `first seq=${hist.chunks[0]?.seq}`, + ); + const win = await historySync(textConv, 0, { limit: 2 }); + record( + "CR-5 ?limit=2 returns the NEWEST 2, ascending, latestSeq = window tail", + win.chunks.length === Math.min(2, logLen) && + win.chunks[0]?.seq === Math.max(1, logLen - 1) && + win.chunks[win.chunks.length - 1]?.seq === logLen && + win.latestSeq === logLen, + `seqs=[${win.chunks.map((c) => c.seq).join(",")}] latestSeq=${win.latestSeq}`, + ); + const whole = await historySync(textConv, 0, { limit: 200 }); + record( + "CR-5 ?limit= larger than the log returns everything (short-chat flow exact)", + whole.chunks.length === logLen, + `${whole.chunks.length}/${logLen} chunks`, + ); + const oldestLoaded = win.chunks[0]?.seq ?? 0; + if (oldestLoaded > 1) { + const back = await historySync(textConv, 0, { beforeSeq: oldestLoaded, limit: 50 }); + record( + "CR-5 ?beforeSeq= pages the older run (seq < bound, ascending from 1)", + back.chunks.length === oldestLoaded - 1 && + back.chunks[0]?.seq === 1 && + back.chunks.every((c) => c.seq < oldestLoaded), + `seqs=[${back.chunks.map((c) => c.seq).join(",")}]`, + ); + } + record("CR-5 limit=0 rejected with 400", (await historyStatus(textConv, "limit=0")) === 400); + record( + "CR-5 beforeSeq=-1 rejected with 400", + (await historyStatus(textConv, "beforeSeq=-1")) === 400, + ); - // ─── Metrics: LIVE token + timing ([email protected] usage/step-complete/done) ────── - // (TurnMetricsEntry is `{ turnId, steps, total }` — the turn aggregate lives on - // `total`, present once the live `done` folded.) - const liveTurns = selectOrderedTurnMetrics(t1.metrics); - const m1 = liveTurns[0]; - const m1Total = m1?.total ?? null; - record( - "turn 1 LIVE metrics: a turn with output tokens", - m1Total !== null && m1Total.usage.outputTokens > 0, - m1Total - ? `in=${m1Total.usage.inputTokens} out=${m1Total.usage.outputTokens} steps=${m1?.steps.length}` - : "no finalized turn total", - ); - if (m1 !== undefined) { - const anyGen = m1.steps.some((s) => s.genTotalMs !== undefined); - const anyTtft = m1.steps.some((s) => s.ttftMs !== undefined); - note( - `live timing: durationMs=${m1Total?.durationMs ?? "—"}, ` + - `genTotalMs present=${anyGen}, ttftMs present=${anyTtft}`, - ); - record( - "turn 1 LIVE metrics carries timing (durationMs or step genTotalMs)", - m1Total?.durationMs !== undefined || anyGen, - "requires the backend runtime to have a clock", - ); - } + // ─── Metrics: LIVE token + timing ([email protected] usage/step-complete/done) ────── + // (TurnMetricsEntry is `{ turnId, steps, total }` — the turn aggregate lives on + // `total`, present once the live `done` folded.) + const liveTurns = selectOrderedTurnMetrics(t1.metrics); + const m1 = liveTurns[0]; + const m1Total = m1?.total ?? null; + record( + "turn 1 LIVE metrics: a turn with output tokens", + m1Total !== null && m1Total.usage.outputTokens > 0, + m1Total + ? `in=${m1Total.usage.inputTokens} out=${m1Total.usage.outputTokens} steps=${m1?.steps.length}` + : "no finalized turn total", + ); + if (m1 !== undefined) { + const anyGen = m1.steps.some((s) => s.genTotalMs !== undefined); + const anyTtft = m1.steps.some((s) => s.ttftMs !== undefined); + note( + `live timing: durationMs=${m1Total?.durationMs ?? "—"}, ` + + `genTotalMs present=${anyGen}, ttftMs present=${anyTtft}`, + ); + record( + "turn 1 LIVE metrics carries timing (durationMs or step genTotalMs)", + m1Total?.durationMs !== undefined || anyGen, + "requires the backend runtime to have a clock", + ); + } - // ─── Metrics: DURABLE endpoint (GET /conversations/:id/metrics) ────────────── - const dm = await metricsSync(textConv); - if ("status" in dm) { - note( - `durable /metrics not available yet (HTTP ${dm.status}) — FE degrades to live-only, as designed`, - ); - record( - "durable /metrics is implemented OR gracefully absent (404)", - dm.status === 404 || dm.status === 405, - `HTTP ${dm.status}`, - ); - } else { - record( - "durable /metrics returned TurnMetrics[]", - Array.isArray(dm.turns), - `${dm.turns.length} turn(s)`, - ); - const durableMerged = selectOrderedTurnMetrics( - applyDurableMetrics(initialMetricsState(), dm.turns), - ); - const d1 = durableMerged[0]; - const d1Total = d1?.total ?? null; - record( - "durable /metrics turn has token usage", - d1Total !== null && d1Total.usage.outputTokens > 0, - d1Total ? `out=${d1Total.usage.outputTokens} steps=${d1?.steps.length}` : "no turn total", - ); - } + // ─── Metrics: DURABLE endpoint (GET /conversations/:id/metrics) ────────────── + const dm = await metricsSync(textConv); + if ("status" in dm) { + note( + `durable /metrics not available yet (HTTP ${dm.status}) — FE degrades to live-only, as designed`, + ); + record( + "durable /metrics is implemented OR gracefully absent (404)", + dm.status === 404 || dm.status === 405, + `HTTP ${dm.status}`, + ); + } else { + record( + "durable /metrics returned TurnMetrics[]", + Array.isArray(dm.turns), + `${dm.turns.length} turn(s)`, + ); + const durableMerged = selectOrderedTurnMetrics( + applyDurableMetrics(initialMetricsState(), dm.turns), + ); + const d1 = durableMerged[0]; + const d1Total = d1?.total ?? null; + record( + "durable /metrics turn has token usage", + d1Total !== null && d1Total.usage.outputTokens > 0, + d1Total ? `out=${d1Total.usage.outputTokens} steps=${d1?.steps.length}` : "no turn total", + ); + } - // ─── Turn 2: tool-call batching ([email protected] stepId) ───────────────────────── - console.log(`\n[live-probe] TURN 2 (tools): "${TOOL_PROMPT}"`); - const toolConv = crypto.randomUUID(); - const t2 = await runTurn(socket, toolConv, TOOL_PROMPT); - if (t2.error !== null) record("turn 2 had no chat.error", false, t2.error); - record("turn 2 reached turn-sealed", t2.sealed); + // ─── Turn 2: tool-call batching ([email protected] stepId) ───────────────────────── + console.log(`\n[live-probe] TURN 2 (tools): "${TOOL_PROMPT}"`); + const toolConv = crypto.randomUUID(); + const t2 = await runTurn(socket, toolConv, TOOL_PROMPT); + if (t2.error !== null) record("turn 2 had no chat.error", false, t2.error); + record("turn 2 reached turn-sealed", t2.sealed); - const liveTool = toolChunksOf(t2.state); - const liveCalls = liveTool.filter((c) => c.chunk.type === "tool-call"); + const liveTool = toolChunksOf(t2.state); + const liveCalls = liveTool.filter((c) => c.chunk.type === "tool-call"); - if (liveCalls.length === 0) { - note( - "INCONCLUSIVE: the model issued no tool calls this run — cannot verify stepId grouping live. " + - "Re-run with a stronger PROBE_TOOL_PROMPT or one tailored to the backend's tool set.", - ); - record("turn 2 tool-call batching (live)", true, "skipped — no tool calls issued"); - } else { - // Every live tool chunk must carry stepId (foldEvent copies it from the event). - const allLiveHaveStep = liveTool.every( - (c) => - (c.chunk.type === "tool-call" || c.chunk.type === "tool-result") && - typeof c.chunk.stepId === "string" && - c.chunk.stepId.length > 0, - ); - record( - "turn 2 every LIVE tool event carries stepId", - allLiveHaveStep, - `${liveCalls.length} call(s), ${liveTool.length - liveCalls.length} result(s)`, - ); + if (liveCalls.length === 0) { + note( + "INCONCLUSIVE: the model issued no tool calls this run — cannot verify stepId grouping live. " + + "Re-run with a stronger PROBE_TOOL_PROMPT or one tailored to the backend's tool set.", + ); + record("turn 2 tool-call batching (live)", true, "skipped — no tool calls issued"); + } else { + // Every live tool chunk must carry stepId (foldEvent copies it from the event). + const allLiveHaveStep = liveTool.every( + (c) => + (c.chunk.type === "tool-call" || c.chunk.type === "tool-result") && + typeof c.chunk.stepId === "string" && + c.chunk.stepId.length > 0, + ); + record( + "turn 2 every LIVE tool event carries stepId", + allLiveHaveStep, + `${liveCalls.length} call(s), ${liveTool.length - liveCalls.length} result(s)`, + ); - const liveGroups = groupRenderedChunks(selectChunks(t2.state)); - const liveBatches = liveGroups.filter((g) => g.kind === "tool-batch"); - const distinctSteps = new Set( - liveCalls.map((c) => (c.chunk.type === "tool-call" ? c.chunk.stepId : undefined)), - ); - note( - `live grouping: ${liveCalls.length} call(s) across ${distinctSteps.size} step(s) → ` + - `${liveBatches.length} batch group(s)`, - ); - if (liveBatches.length > 0) { - record( - "turn 2 grouping produced a parallel batch (2+ calls in one step)", - true, - `${liveBatches.length} batch(es)`, - ); - } else { - note( - "the model used tools but did NOT parallelize (each call its own step) — stepId is verified, " + - "but no multi-call batch occurred to render as a list this run.", - ); - } + const liveGroups = groupRenderedChunks(selectChunks(t2.state)); + const liveBatches = liveGroups.filter((g) => g.kind === "tool-batch"); + const distinctSteps = new Set( + liveCalls.map((c) => (c.chunk.type === "tool-call" ? c.chunk.stepId : undefined)), + ); + note( + `live grouping: ${liveCalls.length} call(s) across ${distinctSteps.size} step(s) → ` + + `${liveBatches.length} batch group(s)`, + ); + if (liveBatches.length > 0) { + record( + "turn 2 grouping produced a parallel batch (2+ calls in one step)", + true, + `${liveBatches.length} batch(es)`, + ); + } else { + note( + "the model used tools but did NOT parallelize (each call its own step) — stepId is verified, " + + "but no multi-call batch occurred to render as a list this run.", + ); + } - // Replay path: persisted tool chunks must also carry chunk.stepId. - const histTool = await historySync(toolConv, 0); - const replayTool = histTool.chunks.filter( - (c) => c.chunk.type === "tool-call" || c.chunk.type === "tool-result", - ); - const allReplayHaveStep = replayTool.every( - (c) => - (c.chunk.type === "tool-call" || c.chunk.type === "tool-result") && - typeof c.chunk.stepId === "string" && - c.chunk.stepId.length > 0, - ); - record( - "turn 2 every REPLAYED tool chunk carries chunk.stepId", - replayTool.length > 0 && allReplayHaveStep, - `${replayTool.length} tool chunk(s) in history`, - ); + // Replay path: persisted tool chunks must also carry chunk.stepId. + const histTool = await historySync(toolConv, 0); + const replayTool = histTool.chunks.filter( + (c) => c.chunk.type === "tool-call" || c.chunk.type === "tool-result", + ); + const allReplayHaveStep = replayTool.every( + (c) => + (c.chunk.type === "tool-call" || c.chunk.type === "tool-result") && + typeof c.chunk.stepId === "string" && + c.chunk.stepId.length > 0, + ); + record( + "turn 2 every REPLAYED tool chunk carries chunk.stepId", + replayTool.length > 0 && allReplayHaveStep, + `${replayTool.length} tool chunk(s) in history`, + ); - // Grouping on the authoritative replayed history matches the live shape. - const replayState = applyHistory(initialState(), await cache.commit(toolConv, histTool.chunks)); - const replayBatches = groupRenderedChunks(selectChunks(replayState)).filter( - (g) => g.kind === "tool-batch", - ); - record( - "turn 2 replay grouping matches live (batch count)", - replayBatches.length === liveBatches.length, - `live=${liveBatches.length} replay=${replayBatches.length}`, - ); - } + // Grouping on the authoritative replayed history matches the live shape. + const replayState = applyHistory(initialState(), await cache.commit(toolConv, histTool.chunks)); + const replayBatches = groupRenderedChunks(selectChunks(replayState)).filter( + (g) => g.kind === "tool-batch", + ); + record( + "turn 2 replay grouping matches live (batch count)", + replayBatches.length === liveBatches.length, + `live=${liveBatches.length} replay=${replayBatches.length}`, + ); + } - socket.close(); + socket.close(); - const passed = checks.filter((c) => c.ok).length; - const total = checks.length; - console.log(`\n[live-probe] ${passed}/${total} checks passed`); - process.exit(passed === total ? 0 : 1); + const passed = checks.filter((c) => c.ok).length; + const total = checks.length; + console.log(`\n[live-probe] ${passed}/${total} checks passed`); + process.exit(passed === total ? 0 : 1); } main().catch((e) => fail(String(e))); diff --git a/scripts/probe-cache-warming.ts b/scripts/probe-cache-warming.ts index 470e43b..1bf1f9b 100644 --- a/scripts/probe-cache-warming.ts +++ b/scripts/probe-cache-warming.ts @@ -14,9 +14,9 @@ * bun scripts/probe-cache-warming.ts */ import type { - ChatDeltaMessage, - ChatErrorMessage, - CloseConversationResponse, + ChatDeltaMessage, + ChatErrorMessage, + CloseConversationResponse, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; import { createSurfaceSocket } from "../src/adapters/ws/index.ts"; @@ -28,17 +28,17 @@ const SURFACE_ID = "cache-warming"; const checks: { name: string; ok: boolean }[] = []; const record = (name: string, ok: boolean, detail?: string) => { - checks.push({ name, ok }); - console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`); + checks.push({ name, ok }); + console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`); }; const log = (msg: string) => console.log(`[${new Date().toISOString().slice(11, 19)}] ${msg}`); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); function summarize(spec: SurfaceSpec | null): string { - const c = parseControls(spec); - const next = - c.nextWarmAt === null ? "null" : `${Math.round((c.nextWarmAt - Date.now()) / 1000)}s`; - return `enabled=${c.enabled} interval=${c.intervalSeconds}s lastPct=${c.lastPct} next=${next} lastWarmAt=${c.lastWarmAt}`; + const c = parseControls(spec); + const next = + c.nextWarmAt === null ? "null" : `${Math.round((c.nextWarmAt - Date.now()) / 1000)}s`; + return `enabled=${c.enabled} interval=${c.intervalSeconds}s lastPct=${c.lastPct} next=${next} lastWarmAt=${c.lastWarmAt}`; } let catalog: { id: string; scope?: string }[] = []; @@ -49,229 +49,229 @@ let specWaiter: (() => void) | null = null; const chatHandlers = new Map<string, (msg: ChatDeltaMessage | ChatErrorMessage) => void>(); const socket = createSurfaceSocket({ - url: WS_URL, - onMessage: (m: SurfaceServerMessage) => { - if (m.type === "catalog") { - catalog = [...m.catalog]; - log(`catalog: ${m.catalog.map((e) => `${e.id}(scope=${e.scope ?? "—"})`).join(", ")}`); - } else if (m.type === "surface") { - latestSpec = m.spec; - latestSpecConv = m.conversationId; - log(`surface(initial) conv=${m.conversationId ?? "—"}: ${summarize(m.spec)}`); - specWaiter?.(); - } else if (m.type === "update") { - if (m.update.surfaceId !== SURFACE_ID) return; - latestSpec = m.update.spec; - latestSpecConv = m.update.conversationId; - log(`update conv=${m.update.conversationId ?? "—"}: ${summarize(m.update.spec)}`); - specWaiter?.(); - } else if (m.type === "error") { - log(`surface ERROR: ${m.surfaceId ?? "—"}: ${m.message}`); - } - }, - onChat: (msg) => { - const id = msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId; - if (id !== undefined) chatHandlers.get(id)?.(msg); - }, + url: WS_URL, + onMessage: (m: SurfaceServerMessage) => { + if (m.type === "catalog") { + catalog = [...m.catalog]; + log(`catalog: ${m.catalog.map((e) => `${e.id}(scope=${e.scope ?? "—"})`).join(", ")}`); + } else if (m.type === "surface") { + latestSpec = m.spec; + latestSpecConv = m.conversationId; + log(`surface(initial) conv=${m.conversationId ?? "—"}: ${summarize(m.spec)}`); + specWaiter?.(); + } else if (m.type === "update") { + if (m.update.surfaceId !== SURFACE_ID) return; + latestSpec = m.update.spec; + latestSpecConv = m.update.conversationId; + log(`update conv=${m.update.conversationId ?? "—"}: ${summarize(m.update.spec)}`); + specWaiter?.(); + } else if (m.type === "error") { + log(`surface ERROR: ${m.surfaceId ?? "—"}: ${m.message}`); + } + }, + onChat: (msg) => { + const id = msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId; + if (id !== undefined) chatHandlers.get(id)?.(msg); + }, }); /** Wait for the next surface/update message (or time out). */ function nextSpec(timeoutMs: number): Promise<boolean> { - return new Promise((resolve) => { - const t = setTimeout(() => { - specWaiter = null; - resolve(false); - }, timeoutMs); - specWaiter = () => { - clearTimeout(t); - specWaiter = null; - resolve(true); - }; - }); + return new Promise((resolve) => { + const t = setTimeout(() => { + specWaiter = null; + resolve(false); + }, timeoutMs); + specWaiter = () => { + clearTimeout(t); + specWaiter = null; + resolve(true); + }; + }); } async function runTinyTurn(conversationId: string, prompt: string): Promise<boolean> { - const done = Promise.withResolvers<boolean>(); - chatHandlers.set(conversationId, (msg) => { - if (msg.type === "chat.error") { - log(`chat.error: ${msg.message}`); - done.resolve(false); - } else if (msg.event.type === "turn-sealed") { - done.resolve(true); - } - }); - socket.send({ type: "chat.send", conversationId, message: prompt }); - const t = setTimeout(() => done.resolve(false), 90_000); - const ok = await done.promise; - clearTimeout(t); - chatHandlers.delete(conversationId); - return ok; + const done = Promise.withResolvers<boolean>(); + chatHandlers.set(conversationId, (msg) => { + if (msg.type === "chat.error") { + log(`chat.error: ${msg.message}`); + done.resolve(false); + } else if (msg.event.type === "turn-sealed") { + done.resolve(true); + } + }); + socket.send({ type: "chat.send", conversationId, message: prompt }); + const t = setTimeout(() => done.resolve(false), 90_000); + const ok = await done.promise; + clearTimeout(t); + chatHandlers.delete(conversationId); + return ok; } function invoke(actionId: string, conversationId: string, payload?: unknown): void { - socket.send( - payload === undefined - ? { type: "invoke", surfaceId: SURFACE_ID, actionId, conversationId } - : { type: "invoke", surfaceId: SURFACE_ID, actionId, payload, conversationId }, - ); + socket.send( + payload === undefined + ? { type: "invoke", surfaceId: SURFACE_ID, actionId, conversationId } + : { type: "invoke", surfaceId: SURFACE_ID, actionId, payload, conversationId }, + ); } async function main() { - await sleep(600); - record( - "catalog includes cache-warming with scope=conversation", - catalog.some((e) => e.id === SURFACE_ID && e.scope === "conversation"), - ); + await sleep(600); + record( + "catalog includes cache-warming with scope=conversation", + catalog.some((e) => e.id === SURFACE_ID && e.scope === "conversation"), + ); - // ── A: the DRAFT/new-tab path — subscribe with NO conversationId ─────────── - log("PHASE A: subscribe with NO conversationId (draft / new tab)"); - socket.send({ type: "subscribe", surfaceId: SURFACE_ID }); - await nextSpec(3000); - record( - "draft subscribe → degenerate spec (no toggle parsed)", - !parseControls(latestSpec).enabled, - ); - socket.send({ type: "unsubscribe", surfaceId: SURFACE_ID }); - await sleep(300); + // ── A: the DRAFT/new-tab path — subscribe with NO conversationId ─────────── + log("PHASE A: subscribe with NO conversationId (draft / new tab)"); + socket.send({ type: "subscribe", surfaceId: SURFACE_ID }); + await nextSpec(3000); + record( + "draft subscribe → degenerate spec (no toggle parsed)", + !parseControls(latestSpec).enabled, + ); + socket.send({ type: "unsubscribe", surfaceId: SURFACE_ID }); + await sleep(300); - // ── B: a FRESH conversation defaults OFF (CR-4a) + echo (CR-4d) ──────────── - const conv = crypto.randomUUID(); - log(`PHASE B: creating conversation ${conv}`); - if (!(await runTinyTurn(conv, "Reply with exactly: ok"))) { - log("FATAL: could not create a conversation"); - process.exit(1); - } - socket.send({ type: "subscribe", surfaceId: SURFACE_ID, conversationId: conv }); - await nextSpec(3000); - const fresh = parseControls(latestSpec); - record("CR-4d: initial surface message echoes conversationId", latestSpecConv === conv); - record("CR-4a: fresh conversation defaults to warming OFF", fresh.enabled === false); - record("CR-4a: nothing scheduled while off (nextWarmAt null)", fresh.nextWarmAt === null); + // ── B: a FRESH conversation defaults OFF (CR-4a) + echo (CR-4d) ──────────── + const conv = crypto.randomUUID(); + log(`PHASE B: creating conversation ${conv}`); + if (!(await runTinyTurn(conv, "Reply with exactly: ok"))) { + log("FATAL: could not create a conversation"); + process.exit(1); + } + socket.send({ type: "subscribe", surfaceId: SURFACE_ID, conversationId: conv }); + await nextSpec(3000); + const fresh = parseControls(latestSpec); + record("CR-4d: initial surface message echoes conversationId", latestSpecConv === conv); + record("CR-4a: fresh conversation defaults to warming OFF", fresh.enabled === false); + record("CR-4a: nothing scheduled while off (nextWarmAt null)", fresh.nextWarmAt === null); - // ── C: opt in + 10s interval → repeated warms, FUTURE nextWarmAt (CR-4b) ─── - log("PHASE C: toggling warming ON"); - const toggleId = fresh.toggleActionId; - if (toggleId === null) { - record("toggle action present", false); - process.exit(1); - } - invoke(toggleId, conv); - await nextSpec(3000); - let c = parseControls(latestSpec); - record("toggle-on update arrived (enabled)", c.enabled === true); - record( - "CR-4b: enable schedules a FUTURE nextWarmAt", - c.nextWarmAt !== null && c.nextWarmAt > Date.now(), - ); + // ── C: opt in + 10s interval → repeated warms, FUTURE nextWarmAt (CR-4b) ─── + log("PHASE C: toggling warming ON"); + const toggleId = fresh.toggleActionId; + if (toggleId === null) { + record("toggle action present", false); + process.exit(1); + } + invoke(toggleId, conv); + await nextSpec(3000); + let c = parseControls(latestSpec); + record("toggle-on update arrived (enabled)", c.enabled === true); + record( + "CR-4b: enable schedules a FUTURE nextWarmAt", + c.nextWarmAt !== null && c.nextWarmAt > Date.now(), + ); - const setIntervalId = c.setIntervalActionId; - if (setIntervalId !== null) { - log("PHASE C: set-interval = 10s"); - invoke(setIntervalId, conv, 10); - await nextSpec(3000); - c = parseControls(latestSpec); - record( - "set-interval update: interval=10 + FUTURE nextWarmAt", - c.intervalSeconds === 10 && c.nextWarmAt !== null && c.nextWarmAt > Date.now(), - ); - } + const setIntervalId = c.setIntervalActionId; + if (setIntervalId !== null) { + log("PHASE C: set-interval = 10s"); + invoke(setIntervalId, conv, 10); + await nextSpec(3000); + c = parseControls(latestSpec); + record( + "set-interval update: interval=10 + FUTURE nextWarmAt", + c.intervalSeconds === 10 && c.nextWarmAt !== null && c.nextWarmAt > Date.now(), + ); + } - log("PHASE C: waiting up to 45s for 2 automatic warms…"); - const deadline = Date.now() + 45_000; - let lastSeen = c.lastWarmAt; - let warms = 0; - let allFuture = true; - while (Date.now() < deadline && warms < 2) { - await nextSpec(Math.max(1, deadline - Date.now())); - const now = parseControls(latestSpec); - if (now.lastWarmAt !== null && now.lastWarmAt !== lastSeen) { - lastSeen = now.lastWarmAt; - warms++; - const future = now.nextWarmAt !== null && now.nextWarmAt > Date.now() - 1000; - if (!future) allFuture = false; - log( - ` automatic warm #${warms}: pct=${now.lastPct} retention=${now.retentionPct} ` + - `nextWarmAt ${future ? "FUTURE" : "STALE/PAST"}`, - ); - } - } - record("automatic warms repeat (2 observed @10s)", warms >= 2, `${warms} warm(s)`); - record("CR-4b: every post-warm update carries a FUTURE nextWarmAt", warms >= 2 && allFuture); + log("PHASE C: waiting up to 45s for 2 automatic warms…"); + const deadline = Date.now() + 45_000; + let lastSeen = c.lastWarmAt; + let warms = 0; + let allFuture = true; + while (Date.now() < deadline && warms < 2) { + await nextSpec(Math.max(1, deadline - Date.now())); + const now = parseControls(latestSpec); + if (now.lastWarmAt !== null && now.lastWarmAt !== lastSeen) { + lastSeen = now.lastWarmAt; + warms++; + const future = now.nextWarmAt !== null && now.nextWarmAt > Date.now() - 1000; + if (!future) allFuture = false; + log( + ` automatic warm #${warms}: pct=${now.lastPct} retention=${now.retentionPct} ` + + `nextWarmAt ${future ? "FUTURE" : "STALE/PAST"}`, + ); + } + } + record("automatic warms repeat (2 observed @10s)", warms >= 2, `${warms} warm(s)`); + record("CR-4b: every post-warm update carries a FUTURE nextWarmAt", warms >= 2 && allFuture); - // ── D: close mid-turn → abort + warming disabled (CR-4c) ─────────────────── - log("PHASE D: starting a long turn, then closing the conversation mid-turn…"); - const seenDone = Promise.withResolvers<string>(); // resolves with done.reason - const seenSealed = Promise.withResolvers<void>(); - let turnStarted = false; - const started = Promise.withResolvers<void>(); - chatHandlers.set(conv, (msg) => { - if (msg.type === "chat.error") { - log(`chat.error: ${msg.message}`); - return; - } - const ev = msg.event; - if (ev.type === "turn-start") { - turnStarted = true; - started.resolve(); - } else if (ev.type === "done") { - seenDone.resolve(ev.reason); - } else if (ev.type === "turn-sealed") { - seenSealed.resolve(); - } - }); - socket.send({ - type: "chat.send", - conversationId: conv, - message: - "Write a detailed 1000-word essay about the history of computing. Take your time and be thorough.", - }); - const startTimeout = setTimeout(() => started.resolve(), 15_000); - await started.promise; - clearTimeout(startTimeout); - record("turn started (watcher saw turn-start)", turnStarted); - await sleep(1000); // let it generate a moment + // ── D: close mid-turn → abort + warming disabled (CR-4c) ─────────────────── + log("PHASE D: starting a long turn, then closing the conversation mid-turn…"); + const seenDone = Promise.withResolvers<string>(); // resolves with done.reason + const seenSealed = Promise.withResolvers<void>(); + let turnStarted = false; + const started = Promise.withResolvers<void>(); + chatHandlers.set(conv, (msg) => { + if (msg.type === "chat.error") { + log(`chat.error: ${msg.message}`); + return; + } + const ev = msg.event; + if (ev.type === "turn-start") { + turnStarted = true; + started.resolve(); + } else if (ev.type === "done") { + seenDone.resolve(ev.reason); + } else if (ev.type === "turn-sealed") { + seenSealed.resolve(); + } + }); + socket.send({ + type: "chat.send", + conversationId: conv, + message: + "Write a detailed 1000-word essay about the history of computing. Take your time and be thorough.", + }); + const startTimeout = setTimeout(() => started.resolve(), 15_000); + await started.promise; + clearTimeout(startTimeout); + record("turn started (watcher saw turn-start)", turnStarted); + await sleep(1000); // let it generate a moment - const res = await fetch(`${HTTP_BASE}/conversations/${encodeURIComponent(conv)}/close`, { - method: "POST", - headers: { Origin: "http://localhost:24204" }, - }); - record("POST /conversations/:id/close → 200", res.ok, `HTTP ${res.status}`); - const body = (await res.json()) as CloseConversationResponse; - record("close aborted the in-flight turn (abortedTurn)", body.abortedTurn === true); + const res = await fetch(`${HTTP_BASE}/conversations/${encodeURIComponent(conv)}/close`, { + method: "POST", + headers: { Origin: "http://localhost:24204" }, + }); + record("POST /conversations/:id/close → 200", res.ok, `HTTP ${res.status}`); + const body = (await res.json()) as CloseConversationResponse; + record("close aborted the in-flight turn (abortedTurn)", body.abortedTurn === true); - const doneReason = await Promise.race([seenDone.promise, sleep(15_000).then(() => "(timeout)")]); - record('watcher received done with reason "aborted"', doneReason === "aborted", doneReason); - const sealed = await Promise.race([ - seenSealed.promise.then(() => true), - sleep(15_000).then(() => false), - ]); - record("turn sealed normally after abort", sealed); - chatHandlers.delete(conv); + const doneReason = await Promise.race([seenDone.promise, sleep(15_000).then(() => "(timeout)")]); + record('watcher received done with reason "aborted"', doneReason === "aborted", doneReason); + const sealed = await Promise.race([ + seenSealed.promise.then(() => true), + sleep(15_000).then(() => false), + ]); + record("turn sealed normally after abort", sealed); + chatHandlers.delete(conv); - // The close also pushed a surface update: warming disabled + unscheduled. - await sleep(1500); - const closed = parseControls(latestSpec); - record( - "CR-4c: close disabled warming + cleared the schedule", - closed.enabled === false && closed.nextWarmAt === null, - summarize(latestSpec), - ); + // The close also pushed a surface update: warming disabled + unscheduled. + await sleep(1500); + const closed = parseControls(latestSpec); + record( + "CR-4c: close disabled warming + cleared the schedule", + closed.enabled === false && closed.nextWarmAt === null, + summarize(latestSpec), + ); - // Idempotency: closing again (now idle) succeeds with abortedTurn false. - const res2 = await fetch(`${HTTP_BASE}/conversations/${encodeURIComponent(conv)}/close`, { - method: "POST", - headers: { Origin: "http://localhost:24204" }, - }); - const body2 = (await res2.json()) as CloseConversationResponse; - record("close is idempotent (200 + abortedTurn:false)", res2.ok && body2.abortedTurn === false); + // Idempotency: closing again (now idle) succeeds with abortedTurn false. + const res2 = await fetch(`${HTTP_BASE}/conversations/${encodeURIComponent(conv)}/close`, { + method: "POST", + headers: { Origin: "http://localhost:24204" }, + }); + const body2 = (await res2.json()) as CloseConversationResponse; + record("close is idempotent (200 + abortedTurn:false)", res2.ok && body2.abortedTurn === false); - socket.close(); - const passed = checks.filter((x) => x.ok).length; - console.log(`\n[probe-cache-warming] ${passed}/${checks.length} checks passed`); - process.exit(passed === checks.length ? 0 : 1); + socket.close(); + const passed = checks.filter((x) => x.ok).length; + console.log(`\n[probe-cache-warming] ${passed}/${checks.length} checks passed`); + process.exit(passed === checks.length ? 0 : 1); } main().catch((e) => { - console.error(`[probe] FATAL: ${e}`); - process.exit(1); + console.error(`[probe] FATAL: ${e}`); + process.exit(1); }); diff --git a/src/App.svelte b/src/App.svelte index ffd5543..27c9271 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,7 +1,79 @@ <script lang="ts"> - import { App, createAppStore } from "./app"; + import { App, createAppStore } from "./app"; + import { createHistoryAdapter } from "./adapters/history"; + import { + createWorkspaceHttp, + createWorkspaceStore, + pageTitle, + parsePath, + WorkspacesHome, + type Route, + } from "./features/workspaces"; + import { resolveHttpUrl } from "./app/resolve-http-url"; - const store = createAppStore(); + // Parse the route BEFORE creating the store so the boot draft + active + // workspace are correct from the first render (no flash of the "default" + // workspace when deep-linking to /<id>). + const history = createHistoryAdapter(); + const initialRoute = parsePath(history.path); + const store = createAppStore( + initialRoute.kind === "workspace" ? { workspaceId: initialRoute.id } : {}, + ); + + // The workspace HTTP edge shares the same httpBase resolution as the store. + const httpBase = resolveHttpUrl( + { + VITE_HTTP_URL: import.meta.env.VITE_HTTP_URL, + VITE_HTTP_PORT: import.meta.env.VITE_HTTP_PORT, + }, + typeof location !== "undefined" ? location : undefined, + ); + const workspaceStore = createWorkspaceStore( + createWorkspaceHttp(httpBase, globalThis.fetch.bind(globalThis)), + ); + + let route = $state<Route>(initialRoute); + + // React to back/forward + programmatic navigation. + $effect(() => { + return history.subscribe((path) => { + route = parsePath(path); + }); + }); + + // On entering a workspace: scope the store to it (idempotent — skip if already + // scoped) + ensure the workspace exists (create-on-miss, so it appears in the + // home list immediately). The backend also auto-creates on `chat.send`. + $effect(() => { + if (route.kind !== "workspace") return; + const id = route.id; + if (store.activeWorkspaceId !== id) { + store.setActiveWorkspace(id); + } + void workspaceStore.ensure(id); + }); + + // Keep the browser tab title in sync with the route: "Dispatch" on the home + // page, "Dispatch: {title}" on a workspace page (falling back to the URL slug + // until the list loads it). Reads `route` + the workspace list, so it re-runs + // on navigation and again once `ensure` refreshes the list. + $effect(() => { + if (typeof document === "undefined") return; + document.title = pageTitle(route, workspaceStore.list); + }); + + function navigate(path: string): void { + history.navigate(path); + } </script> -<App {store} /> +{#if route.kind === "home"} + <WorkspacesHome + store={workspaceStore} + onNavigate={navigate} + computers={store.computers} + hasActive={(id) => store.workspaceHasActiveConversations(id)} + /> +{:else} + <App {store} onNavigate={navigate} /> +{/if} diff --git a/src/adapters/history/index.test.ts b/src/adapters/history/index.test.ts new file mode 100644 index 0000000..faab9d4 --- /dev/null +++ b/src/adapters/history/index.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { createHistoryAdapter, type HistoryWindow } from "./index"; + +/** + * A minimal in-memory `HistoryWindow` fake for deterministic tests. `pathname` + * is a closure variable reflected through the `location` getter; `back(url)` + * simulates an external navigation (back/forward): it sets the path + fires the + * popstate listeners. + */ +function fakeWindow(initial = "/"): HistoryWindow & { back(url: string): void } { + let pathname = initial; + const popstateListeners = new Set<() => void>(); + return { + get location() { + return { + get pathname() { + return pathname; + }, + }; + }, + history: { + pushState(_data, _unused, url) { + if (typeof url === "string") pathname = url; + }, + replaceState(_data, _unused, url) { + if (typeof url === "string") pathname = url; + }, + }, + addEventListener(type, listener) { + if (type === "popstate") popstateListeners.add(listener); + }, + removeEventListener(type, listener) { + if (type === "popstate") popstateListeners.delete(listener); + }, + back(url: string) { + pathname = url; + for (const l of popstateListeners) l(); + }, + }; +} + +describe("createHistoryAdapter", () => { + it("reads the current path", () => { + const w = fakeWindow("/my-ws"); + const h = createHistoryAdapter({ window: w }); + expect(h.path).toBe("/my-ws"); + }); + + it("navigate updates the path + notifies subscribers", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.navigate("/default"); + expect(h.path).toBe("/default"); + expect(seen).toEqual(["/default"]); + }); + + it("replace updates the path WITHOUT notifying", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.replace("/default"); + expect(h.path).toBe("/default"); + expect(seen).toEqual([]); + }); + + it("fires subscribers on popstate (back/forward)", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + w.back("/my-ws"); + expect(seen).toEqual(["/my-ws"]); + }); + + it("unsubscribe stops notifications (navigate + popstate)", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + const unsub = h.subscribe((p) => seen.push(p)); + unsub(); + h.navigate("/default"); + w.back("/other"); + expect(seen).toEqual([]); + }); + + it("degrades to a no-op adapter when there is no location (SSR)", () => { + const w = { location: undefined } as unknown as HistoryWindow; + const h = createHistoryAdapter({ window: w }); + expect(h.path).toBe("/"); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.navigate("/default"); + expect(seen).toEqual([]); + }); +}); diff --git a/src/adapters/history/index.ts b/src/adapters/history/index.ts new file mode 100644 index 0000000..2886053 --- /dev/null +++ b/src/adapters/history/index.ts @@ -0,0 +1,93 @@ +/** + * History adapter — the injected browser effect for client-side routing. + * + * A thin wrapper over `window.history` + the `popstate` event that exposes the + * current pathname, a `navigate` (pushState) that updates the URL without a + * reload, and a `subscribe` for path changes (back/forward buttons + programmatic + * navigation). The route SEMANTICS (path → workspace/home) live in the + * `workspaces` feature's pure `parsePath`; this adapter deals only in path + * strings, so it stays generic + reusable. + * + * The browser edge (`window`/`history`) is INJECTED (defaults to the global) so + * it is testable without the DOM and degrades to a no-op when absent (SSR / no + * `window`). + */ + +/** The minimal browser-history surface this adapter needs. */ +export interface HistoryWindow { + readonly location: { readonly pathname: string }; + readonly history: { + pushState(data: unknown, unused: string, url?: string | URL | null): void; + replaceState(data: unknown, unused: string, url?: string | URL | null): void; + }; + addEventListener(type: "popstate", listener: () => void): void; + removeEventListener(type: "popstate", listener: () => void): void; +} + +export interface HistoryAdapter { + /** The current pathname. */ + readonly path: string; + /** Push a new path (updates the URL without a reload) + notifies subscribers. */ + navigate(path: string): void; + /** Replace the current path without adding a history entry (no notify). */ + replace(path: string): void; + /** Subscribe to path changes (back/forward + navigate). Returns unsubscribe. */ + subscribe(cb: (path: string) => void): () => void; +} + +export interface CreateHistoryOptions { + /** The browser window; defaults to `globalThis`. Inject for tests. */ + readonly window?: HistoryWindow; +} + +function noop(): void {} + +/** A no-op adapter for when there is no `window` (SSR). */ +function createNoopHistory(): HistoryAdapter { + return { + get path() { + return "/"; + }, + navigate: noop, + replace: noop, + subscribe() { + return noop; + }, + }; +} + +export function createHistoryAdapter(opts?: CreateHistoryOptions): HistoryAdapter { + const w = opts?.window ?? (globalThis as unknown as HistoryWindow); + if (w === undefined || w === null || w.location === undefined) { + return createNoopHistory(); + } + + const listeners = new Set<(path: string) => void>(); + const current = (): string => w.location.pathname; + const emit = (): void => { + const p = current(); + for (const cb of listeners) cb(p); + }; + + return { + get path() { + return current(); + }, + navigate(path: string): void { + w.history.pushState(null, "", path); + emit(); + }, + replace(path: string): void { + w.history.replaceState(null, "", path); + }, + subscribe(cb: (path: string) => void): () => void { + listeners.add(cb); + const onPop = (): void => cb(current()); + w.addEventListener("popstate", onPop); + return () => { + listeners.delete(cb); + w.removeEventListener("popstate", onPop); + }; + }, + }; +} diff --git a/src/adapters/idb/index.test.ts b/src/adapters/idb/index.test.ts index 12bb5ad..c05c605 100644 --- a/src/adapters/idb/index.test.ts +++ b/src/adapters/idb/index.test.ts @@ -4,117 +4,117 @@ import { describe, expect, it } from "vitest"; import { createIdbChunkStore } from "./index"; function textChunk(text: string): StoredChunk["chunk"] { - return { type: "text", text }; + return { type: "text", text }; } function makeChunk( - seq: number, - text: string, - role: StoredChunk["role"] = "assistant", + seq: number, + text: string, + role: StoredChunk["role"] = "assistant", ): StoredChunk { - return { seq, role, chunk: textChunk(text) }; + return { seq, role, chunk: textChunk(text) }; } describe("createIdbChunkStore", () => { - it("append then load returns chunks seq-ordered", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - const chunks = [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")]; + it("append then load returns chunks seq-ordered", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + const chunks = [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")]; - await store.append("conv1", chunks); - const loaded = await store.load("conv1"); + await store.append("conv1", chunks); + const loaded = await store.load("conv1"); - expect(loaded).toHaveLength(3); - expect(loaded[0]?.seq).toBe(1); - expect(loaded[1]?.seq).toBe(2); - expect(loaded[2]?.seq).toBe(3); - expect(loaded[0]?.chunk).toEqual(textChunk("a")); - }); + expect(loaded).toHaveLength(3); + expect(loaded[0]?.seq).toBe(1); + expect(loaded[1]?.seq).toBe(2); + expect(loaded[2]?.seq).toBe(3); + expect(loaded[0]?.chunk).toEqual(textChunk("a")); + }); - it("append out-of-order still loads seq-ordered", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - const chunks = [makeChunk(3, "c"), makeChunk(1, "a"), makeChunk(2, "b")]; + it("append out-of-order still loads seq-ordered", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + const chunks = [makeChunk(3, "c"), makeChunk(1, "a"), makeChunk(2, "b")]; - await store.append("conv1", chunks); - const loaded = await store.load("conv1"); + await store.append("conv1", chunks); + const loaded = await store.load("conv1"); - expect(loaded).toHaveLength(3); - expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]); - }); + expect(loaded).toHaveLength(3); + expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]); + }); - it("append is idempotent on duplicate seq", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("append is idempotent on duplicate seq", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "first"), makeChunk(2, "b")]); - await store.append("conv1", [makeChunk(1, "first"), makeChunk(3, "c")]); + await store.append("conv1", [makeChunk(1, "first"), makeChunk(2, "b")]); + await store.append("conv1", [makeChunk(1, "first"), makeChunk(3, "c")]); - const loaded = await store.load("conv1"); - expect(loaded).toHaveLength(3); - expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]); - expect(loaded[0]?.chunk).toEqual(textChunk("first")); - }); + const loaded = await store.load("conv1"); + expect(loaded).toHaveLength(3); + expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]); + expect(loaded[0]?.chunk).toEqual(textChunk("first")); + }); - it("load returns [] for an absent conversation", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("load returns [] for an absent conversation", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - const loaded = await store.load("nonexistent"); - expect(loaded).toEqual([]); - }); + const loaded = await store.load("nonexistent"); + expect(loaded).toEqual([]); + }); - it("delete removes a conversation", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("delete removes a conversation", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "a")]); - await store.append("conv2", [makeChunk(1, "b")]); + await store.append("conv1", [makeChunk(1, "a")]); + await store.append("conv2", [makeChunk(1, "b")]); - await store.delete("conv1"); + await store.delete("conv1"); - expect(await store.load("conv1")).toEqual([]); - const conv2 = await store.load("conv2"); - expect(conv2).toHaveLength(1); - expect(conv2[0]?.chunk).toEqual(textChunk("b")); - }); + expect(await store.load("conv1")).toEqual([]); + const conv2 = await store.load("conv2"); + expect(conv2).toHaveLength(1); + expect(conv2[0]?.chunk).toEqual(textChunk("b")); + }); - it("index aggregates chunkCount and maxSeq", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("index aggregates chunkCount and maxSeq", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")]); - await store.append("conv2", [makeChunk(1, "x")]); + await store.append("conv1", [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")]); + await store.append("conv2", [makeChunk(1, "x")]); - const idx = await store.index(); - expect(idx).toHaveLength(2); + const idx = await store.index(); + expect(idx).toHaveLength(2); - const c1 = idx.find((e) => e.conversationId === "conv1"); - const c2 = idx.find((e) => e.conversationId === "conv2"); + const c1 = idx.find((e) => e.conversationId === "conv1"); + const c2 = idx.find((e) => e.conversationId === "conv2"); - expect(c1?.chunkCount).toBe(3); - expect(c1?.maxSeq).toBe(3); - expect(c2?.chunkCount).toBe(1); - expect(c2?.maxSeq).toBe(1); - }); + expect(c1?.chunkCount).toBe(3); + expect(c1?.maxSeq).toBe(3); + expect(c2?.chunkCount).toBe(1); + expect(c2?.maxSeq).toBe(1); + }); - it("index reports lastAccess after load", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("index reports lastAccess after load", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "a")]); - const idx = await store.index(); + await store.append("conv1", [makeChunk(1, "a")]); + const idx = await store.index(); - const entry = idx.find((e) => e.conversationId === "conv1"); - expect(entry?.lastAccess).toBeTypeOf("number"); - expect(entry?.lastAccess).toBeGreaterThan(0); - }); + const entry = idx.find((e) => e.conversationId === "conv1"); + expect(entry?.lastAccess).toBeTypeOf("number"); + expect(entry?.lastAccess).toBeGreaterThan(0); + }); - it("separate conversations are isolated", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("separate conversations are isolated", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "a1"), makeChunk(2, "a2")]); - await store.append("conv2", [makeChunk(1, "b1")]); + await store.append("conv1", [makeChunk(1, "a1"), makeChunk(2, "a2")]); + await store.append("conv2", [makeChunk(1, "b1")]); - const loaded1 = await store.load("conv1"); - const loaded2 = await store.load("conv2"); + const loaded1 = await store.load("conv1"); + const loaded2 = await store.load("conv2"); - expect(loaded1).toHaveLength(2); - expect(loaded2).toHaveLength(1); - expect(loaded1[0]?.chunk).toEqual(textChunk("a1")); - expect(loaded2[0]?.chunk).toEqual(textChunk("b1")); - }); + expect(loaded1).toHaveLength(2); + expect(loaded2).toHaveLength(1); + expect(loaded1[0]?.chunk).toEqual(textChunk("a1")); + expect(loaded2[0]?.chunk).toEqual(textChunk("b1")); + }); }); diff --git a/src/adapters/idb/index.ts b/src/adapters/idb/index.ts index 302edb5..96b2cbc 100644 --- a/src/adapters/idb/index.ts +++ b/src/adapters/idb/index.ts @@ -1,7 +1,7 @@ import type { StoredChunk } from "@dispatch/wire"; import type { - ConversationCacheIndexEntry, - ConversationChunkStore, + ConversationCacheIndexEntry, + ConversationChunkStore, } from "../../features/conversation-cache"; const DEFAULT_DB_NAME = "dispatch-chunk-cache"; @@ -10,172 +10,172 @@ const CHUNKS_STORE = "chunks"; const META_STORE = "meta"; interface ChunkRecord { - conversationId: string; - seq: number; - role: StoredChunk["role"]; - chunk: StoredChunk["chunk"]; + conversationId: string; + seq: number; + role: StoredChunk["role"]; + chunk: StoredChunk["chunk"]; } interface MetaRecord { - conversationId: string; - lastAccess: number; + conversationId: string; + lastAccess: number; } export interface CreateIdbChunkStoreOptions { - indexedDB?: IDBFactory; - dbName?: string; + indexedDB?: IDBFactory; + dbName?: string; } function requestToPromise<T>(req: IDBRequest<T>): Promise<T> { - return new Promise<T>((resolve, reject) => { - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); + return new Promise<T>((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); } function txComplete(tx: IDBTransaction): Promise<void> { - return new Promise<void>((resolve, reject) => { - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - tx.onabort = () => reject(tx.error); - }); + return new Promise<void>((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); } function openDb(idb: IDBFactory, dbName: string): Promise<IDBDatabase> { - return new Promise<IDBDatabase>((resolve, reject) => { - const req = idb.open(dbName, DB_VERSION); - - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains(CHUNKS_STORE)) { - const store = db.createObjectStore(CHUNKS_STORE, { - keyPath: ["conversationId", "seq"], - }); - store.createIndex("byConversation", "conversationId"); - } - if (!db.objectStoreNames.contains(META_STORE)) { - db.createObjectStore(META_STORE, { keyPath: "conversationId" }); - } - }; - - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); + return new Promise<IDBDatabase>((resolve, reject) => { + const req = idb.open(dbName, DB_VERSION); + + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(CHUNKS_STORE)) { + const store = db.createObjectStore(CHUNKS_STORE, { + keyPath: ["conversationId", "seq"], + }); + store.createIndex("byConversation", "conversationId"); + } + if (!db.objectStoreNames.contains(META_STORE)) { + db.createObjectStore(META_STORE, { keyPath: "conversationId" }); + } + }; + + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); } function keyRangeFor(conversationId: string): IDBKeyRange { - const lower: [string, number] = [conversationId, 0]; - const upper: [string, number] = [conversationId, Number.POSITIVE_INFINITY]; - return IDBKeyRange.bound(lower, upper); + const lower: [string, number] = [conversationId, 0]; + const upper: [string, number] = [conversationId, Number.POSITIVE_INFINITY]; + return IDBKeyRange.bound(lower, upper); } function chunksToStoredChunks(records: ChunkRecord[]): StoredChunk[] { - return records.map((r) => ({ seq: r.seq, role: r.role, chunk: r.chunk })); + return records.map((r) => ({ seq: r.seq, role: r.role, chunk: r.chunk })); } export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): ConversationChunkStore { - const idb = opts?.indexedDB ?? globalThis.indexedDB; - const dbName = opts?.dbName ?? DEFAULT_DB_NAME; - - let dbPromise: Promise<IDBDatabase> | null = null; - - function getDb(): Promise<IDBDatabase> { - if (dbPromise === null) { - dbPromise = openDb(idb, dbName); - } - return dbPromise; - } - - return { - async load(conversationId: string): Promise<readonly StoredChunk[]> { - const db = await getDb(); - const tx = db.transaction(CHUNKS_STORE, "readonly"); - const store = tx.objectStore(CHUNKS_STORE); - const range = keyRangeFor(conversationId); - const records = await requestToPromise<ChunkRecord[]>(store.getAll(range)); - await txComplete(tx); - - records.sort((a, b) => a.seq - b.seq); - return chunksToStoredChunks(records); - }, - - async append(conversationId: string, chunks: readonly StoredChunk[]): Promise<void> { - if (chunks.length === 0) return; - - const db = await getDb(); - const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite"); - const chunkStore = tx.objectStore(CHUNKS_STORE); - const metaStore = tx.objectStore(META_STORE); - - for (const c of chunks) { - chunkStore.put({ - conversationId, - seq: c.seq, - role: c.role, - chunk: c.chunk, - } satisfies ChunkRecord); - } - - metaStore.put({ - conversationId, - lastAccess: Date.now(), - } satisfies MetaRecord); - - await txComplete(tx); - }, - - async delete(conversationId: string): Promise<void> { - const db = await getDb(); - const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite"); - const chunkStore = tx.objectStore(CHUNKS_STORE); - const metaStore = tx.objectStore(META_STORE); - - chunkStore.delete(keyRangeFor(conversationId)); - metaStore.delete(conversationId); - - await txComplete(tx); - }, - - async index(): Promise<readonly ConversationCacheIndexEntry[]> { - const db = await getDb(); - const tx = db.transaction([CHUNKS_STORE, META_STORE], "readonly"); - const chunkStore = tx.objectStore(CHUNKS_STORE); - const metaStore = tx.objectStore(META_STORE); - - const allChunks = await requestToPromise<ChunkRecord[]>(chunkStore.getAll()); - const allMeta = await requestToPromise<MetaRecord[]>(metaStore.getAll()); - await txComplete(tx); - - const metaMap = new Map<string, number>(); - for (const m of allMeta) { - metaMap.set(m.conversationId, m.lastAccess); - } - - const grouped = new Map<string, { chunkCount: number; maxSeq: number }>(); - for (const r of allChunks) { - const existing = grouped.get(r.conversationId); - if (existing === undefined) { - grouped.set(r.conversationId, { chunkCount: 1, maxSeq: r.seq }); - } else { - existing.chunkCount++; - if (r.seq > existing.maxSeq) { - existing.maxSeq = r.seq; - } - } - } - - const result: ConversationCacheIndexEntry[] = []; - for (const [conversationId, stats] of grouped) { - const lastAccess = metaMap.get(conversationId); - result.push({ - conversationId, - chunkCount: stats.chunkCount, - maxSeq: stats.maxSeq, - ...(lastAccess !== undefined ? { lastAccess } : {}), - }); - } - - return result; - }, - }; + const idb = opts?.indexedDB ?? globalThis.indexedDB; + const dbName = opts?.dbName ?? DEFAULT_DB_NAME; + + let dbPromise: Promise<IDBDatabase> | null = null; + + function getDb(): Promise<IDBDatabase> { + if (dbPromise === null) { + dbPromise = openDb(idb, dbName); + } + return dbPromise; + } + + return { + async load(conversationId: string): Promise<readonly StoredChunk[]> { + const db = await getDb(); + const tx = db.transaction(CHUNKS_STORE, "readonly"); + const store = tx.objectStore(CHUNKS_STORE); + const range = keyRangeFor(conversationId); + const records = await requestToPromise<ChunkRecord[]>(store.getAll(range)); + await txComplete(tx); + + records.sort((a, b) => a.seq - b.seq); + return chunksToStoredChunks(records); + }, + + async append(conversationId: string, chunks: readonly StoredChunk[]): Promise<void> { + if (chunks.length === 0) return; + + const db = await getDb(); + const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite"); + const chunkStore = tx.objectStore(CHUNKS_STORE); + const metaStore = tx.objectStore(META_STORE); + + for (const c of chunks) { + chunkStore.put({ + conversationId, + seq: c.seq, + role: c.role, + chunk: c.chunk, + } satisfies ChunkRecord); + } + + metaStore.put({ + conversationId, + lastAccess: Date.now(), + } satisfies MetaRecord); + + await txComplete(tx); + }, + + async delete(conversationId: string): Promise<void> { + const db = await getDb(); + const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite"); + const chunkStore = tx.objectStore(CHUNKS_STORE); + const metaStore = tx.objectStore(META_STORE); + + chunkStore.delete(keyRangeFor(conversationId)); + metaStore.delete(conversationId); + + await txComplete(tx); + }, + + async index(): Promise<readonly ConversationCacheIndexEntry[]> { + const db = await getDb(); + const tx = db.transaction([CHUNKS_STORE, META_STORE], "readonly"); + const chunkStore = tx.objectStore(CHUNKS_STORE); + const metaStore = tx.objectStore(META_STORE); + + const allChunks = await requestToPromise<ChunkRecord[]>(chunkStore.getAll()); + const allMeta = await requestToPromise<MetaRecord[]>(metaStore.getAll()); + await txComplete(tx); + + const metaMap = new Map<string, number>(); + for (const m of allMeta) { + metaMap.set(m.conversationId, m.lastAccess); + } + + const grouped = new Map<string, { chunkCount: number; maxSeq: number }>(); + for (const r of allChunks) { + const existing = grouped.get(r.conversationId); + if (existing === undefined) { + grouped.set(r.conversationId, { chunkCount: 1, maxSeq: r.seq }); + } else { + existing.chunkCount++; + if (r.seq > existing.maxSeq) { + existing.maxSeq = r.seq; + } + } + } + + const result: ConversationCacheIndexEntry[] = []; + for (const [conversationId, stats] of grouped) { + const lastAccess = metaMap.get(conversationId); + result.push({ + conversationId, + chunkCount: stats.chunkCount, + maxSeq: stats.maxSeq, + ...(lastAccess !== undefined ? { lastAccess } : {}), + }); + } + + return result; + }, + }; } diff --git a/src/adapters/local-storage/index.test.ts b/src/adapters/local-storage/index.test.ts index 57103dd..3370dd7 100644 --- a/src/adapters/local-storage/index.test.ts +++ b/src/adapters/local-storage/index.test.ts @@ -2,119 +2,119 @@ import { describe, expect, it } from "vitest"; import { createLocalStore } from "./index"; function createMemoryStorage(): Storage { - const map = new Map<string, string>(); - return { - get length() { - return map.size; - }, - clear() { - map.clear(); - }, - getItem(key: string) { - return map.get(key) ?? null; - }, - key(index: number) { - return [...map.keys()][index] ?? null; - }, - removeItem(key: string) { - map.delete(key); - }, - setItem(key: string, value: string) { - map.set(key, value); - }, - }; + const map = new Map<string, string>(); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key: string) { + return map.get(key) ?? null; + }, + key(index: number) { + return [...map.keys()][index] ?? null; + }, + removeItem(key: string) { + map.delete(key); + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + }; } describe("createLocalStore", () => { - it("save then load round-trips an object", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<{ name: string; count: number }>("test", { storage }); - - store.save({ name: "alice", count: 42 }); - const loaded = store.load(); - - expect(loaded).toEqual({ name: "alice", count: 42 }); - }); - - it("load returns null when key is absent", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<string>("missing", { storage }); - - expect(store.load()).toBeNull(); - }); - - it("load returns null on corrupt JSON", () => { - const storage = createMemoryStorage(); - storage.setItem("corrupt", "{not valid json!!!"); - const store = createLocalStore<object>("corrupt", { storage }); - - expect(store.load()).toBeNull(); - }); - - it("clear removes the value", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<string>("key", { storage }); - - store.save("hello"); - expect(store.load()).toBe("hello"); - - store.clear(); - expect(store.load()).toBeNull(); - }); - - it("save swallows a throwing setItem (quota) without throwing", () => { - const storage = createMemoryStorage(); - const originalSetItem = storage.setItem.bind(storage); - let callCount = 0; - storage.setItem = (_key: string, _value: string) => { - callCount++; - if (callCount > 1) { - throw new DOMException("QuotaExceededError", "QuotaExceededError"); - } - originalSetItem(_key, _value); - }; - - const store = createLocalStore<number[]>("quota", { storage }); - - // First save works - store.save([1, 2, 3]); - expect(store.load()).toEqual([1, 2, 3]); - - // Second save throws but is swallowed - expect(() => store.save([4, 5, 6])).not.toThrow(); - }); - - it("construction with undefined storage yields a safe no-op store", () => { - const store = createLocalStore<string>("noop", { storage: undefined }); - - // All operations are safe no-ops - expect(store.load()).toBeNull(); - expect(() => store.save("hello")).not.toThrow(); - expect(() => store.clear()).not.toThrow(); - }); - - it("round-trips arrays", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<number[]>("arr", { storage }); - - store.save([1, 2, 3]); - expect(store.load()).toEqual([1, 2, 3]); - }); - - it("round-trips nested objects", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<{ a: { b: string[] } }>("nested", { storage }); - - store.save({ a: { b: ["x", "y"] } }); - expect(store.load()).toEqual({ a: { b: ["x", "y"] } }); - }); - - it("overwrites previous value on repeated save", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<string>("key", { storage }); - - store.save("first"); - store.save("second"); - expect(store.load()).toBe("second"); - }); + it("save then load round-trips an object", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<{ name: string; count: number }>("test", { storage }); + + store.save({ name: "alice", count: 42 }); + const loaded = store.load(); + + expect(loaded).toEqual({ name: "alice", count: 42 }); + }); + + it("load returns null when key is absent", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<string>("missing", { storage }); + + expect(store.load()).toBeNull(); + }); + + it("load returns null on corrupt JSON", () => { + const storage = createMemoryStorage(); + storage.setItem("corrupt", "{not valid json!!!"); + const store = createLocalStore<object>("corrupt", { storage }); + + expect(store.load()).toBeNull(); + }); + + it("clear removes the value", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<string>("key", { storage }); + + store.save("hello"); + expect(store.load()).toBe("hello"); + + store.clear(); + expect(store.load()).toBeNull(); + }); + + it("save swallows a throwing setItem (quota) without throwing", () => { + const storage = createMemoryStorage(); + const originalSetItem = storage.setItem.bind(storage); + let callCount = 0; + storage.setItem = (_key: string, _value: string) => { + callCount++; + if (callCount > 1) { + throw new DOMException("QuotaExceededError", "QuotaExceededError"); + } + originalSetItem(_key, _value); + }; + + const store = createLocalStore<number[]>("quota", { storage }); + + // First save works + store.save([1, 2, 3]); + expect(store.load()).toEqual([1, 2, 3]); + + // Second save throws but is swallowed + expect(() => store.save([4, 5, 6])).not.toThrow(); + }); + + it("construction with undefined storage yields a safe no-op store", () => { + const store = createLocalStore<string>("noop", { storage: undefined }); + + // All operations are safe no-ops + expect(store.load()).toBeNull(); + expect(() => store.save("hello")).not.toThrow(); + expect(() => store.clear()).not.toThrow(); + }); + + it("round-trips arrays", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<number[]>("arr", { storage }); + + store.save([1, 2, 3]); + expect(store.load()).toEqual([1, 2, 3]); + }); + + it("round-trips nested objects", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<{ a: { b: string[] } }>("nested", { storage }); + + store.save({ a: { b: ["x", "y"] } }); + expect(store.load()).toEqual({ a: { b: ["x", "y"] } }); + }); + + it("overwrites previous value on repeated save", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<string>("key", { storage }); + + store.save("first"); + store.save("second"); + expect(store.load()).toBe("second"); + }); }); diff --git a/src/adapters/local-storage/index.ts b/src/adapters/local-storage/index.ts index 72135ce..9dd2ffd 100644 --- a/src/adapters/local-storage/index.ts +++ b/src/adapters/local-storage/index.ts @@ -1,58 +1,58 @@ export interface LocalStore<T> { - load(): T | null; - save(value: T): void; - clear(): void; + load(): T | null; + save(value: T): void; + clear(): void; } export interface CreateLocalStoreOptions { - storage?: Storage | undefined; + storage?: Storage | undefined; } function createNoopStore<T>(): LocalStore<T> { - return { - load() { - return null; - }, - save() {}, - clear() {}, - }; + return { + load() { + return null; + }, + save() {}, + clear() {}, + }; } export function createLocalStore<T>(key: string, opts?: CreateLocalStoreOptions): LocalStore<T> { - let storage: Storage | undefined; - if (opts !== undefined && "storage" in opts) { - storage = opts.storage; - } else { - storage = globalThis.localStorage; - } + let storage: Storage | undefined; + if (opts !== undefined && "storage" in opts) { + storage = opts.storage; + } else { + storage = globalThis.localStorage; + } - if (storage === undefined || storage === null) { - return createNoopStore<T>(); - } + if (storage === undefined || storage === null) { + return createNoopStore<T>(); + } - return { - load(): T | null { - try { - const raw = storage.getItem(key); - if (raw === null) { - return null; - } - return JSON.parse(raw) as T; - } catch { - return null; - } - }, + return { + load(): T | null { + try { + const raw = storage.getItem(key); + if (raw === null) { + return null; + } + return JSON.parse(raw) as T; + } catch { + return null; + } + }, - save(value: T): void { - try { - storage.setItem(key, JSON.stringify(value)); - } catch { - // Swallow quota / write errors — persistence is best-effort. - } - }, + save(value: T): void { + try { + storage.setItem(key, JSON.stringify(value)); + } catch { + // Swallow quota / write errors — persistence is best-effort. + } + }, - clear(): void { - storage.removeItem(key); - }, - }; + clear(): void { + storage.removeItem(key); + }, + }; } diff --git a/src/adapters/portal.test.ts b/src/adapters/portal.test.ts new file mode 100644 index 0000000..a5624d5 --- /dev/null +++ b/src/adapters/portal.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { portal } from "./portal"; + +describe("portal action", () => { + afterEach(() => { + // Strip any leftover teleported nodes between tests. + document.querySelectorAll("body > :not(script)").forEach((n) => { + if (n instanceof HTMLElement) n.remove(); + }); + }); + + it("teleports the node to document.body (escaping an ancestor with transform)", () => { + // Simulate the sidebar: a transformed ancestor establishes a containing + // block for `position: fixed`. + const ancestor = document.createElement("div"); + ancestor.style.transform = "translateX(0)"; + document.body.appendChild(ancestor); + + const node = document.createElement("div"); + node.setAttribute("data-testid", "modal"); + ancestor.appendChild(node); + expect(node.parentNode).toBe(ancestor); + + const action = portal(node); + + // After the action, the node is a direct child of <body>, not the ancestor. + expect(node.parentNode).toBe(document.body); + expect(ancestor.contains(node)).toBe(false); + + action.destroy(); + + // On destroy the node is removed from <body>. + expect(document.body.contains(node)).toBe(false); + }); + + it("is a no-op (does not throw) when document is unavailable (SSR guard)", () => { + const originalDocument = globalThis.document; + // @ts-expect-error — deliberately undefined to exercise the SSR guard. + globalThis.document = undefined; + try { + const stub = {} as HTMLElement; + const action = portal(stub); + // Must not throw, and returns a destroy that is safe to call. + action.destroy(); + } finally { + globalThis.document = originalDocument; + } + }); +}); diff --git a/src/adapters/portal.ts b/src/adapters/portal.ts new file mode 100644 index 0000000..afe42e7 --- /dev/null +++ b/src/adapters/portal.ts @@ -0,0 +1,28 @@ +/** + * A Svelte `use:` action that teleports a node to `document.body`, escaping any + * ancestor that establishes a containing block for `position: fixed` (most + * commonly an ancestor with a `transform`, `filter`, `perspective`, or + * `will-change` — e.g. the sidebar's `transform: translateX(...)` container). + * + * Without this, a `position: fixed` modal rendered inside such an ancestor is + * positioned relative to the ANCESTOR, not the viewport (so it only covers the + * sidebar area instead of the full screen). Moving the node to `document.body` + * restores viewport-relative `fixed` positioning. Svelte still owns the node's + * lifecycle (children, bindings, events); we just relocate it + remove it on + * destroy as hygiene. + * + * No-op safely when there is no `document` (SSR / jsdom guards). + */ +export function portal(node: HTMLElement): { destroy(): void } { + if (typeof document === "undefined") { + return { destroy() {} }; + } + document.body.appendChild(node); + return { + destroy() { + if (node.parentNode === document.body) { + document.body.removeChild(node); + } + }, + }; +} diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts index 92d57a8..9d821e2 100644 --- a/src/adapters/ws/index.test.ts +++ b/src/adapters/ws/index.test.ts @@ -3,396 +3,405 @@ import type { WebSocketLike } from "./index"; import { createSurfaceSocket } from "./index"; interface FakeSocket extends WebSocketLike { - sent: string[]; - resolveOpen(): void; - invokeMessage(data: string): void; - invokeClose(): void; + sent: string[]; + resolveOpen(): void; + invokeMessage(data: string): void; + invokeClose(): void; } function fakeSocket(): FakeSocket { - let onopen: (() => void) | null = null; - let onmessage: ((ev: { data: string }) => void) | null = null; - let onclose: ((ev: { code: number; reason: string }) => void) | null = null; - const sent: string[] = []; - - const ws: FakeSocket = { - send(data: string) { - sent.push(data); - }, - close() {}, - get onopen() { - return onopen; - }, - set onopen(fn) { - onopen = fn; - }, - get onmessage() { - return onmessage; - }, - set onmessage(fn) { - onmessage = fn; - }, - get onclose() { - return onclose; - }, - set onclose(fn) { - onclose = fn; - }, - resolveOpen() { - onopen?.(); - }, - invokeMessage(data: string) { - onmessage?.({ data }); - }, - invokeClose() { - onclose?.({ code: 1000, reason: "" }); - }, - sent, - }; - return ws; + let onopen: (() => void) | null = null; + let onmessage: ((ev: { data: string }) => void) | null = null; + let onclose: ((ev: { code: number; reason: string }) => void) | null = null; + const sent: string[] = []; + + const ws: FakeSocket = { + send(data: string) { + sent.push(data); + }, + close() {}, + get onopen() { + return onopen; + }, + set onopen(fn) { + onopen = fn; + }, + get onmessage() { + return onmessage; + }, + set onmessage(fn) { + onmessage = fn; + }, + get onclose() { + return onclose; + }, + set onclose(fn) { + onclose = fn; + }, + resolveOpen() { + onopen?.(); + }, + invokeMessage(data: string) { + onmessage?.({ data }); + }, + invokeClose() { + onclose?.({ code: 1000, reason: "" }); + }, + sent, + }; + return ws; } describe("createSurfaceSocket", () => { - it("sends queued messages once socket opens", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage, - socketFactory: () => ws, - }); - - handle.send({ type: "subscribe", surfaceId: "s1" }); - handle.send({ type: "subscribe", surfaceId: "s2" }); - expect(ws.sent).toHaveLength(0); - - ws.resolveOpen(); - expect(ws.sent).toHaveLength(2); - expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "s1" }); - expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "s2" }); - }); - - it("sends immediately when socket is already open", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.sent.length = 0; - - handle.send({ type: "subscribe", surfaceId: "s1" }); - expect(ws.sent).toHaveLength(1); - }); - - it("routes inbound messages to onMessage via parseServerMessage", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); - expect(onMessage).toHaveBeenCalledOnce(); - expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); - }); - - it("drops malformed inbound messages silently", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage("not json"); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("auto-reconnects on close and fires onReopen after successful reconnect", () => { - vi.useFakeTimers(); - try { - const sockets: ReturnType<typeof fakeSocket>[] = []; - const onMessage = vi.fn(); - const onReopen = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onReopen, - socketFactory: () => { - const ws = fakeSocket(); - sockets.push(ws); - return ws; - }, - }); - - expect(sockets).toHaveLength(1); - sockets[0]?.resolveOpen(); - - // Simulate close - sockets[0]?.invokeClose(); - - // Fast-forward past the backoff delay - vi.advanceTimersByTime(600); - - expect(sockets).toHaveLength(2); - // onReopen should NOT have fired yet (socket not open) - expect(onReopen).not.toHaveBeenCalled(); - - sockets[1]?.resolveOpen(); - expect(onReopen).toHaveBeenCalledOnce(); - } finally { - vi.useRealTimers(); - } - }); - - it("does not fire onReopen on initial connect", () => { - const ws = fakeSocket(); - const onReopen = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - onReopen, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - expect(onReopen).not.toHaveBeenCalled(); - }); - - it("close() prevents further reconnects", () => { - vi.useFakeTimers(); - try { - const sockets: ReturnType<typeof fakeSocket>[] = []; - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => { - const ws = fakeSocket(); - sockets.push(ws); - return ws; - }, - }); - - sockets[0]?.resolveOpen(); - sockets[0]?.invokeClose(); - handle.close(); - - vi.advanceTimersByTime(10_000); - expect(sockets).toHaveLength(1); - } finally { - vi.useRealTimers(); - } - }); - - it("close() prevents further sends", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.sent.length = 0; - handle.close(); - - handle.send({ type: "subscribe", surfaceId: "s1" }); - expect(ws.sent).toHaveLength(0); - }); - - it("queues multiple sends before open and flushes in order", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - handle.send({ type: "subscribe", surfaceId: "a" }); - handle.send({ type: "subscribe", surfaceId: "b" }); - handle.send({ type: "invoke", surfaceId: "a", actionId: "x", payload: 1 }); - ws.resolveOpen(); - - expect(ws.sent).toHaveLength(3); - expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "a" }); - expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "b" }); - expect(JSON.parse(ws.sent[2] ?? "")).toEqual({ - type: "invoke", - surfaceId: "a", - actionId: "x", - payload: 1, - }); - }); - - it("routes chat.delta to onChat", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const onChat = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onChat, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" }; - ws.invokeMessage(JSON.stringify({ type: "chat.delta", event })); - expect(onChat).toHaveBeenCalledOnce(); - expect(onChat).toHaveBeenCalledWith({ type: "chat.delta", event }); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("routes chat.error to onChat", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const onChat = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onChat, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "bad request" })); - expect(onChat).toHaveBeenCalledOnce(); - expect(onChat).toHaveBeenCalledWith({ type: "chat.error", message: "bad request" }); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("routes conversation.open to onConversationOpen", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const onChat = vi.fn(); - const onConversationOpen = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onChat, - onConversationOpen, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage(JSON.stringify({ type: "conversation.open", conversationId: "c1" })); - expect(onConversationOpen).toHaveBeenCalledOnce(); - expect(onConversationOpen).toHaveBeenCalledWith({ - type: "conversation.open", - conversationId: "c1", - }); - expect(onMessage).not.toHaveBeenCalled(); - expect(onChat).not.toHaveBeenCalled(); - }); - - it("routes conversation.statusChanged to onConversationStatusChanged", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const onConversationStatusChanged = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onConversationStatusChanged, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage( - JSON.stringify({ - type: "conversation.statusChanged", - conversationId: "c1", - status: "active", - }), - ); - expect(onConversationStatusChanged).toHaveBeenCalledOnce(); - expect(onConversationStatusChanged).toHaveBeenCalledWith({ - type: "conversation.statusChanged", - conversationId: "c1", - status: "active", - }); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("still routes surface catalog/surface to onMessage", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const onChat = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onChat, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); - expect(onMessage).toHaveBeenCalledOnce(); - expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); - expect(onChat).not.toHaveBeenCalled(); - - ws.invokeMessage( - JSON.stringify({ type: "surface", spec: { id: "s1", region: "r", title: "S", fields: [] } }), - ); - expect(onMessage).toHaveBeenCalledTimes(2); - }); - - it("send accepts and serializes a chat.send message", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - ws.resolveOpen(); - handle.send({ type: "chat.send", message: "hello" }); - expect(ws.sent).toHaveLength(1); - expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "hello" }); - }); - - it("onChat absent is safe (surface-only usage does not throw)", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - expect(() => { - ws.invokeMessage( - JSON.stringify({ - type: "chat.delta", - event: { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "x" }, - }), - ); - ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "boom" })); - }).not.toThrow(); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("chat send is queued until open then flushed", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - handle.send({ type: "chat.send", message: "queued" }); - expect(ws.sent).toHaveLength(0); - - ws.resolveOpen(); - expect(ws.sent).toHaveLength(1); - expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "queued" }); - }); + it("sends queued messages once socket opens", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + handle.send({ type: "subscribe", surfaceId: "s1" }); + handle.send({ type: "subscribe", surfaceId: "s2" }); + expect(ws.sent).toHaveLength(0); + + ws.resolveOpen(); + expect(ws.sent).toHaveLength(2); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "s1" }); + expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "s2" }); + }); + + it("sends immediately when socket is already open", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.sent.length = 0; + + handle.send({ type: "subscribe", surfaceId: "s1" }); + expect(ws.sent).toHaveLength(1); + }); + + it("routes inbound messages to onMessage via parseServerMessage", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); + expect(onMessage).toHaveBeenCalledOnce(); + expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); + }); + + it("drops malformed inbound messages silently", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage("not json"); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("auto-reconnects on close and fires onReopen after successful reconnect", () => { + vi.useFakeTimers(); + try { + const sockets: ReturnType<typeof fakeSocket>[] = []; + const onMessage = vi.fn(); + const onReopen = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onReopen, + socketFactory: () => { + const ws = fakeSocket(); + sockets.push(ws); + return ws; + }, + }); + + expect(sockets).toHaveLength(1); + sockets[0]?.resolveOpen(); + + // Simulate close + sockets[0]?.invokeClose(); + + // Fast-forward past the backoff delay + vi.advanceTimersByTime(600); + + expect(sockets).toHaveLength(2); + // onReopen should NOT have fired yet (socket not open) + expect(onReopen).not.toHaveBeenCalled(); + + sockets[1]?.resolveOpen(); + expect(onReopen).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not fire onReopen on initial connect", () => { + const ws = fakeSocket(); + const onReopen = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + onReopen, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + expect(onReopen).not.toHaveBeenCalled(); + }); + + it("close() prevents further reconnects", () => { + vi.useFakeTimers(); + try { + const sockets: ReturnType<typeof fakeSocket>[] = []; + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => { + const ws = fakeSocket(); + sockets.push(ws); + return ws; + }, + }); + + sockets[0]?.resolveOpen(); + sockets[0]?.invokeClose(); + handle.close(); + + vi.advanceTimersByTime(10_000); + expect(sockets).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("close() prevents further sends", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.sent.length = 0; + handle.close(); + + handle.send({ type: "subscribe", surfaceId: "s1" }); + expect(ws.sent).toHaveLength(0); + }); + + it("queues multiple sends before open and flushes in order", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + handle.send({ type: "subscribe", surfaceId: "a" }); + handle.send({ type: "subscribe", surfaceId: "b" }); + handle.send({ type: "invoke", surfaceId: "a", actionId: "x", payload: 1 }); + ws.resolveOpen(); + + expect(ws.sent).toHaveLength(3); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "a" }); + expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "b" }); + expect(JSON.parse(ws.sent[2] ?? "")).toEqual({ + type: "invoke", + surfaceId: "a", + actionId: "x", + payload: 1, + }); + }); + + it("routes chat.delta to onChat", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onChat = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onChat, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" }; + ws.invokeMessage(JSON.stringify({ type: "chat.delta", event })); + expect(onChat).toHaveBeenCalledOnce(); + expect(onChat).toHaveBeenCalledWith({ type: "chat.delta", event }); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("routes chat.error to onChat", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onChat = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onChat, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "bad request" })); + expect(onChat).toHaveBeenCalledOnce(); + expect(onChat).toHaveBeenCalledWith({ type: "chat.error", message: "bad request" }); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("routes conversation.open to onConversationOpen", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onChat = vi.fn(); + const onConversationOpen = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onChat, + onConversationOpen, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }), + ); + expect(onConversationOpen).toHaveBeenCalledOnce(); + expect(onConversationOpen).toHaveBeenCalledWith({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); + expect(onMessage).not.toHaveBeenCalled(); + expect(onChat).not.toHaveBeenCalled(); + }); + + it("routes conversation.statusChanged to onConversationStatusChanged", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onConversationStatusChanged = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onConversationStatusChanged, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: "w1", + }), + ); + expect(onConversationStatusChanged).toHaveBeenCalledOnce(); + expect(onConversationStatusChanged).toHaveBeenCalledWith({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: "w1", + }); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("still routes surface catalog/surface to onMessage", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onChat = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onChat, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); + expect(onMessage).toHaveBeenCalledOnce(); + expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); + expect(onChat).not.toHaveBeenCalled(); + + ws.invokeMessage( + JSON.stringify({ type: "surface", spec: { id: "s1", region: "r", title: "S", fields: [] } }), + ); + expect(onMessage).toHaveBeenCalledTimes(2); + }); + + it("send accepts and serializes a chat.send message", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + ws.resolveOpen(); + handle.send({ type: "chat.send", message: "hello" }); + expect(ws.sent).toHaveLength(1); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "hello" }); + }); + + it("onChat absent is safe (surface-only usage does not throw)", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + expect(() => { + ws.invokeMessage( + JSON.stringify({ + type: "chat.delta", + event: { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "x" }, + }), + ); + ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "boom" })); + }).not.toThrow(); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("chat send is queued until open then flushed", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + handle.send({ type: "chat.send", message: "queued" }); + expect(ws.sent).toHaveLength(0); + + ws.resolveOpen(); + expect(ws.sent).toHaveLength(1); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "queued" }); + }); }); diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts index d2bc13d..1309db0 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -1,123 +1,123 @@ import type { - ChatDeltaMessage, - ChatErrorMessage, - ConversationCompactedMessage, - ConversationOpenMessage, - ConversationStatusChangedMessage, - WsClientMessage, + ChatDeltaMessage, + ChatErrorMessage, + ConversationCompactedMessage, + ConversationOpenMessage, + ConversationStatusChangedMessage, + WsClientMessage, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; import { nextBackoffMs, parseServerMessage, serialize } from "./logic"; export interface WebSocketLike { - send(data: string): void; - close(): void; - onopen: (() => void) | null; - onmessage: ((ev: { data: string }) => void) | null; - onclose: ((ev: { code: number; reason: string }) => void) | null; + send(data: string): void; + close(): void; + onopen: (() => void) | null; + onmessage: ((ev: { data: string }) => void) | null; + onclose: ((ev: { code: number; reason: string }) => void) | null; } export interface SurfaceSocketOptions { - url: string; - onMessage: (msg: SurfaceServerMessage) => void; - onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void; - /** Broadcast when a conversation is "opened" (e.g. CLI `--open` flag). */ - onConversationOpen?: (msg: ConversationOpenMessage) => void; - /** Broadcast when a conversation's lifecycle status changes (active/idle/closed). */ - onConversationStatusChanged?: (msg: ConversationStatusChangedMessage) => void; - /** Broadcast when a conversation's history has been compacted (reload needed). */ - onConversationCompacted?: (msg: ConversationCompactedMessage) => void; - onReopen?: () => void; - socketFactory?: (url: string) => WebSocketLike; + url: string; + onMessage: (msg: SurfaceServerMessage) => void; + onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void; + /** Broadcast when a conversation is "opened" (e.g. CLI `--open` flag). */ + onConversationOpen?: (msg: ConversationOpenMessage) => void; + /** Broadcast when a conversation's lifecycle status changes (active/idle/closed). */ + onConversationStatusChanged?: (msg: ConversationStatusChangedMessage) => void; + /** Broadcast when a conversation's history has been compacted (reload needed). */ + onConversationCompacted?: (msg: ConversationCompactedMessage) => void; + onReopen?: () => void; + socketFactory?: (url: string) => WebSocketLike; } export interface SurfaceSocketHandle { - send(msg: WsClientMessage): void; - close(): void; + send(msg: WsClientMessage): void; + close(): void; } export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHandle { - const factory = - opts.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as WebSocketLike); + const factory = + opts.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as WebSocketLike); - let socket: WebSocketLike | null = null; - let disposed = false; - let reconnectAttempt = 0; - let reconnectTimer: ReturnType<typeof setTimeout> | null = null; - let isOpen = false; - const queue: string[] = []; + let socket: WebSocketLike | null = null; + let disposed = false; + let reconnectAttempt = 0; + let reconnectTimer: ReturnType<typeof setTimeout> | null = null; + let isOpen = false; + const queue: string[] = []; - function connect(isReconnect: boolean): void { - socket = factory(opts.url); - isOpen = false; + function connect(isReconnect: boolean): void { + socket = factory(opts.url); + isOpen = false; - socket.onopen = () => { - if (disposed) return; - isOpen = true; - reconnectAttempt = 0; - for (const raw of queue.splice(0)) { - socket?.send(raw); - } - if (isReconnect) { - opts.onReopen?.(); - } - }; + socket.onopen = () => { + if (disposed) return; + isOpen = true; + reconnectAttempt = 0; + for (const raw of queue.splice(0)) { + socket?.send(raw); + } + if (isReconnect) { + opts.onReopen?.(); + } + }; - socket.onmessage = (ev) => { - if (disposed) return; - const msg = parseServerMessage(ev.data); - if (msg !== null) { - if (msg.type === "chat.delta" || msg.type === "chat.error") { - opts.onChat?.(msg as ChatDeltaMessage | ChatErrorMessage); - } else if (msg.type === "conversation.open") { - opts.onConversationOpen?.(msg as ConversationOpenMessage); - } else if (msg.type === "conversation.statusChanged") { - opts.onConversationStatusChanged?.(msg as ConversationStatusChangedMessage); - } else if (msg.type === "conversation.compacted") { - opts.onConversationCompacted?.(msg as ConversationCompactedMessage); - } else { - opts.onMessage(msg as SurfaceServerMessage); - } - } - }; + socket.onmessage = (ev) => { + if (disposed) return; + const msg = parseServerMessage(ev.data); + if (msg !== null) { + if (msg.type === "chat.delta" || msg.type === "chat.error") { + opts.onChat?.(msg as ChatDeltaMessage | ChatErrorMessage); + } else if (msg.type === "conversation.open") { + opts.onConversationOpen?.(msg as ConversationOpenMessage); + } else if (msg.type === "conversation.statusChanged") { + opts.onConversationStatusChanged?.(msg as ConversationStatusChangedMessage); + } else if (msg.type === "conversation.compacted") { + opts.onConversationCompacted?.(msg as ConversationCompactedMessage); + } else { + opts.onMessage(msg as SurfaceServerMessage); + } + } + }; - socket.onclose = () => { - if (disposed) return; - isOpen = false; - scheduleReconnect(); - }; - } + socket.onclose = () => { + if (disposed) return; + isOpen = false; + scheduleReconnect(); + }; + } - function scheduleReconnect(): void { - const delay = nextBackoffMs(reconnectAttempt); - reconnectAttempt++; - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - if (disposed) return; - connect(true); - }, delay); - } + function scheduleReconnect(): void { + const delay = nextBackoffMs(reconnectAttempt); + reconnectAttempt++; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (disposed) return; + connect(true); + }, delay); + } - connect(false); + connect(false); - return { - send(msg: WsClientMessage): void { - if (disposed) return; - const raw = serialize(msg); - if (isOpen) { - socket?.send(raw); - } else { - queue.push(raw); - } - }, - close(): void { - disposed = true; - if (reconnectTimer !== null) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - socket?.close(); - socket = null; - }, - }; + return { + send(msg: WsClientMessage): void { + if (disposed) return; + const raw = serialize(msg); + if (isOpen) { + socket?.send(raw); + } else { + queue.push(raw); + } + }, + close(): void { + disposed = true; + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + socket?.close(); + socket = null; + }, + }; } diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index 2463519..dd2b773 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -2,323 +2,399 @@ import { describe, expect, it } from "vitest"; import { nextBackoffMs, parseServerMessage, serialize } from "./logic"; describe("serialize", () => { - it("serializes a subscribe message", () => { - const msg = { type: "subscribe" as const, surfaceId: "s1" }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes an unsubscribe message", () => { - const msg = { type: "unsubscribe" as const, surfaceId: "s1" }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes an invoke message with payload", () => { - const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: true }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes an invoke message without payload", () => { - const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "click" }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes a chat.send message", () => { - const msg = { type: "chat.send" as const, message: "hello" }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes a chat.send message with all fields", () => { - const msg = { - type: "chat.send" as const, - conversationId: "c1", - message: "hello", - model: "openai/gpt-4", - cwd: "/tmp", - }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); + it("serializes a subscribe message", () => { + const msg = { type: "subscribe" as const, surfaceId: "s1" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes an unsubscribe message", () => { + const msg = { type: "unsubscribe" as const, surfaceId: "s1" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes an invoke message with payload", () => { + const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: true }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes an invoke message without payload", () => { + const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "click" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes a chat.send message", () => { + const msg = { type: "chat.send" as const, message: "hello" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes a chat.send message with all fields", () => { + const msg = { + type: "chat.send" as const, + conversationId: "c1", + message: "hello", + model: "openai/gpt-4", + cwd: "/tmp", + }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); }); describe("parseServerMessage", () => { - it("parses a catalog message", () => { - const data = JSON.stringify({ - type: "catalog", - catalog: [{ id: "s1", region: "r", title: "S1" }], - }); - const result = parseServerMessage(data); - expect(result).toEqual({ - type: "catalog", - catalog: [{ id: "s1", region: "r", title: "S1" }], - }); - }); - - it("parses a surface message", () => { - const data = JSON.stringify({ - type: "surface", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - }); - const result = parseServerMessage(data); - expect(result).toEqual({ - type: "surface", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - }); - }); - - it("preserves the conversationId echo on a scoped surface message", () => { - const data = JSON.stringify({ - type: "surface", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - conversationId: "c1", - }); - const result = parseServerMessage(data); - expect(result).toEqual({ - type: "surface", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - conversationId: "c1", - }); - }); - - it("rejects a surface message with a non-string conversationId", () => { - const data = JSON.stringify({ - type: "surface", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - conversationId: 42, - }); - expect(parseServerMessage(data)).toBeNull(); - }); - - it("parses an update message", () => { - const data = JSON.stringify({ - type: "update", - update: { - surfaceId: "s1", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - }, - }); - const result = parseServerMessage(data); - expect(result).toEqual({ - type: "update", - update: { - surfaceId: "s1", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - }, - }); - }); - - it("parses an error message with surfaceId", () => { - const data = JSON.stringify({ type: "error", surfaceId: "s1", message: "boom" }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "error", surfaceId: "s1", message: "boom" }); - }); - - it("parses an error message without surfaceId", () => { - const data = JSON.stringify({ type: "error", message: "global boom" }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "error", message: "global boom" }); - }); - - it("returns null for malformed JSON", () => { - expect(parseServerMessage("not json")).toBeNull(); - expect(parseServerMessage("{broken")).toBeNull(); - expect(parseServerMessage("")).toBeNull(); - }); - - it("returns null for non-object JSON", () => { - expect(parseServerMessage("42")).toBeNull(); - expect(parseServerMessage('"hello"')).toBeNull(); - expect(parseServerMessage("null")).toBeNull(); - expect(parseServerMessage("true")).toBeNull(); - expect(parseServerMessage("[1,2,3]")).toBeNull(); - }); - - it("returns null for unknown type", () => { - expect(parseServerMessage(JSON.stringify({ type: "unknown" }))).toBeNull(); - }); - - it("returns null when type is missing", () => { - expect(parseServerMessage(JSON.stringify({ foo: "bar" }))).toBeNull(); - }); - - it("returns null when type is not a string", () => { - expect(parseServerMessage(JSON.stringify({ type: 42 }))).toBeNull(); - }); - - it("returns null for catalog with non-array catalog field", () => { - expect(parseServerMessage(JSON.stringify({ type: "catalog", catalog: "nope" }))).toBeNull(); - }); - - it("returns null for surface with missing spec fields", () => { - expect(parseServerMessage(JSON.stringify({ type: "surface", spec: { id: "s1" } }))).toBeNull(); - }); - - it("returns null for surface with non-object spec", () => { - expect(parseServerMessage(JSON.stringify({ type: "surface", spec: "nope" }))).toBeNull(); - }); - - it("returns null for update with missing update field", () => { - expect(parseServerMessage(JSON.stringify({ type: "update" }))).toBeNull(); - }); - - it("returns null for update with invalid spec", () => { - expect( - parseServerMessage(JSON.stringify({ type: "update", update: { surfaceId: "s1", spec: {} } })), - ).toBeNull(); - }); - - it("returns null for error with non-string message", () => { - expect(parseServerMessage(JSON.stringify({ type: "error", message: 42 }))).toBeNull(); - }); - - it("returns null for error with invalid surfaceId type", () => { - expect( - parseServerMessage(JSON.stringify({ type: "error", surfaceId: 42, message: "boom" })), - ).toBeNull(); - }); - - it("parses a chat.delta message", () => { - const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hello" }; - const data = JSON.stringify({ type: "chat.delta", event }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "chat.delta", event }); - }); - - it("parses a chat.error message with conversationId", () => { - const data = JSON.stringify({ - type: "chat.error", - conversationId: "c1", - message: "bad request", - }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "chat.error", conversationId: "c1", message: "bad request" }); - }); - - it("parses a chat.error message without conversationId", () => { - const data = JSON.stringify({ type: "chat.error", message: "no conversation" }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "chat.error", message: "no conversation" }); - }); - - it("returns null for chat.delta with non-object event", () => { - expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: "nope" }))).toBeNull(); - }); - - it("returns null for chat.delta with missing event.type", () => { - expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: {} }))).toBeNull(); - }); - - it("returns null for chat.error with non-string message", () => { - expect(parseServerMessage(JSON.stringify({ type: "chat.error", message: 42 }))).toBeNull(); - }); - - it("returns null for chat.error with invalid conversationId type", () => { - expect( - parseServerMessage( - JSON.stringify({ type: "chat.error", conversationId: 42, message: "boom" }), - ), - ).toBeNull(); - }); - - it("parses a conversation.open message", () => { - const data = JSON.stringify({ type: "conversation.open", conversationId: "c1" }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "conversation.open", conversationId: "c1" }); - }); - - it("returns null for conversation.open with missing conversationId", () => { - expect(parseServerMessage(JSON.stringify({ type: "conversation.open" }))).toBeNull(); - }); - - it("returns null for conversation.open with non-string conversationId", () => { - expect( - parseServerMessage(JSON.stringify({ type: "conversation.open", conversationId: 42 })), - ).toBeNull(); - }); - - it("parses a conversation.statusChanged message", () => { - const data = JSON.stringify({ - type: "conversation.statusChanged", - conversationId: "c1", - status: "active", - }); - expect(parseServerMessage(data)).toEqual({ - type: "conversation.statusChanged", - conversationId: "c1", - status: "active", - }); - }); - - it("returns null for conversation.statusChanged with invalid status", () => { - expect( - parseServerMessage( - JSON.stringify({ - type: "conversation.statusChanged", - conversationId: "c1", - status: "done", - }), - ), - ).toBeNull(); - }); - - it("returns null for conversation.statusChanged with missing conversationId", () => { - expect( - parseServerMessage(JSON.stringify({ type: "conversation.statusChanged", status: "idle" })), - ).toBeNull(); - }); + it("parses a catalog message", () => { + const data = JSON.stringify({ + type: "catalog", + catalog: [{ id: "s1", region: "r", title: "S1" }], + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "catalog", + catalog: [{ id: "s1", region: "r", title: "S1" }], + }); + }); + + it("parses a surface message", () => { + const data = JSON.stringify({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }); + }); + + it("preserves the conversationId echo on a scoped surface message", () => { + const data = JSON.stringify({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + conversationId: "c1", + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + conversationId: "c1", + }); + }); + + it("rejects a surface message with a non-string conversationId", () => { + const data = JSON.stringify({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + conversationId: 42, + }); + expect(parseServerMessage(data)).toBeNull(); + }); + + it("parses an update message", () => { + const data = JSON.stringify({ + type: "update", + update: { + surfaceId: "s1", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }, + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "update", + update: { + surfaceId: "s1", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }, + }); + }); + + it("parses an error message with surfaceId", () => { + const data = JSON.stringify({ type: "error", surfaceId: "s1", message: "boom" }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "error", surfaceId: "s1", message: "boom" }); + }); + + it("parses an error message without surfaceId", () => { + const data = JSON.stringify({ type: "error", message: "global boom" }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "error", message: "global boom" }); + }); + + it("returns null for malformed JSON", () => { + expect(parseServerMessage("not json")).toBeNull(); + expect(parseServerMessage("{broken")).toBeNull(); + expect(parseServerMessage("")).toBeNull(); + }); + + it("returns null for non-object JSON", () => { + expect(parseServerMessage("42")).toBeNull(); + expect(parseServerMessage('"hello"')).toBeNull(); + expect(parseServerMessage("null")).toBeNull(); + expect(parseServerMessage("true")).toBeNull(); + expect(parseServerMessage("[1,2,3]")).toBeNull(); + }); + + it("returns null for unknown type", () => { + expect(parseServerMessage(JSON.stringify({ type: "unknown" }))).toBeNull(); + }); + + it("returns null when type is missing", () => { + expect(parseServerMessage(JSON.stringify({ foo: "bar" }))).toBeNull(); + }); + + it("returns null when type is not a string", () => { + expect(parseServerMessage(JSON.stringify({ type: 42 }))).toBeNull(); + }); + + it("returns null for catalog with non-array catalog field", () => { + expect(parseServerMessage(JSON.stringify({ type: "catalog", catalog: "nope" }))).toBeNull(); + }); + + it("returns null for surface with missing spec fields", () => { + expect(parseServerMessage(JSON.stringify({ type: "surface", spec: { id: "s1" } }))).toBeNull(); + }); + + it("returns null for surface with non-object spec", () => { + expect(parseServerMessage(JSON.stringify({ type: "surface", spec: "nope" }))).toBeNull(); + }); + + it("returns null for update with missing update field", () => { + expect(parseServerMessage(JSON.stringify({ type: "update" }))).toBeNull(); + }); + + it("returns null for update with invalid spec", () => { + expect( + parseServerMessage(JSON.stringify({ type: "update", update: { surfaceId: "s1", spec: {} } })), + ).toBeNull(); + }); + + it("returns null for error with non-string message", () => { + expect(parseServerMessage(JSON.stringify({ type: "error", message: 42 }))).toBeNull(); + }); + + it("returns null for error with invalid surfaceId type", () => { + expect( + parseServerMessage(JSON.stringify({ type: "error", surfaceId: 42, message: "boom" })), + ).toBeNull(); + }); + + it("parses a chat.delta message", () => { + const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hello" }; + const data = JSON.stringify({ type: "chat.delta", event }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "chat.delta", event }); + }); + + it("parses a chat.error message with conversationId", () => { + const data = JSON.stringify({ + type: "chat.error", + conversationId: "c1", + message: "bad request", + }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "chat.error", conversationId: "c1", message: "bad request" }); + }); + + it("parses a chat.error message without conversationId", () => { + const data = JSON.stringify({ type: "chat.error", message: "no conversation" }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "chat.error", message: "no conversation" }); + }); + + it("returns null for chat.delta with non-object event", () => { + expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: "nope" }))).toBeNull(); + }); + + it("returns null for chat.delta with missing event.type", () => { + expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: {} }))).toBeNull(); + }); + + it("returns null for chat.error with non-string message", () => { + expect(parseServerMessage(JSON.stringify({ type: "chat.error", message: 42 }))).toBeNull(); + }); + + it("returns null for chat.error with invalid conversationId type", () => { + expect( + parseServerMessage( + JSON.stringify({ type: "chat.error", conversationId: 42, message: "boom" }), + ), + ).toBeNull(); + }); + + it("parses a conversation.open message", () => { + const data = JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); + }); + + it("returns null for conversation.open with missing conversationId", () => { + expect( + parseServerMessage(JSON.stringify({ type: "conversation.open", workspaceId: "w1" })), + ).toBeNull(); + }); + + it("returns null for conversation.open with non-string conversationId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: 42, + workspaceId: "w1", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.open with missing workspaceId", () => { + expect( + parseServerMessage(JSON.stringify({ type: "conversation.open", conversationId: "c1" })), + ).toBeNull(); + }); + + it("returns null for conversation.open with non-string workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: 42, + }), + ), + ).toBeNull(); + }); + + it("parses a conversation.statusChanged message", () => { + const data = JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: "w1", + }); + expect(parseServerMessage(data)).toEqual({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: "w1", + }); + }); + + it("accepts the `queued` status (CR-13 — waiting for a concurrency slot)", () => { + const data = JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "queued", + workspaceId: "w1", + }); + expect(parseServerMessage(data)).toEqual({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "queued", + workspaceId: "w1", + }); + }); + + it("returns null for conversation.statusChanged with missing workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.statusChanged with non-string workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: 42, + }), + ), + ).toBeNull(); + }); + it("returns null for conversation.statusChanged with invalid status", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "done", + workspaceId: "w1", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.statusChanged with missing conversationId", () => { + expect( + parseServerMessage(JSON.stringify({ type: "conversation.statusChanged", status: "idle" })), + ).toBeNull(); + }); }); describe("round-trip: parseServerMessage(serialize(...))", () => { - it("round-trips a subscribe message through serialize only", () => { - const msg = { type: "subscribe" as const, surfaceId: "s1" }; - const wire = serialize(msg); - expect(JSON.parse(wire)).toEqual(msg); - }); - - it("round-trips an invoke message with payload", () => { - const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: false }; - const wire = serialize(msg); - expect(JSON.parse(wire)).toEqual(msg); - }); + it("round-trips a subscribe message through serialize only", () => { + const msg = { type: "subscribe" as const, surfaceId: "s1" }; + const wire = serialize(msg); + expect(JSON.parse(wire)).toEqual(msg); + }); + + it("round-trips an invoke message with payload", () => { + const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: false }; + const wire = serialize(msg); + expect(JSON.parse(wire)).toEqual(msg); + }); }); describe("nextBackoffMs", () => { - it("returns a positive number", () => { - expect(nextBackoffMs(0)).toBeGreaterThan(0); - }); - - it("is capped at 30s + jitter (at most ~36s)", () => { - for (let i = 0; i < 100; i++) { - expect(nextBackoffMs(100)).toBeLessThanOrEqual(36_000); - } - }); - - it("starts around 500ms (±20% jitter)", () => { - for (let i = 0; i < 100; i++) { - const ms = nextBackoffMs(0); - expect(ms).toBeGreaterThanOrEqual(400); - expect(ms).toBeLessThanOrEqual(600); - } - }); - - it("grows exponentially with attempt", () => { - const averages = [0, 1, 2, 3].map((attempt) => { - let sum = 0; - for (let i = 0; i < 200; i++) { - sum += nextBackoffMs(attempt); - } - return sum / 200; - }); - for (let i = 1; i < averages.length; i++) { - const prev = averages[i - 1]; - if (prev === undefined) throw new Error("unreachable"); - expect(averages[i]).toBeGreaterThan(prev); - } - }); - - it("treats negative attempt as 0", () => { - for (let i = 0; i < 50; i++) { - const ms = nextBackoffMs(-5); - expect(ms).toBeGreaterThanOrEqual(400); - expect(ms).toBeLessThanOrEqual(600); - } - }); + it("returns a positive number", () => { + expect(nextBackoffMs(0)).toBeGreaterThan(0); + }); + + it("is capped at 30s + jitter (at most ~36s)", () => { + for (let i = 0; i < 100; i++) { + expect(nextBackoffMs(100)).toBeLessThanOrEqual(36_000); + } + }); + + it("starts around 500ms (±20% jitter)", () => { + for (let i = 0; i < 100; i++) { + const ms = nextBackoffMs(0); + expect(ms).toBeGreaterThanOrEqual(400); + expect(ms).toBeLessThanOrEqual(600); + } + }); + + it("grows exponentially with attempt", () => { + const averages = [0, 1, 2, 3].map((attempt) => { + let sum = 0; + for (let i = 0; i < 200; i++) { + sum += nextBackoffMs(attempt); + } + return sum / 200; + }); + for (let i = 1; i < averages.length; i++) { + const prev = averages[i - 1]; + if (prev === undefined) throw new Error("unreachable"); + expect(averages[i]).toBeGreaterThan(prev); + } + }); + + it("treats negative attempt as 0", () => { + for (let i = 0; i < 50; i++) { + const ms = nextBackoffMs(-5); + expect(ms).toBeGreaterThanOrEqual(400); + expect(ms).toBeLessThanOrEqual(600); + } + }); }); diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index b11c5c4..03ef763 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -1,38 +1,38 @@ import type { - ChatDeltaMessage, - ChatErrorMessage, - ConversationCompactedMessage, - ConversationOpenMessage, - ConversationStatusChangedMessage, - WsClientMessage, - WsServerMessage, + ChatDeltaMessage, + ChatErrorMessage, + ConversationCompactedMessage, + ConversationOpenMessage, + ConversationStatusChangedMessage, + WsClientMessage, + WsServerMessage, } from "@dispatch/transport-contract"; import type { - CatalogMessage, - SurfaceErrorMessage, - SurfaceMessage, - SurfaceUpdateMessage, + CatalogMessage, + SurfaceErrorMessage, + SurfaceMessage, + SurfaceUpdateMessage, } from "@dispatch/ui-contract"; const VALID_SERVER_TYPES = new Set([ - "catalog", - "surface", - "update", - "error", - "chat.delta", - "chat.error", - "conversation.open", - "conversation.statusChanged", - "conversation.compacted", + "catalog", + "surface", + "update", + "error", + "chat.delta", + "chat.error", + "conversation.open", + "conversation.statusChanged", + "conversation.compacted", ]); /** Serialize a client message to a JSON string for the wire. */ export function serialize(msg: WsClientMessage): string { - return JSON.stringify(msg); + return JSON.stringify(msg); } function isRecord(v: unknown): v is Record<string, unknown> { - return v !== null && typeof v === "object" && !Array.isArray(v); + return v !== null && typeof v === "object" && !Array.isArray(v); } /** @@ -40,117 +40,126 @@ function isRecord(v: unknown): v is Record<string, unknown> { * Returns null for malformed JSON or shapes that don't match the protocol. */ export function parseServerMessage(data: string): WsServerMessage | null { - let parsed: unknown; - try { - parsed = JSON.parse(data); - } catch { - return null; - } - if (!isRecord(parsed)) { - return null; - } - const t = parsed.type; - if (typeof t !== "string" || !VALID_SERVER_TYPES.has(t)) { - return null; - } - switch (t) { - case "catalog": { - if (!Array.isArray(parsed.catalog)) return null; - return { type: "catalog", catalog: parsed.catalog as CatalogMessage["catalog"] }; - } - case "surface": { - const spec = parsed.spec; - if (!isRecord(spec)) return null; - if (typeof spec.id !== "string") return null; - if (typeof spec.region !== "string") return null; - if (typeof spec.title !== "string") return null; - if (!Array.isArray(spec.fields)) return null; - // Preserve the conversationId echo (a conversation-scoped surface's initial - // reply carries it) — dropping it would defeat the protocol reducer's - // stale-scope filtering on a fast conversation switch. - const conversationId = parsed.conversationId; - if (conversationId !== undefined && typeof conversationId !== "string") return null; - const surfaceSpec = spec as unknown as SurfaceMessage["spec"]; - return conversationId !== undefined - ? { type: "surface", spec: surfaceSpec, conversationId } - : { type: "surface", spec: surfaceSpec }; - } - case "update": { - const update = parsed.update; - if (!isRecord(update)) return null; - if (typeof update.surfaceId !== "string") return null; - const spec = update.spec; - if (!isRecord(spec)) return null; - if (typeof spec.id !== "string") return null; - if (typeof spec.region !== "string") return null; - if (typeof spec.title !== "string") return null; - if (!Array.isArray(spec.fields)) return null; - return { type: "update", update: update as unknown as SurfaceUpdateMessage["update"] }; - } - case "error": { - if (typeof parsed.message !== "string") return null; - const surfaceId = parsed.surfaceId; - if (surfaceId !== undefined && typeof surfaceId !== "string") return null; - const msg: SurfaceErrorMessage = - surfaceId !== undefined - ? { type: "error", surfaceId, message: parsed.message } - : { type: "error", message: parsed.message }; - return msg; - } - case "chat.delta": { - const event = parsed.event; - if (!isRecord(event)) return null; - if (typeof event.type !== "string") return null; - return { type: "chat.delta", event: event as unknown as ChatDeltaMessage["event"] }; - } - case "chat.error": { - if (typeof parsed.message !== "string") return null; - const conversationId = parsed.conversationId; - if (conversationId !== undefined && typeof conversationId !== "string") return null; - const msg: ChatErrorMessage = - conversationId !== undefined - ? { type: "chat.error", conversationId, message: parsed.message } - : { type: "chat.error", message: parsed.message }; - return msg; - } - case "conversation.open": { - if (typeof parsed.conversationId !== "string") return null; - const msg: ConversationOpenMessage = { - type: "conversation.open", - conversationId: parsed.conversationId, - }; - return msg; - } - case "conversation.statusChanged": { - if (typeof parsed.conversationId !== "string") return null; - if (typeof parsed.status !== "string") return null; - if (parsed.status !== "active" && parsed.status !== "idle" && parsed.status !== "closed") { - return null; - } - const msg: ConversationStatusChangedMessage = { - type: "conversation.statusChanged", - conversationId: parsed.conversationId, - status: parsed.status, - }; - return msg; - } - case "conversation.compacted": { - if (typeof parsed.conversationId !== "string") return null; - if (typeof parsed.newConversationId !== "string") return null; - if (typeof parsed.messagesSummarized !== "number") return null; - if (typeof parsed.messagesKept !== "number") return null; - const msg: ConversationCompactedMessage = { - type: "conversation.compacted", - conversationId: parsed.conversationId, - newConversationId: parsed.newConversationId, - messagesSummarized: parsed.messagesSummarized, - messagesKept: parsed.messagesKept, - }; - return msg; - } - default: - return null; - } + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return null; + } + if (!isRecord(parsed)) { + return null; + } + const t = parsed.type; + if (typeof t !== "string" || !VALID_SERVER_TYPES.has(t)) { + return null; + } + switch (t) { + case "catalog": { + if (!Array.isArray(parsed.catalog)) return null; + return { type: "catalog", catalog: parsed.catalog as CatalogMessage["catalog"] }; + } + case "surface": { + const spec = parsed.spec; + if (!isRecord(spec)) return null; + if (typeof spec.id !== "string") return null; + if (typeof spec.region !== "string") return null; + if (typeof spec.title !== "string") return null; + if (!Array.isArray(spec.fields)) return null; + // Preserve the conversationId echo (a conversation-scoped surface's initial + // reply carries it) — dropping it would defeat the protocol reducer's + // stale-scope filtering on a fast conversation switch. + const conversationId = parsed.conversationId; + if (conversationId !== undefined && typeof conversationId !== "string") return null; + const surfaceSpec = spec as unknown as SurfaceMessage["spec"]; + return conversationId !== undefined + ? { type: "surface", spec: surfaceSpec, conversationId } + : { type: "surface", spec: surfaceSpec }; + } + case "update": { + const update = parsed.update; + if (!isRecord(update)) return null; + if (typeof update.surfaceId !== "string") return null; + const spec = update.spec; + if (!isRecord(spec)) return null; + if (typeof spec.id !== "string") return null; + if (typeof spec.region !== "string") return null; + if (typeof spec.title !== "string") return null; + if (!Array.isArray(spec.fields)) return null; + return { type: "update", update: update as unknown as SurfaceUpdateMessage["update"] }; + } + case "error": { + if (typeof parsed.message !== "string") return null; + const surfaceId = parsed.surfaceId; + if (surfaceId !== undefined && typeof surfaceId !== "string") return null; + const msg: SurfaceErrorMessage = + surfaceId !== undefined + ? { type: "error", surfaceId, message: parsed.message } + : { type: "error", message: parsed.message }; + return msg; + } + case "chat.delta": { + const event = parsed.event; + if (!isRecord(event)) return null; + if (typeof event.type !== "string") return null; + return { type: "chat.delta", event: event as unknown as ChatDeltaMessage["event"] }; + } + case "chat.error": { + if (typeof parsed.message !== "string") return null; + const conversationId = parsed.conversationId; + if (conversationId !== undefined && typeof conversationId !== "string") return null; + const msg: ChatErrorMessage = + conversationId !== undefined + ? { type: "chat.error", conversationId, message: parsed.message } + : { type: "chat.error", message: parsed.message }; + return msg; + } + case "conversation.open": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.workspaceId !== "string") return null; + const msg: ConversationOpenMessage = { + type: "conversation.open", + conversationId: parsed.conversationId, + workspaceId: parsed.workspaceId, + }; + return msg; + } + case "conversation.statusChanged": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.status !== "string") return null; + if ( + parsed.status !== "active" && + parsed.status !== "queued" && + parsed.status !== "idle" && + parsed.status !== "closed" + ) { + return null; + } + if (typeof parsed.workspaceId !== "string") return null; + const msg: ConversationStatusChangedMessage = { + type: "conversation.statusChanged", + conversationId: parsed.conversationId, + status: parsed.status, + workspaceId: parsed.workspaceId, + }; + return msg; + } + case "conversation.compacted": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.newConversationId !== "string") return null; + if (typeof parsed.messagesSummarized !== "number") return null; + if (typeof parsed.messagesKept !== "number") return null; + const msg: ConversationCompactedMessage = { + type: "conversation.compacted", + conversationId: parsed.conversationId, + newConversationId: parsed.newConversationId, + messagesSummarized: parsed.messagesSummarized, + messagesKept: parsed.messagesKept, + }; + return msg; + } + default: + return null; + } } /** @@ -158,10 +167,10 @@ export function parseServerMessage(data: string): WsServerMessage | null { * Base: 500ms, doubles each attempt, caps at 30s, adds ±20% jitter. */ export function nextBackoffMs(attempt: number): number { - const base = 500; - const max = 30_000; - const exponential = base * 2 ** Math.max(0, attempt); - const capped = Math.min(exponential, max); - const jitter = 0.8 + Math.random() * 0.4; - return Math.round(capped * jitter); + const base = 500; + const max = 30_000; + const exponential = base * 2 ** Math.max(0, attempt); + const capped = Math.min(exponential, max); + const jitter = 0.8 + Math.random() * 0.4; + return Math.round(capped * jitter); } diff --git a/src/app.css b/src/app.css index 4c59d90..f5b269c 100644 --- a/src/app.css +++ b/src/app.css @@ -9,111 +9,111 @@ applied via <html data-theme="monokai">). Themes not listed here are NOT bundled, so monokai must be named explicitly, not merely referenced. */ @plugin "daisyui" { - themes: monokai --default; + themes: monokai --default; } /* Rendered-Markdown (assistant messages) typography — scoped to .markdown-body so it never leaks into the rest of the app. */ .markdown-body { - & p { - margin-block: 0.5em; - &:first-child { - margin-block-start: 0; - } - &:last-child { - margin-block-end: 0; - } - } - & h1, - & h2, - & h3, - & h4, - & h5, - & h6 { - font-weight: 600; - line-height: 1.25; - margin-block: 0.75em 0.25em; - &:first-child { - margin-block-start: 0; - } - } - & h1 { - font-size: 1.4em; - } - & h2 { - font-size: 1.2em; - } - & h3 { - font-size: 1.1em; - } - & ul, - & ol { - padding-inline-start: 1.5em; - margin-block: 0.5em; - } - & ul { - list-style-type: disc; - } - & ol { - list-style-type: decimal; - } - & li { - margin-block: 0.15em; - } - & pre { - overflow-x: auto; - border-radius: var(--radius-box); - margin-block: 0.5em; - } - & pre code { - display: block; - padding: 0.75em 1em; - font-size: 0.8125em; - line-height: 1.5; - } - & :not(pre) > code { - font-size: 0.875em; - padding: 0.15em 0.4em; - border-radius: var(--radius-selector); - background-color: oklch(var(--color-base-content) / 0.1); - } - & blockquote { - border-inline-start: 3px solid oklch(var(--color-base-content) / 0.2); - padding-inline-start: 0.75em; - margin-block: 0.5em; - opacity: 0.8; - } - & a { - color: oklch(var(--color-primary)); - text-decoration: underline; - &:hover { - opacity: 0.8; - } - } - & strong { - font-weight: 600; - } - & table { - width: 100%; - border-collapse: collapse; - margin-block: 0.5em; - font-size: 0.875em; - } - & th, - & td { - border: 1px solid oklch(var(--color-base-content) / 0.15); - padding: 0.4em 0.75em; - text-align: start; - } - & th { - font-weight: 600; - background-color: oklch(var(--color-base-200)); - } - & hr { - border: none; - border-top: 1px solid oklch(var(--color-base-content) / 0.2); - margin-block: 0.75em; - } + & p { + margin-block: 0.5em; + &:first-child { + margin-block-start: 0; + } + &:last-child { + margin-block-end: 0; + } + } + & h1, + & h2, + & h3, + & h4, + & h5, + & h6 { + font-weight: 600; + line-height: 1.25; + margin-block: 0.75em 0.25em; + &:first-child { + margin-block-start: 0; + } + } + & h1 { + font-size: 1.4em; + } + & h2 { + font-size: 1.2em; + } + & h3 { + font-size: 1.1em; + } + & ul, + & ol { + padding-inline-start: 1.5em; + margin-block: 0.5em; + } + & ul { + list-style-type: disc; + } + & ol { + list-style-type: decimal; + } + & li { + margin-block: 0.15em; + } + & pre { + overflow-x: auto; + border-radius: var(--radius-box); + margin-block: 0.5em; + } + & pre code { + display: block; + padding: 0.75em 1em; + font-size: 0.8125em; + line-height: 1.5; + } + & :not(pre) > code { + font-size: 0.875em; + padding: 0.15em 0.4em; + border-radius: var(--radius-selector); + background-color: oklch(var(--color-base-content) / 0.1); + } + & blockquote { + border-inline-start: 3px solid oklch(var(--color-base-content) / 0.2); + padding-inline-start: 0.75em; + margin-block: 0.5em; + opacity: 0.8; + } + & a { + color: oklch(var(--color-primary)); + text-decoration: underline; + &:hover { + opacity: 0.8; + } + } + & strong { + font-weight: 600; + } + & table { + width: 100%; + border-collapse: collapse; + margin-block: 0.5em; + font-size: 0.875em; + } + & th, + & td { + border: 1px solid oklch(var(--color-base-content) / 0.15); + padding: 0.4em 0.75em; + text-align: start; + } + & th { + font-weight: 600; + background-color: oklch(var(--color-base-200)); + } + & hr { + border: none; + border-top: 1px solid oklch(var(--color-base-content) / 0.2); + margin-block: 0.75em; + } } /* App shell fills the viewport and never scrolls/overflows at the page level — @@ -121,9 +121,9 @@ html, body, #app { - height: 100%; + height: 100%; } body { - overflow: hidden; + overflow: hidden; } diff --git a/src/app/App.svelte b/src/app/App.svelte index 9225cc7..f0cd7ec 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import type { ReasoningEffort } from "@dispatch/transport-contract"; + import type { ImageInput } from "@dispatch/transport-contract"; import type { InvokeMessage } from "@dispatch/ui-contract"; import { tick } from "svelte"; import Table from "../components/Table.svelte"; @@ -16,12 +16,19 @@ ModelSelector, ReasoningEffortSelector, type CompactNowResult, - type ReasoningEffortSaveResult, + type ComposerStatus, type SaveCompactPercentResult, + type ThinkingSelection, + type ThinkingSelectionSaveResult, } from "../features/chat"; import { manifest as conversationCacheManifest } from "../features/conversation-cache"; import { manifest as markdownManifest } from "../features/markdown"; import { + McpStatusView, + manifest as mcpManifest, + type McpStatusResult, + } from "../features/mcp"; + import { ChatLimitField, manifest as settingsManifest, type ChatLimitSaveResult, @@ -35,20 +42,65 @@ import { parseMessageQueuePayload } from "../features/surface-host/logic/message-queue"; import { parseTodoPayload } from "../features/surface-host/logic/todo"; import TodoList from "../features/surface-host/ui/TodoList.svelte"; - import { manifest as tabsManifest, TabBar } from "../features/tabs"; + import { manifest as tabsManifest, TabList } from "../features/tabs"; import { manifest as viewsManifest, ViewSidebar } from "../features/views"; import { CwdField, type CwdSaveResult, LspStatusView, type LspStatusResult, - manifest as workspaceManifest, - } from "../features/workspace"; + manifest as cwdLspManifest, + } from "../features/cwd-lsp"; + import { + ComputerField, + manifest as computerManifest, + type ComputerSaveResult, + type ComputerStatusResult, + type LoadComputerStatus, + type SaveComputer, + type TestComputer, + type TestComputerResult, + } from "../features/computer"; + import { + HeartbeatView, + manifest as heartbeatManifest, + RunModal, + type HeartbeatConfigResult, + type HeartbeatNextRunResult, + type HeartbeatRunView, + type HeartbeatRunsResult, + type HeartbeatStopResult, + } from "../features/heartbeat"; + import { + ConcurrencyView, + manifest as concurrencyManifest, + type DeleteConcurrencyLimit, + type LoadConcurrencyLimits, + type LoadConcurrencyStatus, + type SaveConcurrencyCooldown, + type SaveConcurrencyLimit, + } from "../features/concurrency"; + import type { ChatStore } from "../features/chat"; + import { + SystemPromptBuilder, + type LoadSystemPrompt as LoadSystemPromptAlias, + type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias, + type SaveSystemPrompt as SaveSystemPromptAlias, + manifest as systemPromptManifest, + } from "../features/system-prompt"; + import { + VisionSettingsView, + manifest as visionManifest, + type LoadVisionSettingsResult, + type SaveVisionSettingsResult, + type VisionSettingsPatch, + } from "../features/vision"; import type { AppStore } from "./store.svelte"; + import ErrorModal from "./ErrorModal.svelte"; import { createLocalStore } from "../adapters/local-storage"; import { untrack } from "svelte"; - let { store }: { store: AppStore } = $props(); + let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props(); // The backend's conversation-scoped cache-warming surface. Referenced by id at // the composition root (sanctioned discovery-by-id) to give it a dedicated view @@ -65,17 +117,23 @@ // The view kinds offered in the sidebar's dropdown. Generic data — the // `viewContent` snippet below maps each kind id to its renderer. const viewKinds = [ + { id: "tabs", label: "Tabs" }, { id: "model", label: "Model" }, { id: "lsp", label: "Language Servers" }, + { id: "mcp", label: "MCP Servers" }, { id: "extensions", label: "Extensions" }, { id: "cache-warming", label: "Cache Warming" }, { id: "tasks", label: "Tasks" }, { id: "compaction", label: "Compaction" }, + { id: "heartbeat", label: "Heartbeat" }, + { id: "concurrency", label: "Concurrency" }, + { id: "system-prompt", label: "System Prompt" }, { id: "settings", label: "Settings" }, ] as const; - // Default sidebar layout: just the Model view. - const DEFAULT_VIEWS: readonly string[] = ["model"]; + // Default sidebar layout: the Tabs list (the moved-from-top tab bar) plus the + // Model view, so a fresh user sees their conversations + model controls. + const DEFAULT_VIEWS: readonly string[] = ["tabs", "model"]; const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", { storage: untrack(() => store.storage), }); @@ -99,9 +157,15 @@ conversationCacheManifest, markdownManifest, cacheWarmingManifest, - workspaceManifest, + cwdLspManifest, + mcpManifest, + computerManifest, smartScrollManifest, settingsManifest, + systemPromptManifest, + heartbeatManifest, + concurrencyManifest, + visionManifest, ].map((m) => [m.name, m.description] as const); // Smart-scroll: keep the transcript pinned to the bottom while it streams, @@ -173,6 +237,31 @@ return parseTodoPayload(field.payload); }); + // Top-bar title: the active tab's title, or "New Tab" when no tab is active + // (a fresh, unstarted draft — the conversation hasn't been sent yet, so no + // tab exists). Pure-derived from the (workspace-filtered) tab set + the + // active id; reflects whichever tab is selected in the sidebar's Tabs view. + const NEW_TAB_TITLE = "New Tab"; + const topBarTitle = $derived.by(() => { + const id = store.activeConversationId; + if (id === null) return NEW_TAB_TITLE; + const tab = store.tabs.find((t) => t.conversationId === id); + return tab?.title ?? NEW_TAB_TITLE; + }); + + // The composer status-bar status. Priority: error > queued > running > idle. + // `queued` (the turn is in flight but waiting for a concurrency slot — CR-13) + // wins over `running` so the corner shows a ring, not dots, during the wait. + // `turn-start` fires before the slot is granted, so `generating` is already + // true while `conversationStatus === "queued"`; the explicit queued check is + // what distinguishes the two. + const composerStatus = $derived.by<ComposerStatus>(() => { + if (store.activeChat.error) return "error"; + const id = store.activeConversationId; + if (id !== null && store.conversationStatus(id) === "queued") return "queued"; + return store.activeChat.generating ? "running" : "idle"; + }); + // Conversation/tab switch → snap to the bottom of the new transcript. $effect(() => { void store.activeConversationId; @@ -187,6 +276,10 @@ }); const storedSidebarOpen = sidebarOpenStore.load(); let sidebarOpen = $state(storedSidebarOpen ?? (typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true)); + let systemPromptModalOpen = $state(false); + // The heartbeat run currently open in the fullscreen run-chat modal (null = + // closed). Holds a snapshot run view; the modal re-mounts per run (keyed). + let heartbeatRun = $state<HeartbeatRunView | null>(null); $effect(() => { sidebarOpenStore.save(sidebarOpen); @@ -196,14 +289,18 @@ store.invoke(msg.surfaceId, msg.actionId, msg.payload); } - function handleSend(text: string) { - store.send(text); + function handleSend(text: string, images?: readonly ImageInput[]): void { + store.send(text, images); } function handleQueue(text: string) { store.queueMessage(text); } + function handleCancelQueuedMessage(messageId: string) { + store.cancelQueuedMessage(messageId); + } + function handleStop() { store.stopGeneration(); } @@ -225,14 +322,35 @@ : { ok: false, error: result.error }; } - // Adapt the store's reasoning-effort result to the chat feature's port. - async function saveReasoningEffort( - level: ReasoningEffort, - ): Promise<ReasoningEffortSaveResult | null> { - const result = await store.setReasoningEffort(level); + // Adapt the store's reasoning-effort + thinking results to the chat + // feature's combined selector port. The selector sends ONE selection ("off" + // or a level); the adapter fans it out to the right per-axis PUT(s). "off" is + // a SEPARATE signal from the effort level: it persists `thinking: false` + // (the umans route maps that to `reasoning_effort: "none"`), leaving the + // effort level untouched so an off→on toggle restores it. A level ensures + // thinking is ON (the level is meaningless while thinking is off) then sets + // the effort level. + async function saveThinkingSelection( + selection: ThinkingSelection, + ): Promise<ThinkingSelectionSaveResult | null> { + if (selection === "off") { + const result = await store.setThinking(false); + if (result === null) return null; + return result.ok + ? { ok: true, selection: "off" } + : { ok: false, error: result.error }; + } + // A level: enable thinking first if it is currently off, then set the level. + if (store.thinking === false) { + const on = await store.setThinking(true); + if (on !== null && !on.ok) { + return { ok: false, error: on.error }; + } + } + const result = await store.setReasoningEffort(selection); if (result === null) return null; return result.ok - ? { ok: true, reasoningEffort: result.reasoningEffort } + ? { ok: true, selection: result.reasoningEffort } : { ok: false, error: result.error }; } @@ -259,6 +377,27 @@ : { ok: false, error: result.error }; } + // Adapt the store's global vision-settings API to the vision feature's ports. + async function loadVisionSettings(): Promise<LoadVisionSettingsResult> { + // The store seeds `visionSettings` on boot; a refresh keeps it current. + await store.refreshVisionSettings(); + const settings = store.visionSettings; + if (settings === null) { + return { ok: false, error: "Vision settings not available." }; + } + return { ok: true, settings }; + } + + async function saveVisionSettings( + patch: VisionSettingsPatch, + ): Promise<SaveVisionSettingsResult> { + const result = await store.setVisionSettings(patch); + if (result === null) return { ok: false, error: "Vision settings not available." }; + return result.ok + ? { ok: true, settings: result.settings } + : { ok: false, error: result.error }; + } + // Adapt the store's chat-limit result to the settings feature's port. On a // raise the active chat refills (prepends older history); preserve the // reader's viewport over the prepend (the manual analogue of CSS scroll @@ -278,7 +417,7 @@ : { ok: false, error: result.error }; } - // Adapt the store's cwd/LSP results to the workspace feature's ports. + // Adapt the store's cwd/LSP results to the cwd-lsp feature's ports. async function saveCwd(cwd: string): Promise<CwdSaveResult | null> { const result = await store.setCwd(cwd); if (result === null) return null; @@ -292,34 +431,149 @@ ? { ok: true, cwd: result.response.cwd, servers: result.response.servers } : { ok: false, error: result.error }; } + + // Adapt the store's computer results to the computer feature's ports. + async function saveComputer(computerId: string | null): Promise<ComputerSaveResult | null> { + const result = await store.setComputer(computerId); + if (result === null) return null; + return result.ok ? { ok: true, computerId: result.computerId } : { ok: false, error: result.error }; + } + + const loadComputerStatus: LoadComputerStatus = async ( + alias: string, + ): Promise<ComputerStatusResult | null> => { + const result = await store.computerStatus(alias); + if (result === null) return null; + return result.ok ? { ok: true, status: result.response } : { ok: false, error: result.error }; + }; + + const testComputer: TestComputer = async ( + alias: string, + ): Promise<TestComputerResult | null> => { + const result = await store.testComputer(alias); + if (result === null) return null; + return result.ok ? { ok: true, response: result.response } : { ok: false, error: result.error }; + }; + + async function loadMcpStatus(): Promise<McpStatusResult | null> { + const result = await store.mcpStatus(); + if (result === null) return null; + return result.ok + ? { ok: true, cwd: result.response.cwd, servers: result.response.servers } + : { ok: false, error: result.error }; + } + + // Adapt the store's system prompt results to the system-prompt feature's ports. + const loadSystemPromptPrompt: LoadSystemPromptAlias = () => store.loadSystemPrompt(); + + const loadSystemPromptVariablesPrompt: LoadSystemPromptVariablesAlias = () => + store.loadSystemPromptVariables(); + + const saveSystemPromptPrompt: SaveSystemPromptAlias = (template) => store.setSystemPrompt(template); + + // Adapt the store's heartbeat results to the heartbeat feature's ports. The + // store returns the feature's result types directly (the API is a plain REST + // surface, not a transport-contract type), so the adapter is a thin passthrough + // (kept for structural consistency with cwd-lsp/mcp/computer — see AGENTS.md + // "contracts are the cross-unit surface"). + async function loadHeartbeatConfig(): Promise<HeartbeatConfigResult> { + return store.heartbeatConfig(); + } + + async function saveHeartbeatConfig( + patch: Parameters<typeof store.setHeartbeatConfig>[0], + ): Promise<HeartbeatConfigResult> { + return store.setHeartbeatConfig(patch); + } + + async function loadHeartbeatRuns(): Promise<HeartbeatRunsResult> { + return store.heartbeatRuns(); + } + + async function stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> { + return store.stopHeartbeatRun(runId); + } + + async function loadHeartbeatNextRun(): Promise<HeartbeatNextRunResult> { + return store.heartbeatNextRun(); + } + + // Run-chat modal: open a live watch on the run's conversation (the store owns + // the ChatStore + the `chat.subscribe` stream), and tear it down on close. + function openRunChat(conversationId: string): ChatStore { + return store.watchConversation(conversationId); + } + function closeRunChat(conversationId: string): void { + store.unwatchConversation(conversationId); + } + + // Adapt the store's concurrency results to the feature's ports. The store + // returns the feature's result types directly (the API is a plain REST surface + // under /concurrency, not a workspace/conversation-scoped one), so the adapter + // is a thin passthrough (kept for structural consistency — AGENTS.md "contracts + // are the cross-unit surface"). + const loadConcurrencyLimits: LoadConcurrencyLimits = () => store.concurrencyLimits(); + const saveConcurrencyLimit: SaveConcurrencyLimit = (providerId, limit) => + store.setConcurrencyLimit(providerId, limit); + const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) => + store.deleteConcurrencyLimit(providerId); + const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus(); + const saveConcurrencyCooldown: SaveConcurrencyCooldown = (providerId, cooldownMs) => + store.setConcurrencyCooldown(providerId, cooldownMs); </script> <main class="relative flex h-screen overflow-hidden"> <!-- LEFT: everything except the sidebar. The full-height sidebar is a sibling - (below), so opening it shrinks this ENTIRE column — tab row included, which - slides the hamburger left. --> + (below), so opening it shrinks this ENTIRE column. --> <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]"> - <!-- Tab row: the tab strip fills + scrolls internally (flex-1 min-w-0), with - a permanently seated hamburger pinned to the far right. --> - <div class="flex min-w-0 items-center"> - <TabBar - tabs={store.tabs} - activeConversationId={store.activeConversationId} - statusFor={(id) => store.conversationStatus(id)} - onSelect={(id) => store.selectTab(id)} - onClose={(id) => store.closeTab(id)} - onNewDraft={() => store.newDraft()} - onRename={(id, title) => store.renameTab(id, title)} - /> + <!-- Slim header: the tab bar moved into the sidebar (the "Tabs" view), so + the top row now shows the active tab's title on the left (or "New Tab" + for an unstarted draft), with the build version + sidebar toggle on + the right. --> + <div class="flex items-center justify-between gap-2 px-2 py-2"> + <span + class="min-w-0 flex-1 shrink truncate pl-2 text-sm font-medium opacity-70" + data-testid="top-bar-title" + title={topBarTitle} + aria-label="Active conversation title" + > + {topBarTitle} + </span> + <a + href="/" + class="btn btn-ghost btn-sm shrink-0 px-2" + aria-label="Back to dashboard" + title="Back to dashboard" + onclick={(e) => { + e.preventDefault(); + onNavigate("/"); + }} + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + class="size-4" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V15a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V9.75M8.25 21h8.25" + /> + </svg> + </a> <span class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30" title="Build version (git short hash)" > - {__APP_VERSION__} + build: {__APP_VERSION__} </span> <button class="btn btn-square btn-ghost btn-sm mx-1 shrink-0" - aria-label="Toggle sidebar" + aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"} aria-expanded={sidebarOpen} onclick={() => (sidebarOpen = !sidebarOpen)} > @@ -332,11 +586,21 @@ class="size-5" aria-hidden="true" > - <path - stroke-linecap="round" - stroke-linejoin="round" - d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5" - /> + {#if sidebarOpen} + <!-- Sidebar open → chevrons point right --> + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m11.25 4.5 7.5 7.5-7.5 7.5m-7.5-15 7.5 7.5-7.5 7.5" + /> + {:else} + <!-- Sidebar closed → chevrons point left --> + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" + /> + {/if} </svg> </button> </div> @@ -355,7 +619,7 @@ </div> {/if} - <div class="relative min-h-0 min-w-0 flex-1"> + <div class="relative min-h-0 min-w-0 flex-1 pr-4"> <div bind:this={transcriptEl} class="h-full overflow-y-auto"> <div bind:this={transcriptContentEl}> {#key store.activeConversationId} @@ -365,6 +629,8 @@ hasEarlier={store.activeChat.hasEarlier} onShowEarlier={handleShowEarlier} thinkingKeyBase={store.activeChat.thinkingKeyBase} + providerRetry={store.activeChat.providerRetry} + apiBaseUrl={store.httpBase} /> {/key} </div> @@ -385,7 +651,11 @@ the generic SurfaceView (dispatches on rendererId, never surface id); only shown when the queue is non-empty — an idle queue is hidden. --> <div class="px-4 pt-2"> - <SurfaceView spec={messageQueueSpec} onInvoke={handleInvoke} /> + <SurfaceView + spec={messageQueueSpec} + onInvoke={handleInvoke} + onCancelQueuedMessage={handleCancelQueuedMessage} + /> </div> {/if} @@ -395,11 +665,7 @@ onStop={handleStop} contextSize={store.activeChat.currentContextSize} contextWindow={store.modelInfo[store.activeModel]?.contextWindow} - status={store.activeChat.error - ? "error" - : store.activeChat.generating - ? "running" - : "idle"} + status={composerStatus} /> </div> @@ -412,7 +678,7 @@ class:w-0={!sidebarOpen} > <div - class="flex h-full w-80 flex-col gap-2 overflow-y-auto border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out" + class="flex h-full w-80 flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 transition-transform duration-300 ease-out" style="transform: translateX({sidebarOpen ? '0' : '100%'})" > <ViewSidebar kinds={viewKinds} initial={sidebarPanels} onChange={handleSidebarChange} content={viewContent} /> @@ -435,16 +701,77 @@ {/if} </main> +{#if store.fatalError} + <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} /> +{/if} + +{#if systemPromptModalOpen} + <SystemPromptBuilder + loadPrompt={loadSystemPromptPrompt} + savePrompt={saveSystemPromptPrompt} + loadVariables={loadSystemPromptVariablesPrompt} + onClose={() => (systemPromptModalOpen = false)} + /> +{/if} + +{#if heartbeatRun !== null} + <!-- Keyed per run so switching runs (or re-opening) re-mounts the modal — a + fresh watch store lifecycle per run. The modal owns the live watch + (openChat/closeChat) and the Stop button. --> + {#key heartbeatRun.id} + <RunModal + run={heartbeatRun} + openChat={openRunChat} + closeChat={closeRunChat} + stopRun={stopHeartbeatRun} + onClose={() => (heartbeatRun = null)} + apiBaseUrl={store.httpBase} + /> + {/key} +{/if} + {#snippet viewContent(kind: string)} - {#if kind === "model"} + {#if kind === "tabs"} + <!-- The conversation tab list (moved out of the top bar into the sidebar). + Re-mount per workspace so the filtered tab set + scroll reset cleanly + on a workspace switch. --> + {#key store.activeWorkspaceId} + <TabList + tabs={store.tabs} + activeConversationId={store.activeConversationId} + statusFor={(id) => store.conversationStatus(id)} + onSelect={(id) => store.selectTab(id)} + onClose={(id) => store.closeTab(id)} + onNewDraft={() => store.newDraft()} + onRename={(id, title) => store.renameTab(id, title)} + /> + {/key} + {:else if kind === "model"} <div class="flex flex-col gap-3"> - <ModelSelector models={store.models} selected={store.activeModel} onSelect={handleSelectModel} /> + <ModelSelector + models={store.models} + selected={store.activeModel} + onSelect={handleSelectModel} + modelInfo={store.modelInfo} + /> <!-- Keyed on the workspace conversation (active tab OR draft) so the inputs re-mount per conversation — incl. switching between drafts — and can't bleed across tabs. Editable for a draft too (cwd + effort apply from turn 1). --> {#key store.currentConversationId} - <ReasoningEffortSelector persisted={store.reasoningEffort} save={saveReasoningEffort} /> + <ReasoningEffortSelector + persistedEffort={store.reasoningEffort} + persistedThinking={store.thinking} + save={saveThinkingSelection} + /> <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} /> + <ComputerField + computerId={store.computerId} + canEdit={true} + computers={store.computers} + save={saveComputer} + loadStatus={loadComputerStatus} + test={testComputer} + /> {/key} </div> {:else if kind === "lsp"} @@ -452,6 +779,11 @@ {#key store.currentConversationId} <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} /> {/key} + {:else if kind === "mcp"} + <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. --> + {#key store.currentConversationId} + <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} /> + {/key} {:else if kind === "extensions"} <section> <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3> @@ -475,16 +807,15 @@ /> {/key} {:else if kind === "tasks"} - <!-- Re-mount per conversation so the task list is isolated per conversation. --> + <!-- Re-mount per conversation so the task list is isolated per conversation. + TodoList always reserves its fixed 60vh height (empty or full). --> {#key store.activeConversationId} - {#if todoData !== null && todoData.todos.length > 0} - <TodoList payload={todoData} /> - {:else} - <p class="text-xs opacity-60">No tasks yet.</p> - {/if} + <TodoList payload={todoData} /> {/key} {:else if kind === "compaction"} - <!-- Re-mount per conversation so the percent + feedback can't bleed across tabs. --> + <!-- Message compaction is per-conversation (keyed so the percent + feedback + can't bleed across tabs). Vision (image-compaction) settings are GLOBAL, + so they stay mounted across conversation switches (no {#key}). --> {#key store.currentConversationId} <CompactionView percent={store.compactPercent} @@ -493,11 +824,58 @@ savePercent={saveCompactPercent} /> {/key} + <div class="divider my-1 text-xs text-base-content/40">Image compaction</div> + <VisionSettingsView + models={store.models} + modelInfo={store.modelInfo} + load={loadVisionSettings} + save={saveVisionSettings} + /> + {:else if kind === "system-prompt"} + <!-- Global system prompt template. Opens a full-page modal editor (half + template / half variable palette). Not conversation-scoped (no {#key}). --> + <div class="flex flex-col gap-2"> + <p class="text-xs opacity-60"> + Edit the global system prompt template with variable placeholders. Opens a full-page editor. + </p> + <button + type="button" + class="btn btn-primary btn-sm" + onclick={() => (systemPromptModalOpen = true)} + > + Open builder + </button> + </div> {:else if kind === "settings"} <!-- FE-local settings. Not conversation-scoped (no {#key}: the chat limit is global), so the field stays mounted across tab switches. --> <div class="flex flex-col gap-3"> <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} /> </div> + {:else if kind === "heartbeat"} + <!-- Workspace-scoped autonomous-agent heartbeat (config + run history). + Not conversation-scoped (no {#key}); the config + runs are per-workspace. --> + <HeartbeatView + models={store.models} + loadConfig={loadHeartbeatConfig} + saveConfig={saveHeartbeatConfig} + loadRuns={loadHeartbeatRuns} + stopRun={stopHeartbeatRun} + loadVariables={loadSystemPromptVariablesPrompt} + loadDefaultPrompt={loadSystemPromptPrompt} + loadNextRun={loadHeartbeatNextRun} + onOpenRun={(run) => (heartbeatRun = run)} + /> + {:else if kind === "concurrency"} + <!-- Per-provider concurrency limits + live status. GLOBAL (not workspace- or + conversation-scoped), so the panel stays mounted across tab switches. --> + <ConcurrencyView + models={store.models} + loadLimits={loadConcurrencyLimits} + saveLimit={saveConcurrencyLimit} + deleteLimit={deleteConcurrencyLimit} + loadStatus={loadConcurrencyStatus} + saveCooldown={saveConcurrencyCooldown} + /> {/if} {/snippet} diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 6a39296..dcdcb9b 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -1,418 +1,586 @@ -import type { WsServerMessage } from "@dispatch/transport-contract"; +import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { WebSocketLike } from "../adapters/ws"; import App from "./App.svelte"; import { createAppStore } from "./store.svelte"; interface FakeSocket extends WebSocketLike { - sent: string[]; - resolveOpen(): void; - feedServerMessage(data: WsServerMessage): void; - feedSurfaceMessage(data: SurfaceServerMessage): void; + sent: string[]; + resolveOpen(): void; + feedServerMessage(data: WsServerMessage): void; + feedSurfaceMessage(data: SurfaceServerMessage): void; } function fakeSocket(): FakeSocket { - let onopen: (() => void) | null = null; - let onmessage: ((ev: { data: string }) => void) | null = null; - const sent: string[] = []; - - const ws: FakeSocket = { - send(data: string) { - sent.push(data); - }, - close() {}, - get onopen() { - return onopen; - }, - set onopen(fn) { - onopen = fn; - }, - get onmessage() { - return onmessage; - }, - set onmessage(fn) { - onmessage = fn; - }, - get onclose() { - return null; - }, - set onclose(_fn) {}, - resolveOpen() { - onopen?.(); - }, - feedServerMessage(msg: WsServerMessage) { - onmessage?.({ data: JSON.stringify(msg) }); - }, - feedSurfaceMessage(msg: SurfaceServerMessage) { - onmessage?.({ data: JSON.stringify(msg) }); - }, - sent, - }; - return ws; + let onopen: (() => void) | null = null; + let onmessage: ((ev: { data: string }) => void) | null = null; + const sent: string[] = []; + + const ws: FakeSocket = { + send(data: string) { + sent.push(data); + }, + close() {}, + get onopen() { + return onopen; + }, + set onopen(fn) { + onopen = fn; + }, + get onmessage() { + return onmessage; + }, + set onmessage(fn) { + onmessage = fn; + }, + get onclose() { + return null; + }, + set onclose(_fn) {}, + resolveOpen() { + onopen?.(); + }, + feedServerMessage(msg: WsServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + feedSurfaceMessage(msg: SurfaceServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + sent, + }; + return ws; } function fakeFetchImpl(): typeof fetch { - return async (input: string | URL | Request): Promise<Response> => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { - status: 200, - }); - } - if (url.endsWith("/cwd")) { - return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 }); - } - if (url.endsWith("/lsp")) { - return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), { - status: 200, - }); - } - return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); - }; + return async (input: string | URL | Request): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + status: 200, + }); + } + if (url.includes("/conversations?status=")) { + return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); + } + if (url.endsWith("/cwd")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 }); + } + if (url.endsWith("/lsp")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), { + status: 200, + }); + } + return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + }; } function createFakeStorage(): Storage { - const map = new Map<string, string>(); - return { - get length() { - return map.size; - }, - clear() { - map.clear(); - }, - getItem(key: string): string | null { - return map.get(key) ?? null; - }, - key(_index: number): string | null { - return null; - }, - removeItem(key: string) { - map.delete(key); - }, - setItem(key: string, value: string) { - map.set(key, value); - }, - }; + const map = new Map<string, string>(); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key: string): string | null { + return map.get(key) ?? null; + }, + key(_index: number): string | null { + return null; + }, + removeItem(key: string) { + map.delete(key); + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + }; } function createFakeStorageWithViews(views: readonly string[] = ["extensions"]): Storage { - const storage = createFakeStorage(); - storage.setItem("dispatch.sidebar.views", JSON.stringify(views)); - return storage; + const storage = createFakeStorage(); + storage.setItem("dispatch.sidebar.views", JSON.stringify(views)); + return storage; } function sentMessages(ws: FakeSocket) { - return ws.sent.map((s) => JSON.parse(s)); + return ws.sent.map((s) => JSON.parse(s)); } function activeConversationId(store: ReturnType<typeof createAppStore>): string { - const id = store.activeConversationId; - expect(id).not.toBeNull(); - return id as string; + const id = store.activeConversationId; + expect(id).not.toBeNull(); + return id as string; } describe("App component interaction tests", () => { - it("renders the model selector and composer in draft mode", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - render(App, { props: { store } }); - - expect(screen.getByRole("textbox", { name: "Message input" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(); - expect(screen.getByRole("combobox", { name: "Model selector" })).toBeInTheDocument(); - - store.dispose(); - }); - - it("auto-subscribes to every catalog entry on render (no buttons to click)", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.sent.length = 0; - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - render(App, { props: { store } }); - - const subscribed = sentMessages(ws) - .filter((m: { type: string }) => m.type === "subscribe") - .map((m: { surfaceId: string }) => m.surfaceId); - expect(subscribed).toContain("s1"); - expect(subscribed).toContain("s2"); - - store.dispose(); - }); - - it("renders every surface expanded once their specs arrive", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorageWithViews(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - render(App, { props: { store } }); - - // No interaction: specs arrive and both surfaces render expanded. - ws.feedSurfaceMessage({ - type: "surface", - spec: { - id: "s1", - region: "sidebar", - title: "Surface One", - fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], - }, - }); - ws.feedSurfaceMessage({ - type: "surface", - spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] }, - }); - - expect(await screen.findByRole("heading", { name: "Surface One" })).toBeInTheDocument(); - expect(await screen.findByRole("heading", { name: "Surface Two" })).toBeInTheDocument(); - expect(await screen.findByText("Tokens")).toBeInTheDocument(); - expect(await screen.findByText("1,234")).toBeInTheDocument(); - - store.dispose(); - }); - - it("an error message renders the alert banner", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "error", - message: "Something went wrong", - }); - - render(App, { props: { store } }); - - const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent("Something went wrong"); - - store.dispose(); - }); - - it("invoking a field action sends an invoke", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorageWithViews(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], - }); - - render(App, { props: { store } }); - - const user = userEvent.setup(); - // Surface is auto-subscribed; its spec arrives and renders expanded. - ws.feedSurfaceMessage({ - type: "surface", - spec: { - id: "s1", - region: "sidebar", - title: "Surface One", - fields: [ - { - kind: "toggle", - label: "Dark Mode", - value: false, - action: { actionId: "toggle-dark" }, - }, - ], - }, - }); - - ws.sent.length = 0; - const checkbox = await screen.findByRole("checkbox", { name: "Dark Mode" }); - await user.click(checkbox); - - const msgs = sentMessages(ws); - const invoke = msgs.find( - (m: { type: string; surfaceId: string; actionId: string; payload: unknown }) => - m.type === "invoke" && - m.surfaceId === "s1" && - m.actionId === "toggle-dark" && - m.payload === true, - ); - expect(invoke).toBeTruthy(); - - store.dispose(); - }); - - it("typing and sending a message posts chat.send on the socket", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - render(App, { props: { store } }); - - const user = userEvent.setup(); - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, "hello from UI"); - - ws.sent.length = 0; - const sendBtn = screen.getByRole("button", { name: "Send" }); - await user.click(sendBtn); - - const msgs = sentMessages(ws); - const chatSend = msgs.find((m: { type: string }) => m.type === "chat.send") as - | { type: string; conversationId: string; message: string } - | undefined; - expect(chatSend).toBeTruthy(); - expect(chatSend?.message).toBe("hello from UI"); - - store.dispose(); - }); - - it("incoming chat.delta renders text in the chat transcript", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - // Promote draft to tab - store.send("test"); - const convId = activeConversationId(store); - - render(App, { props: { store } }); - - ws.feedServerMessage({ - type: "chat.delta", - event: { - type: "turn-start", - conversationId: convId, - turnId: "turn-1", - }, - }); - - ws.feedServerMessage({ - type: "chat.delta", - event: { - type: "text-delta", - conversationId: convId, - turnId: "turn-1", - delta: "Hi there!", - }, - }); - - expect(await screen.findByText("Hi there!")).toBeInTheDocument(); - - store.dispose(); - }); - - it("renders a custom 'table' field of a surface as a table", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorageWithViews(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], - }); - - render(App, { props: { store } }); - - // Auto-subscribed; the custom-table spec arrives and renders expanded. - ws.feedSurfaceMessage({ - type: "surface", - spec: { - id: "s1", - region: "sidebar", - title: "Surface One", - fields: [ - { - kind: "custom", - rendererId: "table", - payload: { - columns: ["Name", "Scope"], - rows: [["cache-warm", "backend"]], - }, - }, - ], - }, - }); - - expect(await screen.findByRole("columnheader", { name: "Name" })).toBeInTheDocument(); - expect(await screen.findByText("cache-warm")).toBeInTheDocument(); - expect(await screen.findByText("backend")).toBeInTheDocument(); - - store.dispose(); - }); - - it("the Extensions view lists frontend modules aggregated from feature manifests", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorageWithViews(), - }); - ws.resolveOpen(); - - render(App, { props: { store } }); - - // Extensions view is pre-populated in the fake storage, so the modules table renders immediately. - expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument(); - for (const name of [ - "chat", - "tabs", - "surface-host", - "views", - "conversation-cache", - "markdown", - ]) { - expect(screen.getByRole("cell", { name })).toBeInTheDocument(); - } - - store.dispose(); - }); + it("renders the model selector and composer in draft mode", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + expect(screen.getByRole("textbox", { name: "Message input" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Model selector" })).toBeInTheDocument(); + + store.dispose(); + }); + + it("shows 'New Tab' in the top bar for an unstarted draft (no active tab)", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + expect(store.activeConversationId).toBeNull(); + expect(screen.getByTestId("top-bar-title")).toHaveTextContent("New Tab"); + + store.dispose(); + }); + + it("shows the active tab's title in the top bar once a tab is started", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + // Promote draft → tab: the tab's title is derived from the first message. + store.send("Refactor the auth module"); + expect(store.activeConversationId).not.toBeNull(); + + // The top-bar title updates reactively; findByTestId awaits the flush. + expect(await screen.findByTestId("top-bar-title")).toHaveTextContent( + "Refactor the auth module", + ); + + store.dispose(); + }); + + it("the dashboard home button navigates to '/' (back to the workspaces home)", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const onNavigate = vi.fn(); + render(App, { props: { store, onNavigate } }); + + const homeBtn = screen.getByRole("link", { name: "Back to dashboard" }); + expect(homeBtn).toHaveAttribute("href", "/"); + await userEvent.setup().click(homeBtn); + + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate).toHaveBeenCalledWith("/"); + + store.dispose(); + }); + + it("auto-subscribes to every catalog entry on render (no buttons to click)", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.sent.length = 0; + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + const subscribed = sentMessages(ws) + .filter((m: { type: string }) => m.type === "subscribe") + .map((m: { surfaceId: string }) => m.surfaceId); + expect(subscribed).toContain("s1"); + expect(subscribed).toContain("s2"); + + store.dispose(); + }); + + it("renders every surface expanded once their specs arrive", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorageWithViews(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + // No interaction: specs arrive and both surfaces render expanded. + ws.feedSurfaceMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], + }, + }); + ws.feedSurfaceMessage({ + type: "surface", + spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] }, + }); + + expect(await screen.findByRole("heading", { name: "Surface One" })).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Surface Two" })).toBeInTheDocument(); + expect(await screen.findByText("Tokens")).toBeInTheDocument(); + expect(await screen.findByText("1,234")).toBeInTheDocument(); + + store.dispose(); + }); + + it("an error message renders the alert banner", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "error", + message: "Something went wrong", + }); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Something went wrong"); + + store.dispose(); + }); + + it("invoking a field action sends an invoke", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorageWithViews(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + const user = userEvent.setup(); + // Surface is auto-subscribed; its spec arrives and renders expanded. + ws.feedSurfaceMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [ + { + kind: "toggle", + label: "Dark Mode", + value: false, + action: { actionId: "toggle-dark" }, + }, + ], + }, + }); + + ws.sent.length = 0; + const checkbox = await screen.findByRole("checkbox", { name: "Dark Mode" }); + await user.click(checkbox); + + const msgs = sentMessages(ws); + const invoke = msgs.find( + (m: { type: string; surfaceId: string; actionId: string; payload: unknown }) => + m.type === "invoke" && + m.surfaceId === "s1" && + m.actionId === "toggle-dark" && + m.payload === true, + ); + expect(invoke).toBeTruthy(); + + store.dispose(); + }); + + it("typing and sending a message posts chat.send on the socket", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + const user = userEvent.setup(); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "hello from UI"); + + ws.sent.length = 0; + const sendBtn = screen.getByRole("button", { name: "Send" }); + await user.click(sendBtn); + + const msgs = sentMessages(ws); + const chatSend = msgs.find((m: { type: string }) => m.type === "chat.send") as + | { type: string; conversationId: string; message: string } + | undefined; + expect(chatSend).toBeTruthy(); + expect(chatSend?.message).toBe("hello from UI"); + + store.dispose(); + }); + + it("incoming chat.delta renders text in the chat transcript", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // Promote draft to tab + store.send("test"); + const convId = activeConversationId(store); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "turn-start", + conversationId: convId, + turnId: "turn-1", + }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "text-delta", + conversationId: convId, + turnId: "turn-1", + delta: "Hi there!", + }, + }); + + expect(await screen.findByText("Hi there!")).toBeInTheDocument(); + + store.dispose(); + }); + + it("renders a custom 'table' field of a surface as a table", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorageWithViews(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + // Auto-subscribed; the custom-table spec arrives and renders expanded. + ws.feedSurfaceMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [ + { + kind: "custom", + rendererId: "table", + payload: { + columns: ["Name", "Scope"], + rows: [["cache-warm", "backend"]], + }, + }, + ], + }, + }); + + expect(await screen.findByRole("columnheader", { name: "Name" })).toBeInTheDocument(); + expect(await screen.findByText("cache-warm")).toBeInTheDocument(); + expect(await screen.findByText("backend")).toBeInTheDocument(); + + store.dispose(); + }); + + it("the Extensions view lists frontend modules aggregated from feature manifests", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorageWithViews(), + }); + ws.resolveOpen(); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + // Extensions view is pre-populated in the fake storage, so the modules table renders immediately. + expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument(); + for (const name of [ + "chat", + "tabs", + "surface-host", + "views", + "conversation-cache", + "markdown", + ]) { + expect(screen.getByRole("cell", { name })).toBeInTheDocument(); + } + + store.dispose(); + }); + + it("shows a full-screen error modal when fetchOpenConversations fails", async () => { + // A fetch that throws for the conversations list endpoint (simulating a + // network failure / unreachable backend on a new device). + const failingFetch = async (input: string | URL | Request): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + status: 200, + }); + } + if (url.includes("/conversations?status=")) { + throw new TypeError("Failed to fetch: network error"); + } + if (url.endsWith("/cwd")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 }); + } + if (url.endsWith("/lsp")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), { + status: 200, + }); + } + return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + }; + + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: failingFetch, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + // The modal should appear with the error text + const dialog = await screen.findByRole("dialog", { name: "Error" }, { timeout: 3000 }); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveTextContent("Failed to load conversations"); + expect(dialog).toHaveTextContent("TypeError"); + expect(dialog).toHaveTextContent("network error"); + + // Dismiss button clears the modal + const dismissBtn = screen.getByRole("button", { name: "Dismiss error" }); + await userEvent.setup().click(dismissBtn); + expect(store.fatalError).toBeNull(); + + store.dispose(); + }); + + it("does not show the error modal when fetchOpenConversations succeeds", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), // returns valid empty conversations list + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + // Wait a tick for boot async to settle + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(store.fatalError).toBeNull(); + + store.dispose(); + }); + + it("sends workspaceId when setting cwd", async () => { + let capturedBody: SetCwdRequest | undefined; + const fetchWithCapture = async ( + input: string | URL | Request, + init?: RequestInit, + ): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/cwd") && init?.method === "PUT") { + capturedBody = JSON.parse(init.body as string) as SetCwdRequest; + return new Response(JSON.stringify({ conversationId: "c", cwd: capturedBody.cwd }), { + status: 200, + }); + } + return fakeFetchImpl()(input); + }; + + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fetchWithCapture, + localStorage: createFakeStorage(), + workspaceId: "my-team", + }); + ws.resolveOpen(); + + // Let async boot settle before mutating cwd. + await new Promise((resolve) => setTimeout(resolve, 50)); + + const result = await store.setCwd("arch-rewrite"); + expect(result).toMatchObject({ ok: true, cwd: "arch-rewrite" }); + expect(capturedBody).toEqual({ cwd: "arch-rewrite", workspaceId: "my-team" }); + + store.dispose(); + }); }); diff --git a/src/app/ErrorModal.svelte b/src/app/ErrorModal.svelte new file mode 100644 index 0000000..0415827 --- /dev/null +++ b/src/app/ErrorModal.svelte @@ -0,0 +1,150 @@ +<script lang="ts"> + /** + * Full-screen error modal — surfaces critical errors that would otherwise be + * silently swallowed (e.g. the cross-device tab-restore fetch failing). Shows + * the full error text + stack trace in a scrollable `<pre>`, a Copy button + * (clipboard), and an X to dismiss. Pure presentation: the error string and + * dismiss callback are injected as props. + */ + let { + error, + onDismiss, + }: { + error: string; + onDismiss: () => void; + } = $props(); + + let copied = $state(false); + let copyTimer: ReturnType<typeof setTimeout> | undefined; + + async function handleCopy(): Promise<void> { + try { + await navigator.clipboard.writeText(error); + copied = true; + clearTimeout(copyTimer); + copyTimer = setTimeout(() => { + copied = false; + }, 2000); + } catch { + // Clipboard API may be unavailable (non-secure context). Fallback: + // select the text so the user can Ctrl+C manually. + const pre = document.getElementById("error-modal-text"); + if (pre !== null) { + const range = document.createRange(); + range.selectNodeContents(pre); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + } + } + + function handleKeydown(event: KeyboardEvent): void { + if (event.key === "Escape") { + event.preventDefault(); + onDismiss(); + } + } +</script> + +<svelte:window onkeydown={handleKeydown} /> + +<!-- Full-screen overlay: fixed, high z-index, semi-transparent backdrop. --> +<!-- svelte-ignore a11y_no_static_element_interactions --> +<div + class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" + role="dialog" + aria-modal="true" + aria-label="Error" +> + <div class="flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-base-100 shadow-xl"> + <!-- Header --> + <div class="flex items-center justify-between border-b border-base-300 px-4 py-3"> + <div class="flex items-center gap-2"> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + class="size-5 text-error" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" + /> + </svg> + <h2 class="text-lg font-semibold text-error">Something went wrong</h2> + </div> + <button + type="button" + class="btn btn-ghost btn-sm btn-square" + aria-label="Dismiss error" + onclick={onDismiss} + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + class="size-5" + aria-hidden="true" + > + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> + </svg> + </button> + </div> + + <!-- Body: scrollable error text --> + <div class="overflow-auto p-4"> + <pre + id="error-modal-text" + class="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed opacity-80">{error}</pre> + </div> + + <!-- Footer: copy button --> + <div class="flex items-center justify-between gap-2 border-t border-base-300 px-4 py-3"> + <span class="text-xs opacity-50">Press Esc to dismiss</span> + <button + type="button" + class="btn btn-sm {copied ? 'btn-success' : 'btn-outline'}" + onclick={handleCopy} + > + {#if copied} + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + class="size-4" + aria-hidden="true" + > + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> + </svg> + Copied + {:else} + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + class="size-4" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m18 3.75h-3.75m3.75 0-2.25 2.25m2.25-2.25 2.25 2.25M4.5 6.75 6.75 4.5" + /> + </svg> + Copy + {/if} + </button> + </div> + </div> +</div> diff --git a/src/app/resolve-http-url.test.ts b/src/app/resolve-http-url.test.ts index 90edcbb..0da2591 100644 --- a/src/app/resolve-http-url.test.ts +++ b/src/app/resolve-http-url.test.ts @@ -2,55 +2,55 @@ import { describe, expect, it } from "vitest"; import { resolveHttpUrl } from "./resolve-http-url"; describe("resolveHttpUrl", () => { - it("explicit url wins over everything", () => { - const result = resolveHttpUrl( - { VITE_HTTP_URL: "https://env.example.com:9999" }, - { protocol: "https:", hostname: "page.example.com" }, - ); - expect(result).toBe("https://env.example.com:9999"); - }); - - it("VITE_HTTP_URL wins over derivation", () => { - const result = resolveHttpUrl( - { VITE_HTTP_URL: "https://env.example.com:8888" }, - { protocol: "http:", hostname: "page.example.com" }, - ); - expect(result).toBe("https://env.example.com:8888"); - }); - - it("derives http://<hostname>:24203 from http location", () => { - const result = resolveHttpUrl({}, { protocol: "http:", hostname: "100.126.75.103" }); - expect(result).toBe("http://100.126.75.103:24203"); - }); - - it("derives https://<hostname>:24203 from https location", () => { - const result = resolveHttpUrl({}, { protocol: "https:", hostname: "arch-razer" }); - expect(result).toBe("https://arch-razer:24203"); - }); - - it("uses VITE_HTTP_PORT when set", () => { - const result = resolveHttpUrl( - { VITE_HTTP_PORT: "3000" }, - { protocol: "http:", hostname: "localhost" }, - ); - expect(result).toBe("http://localhost:3000"); - }); - - it("falls back to http://localhost:24203 when location is missing", () => { - const result = resolveHttpUrl({}); - expect(result).toBe("http://localhost:24203"); - }); - - it("VITE_HTTP_URL empty string treated as unset", () => { - const result = resolveHttpUrl({ VITE_HTTP_URL: "" }, { protocol: "http:", hostname: "myhost" }); - expect(result).toBe("http://myhost:24203"); - }); - - it("VITE_HTTP_PORT empty string falls back to default", () => { - const result = resolveHttpUrl( - { VITE_HTTP_PORT: "" }, - { protocol: "http:", hostname: "localhost" }, - ); - expect(result).toBe("http://localhost:24203"); - }); + it("explicit url wins over everything", () => { + const result = resolveHttpUrl( + { VITE_HTTP_URL: "https://env.example.com:9999" }, + { protocol: "https:", hostname: "page.example.com" }, + ); + expect(result).toBe("https://env.example.com:9999"); + }); + + it("VITE_HTTP_URL wins over derivation", () => { + const result = resolveHttpUrl( + { VITE_HTTP_URL: "https://env.example.com:8888" }, + { protocol: "http:", hostname: "page.example.com" }, + ); + expect(result).toBe("https://env.example.com:8888"); + }); + + it("derives http://<hostname>:24203 from http location", () => { + const result = resolveHttpUrl({}, { protocol: "http:", hostname: "100.126.75.103" }); + expect(result).toBe("http://100.126.75.103:24203"); + }); + + it("derives https://<hostname>:24203 from https location", () => { + const result = resolveHttpUrl({}, { protocol: "https:", hostname: "arch-razer" }); + expect(result).toBe("https://arch-razer:24203"); + }); + + it("uses VITE_HTTP_PORT when set", () => { + const result = resolveHttpUrl( + { VITE_HTTP_PORT: "3000" }, + { protocol: "http:", hostname: "localhost" }, + ); + expect(result).toBe("http://localhost:3000"); + }); + + it("falls back to http://localhost:24203 when location is missing", () => { + const result = resolveHttpUrl({}); + expect(result).toBe("http://localhost:24203"); + }); + + it("VITE_HTTP_URL empty string treated as unset", () => { + const result = resolveHttpUrl({ VITE_HTTP_URL: "" }, { protocol: "http:", hostname: "myhost" }); + expect(result).toBe("http://myhost:24203"); + }); + + it("VITE_HTTP_PORT empty string falls back to default", () => { + const result = resolveHttpUrl( + { VITE_HTTP_PORT: "" }, + { protocol: "http:", hostname: "localhost" }, + ); + expect(result).toBe("http://localhost:24203"); + }); }); diff --git a/src/app/resolve-http-url.ts b/src/app/resolve-http-url.ts index 357d2fc..1e20eb2 100644 --- a/src/app/resolve-http-url.ts +++ b/src/app/resolve-http-url.ts @@ -1,28 +1,28 @@ export interface HttpUrlEnv { - readonly VITE_HTTP_URL?: string; - readonly VITE_HTTP_PORT?: string; + readonly VITE_HTTP_URL?: string; + readonly VITE_HTTP_PORT?: string; } export interface HttpUrlLocation { - readonly protocol: string; - readonly hostname: string; + readonly protocol: string; + readonly hostname: string; } const DEFAULT_PORT = "24203"; const DEFAULT_FALLBACK = "http://localhost:24203"; export function resolveHttpUrl(env: HttpUrlEnv, location?: HttpUrlLocation): string { - if (env.VITE_HTTP_URL !== undefined && env.VITE_HTTP_URL !== "") { - return env.VITE_HTTP_URL; - } + if (env.VITE_HTTP_URL !== undefined && env.VITE_HTTP_URL !== "") { + return env.VITE_HTTP_URL; + } - if (location === undefined) { - return DEFAULT_FALLBACK; - } + if (location === undefined) { + return DEFAULT_FALLBACK; + } - const port = - env.VITE_HTTP_PORT !== undefined && env.VITE_HTTP_PORT !== "" - ? env.VITE_HTTP_PORT - : DEFAULT_PORT; - return `${location.protocol}//${location.hostname}:${port}`; + const port = + env.VITE_HTTP_PORT !== undefined && env.VITE_HTTP_PORT !== "" + ? env.VITE_HTTP_PORT + : DEFAULT_PORT; + return `${location.protocol}//${location.hostname}:${port}`; } diff --git a/src/app/resolve-ws-url.test.ts b/src/app/resolve-ws-url.test.ts index 24c2f24..b5ba6c3 100644 --- a/src/app/resolve-ws-url.test.ts +++ b/src/app/resolve-ws-url.test.ts @@ -2,52 +2,52 @@ import { describe, expect, it } from "vitest"; import { resolveWsUrl } from "./resolve-ws-url"; describe("resolveWsUrl", () => { - it("explicit url wins over everything", () => { - const result = resolveWsUrl( - { VITE_WS_URL: "wss://env.example.com:9999" }, - { protocol: "https:", hostname: "page.example.com" }, - ); - expect(result).toBe("wss://env.example.com:9999"); - }); - - it("VITE_WS_URL wins over derivation", () => { - const result = resolveWsUrl( - { VITE_WS_URL: "wss://env.example.com:9999" }, - { protocol: "https:", hostname: "page.example.com" }, - ); - expect(result).toBe("wss://env.example.com:9999"); - }); - - it("derives ws://<hostname>:24205 from http location", () => { - const result = resolveWsUrl({}, { protocol: "http:", hostname: "100.126.75.103" }); - expect(result).toBe("ws://100.126.75.103:24205"); - }); - - it("derives wss://<hostname>:24205 from https location", () => { - const result = resolveWsUrl({}, { protocol: "https:", hostname: "arch-razer" }); - expect(result).toBe("wss://arch-razer:24205"); - }); - - it("uses VITE_WS_PORT when set", () => { - const result = resolveWsUrl( - { VITE_WS_PORT: "3000" }, - { protocol: "http:", hostname: "localhost" }, - ); - expect(result).toBe("ws://localhost:3000"); - }); - - it("falls back to ws://localhost:24205 when location is missing", () => { - const result = resolveWsUrl({}); - expect(result).toBe("ws://localhost:24205"); - }); - - it("VITE_WS_URL empty string treated as unset", () => { - const result = resolveWsUrl({ VITE_WS_URL: "" }, { protocol: "http:", hostname: "myhost" }); - expect(result).toBe("ws://myhost:24205"); - }); - - it("VITE_WS_PORT empty string falls back to default", () => { - const result = resolveWsUrl({ VITE_WS_PORT: "" }, { protocol: "http:", hostname: "localhost" }); - expect(result).toBe("ws://localhost:24205"); - }); + it("explicit url wins over everything", () => { + const result = resolveWsUrl( + { VITE_WS_URL: "wss://env.example.com:9999" }, + { protocol: "https:", hostname: "page.example.com" }, + ); + expect(result).toBe("wss://env.example.com:9999"); + }); + + it("VITE_WS_URL wins over derivation", () => { + const result = resolveWsUrl( + { VITE_WS_URL: "wss://env.example.com:9999" }, + { protocol: "https:", hostname: "page.example.com" }, + ); + expect(result).toBe("wss://env.example.com:9999"); + }); + + it("derives ws://<hostname>:24205 from http location", () => { + const result = resolveWsUrl({}, { protocol: "http:", hostname: "100.126.75.103" }); + expect(result).toBe("ws://100.126.75.103:24205"); + }); + + it("derives wss://<hostname>:24205 from https location", () => { + const result = resolveWsUrl({}, { protocol: "https:", hostname: "arch-razer" }); + expect(result).toBe("wss://arch-razer:24205"); + }); + + it("uses VITE_WS_PORT when set", () => { + const result = resolveWsUrl( + { VITE_WS_PORT: "3000" }, + { protocol: "http:", hostname: "localhost" }, + ); + expect(result).toBe("ws://localhost:3000"); + }); + + it("falls back to ws://localhost:24205 when location is missing", () => { + const result = resolveWsUrl({}); + expect(result).toBe("ws://localhost:24205"); + }); + + it("VITE_WS_URL empty string treated as unset", () => { + const result = resolveWsUrl({ VITE_WS_URL: "" }, { protocol: "http:", hostname: "myhost" }); + expect(result).toBe("ws://myhost:24205"); + }); + + it("VITE_WS_PORT empty string falls back to default", () => { + const result = resolveWsUrl({ VITE_WS_PORT: "" }, { protocol: "http:", hostname: "localhost" }); + expect(result).toBe("ws://localhost:24205"); + }); }); diff --git a/src/app/resolve-ws-url.ts b/src/app/resolve-ws-url.ts index a264606..1c6e259 100644 --- a/src/app/resolve-ws-url.ts +++ b/src/app/resolve-ws-url.ts @@ -1,27 +1,27 @@ export interface WsUrlEnv { - readonly VITE_WS_URL?: string; - readonly VITE_WS_PORT?: string; + readonly VITE_WS_URL?: string; + readonly VITE_WS_PORT?: string; } export interface WsUrlLocation { - readonly protocol: string; - readonly hostname: string; + readonly protocol: string; + readonly hostname: string; } const DEFAULT_PORT = "24205"; const DEFAULT_FALLBACK = "ws://localhost:24205"; export function resolveWsUrl(env: WsUrlEnv, location?: WsUrlLocation): string { - if (env.VITE_WS_URL !== undefined && env.VITE_WS_URL !== "") { - return env.VITE_WS_URL; - } + if (env.VITE_WS_URL !== undefined && env.VITE_WS_URL !== "") { + return env.VITE_WS_URL; + } - if (location === undefined) { - return DEFAULT_FALLBACK; - } + if (location === undefined) { + return DEFAULT_FALLBACK; + } - const wsProtocol = location.protocol === "https:" ? "wss" : "ws"; - const port = - env.VITE_WS_PORT !== undefined && env.VITE_WS_PORT !== "" ? env.VITE_WS_PORT : DEFAULT_PORT; - return `${wsProtocol}://${location.hostname}:${port}`; + const wsProtocol = location.protocol === "https:" ? "wss" : "ws"; + const port = + env.VITE_WS_PORT !== undefined && env.VITE_WS_PORT !== "" ? env.VITE_WS_PORT : DEFAULT_PORT; + return `${wsProtocol}://${location.hostname}:${port}`; } diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 6cff5f8..629e6c6 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -1,49 +1,92 @@ import type { - ChatDeltaMessage, - ChatErrorMessage, - CompactPercentResponse, - CompactResponse, - ConversationCompactedMessage, - ConversationHistoryResponse, - ConversationListResponse, - ConversationMetricsResponse, - ConversationOpenMessage, - ConversationStatusChangedMessage, - CwdResponse, - LspStatusResponse, - ModelMetadata, - ModelsResponse, - ReasoningEffort, - ReasoningEffortResponse, - SetCompactPercentRequest, - SetCwdRequest, - SetReasoningEffortRequest, - SetTitleRequest, - WarmRequest, - WarmResponse, + ChatDeltaMessage, + ChatErrorMessage, + CompactPercentResponse, + CompactResponse, + ComputerListResponse, + ComputerStatusResponse, + ConversationCompactedMessage, + ConversationComputerResponse, + ConversationHistoryResponse, + ConversationListResponse, + ConversationMetricsResponse, + ConversationOpenMessage, + ConversationStatusChangedMessage, + CwdResponse, + LspStatusResponse, + McpStatusResponse, + ModelMetadata, + ModelResponse, + ModelsResponse, + ReasoningEffort, + ReasoningEffortResponse, + SetCompactPercentRequest, + SetConversationComputerRequest, + SetCwdRequest, + SetModelRequest, + SetReasoningEffortRequest, + SetSystemPromptTemplateRequest, + SetTitleRequest, + SetVisionSettingsRequest, + SystemPromptTemplateResponse, + SystemPromptVariable, + SystemPromptVariablesResponse, + TestComputerResponse, + WarmRequest, + WarmResponse, } from "@dispatch/transport-contract"; import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; -import type { ConversationStatus } from "@dispatch/wire"; +import type { ComputerEntry, ConversationStatus, ImageInput } from "@dispatch/wire"; +import { untrack } from "svelte"; import { createIdbChunkStore } from "../adapters/idb"; import { createLocalStore } from "../adapters/local-storage"; import type { WebSocketLike } from "../adapters/ws"; import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws"; import { normalizeChatLimit } from "../core/chunks"; import { - applyServerMessage, - getSurfaceSpec, - type ProtocolState, - initialState as protocolInitialState, - invoke as protocolInvoke, - subscribe as protocolSubscribe, - unsubscribe as protocolUnsubscribe, + applyServerMessage, + getSurfaceSpec, + type ProtocolState, + initialState as protocolInitialState, + invoke as protocolInvoke, + subscribe as protocolSubscribe, + unsubscribe as protocolUnsubscribe, } from "../core/protocol"; import type { ChatStore, HistorySync, MetricsSync } from "../features/chat"; import { createChatStore } from "../features/chat"; +import type { SetThinkingRequest, ThinkingResponse } from "../features/chat/reasoning-effort"; +import type { + ConcurrencyCooldownResult, + ConcurrencyDeleteResult, + ConcurrencyLimitResult, + ConcurrencyLimitsResult, + ConcurrencyStatusResult, +} from "../features/concurrency"; +import { + normalizeConcurrencyCooldown, + normalizeConcurrencyLimit, + normalizeConcurrencyLimits, + normalizeConcurrencyStatus, +} from "../features/concurrency"; import type { ConversationCache } from "../features/conversation-cache"; import { createConversationCache } from "../features/conversation-cache"; +import type { + HeartbeatConfig, + HeartbeatConfigPatch, + HeartbeatConfigResult, + HeartbeatNextRunResult, + HeartbeatRun, + HeartbeatRunsResult, + HeartbeatStopResult, +} from "../features/heartbeat"; +import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat"; import type { Tab, TabsState } from "../features/tabs"; import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs"; +import { + normalizeVisionSettings, + type VisionSettings, + type VisionSettingsPatch, +} from "../features/vision"; import { resolveHttpUrl } from "./resolve-http-url"; import { resolveWsUrl } from "./resolve-ws-url"; import { randomId } from "./uuid"; @@ -52,1048 +95,2173 @@ const DEFAULT_MODEL = "opencode/deepseek-v4-flash"; /** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */ export type WarmResult = - | { readonly ok: true; readonly response: WarmResponse } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly response: WarmResponse } + | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/cwd`. */ export type CwdResult = - | { readonly ok: true; readonly cwd: string | null } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly cwd: string | null } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `PUT /conversations/:id/computer` (set/clear the per-conversation computer). */ +export type ComputerResult = + | { readonly ok: true; readonly computerId: string | null } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `GET /computers/:alias/status` (the live connection state). */ +export type ComputerStatusResult = + | { readonly ok: true; readonly response: ComputerStatusResponse } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `POST /computers/:alias/test` (one-shot connectivity probe). */ +export type TestComputerResult = + | { readonly ok: true; readonly response: TestComputerResponse } + | { readonly ok: false; readonly error: string }; /** Outcome of `GET /conversations/:id/lsp`. */ export type LspResult = - | { readonly ok: true; readonly response: LspStatusResponse } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly response: LspStatusResponse } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `GET /conversations/:id/mcp`. */ +export type McpResult = + | { readonly ok: true; readonly response: McpStatusResponse } + | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/reasoning-effort`. */ export type ReasoningEffortResult = - | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } + | { readonly ok: false; readonly error: string }; + +/** + * Outcome of `PUT /conversations/:id/thinking` (PROPOSED — see + * `backend-handoff.md`; the endpoint is not yet shipped by the backend). + */ +export type ThinkingResult = + | { readonly ok: true; readonly thinking: boolean } + | { readonly ok: false; readonly error: string }; /** Outcome of `POST /conversations/:id/compact` (manual compaction). */ export type CompactResult = - | { readonly ok: true; readonly response: CompactResponse } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly response: CompactResponse } + | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/compact-percent`. */ export type CompactPercentResult = - | { readonly ok: true; readonly percent: number } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly percent: number } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `PUT /settings/vision` (global vision-settings save). */ +export type VisionSettingsResult = + | { readonly ok: true; readonly settings: VisionSettings } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `GET /system-prompt` (global template load). */ +export type SystemPromptLoadResult = + | { readonly ok: true; readonly template: string } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `PUT /system-prompt` (global template save). */ +export type SystemPromptSaveResult = SystemPromptLoadResult; + +/** Outcome of `GET /system-prompt/variables` (variable catalog). */ +export type SystemPromptVariablesResult = + | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] } + | { readonly ok: false; readonly error: string }; /** Outcome of persisting a chat-limit setting (localStorage; FE-local). */ export type ChatLimitResult = - | { readonly ok: true; readonly chatLimit: number } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly chatLimit: number } + | { readonly ok: false; readonly error: string }; export interface AppStore { - readonly tabs: readonly Tab[]; - readonly activeConversationId: string | null; - readonly activeChat: ChatStore; - readonly models: readonly string[]; - /** Per-model metadata (contextWindow, etc.) from `GET /models`. */ - readonly modelInfo: Readonly<Record<string, ModelMetadata>>; - readonly activeModel: string; - readonly catalog: ProtocolState["catalog"]; - /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */ - readonly surfaces: readonly SurfaceSpec[]; - readonly lastError: ProtocolState["lastError"]; - /** The localStorage instance the store uses for persistence (tabs, chatLimit). - * Exposed so the shell can persist sidebar layout via the same adapter. */ - readonly storage: Storage | undefined; - /** The current spec for one surface by id (discovery-by-id), or null if absent. */ - surface(surfaceId: string): SurfaceSpec | null; - send(text: string): void; - /** - * Enqueue a steering message onto the focused conversation's queue - * (`chat.queue` WS op). While a turn is generating, the message is delivered - * mid-turn at the next tool-result boundary; when idle, the server - * auto-starts a turn (equivalent to `send`). Safe to offer whenever the user - * wants to add input — the server owns the idle-vs-generating decision. - */ - queueMessage(text: string): void; - selectModel(model: string): void; - newDraft(): void; - selectTab(conversationId: string): void; - closeTab(conversationId: string): void; - renameTab(conversationId: string, title: string): void; - invoke(surfaceId: string, actionId: string, payload?: unknown): void; - /** - * Manually warm the focused conversation's prompt cache (`POST /chat/warm`). - * Returns null when no conversation is focused (a draft has nothing to warm). - */ - warmNow(): Promise<WarmResult | null>; - /** The workspace conversation's persisted working directory, or null when unset. */ - readonly cwd: string | null; - /** The conversation workspace settings target: the active tab, or the pending draft's id. */ - readonly currentConversationId: string; - /** - * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`). - * Works for a draft too (its id survives promotion), so the first turn runs in it. - */ - setCwd(cwd: string): Promise<CwdResult | null>; - /** - * The workspace conversation's persisted reasoning effort, or null when never - * set (the server then resolves turns at the default, `"high"`). - */ - readonly reasoningEffort: ReasoningEffort | null; - /** - * Persist the workspace conversation's reasoning effort - * (`PUT /conversations/:id/reasoning-effort`). Works for a draft too (its id - * survives promotion), so the first turn already runs at the chosen level. - * Takes effect from the NEXT turn; resolution stays server-owned. - */ - setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null>; - /** - * Manually trigger conversation compaction (`POST /conversations/:id/compact`). - * Summarizes old messages + retains the most recent N. Returns null when no - * conversation is focused (a draft has nothing to compact). - */ - compactNow(keepLastN?: number): Promise<CompactResult | null>; - /** - * Stop an in-flight generation (`POST /conversations/:id/stop`). Aborts the - * turn without closing the conversation — partial messages are persisted, the - * turn seals with `reason: "aborted"`, and the conversation goes `active → idle`. - * Returns null when no conversation is focused. - */ - stopGeneration(): void; - /** - * The workspace conversation's auto-compact percent (0-100). `0` = disabled - * (manual only); a positive number = auto-compact triggers when the last - * turn's input tokens exceed it. Seeded from the backend on focus change. - */ - readonly compactPercent: number | null; - /** - * Persist the workspace conversation's auto-compact percent - * (`PUT /conversations/:id/compact-percent`). `0` disables; 1-100 sets the - * trigger percentage of the model's context window. Default (null) is 85. - * number enables. Works for a draft too (its id survives promotion). - */ - setCompactPercent(percent: number): Promise<CompactPercentResult | null>; - /** - * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`). - * The backend lazily spawns servers, so this may take a moment on the first call for a cwd. - */ - lspStatus(): Promise<LspResult | null>; - /** The persisted chat limit (max loaded chunks per conversation). */ - readonly chatLimit: number; - /** - * A conversation's backend lifecycle status (`active`/`idle`/`closed`), or - * `undefined` when unknown. Drives the tab-bar generating indicator - * (cross-device: a tab spinning because another device's turn is running). - */ - conversationStatus(conversationId: string): ConversationStatus | undefined; - /** - * Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to - * localStorage and propagates to every live chat store (trim if lower, - * deferred via the unload gate while a reader is scrolled up; no-op if - * higher — page unloaded history back in via "Show earlier"). Stores created - * afterwards pick the new limit up at creation. Always succeeds (FE-local). - */ - setChatLimit(limit: number): Promise<ChatLimitResult>; - /** - * Wire the chat-limit unload gate (composition-root injection, called once by - * the shell after it owns the scroll region): unloading old chunks is allowed - * only while the gate returns true — i.e. the reader is stuck to the bottom — - * so a trim never yanks content out from under someone reading history. - * Before attachment unloading is allowed (the initial view starts at the - * bottom). - */ - attachUnloadGate(gate: () => boolean): void; - dispose(): void; + readonly tabs: readonly Tab[]; + readonly activeConversationId: string | null; + /** The workspace currently in view (URL slug); tabs are filtered to it. */ + readonly activeWorkspaceId: string; + /** + * The resolved HTTP API base URL (e.g. `http://localhost:24203`). Used to + * resolve relative image URLs served by the backend (`/images/…`) into + * absolute URLs for `<img src>`. + */ + readonly httpBase: string; + readonly activeChat: ChatStore; + readonly models: readonly string[]; + /** Per-model metadata (contextWindow, etc.) from `GET /models`. */ + readonly modelInfo: Readonly<Record<string, ModelMetadata>>; + readonly activeModel: string; + readonly catalog: ProtocolState["catalog"]; + /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */ + readonly surfaces: readonly SurfaceSpec[]; + readonly lastError: ProtocolState["lastError"]; + /** The localStorage instance the store uses for persistence (tabs, chatLimit). + * Exposed so the shell can persist sidebar layout via the same adapter. */ + readonly storage: Storage | undefined; + /** The current spec for one surface by id (discovery-by-id), or null if absent. */ + surface(surfaceId: string): SurfaceSpec | null; + /** + * Send a user message (start a turn). Forwards any staged `images` + * (`ImageInput[]` — base64 data URLs / https URLs) on the `chat.send` op; + * the server passes them to a vision-capable model natively or transcribes + * them via vision handoff for a non-vision model. Omitted on the wire when + * none are staged. On a draft, promotes to a tab first. + */ + send(text: string, images?: readonly ImageInput[]): void; + /** + * Enqueue a steering message onto the focused conversation's queue + * (`chat.queue` WS op). While a turn is generating, the message is delivered + * mid-turn at the next tool-result boundary; when idle, the server + * auto-starts a turn (equivalent to `send`). Safe to offer whenever the user + * wants to add input — the server owns the idle-vs-generating decision. + */ + queueMessage(text: string): void; + /** + * Cancel (remove) a single queued steering message by id so it never runs + * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: the + * message-queue surface update reconciles the queue UI (the cancelled message + * leaves the snapshot). Targets the focused conversation's queue. The caller + * optimistically hides the row; a cancel of an already-drained / unknown + * message is a silent server no-op (nothing to roll back). + */ + cancelQueuedMessage(messageId: string): void; + selectModel(model: string): void; + newDraft(): void; + /** Switch the active workspace (on route change) + reset to a fresh draft in it. */ + setActiveWorkspace(workspaceId: string): void; + selectTab(conversationId: string): void; + closeTab(conversationId: string): void; + renameTab(conversationId: string, title: string): void; + invoke(surfaceId: string, actionId: string, payload?: unknown): void; + /** + * Manually warm the focused conversation's prompt cache (`POST /chat/warm`). + * Returns null when no conversation is focused (a draft has nothing to warm). + */ + warmNow(): Promise<WarmResult | null>; + /** The workspace conversation's persisted working directory, or null when unset. */ + readonly cwd: string | null; + /** The conversation workspace settings target: the active tab, or the pending draft's id. */ + readonly currentConversationId: string; + /** + * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`). + * Works for a draft too (its id survives promotion), so the first turn runs in it. + */ + setCwd(cwd: string): Promise<CwdResult | null>; + /** + * The workspace conversation's persisted computer (an SSH `Host` alias), or + * null when never set / local. Seeded from the backend on focus change. + */ + readonly computerId: string | null; + /** + * Persist the workspace conversation's computer (`PUT /conversations/:id/computer`). + * Pass null to clear → the conversation inherits the workspace default → local. + * Works for a draft too (its id survives promotion). Not seen by the agent — a + * user-facing tool-execution target only. + */ + setComputer(computerId: string | null): Promise<ComputerResult | null>; + /** + * Every remote computer discovered from the user's `~/.ssh/config` + * (`GET /computers`), fetched on boot. Read-only — there is no Computer CRUD + * (the user edits their ssh config to add one). Empty until the `ssh` + * extension lands. + */ + readonly computers: readonly ComputerEntry[]; + /** + * The live connection state of a computer (`GET /computers/:alias/status`): + * whether Dispatch currently holds an open SSH session to it. Returns null + * only if no alias is given (the focused conversation is local). Polled by the + * `ComputerField` while a computer is selected. + */ + computerStatus(alias: string): Promise<ComputerStatusResult | null>; + /** + * One-shot connectivity probe (`POST /computers/:alias/test`): Dispatch opens + * an SSH connection to the alias, runs a trivial command, then closes. `ok` is + * true on success; `error` carries the failure reason otherwise. + */ + testComputer(alias: string): Promise<TestComputerResult | null>; + /** + * The workspace conversation's persisted reasoning effort, or null when never + * set (the server then resolves turns at the default, `"high"`). + */ + readonly reasoningEffort: ReasoningEffort | null; + /** + * Persist the workspace conversation's reasoning effort + * (`PUT /conversations/:id/reasoning-effort`). Works for a draft too (its id + * survives promotion), so the first turn already runs at the chosen level. + * Takes effect from the NEXT turn; resolution stays server-owned. + */ + setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null>; + /** + * The workspace conversation's persisted thinking flag, or null when never + * set (the server then resolves turns with thinking ON — the default). + * `false` ⇒ thinking disabled entirely (the SEPARATE "off" axis — NOT a + * zero-effort level; the umans route maps it to `reasoning_effort: "none"`). + * PROPOSED backend contract — see `backend-handoff.md`. + */ + readonly thinking: boolean | null; + /** + * Persist the workspace conversation's thinking flag + * (`PUT /conversations/:id/thinking`). Works for a draft too (its id survives + * promotion), so the first turn already runs with the chosen setting. Takes + * effect from the NEXT turn; resolution stays server-owned. + * PROPOSED backend contract — see `backend-handoff.md`. + */ + setThinking(enabled: boolean): Promise<ThinkingResult | null>; + /** + * Manually trigger conversation compaction (`POST /conversations/:id/compact`). + * Summarizes old messages + retains the most recent N. Returns null when no + * conversation is focused (a draft has nothing to compact). + */ + compactNow(keepLastN?: number): Promise<CompactResult | null>; + /** + * Stop an in-flight generation (`POST /conversations/:id/stop`). Aborts the + * turn without closing the conversation — partial messages are persisted, the + * turn seals with `reason: "aborted"`, and the conversation goes `active → idle`. + * Returns null when no conversation is focused. + */ + stopGeneration(): void; + /** + * The workspace conversation's auto-compact percent (0-100). `0` = disabled + * (manual only); a positive number = auto-compact triggers when the last + * turn's input tokens exceed it. Seeded from the backend on focus change. + */ + readonly compactPercent: number | null; + /** + * Persist the workspace conversation's auto-compact percent + * (`PUT /conversations/:id/compact-percent`). `0` disables; 1-100 sets the + * trigger percentage of the model's context window. Default (null) is 85. + * number enables. Works for a draft too (its id survives promotion). + */ + setCompactPercent(percent: number): Promise<CompactPercentResult | null>; + /** + * The GLOBAL vision settings (`GET /settings/vision`): `imageLimit` (max + * native images per turn before compaction; 0 = disabled) + `compactionModel` + * (which vision model transcribes old images; null = auto). Shared across all + * conversations. Seeded on boot; `null` = not yet fetched. + */ + readonly visionSettings: VisionSettings | null; + /** + * Refetch the global vision settings (`GET /settings/vision`). Called by the + * vision-settings view on mount; also seeded on boot. + */ + refreshVisionSettings(): Promise<void>; + /** + * Save a PARTIAL vision-settings update (`PUT /settings/vision`). Either + * field may be omitted. Returns the merged settings on success. + */ + setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | null>; + /** + * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`). + * The backend lazily spawns servers, so this may take a moment on the first call for a cwd. + */ + lspStatus(): Promise<LspResult | null>; + /** + * Fetch the workspace conversation's MCP server status (`GET /conversations/:id/mcp`). + * Mirrors the LSP status endpoint: returns `{cwd, servers}` with empty `servers` + * when no cwd is set; the backend lazily connects servers, so this may take a + * moment on the first call for a cwd. + */ + mcpStatus(): Promise<McpResult | null>; + /** + * Load the global system prompt template (`GET /system-prompt`). The template is + * conversation-agnostic; it is resolved once per conversation on first turn and + * persisted for prompt-cache safety. + */ + loadSystemPrompt(): Promise<SystemPromptLoadResult>; + /** + * Persist the global system prompt template (`PUT /system-prompt`). Changes apply + * to new conversations on their first turn; existing conversations keep their + * resolved system prompt until compaction. + */ + setSystemPrompt(template: string): Promise<SystemPromptSaveResult>; + /** + * Load the static catalog of available system prompt variables (`GET /system-prompt/variables`). + * Used by the builder to render the variable selector buttons. + */ + loadSystemPromptVariables(): Promise<SystemPromptVariablesResult>; + /** The persisted chat limit (max loaded chunks per conversation). */ + readonly chatLimit: number; + /** + * A conversation's backend lifecycle status (`active`/`idle`/`closed`), or + * `undefined` when unknown. Drives the tab-bar generating indicator + * (cross-device: a tab spinning because another device's turn is running). + */ + conversationStatus(conversationId: string): ConversationStatus | undefined; + /** + * Whether at least one conversation in the given workspace is currently + * active or queued (generating / waiting for a concurrency slot) — drives + * the loading-dots indicator on workspace cards. Backed by a once-derived + * `activeWorkspaces` set (the open-tab set × the backend lifecycle statuses) + * so this is an O(1) lookup, not a per-card scan of the full tab list. + * Reactive: the set is a `$derived`, so a Svelte template expression calling + * this re-runs when the tab set or status map changes. + */ + workspaceHasActiveConversations(workspaceId: string): boolean; + /** + * Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to + * localStorage and propagates to every live chat store (trim if lower, + * deferred via the unload gate while a reader is scrolled up; no-op if + * higher — page unloaded history back in via "Show earlier"). Stores created + * afterwards pick the new limit up at creation. Always succeeds (FE-local). + */ + setChatLimit(limit: number): Promise<ChatLimitResult>; + /** + * Wire the chat-limit unload gate (composition-root injection, called once by + * the shell after it owns the scroll region): unloading old chunks is allowed + * only while the gate returns true — i.e. the reader is stuck to the bottom — + * so a trim never yanks content out from under someone reading history. + * Before attachment unloading is allowed (the initial view starts at the + * bottom). + */ + attachUnloadGate(gate: () => boolean): void; + /** + * Load the active workspace's heartbeat config + * (`GET /workspaces/:id/heartbeat`). Workspace-scoped (NOT per-conversation): + * the backend runs an autonomous agent loop on a configured interval, writing + * each run into a dedicated conversation. The config covers the system/task + * prompts, model, reasoning effort, interval, and an enabled flag. + */ + heartbeatConfig(): Promise<HeartbeatConfigResult>; + /** + * Persist a partial heartbeat config patch + * (`PUT /workspaces/:id/heartbeat`). The backend merges the patch onto the + * stored config; returns the full updated config. + */ + setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult>; + /** + * Load the active workspace's heartbeat run history + * (`GET /workspaces/:id/heartbeat/runs`). Each run references the conversation + * it wrote to — open one via {@link watchConversation} to see its chat live. + */ + heartbeatRuns(): Promise<HeartbeatRunsResult>; + /** + * Stop a running heartbeat run (`POST /workspaces/:id/heartbeat/runs/:runId/stop`). + * The run's in-flight turn seals (its conversation keeps streaming until it + * ends); the run's status flips to `stopped` (visible on the next runs poll). + */ + stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult>; + /** + * Fetch the server-authoritative next-run timestamp + * (`GET /workspaces/:id/heartbeat/next-run`) — when the next heartbeat run + * will fire (ISO 8601), or null when disabled / no run scheduled. The FE shows + * a live countdown from this. When the endpoint is absent (404 — backend + * hasn't shipped CR-HB-3 yet) it returns `ok: false` so the FE falls back to + * an approximation from the runs + config. + */ + heartbeatNextRun(): Promise<HeartbeatNextRunResult>; + /** + * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat + * modal): ensures a live {@link ChatStore} for the conversation, subscribing + * to its turn stream (`chat.subscribe`) + loading history. Reuses the open + * tab's store if the conversation is already a tab; otherwise creates an + * EPHEMERAL watch store (separate from tabs — never opens a tab). Deltas are + * routed to it automatically. Pair every open with {@link unwatchConversation} + * on close to unsubscribe + dispose the ephemeral store. + */ + watchConversation(conversationId: string): ChatStore; + /** Dispose + unsubscribe a watch opened by {@link watchConversation}. */ + unwatchConversation(conversationId: string): void; + /** + * Load all configured per-provider concurrency limits + * (`GET /concurrency/limits`). Global (not workspace-scoped). Returns an empty + * list when the concurrency extension isn't loaded (`{ limits: [] }`). + */ + concurrencyLimits(): Promise<ConcurrencyLimitsResult>; + /** + * Fetch the configured limit for one provider + * (`GET /concurrency/limits/:providerId`). `404` (no limit configured) and + * `503` (extension not loaded) both surface as `ok: false`. + */ + getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult>; + /** + * Set or update a provider's concurrency limit + * (`PUT /concurrency/limits/:providerId`, body `{ limit }`). `limit` must be a + * positive integer (a non-positive body is `400`). At the cap, further requests + * queue oldest-agent-first rather than being sent immediately. + */ + setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult>; + /** + * Remove a provider's concurrency limit (`DELETE /concurrency/limits/:providerId`), + * making it unlimited. `404` (not configured) and `503` (extension not loaded) + * both surface as `ok: false`. + */ + deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult>; + /** + * Fetch live concurrency status for every provider with a configured limit + * (`GET /concurrency/status`): in-flight slots held, agents queued, and a paused + * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Each + * entry also carries the per-slot release `cooldownMs` + an `autoReduced` flag + * (true when a 429 auto-reduced the limit by 1; the FE renders a banner). Returns + * an empty list when the extension isn't loaded (`{ providers: [] }`). + */ + concurrencyStatus(): Promise<ConcurrencyStatusResult>; + /** + * Fetch the per-slot release cooldown (ms) for one provider + * (`GET /concurrency/cooldown/:providerId`). `404` (no concurrency config at + * all) and `503` (extension not loaded) both surface as `ok: false`. + */ + getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult>; + /** + * Set the per-slot release cooldown (ms) for one provider + * (`PUT /concurrency/cooldown/:providerId`, body `{ cooldownMs }`). `cooldownMs` + * must be a non-negative integer (0 = no cooldown / instant re-admission); an + * invalid body is `400`. Persists + applies to subsequently recycled slots. + */ + setConcurrencyCooldown( + providerId: string, + cooldownMs: number, + ): Promise<ConcurrencyCooldownResult>; + /** + * A critical error that blocks normal operation (e.g. the cross-device tab + * restore fetch failed). When non-null, a full-screen modal is shown with the + * error details. Cleared by `clearFatalError` (the modal's dismiss button). + */ + readonly fatalError: string | null; + /** Dismiss the fatal error (called by the error modal's X button). */ + clearFatalError(): void; + dispose(): void; } export interface CreateAppStoreOptions { - url?: string; - httpUrl?: string; - socketFactory?: (url: string) => WebSocketLike; - fetchImpl?: typeof fetch; - indexedDB?: IDBFactory; - conversationId?: string; - localStorage?: Storage; + url?: string; + httpUrl?: string; + socketFactory?: (url: string) => WebSocketLike; + fetchImpl?: typeof fetch; + indexedDB?: IDBFactory; + conversationId?: string; + localStorage?: Storage; + /** The workspace to scope to at boot (its URL slug); "default" if absent. */ + workspaceId?: string; } function createHistorySync(httpBase: string, fetchImpl: typeof fetch): HistorySync { - return async (conversationId, sinceSeq, window) => { - let url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}?sinceSeq=${sinceSeq}`; - // CR-5 windowing ([email protected]): both must be positive - // integers when present (the server 400s otherwise; callers guarantee it). - if (window?.limit !== undefined) url += `&limit=${window.limit}`; - if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`; - const res = await fetchImpl(url); - if (!res.ok) { - throw new Error(`History sync failed: ${res.status}`); - } - return (await res.json()) as ConversationHistoryResponse; - }; + return async (conversationId, sinceSeq, window) => { + let url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}?sinceSeq=${sinceSeq}`; + // CR-5 windowing ([email protected]): both must be positive + // integers when present (the server 400s otherwise; callers guarantee it). + if (window?.limit !== undefined) url += `&limit=${window.limit}`; + if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`; + const res = await fetchImpl(url); + if (!res.ok) { + throw new Error(`History sync failed: ${res.status}`); + } + return (await res.json()) as ConversationHistoryResponse; + }; } function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSync { - return async (conversationId: string) => { - const url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}/metrics`; - const res = await fetchImpl(url); - if (!res.ok) return { turns: [] }; - return (await res.json()) as ConversationMetricsResponse; - }; + return async (conversationId: string) => { + const url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}/metrics`; + const res = await fetchImpl(url); + if (!res.ok) return { turns: [] }; + return (await res.json()) as ConversationMetricsResponse; + }; } export function createAppStore(opts?: CreateAppStoreOptions): AppStore { - let protocol = $state<ProtocolState>(protocolInitialState()); - let models = $state<readonly string[]>([]); - let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({}); - let activeModel = $state(DEFAULT_MODEL); - - const wsLocation = typeof location !== "undefined" ? location : undefined; - const wsUrl = - opts?.url ?? - resolveWsUrl( - { VITE_WS_URL: import.meta.env.VITE_WS_URL, VITE_WS_PORT: import.meta.env.VITE_WS_PORT }, - wsLocation, - ); - - const httpLocation = typeof location !== "undefined" ? location : undefined; - const httpBase = - opts?.httpUrl ?? - resolveHttpUrl( - { - VITE_HTTP_URL: import.meta.env.VITE_HTTP_URL, - VITE_HTTP_PORT: import.meta.env.VITE_HTTP_PORT, - }, - httpLocation, - ); - - const fetchImpl = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis); - const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB; - const localStorageOpt = opts?.localStorage ?? globalThis.localStorage; - - const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", { - storage: localStorageOpt, - }); - const tabsStore: TabsStore = createTabsStore(storageAdapter); - - // The chat limit (max loaded chunks per conversation) — a persisted local - // setting surfaced in the sidebar's Settings view. Reactive so the field + - // any live-apply re-trim update together. The default is written back on - // first run so the knob is discoverable in localStorage too. - const chatLimitStore = createLocalStore<number>("dispatch.chatLimit", { - storage: localStorageOpt, - }); - const storedChatLimit = chatLimitStore.load(); - const normalizedChatLimit = normalizeChatLimit(storedChatLimit); - let chatLimit = $state(normalizedChatLimit); - if (storedChatLimit === null) { - chatLimitStore.save(normalizedChatLimit); - } - - // Unload gate — attached by the shell once it owns the scroll region (see - // `AppStore.attachUnloadGate`). Until then, unloading is allowed. - let unloadGate: (() => boolean) | null = null; - - const cache: ConversationCache = createConversationCache( - createIdbChunkStore({ indexedDB: indexedDBFactory }), - ); - - const historySync = createHistorySync(httpBase, fetchImpl); - const metricsSync = createMetricsSync(httpBase, fetchImpl); - - const chatStores = new Map<string, ChatStore>(); - - function createChatFor(conversationId: string, model: string): ChatStore { - return createChatStore({ - conversationId, - model, - transport: { - send(msg) { - socket?.send(msg); - }, - }, - historySync, - metricsSync, - cache, - // Read from the persisted store (kept in sync with the reactive `chatLimit` - // by `setChatLimit` + boot) so this snapshot doesn't reference the `$state` - // — each store captures its limit at creation; live updates go through - // `setChatLimit`. - chatLimit: normalizeChatLimit(chatLimitStore.load()), - canUnload: () => (unloadGate === null ? true : unloadGate()), - }); - } - - const initialDraftId = randomId(); - let draftStore: ChatStore = createChatFor(initialDraftId, DEFAULT_MODEL); - let draftConversationId: string = initialDraftId; - - let activeChat = $state<ChatStore>(draftStore as ChatStore); - - // The active conversation's persisted working directory (per-tab). Seeded from - // the backend on focus change; null for a draft / when unset. - let cwd = $state<string | null>(null); - - /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */ - async function refreshCwd(): Promise<void> { - const id = workspaceConversationId(); - try { - const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`); - if (!res.ok) return; - const data = (await res.json()) as CwdResponse; - // Guard a slow response losing a race with a conversation switch. - if (workspaceConversationId() === id) cwd = data.cwd ?? null; - } catch { - // Non-fatal: a cwd fetch failure just leaves the prior value. - } - } - - // The workspace conversation's persisted reasoning effort. Seeded from the - // backend on focus change; null = never set (the server default applies). - let reasoningEffort = $state<ReasoningEffort | null>(null); - - /** Refetch the workspace conversation's reasoning effort (works for a draft too). */ - async function refreshReasoningEffort(): Promise<void> { - const id = workspaceConversationId(); - // Clear immediately so a switch never shows the PREVIOUS conversation's level - // while the fetch is in flight (null renders as the server default). - reasoningEffort = null; - try { - const res = await fetchImpl( - `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`, - ); - if (!res.ok) return; - const data = (await res.json()) as ReasoningEffortResponse; - // Guard a slow response losing a race with a conversation switch. - if (workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null; - } catch { - // Non-fatal: an effort fetch failure just leaves the default rendering. - } - } - - // The workspace conversation's auto-compact percent. Seeded from the - // backend on focus change; null = not yet fetched. 0 = disabled. - let compactPercent = $state<number | null>(null); - - /** Refetch the workspace conversation's compact percent (works for a draft too). */ - async function refreshCompactPercent(): Promise<void> { - const id = workspaceConversationId(); - compactPercent = null; - try { - const res = await fetchImpl( - `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`, - ); - if (!res.ok) return; - const data = (await res.json()) as CompactPercentResponse; - if (workspaceConversationId() === id) compactPercent = data.threshold; - } catch { - // Non-fatal: a percent fetch failure just leaves null. - } - } - - function getActiveChat(): ChatStore { - const activeId = tabsStore.activeConversationId; - if (activeId === null) { - return draftStore; - } - return chatStores.get(activeId) ?? draftStore; - } - - function refreshActiveChat(): void { - activeChat = getActiveChat(); - } - - function handleChatMessage(msg: ChatDeltaMessage | ChatErrorMessage): void { - let targetId: string | undefined; - if (msg.type === "chat.delta") { - targetId = msg.event.conversationId; - } else { - targetId = msg.conversationId; - } - - if (targetId !== undefined) { - const store = chatStores.get(targetId); - if (store !== undefined) { - store.handleDelta(msg); - return; - } - } - - // fallback: try all stores (chat.error without conversationId) - for (const store of chatStores.values()) { - store.handleDelta(msg); - } - } - - /** - * Start watching a conversation's live turn events (`chat.subscribe`). Sent for - * EVERY open conversation — not just the active one — so a backgrounded tab keeps - * streaming a running turn, and a reloaded/second client re-attaches to an - * in-flight turn (the server replays it from `turn-start`). Idempotent server-side; - * the socket queues it until the connection is open. NOT needed right after - * `chat.send` (that auto-subscribes the sending connection). - */ - function subscribeChat(conversationId: string): void { - socket?.send({ type: "chat.subscribe", conversationId }); - } - - /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */ - function unsubscribeChat(conversationId: string): void { - socket?.send({ type: "chat.unsubscribe", conversationId }); - } - - /** - * Tell the backend the user EXPLICITLY closed this conversation's tab - * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with - * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF). - * Distinct from a disconnect / `chat.unsubscribe`, which deliberately leave - * both running. Fire-and-forget: a failure is non-fatal (worst case the - * warming keeps running until a later close/toggle), and the endpoint is - * idempotent server-side. - */ - function closeConversation(conversationId: string): void { - void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, { - method: "POST", - }).catch(() => { - // Non-fatal — see doc comment. - }); - } - - /** The conversation the surfaces should scope to (undefined for a draft). */ - function focusedConversationId(): string | undefined { - return tabsStore.activeConversationId ?? undefined; - } - - /** - * The conversation id workspace settings (cwd / LSP) target: the active tab, or - * the pending draft's id when in draft mode. Unlike `focusedConversationId`, this - * is NEVER undefined — the draft has a stable client-minted id that survives - * promotion (first send), so a cwd set on a draft carries into the real turn. - */ - function workspaceConversationId(): string { - return tabsStore.activeConversationId ?? draftConversationId; - } - - function handleServerMessage(msg: SurfaceServerMessage): void { - protocol = applyServerMessage(protocol, msg); - // Surfaces are auto-expanded: whenever the catalog changes, subscribe to - // every entry (and drop subscriptions for entries that vanished). - if (msg.type === "catalog") { - syncSubscriptions(); - } - } - - /** - * Subscribe to every catalog entry, scoped to the focused conversation, and - * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped - * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe` - * emits unsubscribe-old + subscribe-new); a global surface ignores the id. - */ - function syncSubscriptions(): void { - const cid = focusedConversationId(); - for (const entry of protocol.catalog) { - // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id - // so a conversation switch doesn't churn a redundant unsubscribe+subscribe - // round trip ([email protected] catalog `scope`; ABSENT = assume - // conversation-scoped, the conservative pre-0.2.0 policy). - const scoped = entry.scope === "global" ? undefined : cid; - const result = protocolSubscribe(protocol, entry.id, scoped); - protocol = result.state; - for (const msg of result.outgoing) { - socket?.send(msg); - } - } - const catalogIds = new Set(protocol.catalog.map((e) => e.id)); - for (const id of [...protocol.subscriptions.keys()]) { - if (!catalogIds.has(id)) { - const result = protocolUnsubscribe(protocol, id); - protocol = result.state; - for (const msg of result.outgoing) { - socket?.send(msg); - } - } - } - } - - let socket: ReturnType<typeof createSurfaceSocket> | null = null; - - /** - * Open a conversation tab — used by the `conversation.open` WS broadcast - * (CLI `--open` flag). If the conversation is already open, this is a no-op; - * otherwise create a chat store, load its history, subscribe to its live - * turns, and add the tab WITHOUT switching the active conversation (the user - * stays on their current tab; the new tab appears in the strip). - */ - function openConversation(conversationId: string): void { - if (chatStores.has(conversationId)) return; - const store = createChatFor(conversationId, activeModel); - chatStores.set(conversationId, store); - void store.load(); - subscribeChat(conversationId); - tabsStore.openTab({ - conversationId, - model: activeModel, - title: "Conversation", - }); - } - - /** - * Remove a tab + its chat store locally (NO `POST /close` — used when the - * backend already marked the conversation `closed` via `conversation.statusChanged`). - */ - function removeTabLocally(conversationId: string): void { - unsubscribeChat(conversationId); - const store = chatStores.get(conversationId); - if (store !== undefined) { - store.dispose(); - chatStores.delete(conversationId); - } - void cache.delete(conversationId); - tabsStore.closeTab(conversationId); - conversationStatuses.delete(conversationId); - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshReasoningEffort(); - void refreshCompactPercent(); - } - - // Conversation lifecycle status (backend-owned, pushed via WS + - // fetched on connect). Keyed by conversationId. - let conversationStatuses = $state<Map<string, ConversationStatus>>(new Map()); - - /** - * Fetch `GET /conversations?status=active,idle` on connect to restore the - * tab bar across devices. Merges: opens tabs for conversations not already - * open, removes tabs for conversations that are no longer active/idle - * (closed on another device), and subscribes to `active` conversations' - * live streams. - */ - async function fetchOpenConversations(): Promise<void> { - try { - const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`); - if (!res.ok) return; - const data = (await res.json()) as ConversationListResponse; - - // Update the status map from the authoritative backend list. - const newStatuses = new Map<string, ConversationStatus>(); - for (const conv of data.conversations) { - newStatuses.set(conv.id, conv.status); - } - conversationStatuses = newStatuses; - - // Open tabs for conversations not already open. - const existingIds = new Set(chatStores.keys()); - for (const conv of data.conversations) { - if (!existingIds.has(conv.id)) { - const store = createChatFor(conv.id, activeModel); - chatStores.set(conv.id, store); - void store.load(); - subscribeChat(conv.id); - tabsStore.openTab({ - conversationId: conv.id, - model: activeModel, - title: conv.title, - }); - } else { - // Already open — update the title from the backend if it differs. - tabsStore.setTitle(conv.id, conv.title); - } - } - - // Remove tabs for conversations no longer active/idle (closed elsewhere). - const backendIds = new Set(data.conversations.map((c) => c.id)); - for (const tab of tabsStore.tabs) { - if (!backendIds.has(tab.conversationId)) { - removeTabLocally(tab.conversationId); - } - } - } catch { - // Non-fatal: fall back to the localStorage-restored tabs. - } - } - - const socketOpts: SurfaceSocketOptions = { - url: wsUrl, - onMessage: handleServerMessage, - onChat: handleChatMessage, - onConversationOpen(msg: ConversationOpenMessage): void { - openConversation(msg.conversationId); - }, - onConversationStatusChanged(msg: ConversationStatusChangedMessage): void { - const { conversationId, status } = msg; - if (status === "closed") { - // Closed on another device (or the backend) — remove the tab locally. - if (chatStores.has(conversationId)) { - removeTabLocally(conversationId); - } - return; - } - // active / idle — update the status map (drives the tab spinner). - conversationStatuses = new Map(conversationStatuses).set(conversationId, status); - // If this is a new active conversation we don't have a tab for, open one. - if (status === "active" && !chatStores.has(conversationId)) { - openConversation(conversationId); - } - }, - onConversationCompacted(msg: ConversationCompactedMessage): void { - // Compaction keeps the conversation ID — the old full history is forked - // to an archive (newConversationId). Just reload the same conversation's - // history (dispose stale store + cache + re-fetch). - const cid = msg.conversationId; - const wasActive = tabsStore.activeConversationId === cid; - const store = chatStores.get(cid); - if (store !== undefined) { - store.dispose(); - } - void cache.delete(cid); - const fresh = createChatFor(cid, activeModel); - chatStores.set(cid, fresh); - void fresh.load(); - if (wasActive) { - refreshActiveChat(); - } - }, - onReopen() { - // The server forgot our subscriptions on reconnect; re-send each with the - // conversation it was subscribed under (protocolSubscribe would no-op since - // they're still in our local map, so emit the wire messages directly). - for (const [surfaceId, sub] of protocol.subscriptions) { - const msg: SubscribeMessage = - sub.conversationId === undefined - ? { type: "subscribe", surfaceId } - : { type: "subscribe", surfaceId, conversationId: sub.conversationId }; - socket?.send(msg); - } - // Re-attach to every open conversation's turn stream. A turn that kept - // running while we were disconnected resumes streaming (server replays it - // from `turn-start`); one that sealed while we were gone is committed from - // history by `resync()` (which also clears a now-stale "generating"). - for (const tab of tabsStore.tabs) { - subscribeChat(tab.conversationId); - chatStores.get(tab.conversationId)?.resync(); - } - }, - }; - if (opts?.socketFactory !== undefined) { - socketOpts.socketFactory = opts.socketFactory; - } - socket = createSurfaceSocket(socketOpts); - - // Fetch model catalog - void fetchImpl(`${httpBase}/models`) - .then((res) => { - if (!res.ok) return; - return res.json() as Promise<ModelsResponse>; - }) - .then((data) => { - if (data === undefined) return; - models = data.models; - modelInfo = data.modelInfo ?? {}; - if (data.models.length > 0 && !data.models.includes(activeModel)) { - const first = data.models[0]; - if (first !== undefined) { - activeModel = first; - draftStore.setModel(first); - } - } - }) - .catch(() => { - // Model fetch failure is non-fatal; use defaults. - }); - - // Restore persisted tabs - const persistedState = storageAdapter.load(); - if (persistedState !== null && persistedState.tabs.length > 0) { - for (const tab of persistedState.tabs) { - const store = createChatFor(tab.conversationId, tab.model); - chatStores.set(tab.conversationId, store); - void store.load(); - // Watch each restored conversation's live turns: after a reload mid-turn the - // server replays the in-flight turn so we keep rendering it. Queued until the - // socket opens. - subscribeChat(tab.conversationId); - } - if (persistedState.activeConversationId !== null) { - const activeTab = persistedState.tabs.find( - (t) => t.conversationId === persistedState.activeConversationId, - ); - if (activeTab !== undefined) { - activeModel = activeTab.model; - } - } - } - - refreshActiveChat(); - void refreshCwd(); - void refreshReasoningEffort(); - void refreshCompactPercent(); - - // Fetch the authoritative open-conversation list from the backend (cross- - // device tab sync). Merges with the localStorage-restored tabs: opens new - // ones, removes closed ones, updates titles + statuses. - void fetchOpenConversations(); - - return { - get tabs(): readonly Tab[] { - return tabsStore.tabs; - }, - get activeConversationId(): string | null { - return tabsStore.activeConversationId; - }, - get activeChat(): ChatStore { - return activeChat; - }, - get models(): readonly string[] { - return models; - }, - get modelInfo(): Readonly<Record<string, ModelMetadata>> { - return modelInfo; - }, - get activeModel(): string { - return activeModel; - }, - get catalog() { - return protocol.catalog; - }, - get surfaces(): readonly SurfaceSpec[] { - const out: SurfaceSpec[] = []; - for (const entry of protocol.catalog) { - const spec = getSurfaceSpec(protocol, entry.id); - if (spec) out.push(spec); - } - return out; - }, - get lastError() { - return protocol.lastError; - }, - get storage() { - return localStorageOpt; - }, - get cwd(): string | null { - return cwd; - }, - get reasoningEffort(): ReasoningEffort | null { - return reasoningEffort; - }, - get compactPercent(): number | null { - return compactPercent; - }, - get chatLimit(): number { - return chatLimit; - }, - conversationStatus(conversationId: string): ConversationStatus | undefined { - return conversationStatuses.get(conversationId); - }, - get currentConversationId(): string { - return workspaceConversationId(); - }, - - surface(surfaceId: string): SurfaceSpec | null { - return getSurfaceSpec(protocol, surfaceId); - }, - - send(text: string): void { - if (tabsStore.activeConversationId === null) { - // Draft: promote to tab on first send - const conversationId = draftConversationId; - const model = activeModel; - tabsStore.createTab({ - conversationId, - model, - title: deriveTitle(text), - }); - chatStores.set(conversationId, draftStore); - void draftStore.load(); - - // Prepare next draft - const nextDraftId = randomId(); - draftStore = createChatFor(nextDraftId, activeModel); - draftConversationId = nextDraftId; - - refreshActiveChat(); - // The draft became a real conversation: re-scope conversation-scoped - // surfaces (e.g. cache-warming) to its id. - syncSubscriptions(); - void refreshCwd(); - void refreshReasoningEffort(); - void refreshCompactPercent(); - // Now send on the promoted store - chatStores.get(conversationId)?.send(text); - } else { - activeChat.send(text); - } - }, - - queueMessage(text: string): void { - // Only offered while generating (Composer switches to `chat.queue` - // when `status === "running"`), so a draft (never generating) never - // reaches here. `chat.queue` auto-starts a turn if idle, so even a race - // (turn sealed between the status read and the send) is safe — the - // server starts a fresh turn with the message as its opening prompt. - activeChat.queueMessage(text); - }, - - selectModel(model: string): void { - activeModel = model; - const activeId = tabsStore.activeConversationId; - if (activeId !== null) { - tabsStore.setModel(activeId, model); - chatStores.get(activeId)?.setModel(model); - } else { - draftStore.setModel(model); - } - }, - - newDraft(): void { - tabsStore.newDraft(); - const nextDraftId = randomId(); - draftStore = createChatFor(nextDraftId, activeModel); - draftConversationId = nextDraftId; - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshReasoningEffort(); - void refreshCompactPercent(); - }, - - selectTab(conversationId: string): void { - tabsStore.selectTab(conversationId); - const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId); - if (tab !== undefined) { - activeModel = tab.model; - } - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshReasoningEffort(); - void refreshCompactPercent(); - }, - - closeTab(conversationId: string): void { - // The user is DONE with this chat: abort any in-flight turn + stop/disable - // its cache-warming, server-side (POST /close sets status → "closed"). - closeConversation(conversationId); - removeTabLocally(conversationId); - }, - - renameTab(conversationId: string, title: string): void { - tabsStore.setTitle(conversationId, title); - void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/title`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title } satisfies SetTitleRequest), - }).catch(() => { - // Best-effort — the local tab is already renamed. - }); - }, - - invoke(surfaceId: string, actionId: string, payload?: unknown): void { - const result = protocolInvoke( - protocol, - surfaceId, - actionId, - payload, - focusedConversationId(), - ); - protocol = result.state; - for (const msg of result.outgoing) { - socket?.send(msg); - } - }, - - async warmNow(): Promise<WarmResult | null> { - const conversationId = tabsStore.activeConversationId; - if (conversationId === null) return null; - const body: WarmRequest = { conversationId, model: activeModel }; - try { - const res = await fetchImpl(`${httpBase}/chat/warm`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { ok: false, error: errBody?.error ?? `Warm failed (HTTP ${res.status})` }; - } - return { ok: true, response: (await res.json()) as WarmResponse }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" }; - } - }, - - async setCwd(value: string): Promise<CwdResult | null> { - const id = workspaceConversationId(); - const body: SetCwdRequest = { cwd: value }; - try { - const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { ok: false, error: errBody?.error ?? `Set cwd failed (HTTP ${res.status})` }; - } - const data = (await res.json()) as CwdResponse; - const next = data.cwd ?? null; - if (workspaceConversationId() === id) cwd = next; - return { ok: true, cwd: next }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" }; - } - }, - - async setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null> { - const id = workspaceConversationId(); - const body: SetReasoningEffortRequest = { reasoningEffort: level }; - try { - const res = await fetchImpl( - `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`, - { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }, - ); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { - ok: false, - error: errBody?.error ?? `Set reasoning effort failed (HTTP ${res.status})`, - }; - } - const data = (await res.json()) as ReasoningEffortResponse; - const next = data.reasoningEffort ?? level; - if (workspaceConversationId() === id) reasoningEffort = next; - return { ok: true, reasoningEffort: next }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Set reasoning effort request failed", - }; - } - }, - - stopGeneration(): void { - const conversationId = tabsStore.activeConversationId; - if (conversationId === null) return; - void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, { - method: "POST", - }).catch(() => { - // Non-fatal — the existing event flow handles the turn settle. - }); - }, - - async compactNow(keepLastN?: number): Promise<CompactResult | null> { - const conversationId = tabsStore.activeConversationId; - if (conversationId === null) return null; - const body: Record<string, unknown> = {}; - if (keepLastN !== undefined) body.keepLastN = keepLastN; - try { - const res = await fetchImpl( - `${httpBase}/conversations/${encodeURIComponent(conversationId)}/compact`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }, - ); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { - ok: false, - error: errBody?.error ?? `Compact failed (HTTP ${res.status})`, - }; - } - const data = (await res.json()) as CompactResponse; - return { ok: true, response: data }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Compact request failed", - }; - } - }, - - async setCompactPercent(percent: number): Promise<CompactPercentResult | null> { - const id = workspaceConversationId(); - const body: SetCompactPercentRequest = { threshold: percent }; - try { - const res = await fetchImpl( - `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`, - { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }, - ); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { - ok: false, - error: errBody?.error ?? `Set compact percent failed (HTTP ${res.status})`, - }; - } - const data = (await res.json()) as CompactPercentResponse; - if (workspaceConversationId() === id) compactPercent = data.threshold; - return { ok: true, percent: data.threshold }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Set compact percent request failed", - }; - } - }, - - async setChatLimit(limit: number): Promise<ChatLimitResult> { - const next = normalizeChatLimit(limit); - chatLimitStore.save(next); - chatLimit = next; - // Propagate to every live chat store. The ACTIVE one is awaited so its - // refill (on a raise) lands before the caller returns — letting the - // shell preserve scroll over the prepended older chunks. Background - // stores refill fire-and-forget. Future stores pick the new limit up at - // creation (via the persisted store). - const active = getActiveChat(); - await active.setChatLimit(next); - for (const s of chatStores.values()) { - if (s !== active) void s.setChatLimit(next); - } - if (draftStore !== active) void draftStore.setChatLimit(next); - return { ok: true, chatLimit: next }; - }, - - async lspStatus(): Promise<LspResult | null> { - const id = workspaceConversationId(); - try { - const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/lsp`); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { ok: false, error: errBody?.error ?? `LSP status failed (HTTP ${res.status})` }; - } - // Normalize the untyped body at this network seam so a malformed/partial - // response can never crash the renderer (servers is guaranteed an array). - const data = (await res.json()) as Partial<LspStatusResponse>; - const response: LspStatusResponse = { - conversationId: data.conversationId ?? id, - cwd: data.cwd ?? null, - servers: Array.isArray(data.servers) ? data.servers : [], - }; - return { ok: true, response }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "LSP status request failed", - }; - } - }, - attachUnloadGate(gate: () => boolean): void { - unloadGate = gate; - }, - - dispose(): void { - for (const store of chatStores.values()) { - store.dispose(); - } - chatStores.clear(); - draftStore.dispose(); - socket?.close(); - socket = null; - }, - }; + let protocol = $state<ProtocolState>(protocolInitialState()); + let models = $state<readonly string[]>([]); + let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({}); + // Discovered SSH computers (`GET /computers`). Global (like `models`); empty + // until the `ssh` extension lands. Read-only — no CRUD (the user edits their + // `~/.ssh/config`). + let computers = $state<readonly ComputerEntry[]>([]); + let activeModel = $state(DEFAULT_MODEL); + let fatalError = $state<string | null>(null); + + // The workspace currently in view (its URL slug); "default" until routing + // sets it. Tabs are filtered to this workspace; a new conversation is stamped + // with it on `chat.send`. + let activeWorkspaceId = $state<string>(opts?.workspaceId ?? "default"); + + const wsLocation = typeof location !== "undefined" ? location : undefined; + const wsUrl = + opts?.url ?? + resolveWsUrl( + { VITE_WS_URL: import.meta.env.VITE_WS_URL, VITE_WS_PORT: import.meta.env.VITE_WS_PORT }, + wsLocation, + ); + + const httpLocation = typeof location !== "undefined" ? location : undefined; + const httpBase = + opts?.httpUrl ?? + resolveHttpUrl( + { + VITE_HTTP_URL: import.meta.env.VITE_HTTP_URL, + VITE_HTTP_PORT: import.meta.env.VITE_HTTP_PORT, + }, + httpLocation, + ); + + const fetchImpl = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis); + const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB; + const localStorageOpt = opts?.localStorage ?? globalThis.localStorage; + + const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", { + storage: localStorageOpt, + }); + const tabsStore: TabsStore = createTabsStore(storageAdapter); + + // The chat limit (max loaded chunks per conversation) — a persisted local + // setting surfaced in the sidebar's Settings view. Reactive so the field + + // any live-apply re-trim update together. The default is written back on + // first run so the knob is discoverable in localStorage too. + const chatLimitStore = createLocalStore<number>("dispatch.chatLimit", { + storage: localStorageOpt, + }); + const storedChatLimit = chatLimitStore.load(); + const normalizedChatLimit = normalizeChatLimit(storedChatLimit); + let chatLimit = $state(normalizedChatLimit); + if (storedChatLimit === null) { + chatLimitStore.save(normalizedChatLimit); + } + + // Unload gate — attached by the shell once it owns the scroll region (see + // `AppStore.attachUnloadGate`). Until then, unloading is allowed. + let unloadGate: (() => boolean) | null = null; + + const cache: ConversationCache = createConversationCache( + createIdbChunkStore({ indexedDB: indexedDBFactory }), + ); + + const historySync = createHistorySync(httpBase, fetchImpl); + const metricsSync = createMetricsSync(httpBase, fetchImpl); + + const chatStores = new Map<string, ChatStore>(); + + // Ephemeral chat stores for MODAL viewers (the heartbeat run-chat modal): a + // watch on a conversation's live turn stream WITHOUT opening a tab. Separate + // from `chatStores` (tabs) so closing a modal never disturbs the tab strip, + // and a tab's conversation reuses its own store (see `watchConversation`). + // Deltas are routed here in addition to `chatStores`. + const watchStores = new Map<string, ChatStore>(); + + function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore { + return createChatStore({ + conversationId, + model, + workspaceId, + transport: { + send(msg) { + socket?.send(msg); + }, + }, + historySync, + metricsSync, + cache, + // Read from the persisted store (kept in sync with the reactive `chatLimit` + // by `setChatLimit` + boot) so this snapshot doesn't reference the `$state` + // — each store captures its limit at creation; live updates go through + // `setChatLimit`. + chatLimit: normalizeChatLimit(chatLimitStore.load()), + canUnload: () => (unloadGate === null ? true : unloadGate()), + onError: (context, err) => { + reportError(`${context} (conversation: ${conversationId})`, err); + }, + }); + } + + const initialDraftId = randomId(); + // Read `activeWorkspaceId` with untrack to suppress Svelte's + // `state_referenced_locally` warning — this intentionally captures the + // INITIAL workspace for the boot draft. When the workspace changes later, + // `setActiveWorkspace` creates a fresh draft store with the new id. + let draftStore: ChatStore = createChatFor( + initialDraftId, + DEFAULT_MODEL, + untrack(() => activeWorkspaceId), + ); + let draftConversationId: string = initialDraftId; + + let activeChat = $state<ChatStore>(draftStore as ChatStore); + + // The active conversation's persisted working directory (per-tab). Seeded from + // the backend on focus change; null for a draft / when unset. + let cwd = $state<string | null>(null); + + /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */ + async function refreshCwd(): Promise<void> { + const id = workspaceConversationId(); + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`); + if (!res.ok) return; + const data = (await res.json()) as CwdResponse; + // Guard a slow response losing a race with a conversation switch. + if (workspaceConversationId() === id) cwd = data.cwd ?? null; + } catch (err) { + reportError("Failed to load working directory", err); + } + } + + // The active conversation's persisted computer (SSH Host alias). Seeded on + // focus change; null = local / never set (inherits the workspace default). + let computerId = $state<string | null>(null); + + /** + * Refetch the workspace conversation's persisted computer into reactive state + * (works for a draft too). A draft's id 404s until promoted; `res.ok` is false + * so it is a silent no-op (mirrors `refreshCwd` for a draft). + */ + async function refreshComputer(): Promise<void> { + const id = workspaceConversationId(); + // Clear immediately so a switch never shows the PREVIOUS conversation's + // computer while the fetch is in flight (null renders as "Local"). + computerId = null; + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/computer`); + if (!res.ok) return; + const data = (await res.json()) as ConversationComputerResponse; + // Guard a slow response losing a race with a conversation switch. + if (workspaceConversationId() === id) computerId = data.computerId ?? null; + } catch (err) { + reportError("Failed to load computer", err); + } + } + + /** Refetch the workspace conversation's persisted model (works for a draft too). */ + async function refreshModel(): Promise<void> { + const id = workspaceConversationId(); + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/model`); + if (!res.ok) return; + const data = (await res.json()) as ModelResponse; + if (workspaceConversationId() !== id) return; + if (typeof data.model === "string" && data.model.length > 0) { + activeModel = data.model; + const activeId = tabsStore.activeConversationId; + if (activeId !== null) { + tabsStore.setModel(activeId, data.model); + chatStores.get(activeId)?.setModel(data.model); + } else { + draftStore.setModel(data.model); + } + } + } catch (err) { + reportError("Failed to load model", err); + } + } + + // The workspace conversation's persisted reasoning effort. Seeded from the + // backend on focus change; null = never set (the server default applies). + let reasoningEffort = $state<ReasoningEffort | null>(null); + + /** Refetch the workspace conversation's reasoning effort (works for a draft too). */ + async function refreshReasoningEffort(): Promise<void> { + const id = workspaceConversationId(); + // Clear immediately so a switch never shows the PREVIOUS conversation's level + // while the fetch is in flight (null renders as the server default). + reasoningEffort = null; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`, + ); + if (!res.ok) return; + const data = (await res.json()) as ReasoningEffortResponse; + // Guard a slow response losing a race with a conversation switch. + if (workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null; + } catch (err) { + reportError("Failed to load reasoning effort", err); + } + } + + // The workspace conversation's persisted thinking flag (SEPARATE from the + // effort level). Seeded from the backend on focus change; null = never set + // (thinking ON — the default). PROPOSED endpoint (see backend-handoff.md): + // a 404 (endpoint not yet shipped) leaves `thinking` null ⇒ ON (default), so + // the selector simply shows the effort level until the backend ships it. + let thinking = $state<boolean | null>(null); + + /** Refetch the workspace conversation's thinking flag (works for a draft too). */ + async function refreshThinking(): Promise<void> { + const id = workspaceConversationId(); + // Clear immediately so a switch never shows the PREVIOUS conversation's + // setting while the fetch is in flight (null ⇒ ON, the default). + thinking = null; + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/thinking`); + if (!res.ok) return; + const data = (await res.json()) as ThinkingResponse; + // Guard a slow response losing a race with a conversation switch. + if (workspaceConversationId() === id) thinking = data.thinking ?? null; + } catch (err) { + reportError("Failed to load thinking setting", err); + } + } + + // The workspace conversation's auto-compact percent. Seeded from the + // backend on focus change; null = not yet fetched. 0 = disabled. + let compactPercent = $state<number | null>(null); + + // The GLOBAL vision settings (shared across all conversations). Seeded on + // boot; null = not yet fetched. + let visionSettings = $state<VisionSettings | null>(null); + + /** Refetch the global vision settings (`GET /settings/vision`). */ + async function refreshVisionSettings(): Promise<void> { + try { + const res = await fetchImpl(`${httpBase}/settings/vision`); + if (!res.ok) return; + const data = normalizeVisionSettings(await res.json()); + visionSettings = data; + } catch (err) { + reportError("Failed to load vision settings", err); + } + } + + /** Refetch the workspace conversation's compact percent (works for a draft too). */ + async function refreshCompactPercent(): Promise<void> { + const id = workspaceConversationId(); + compactPercent = null; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`, + ); + if (!res.ok) return; + const data = (await res.json()) as CompactPercentResponse; + if (workspaceConversationId() === id) compactPercent = data.threshold; + } catch (err) { + reportError("Failed to load compact percent", err); + } + } + + function getActiveChat(): ChatStore { + const activeId = tabsStore.activeConversationId; + if (activeId === null) { + return draftStore; + } + return chatStores.get(activeId) ?? draftStore; + } + + function refreshActiveChat(): void { + activeChat = getActiveChat(); + } + + function handleChatMessage(msg: ChatDeltaMessage | ChatErrorMessage): void { + let targetId: string | undefined; + if (msg.type === "chat.delta") { + targetId = msg.event.conversationId; + } else { + targetId = msg.conversationId; + } + + if (targetId !== undefined) { + const store = chatStores.get(targetId) ?? watchStores.get(targetId); + if (store !== undefined) { + store.handleDelta(msg); + return; + } + } + + // fallback: try all stores (chat.error without conversationId) + for (const store of chatStores.values()) { + store.handleDelta(msg); + } + for (const store of watchStores.values()) { + store.handleDelta(msg); + } + } + + /** + * Start watching a conversation's live turn events (`chat.subscribe`). Sent for + * EVERY open conversation — not just the active one — so a backgrounded tab keeps + * streaming a running turn, and a reloaded/second client re-attaches to an + * in-flight turn (the server replays it from `turn-start`). Idempotent server-side; + * the socket queues it until the connection is open. NOT needed right after + * `chat.send` (that auto-subscribes the sending connection). + */ + function subscribeChat(conversationId: string): void { + socket?.send({ type: "chat.subscribe", conversationId }); + } + + /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */ + function unsubscribeChat(conversationId: string): void { + socket?.send({ type: "chat.unsubscribe", conversationId }); + } + + /** + * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat + * modal). Returns a live {@link ChatStore} for the conversation's turn stream. + * If the conversation is already an open TAB, reuses its store (it is already + * subscribed + streaming); otherwise creates an EPHEMERAL watch store in + * `watchStores` (separate from tabs — never opens a tab), subscribes to its + * live turn stream, and loads history. Deltas route to it via `handleChatMessage`. + * Pair with {@link unwatchConversation} on close. + */ + function watchConversation(conversationId: string): ChatStore { + // An open tab already has a live store + subscription — reuse it. + const tabStore = chatStores.get(conversationId); + if (tabStore !== undefined) return tabStore; + const existing = watchStores.get(conversationId); + if (existing !== undefined) return existing; + const store = createChatFor(conversationId, activeModel, activeWorkspaceId); + watchStores.set(conversationId, store); + void store.load(); + subscribeChat(conversationId); + return store; + } + + /** + * Dispose + unsubscribe a watch opened by {@link watchConversation}. A no-op if + * the conversation was (or became) an open TAB — the tab owns its store + + * subscription, so nothing is torn down (closing the modal must not disturb the + * tab strip). Only the ephemeral watch store is disposed + unsubscribed. + */ + function unwatchConversation(conversationId: string): void { + // A tab reuses its own store — leave it (and its subscription) intact. + if (chatStores.has(conversationId)) return; + const store = watchStores.get(conversationId); + if (store === undefined) return; + store.dispose(); + watchStores.delete(conversationId); + unsubscribeChat(conversationId); + } + + /** + * Tell the backend the user EXPLICITLY closed this conversation's tab + * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with + * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF). + * Distinct from a disconnect / `chat.unsubscribe`, which deliberately leave + * both running. Fire-and-forget: a failure is non-fatal (worst case the + * warming keeps running until a later close/toggle), and the endpoint is + * idempotent server-side. + */ + function closeConversation(conversationId: string): void { + void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, { + method: "POST", + }).catch((err) => { + reportError("Failed to close conversation", err); + }); + } + + /** The conversation the surfaces should scope to (undefined for a draft). */ + function focusedConversationId(): string | undefined { + return tabsStore.activeConversationId ?? undefined; + } + + /** + * The conversation id workspace settings (cwd / LSP) target: the active tab, or + * the pending draft's id when in draft mode. Unlike `focusedConversationId`, this + * is NEVER undefined — the draft has a stable client-minted id that survives + * promotion (first send), so a cwd set on a draft carries into the real turn. + */ + function workspaceConversationId(): string { + return tabsStore.activeConversationId ?? draftConversationId; + } + + function handleServerMessage(msg: SurfaceServerMessage): void { + protocol = applyServerMessage(protocol, msg); + // Surfaces are auto-expanded: whenever the catalog changes, subscribe to + // every entry (and drop subscriptions for entries that vanished). + if (msg.type === "catalog") { + syncSubscriptions(); + } + } + + /** + * Subscribe to every catalog entry, scoped to the focused conversation, and + * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped + * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe` + * emits unsubscribe-old + subscribe-new); a global surface ignores the id. + */ + function syncSubscriptions(): void { + const cid = focusedConversationId(); + for (const entry of protocol.catalog) { + // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id + // so a conversation switch doesn't churn a redundant unsubscribe+subscribe + // round trip ([email protected] catalog `scope`; ABSENT = assume + // conversation-scoped, the conservative pre-0.2.0 policy). + const scoped = entry.scope === "global" ? undefined : cid; + const result = protocolSubscribe(protocol, entry.id, scoped); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + } + const catalogIds = new Set(protocol.catalog.map((e) => e.id)); + for (const id of [...protocol.subscriptions.keys()]) { + if (!catalogIds.has(id)) { + const result = protocolUnsubscribe(protocol, id); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + } + } + } + + let socket: ReturnType<typeof createSurfaceSocket> | null = null; + + /** + * Open a conversation tab — used by the `conversation.open` WS broadcast + * (CLI `--open` flag) and by `conversation.statusChanged` when a new active + * conversation is discovered. If the conversation is already open, this is a + * no-op; otherwise create a chat store, load its history, subscribe to its live + * turns, and add the tab WITHOUT switching the active conversation (the user + * stays on their current tab; the new tab appears in the strip). The tab is + * stamped with the conversation's actual `workspaceId`, NOT the viewer's + * currently active workspace. + */ + function openConversation(conversationId: string, workspaceId: string): void { + if (chatStores.has(conversationId)) return; + const store = createChatFor(conversationId, activeModel, workspaceId); + chatStores.set(conversationId, store); + void store.load(); + subscribeChat(conversationId); + tabsStore.openTab({ + conversationId, + model: activeModel, + title: "Conversation", + workspaceId, + }); + } + + /** + * Remove a tab + its chat store locally (NO `POST /close` — used when the + * backend already marked the conversation `closed` via `conversation.statusChanged`). + */ + function removeTabLocally(conversationId: string): void { + unsubscribeChat(conversationId); + const store = chatStores.get(conversationId); + if (store !== undefined) { + store.dispose(); + chatStores.delete(conversationId); + } + void cache.delete(conversationId); + tabsStore.closeTab(conversationId); + conversationStatuses.delete(conversationId); + refreshActiveChat(); + syncSubscriptions(); + void refreshCwd(); + void refreshComputer(); + void refreshReasoningEffort(); + void refreshThinking(); + void refreshCompactPercent(); + } + + /** + * Surface a swallowed error to the user via the full-screen error modal + * (`fatalError` → `ErrorModal`). Logs to `console.error` too so the stack is + * in devtools. Called from catch blocks that previously swallowed errors silently. + */ + function reportError(context: string, err: unknown): void { + console.error(`[reportError] ${context}`, err); + const detail = + err instanceof Error + ? `${err.name}: ${err.message}\n\n${err.stack ?? "(no stack trace available)"}` + : String(err); + fatalError = `${context}\n\n${detail}`; + } + + // Conversation lifecycle status (backend-owned, pushed via WS + + // fetched on connect). Keyed by conversationId. + let conversationStatuses = $state<Map<string, ConversationStatus>>(new Map()); + + // The set of workspaces with ≥1 active/queued conversation, derived ONCE + // (not recomputed per card). Every active/queued conversation has an open + // tab stamped with its workspace, so the tabs are the conversation→workspace + // map; cross-reference with the lifecycle statuses. `$derived` recomputes + // lazily when the tab set or status map changes, so each + // `workspaceHasActiveConversations` call is an O(1) lookup instead of a scan + // of the full tab list per card. + const activeWorkspaces = $derived.by(() => { + const out = new Set<string>(); + for (const tab of tabsStore.tabs) { + const status = conversationStatuses.get(tab.conversationId); + if (status === "active" || status === "queued") out.add(tab.workspaceId); + } + return out; + }); + + /** + * Fetch `GET /conversations?status=active,idle` on connect to restore the + * tab bar across devices. Merges: opens tabs for conversations not already + * open, removes tabs for conversations that are no longer active/idle + * (closed on another device), and subscribes to `active` conversations' + * live streams. + */ + async function fetchOpenConversations(): Promise<void> { + try { + const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`); + if (!res.ok) return; + const data = (await res.json()) as ConversationListResponse; + + // Update the status map from the authoritative backend list. + const newStatuses = new Map<string, ConversationStatus>(); + for (const conv of data.conversations) { + newStatuses.set(conv.id, conv.status); + } + conversationStatuses = newStatuses; + + // Open tabs for conversations not already open. + const existingIds = new Set(chatStores.keys()); + for (const conv of data.conversations) { + if (!existingIds.has(conv.id)) { + const store = createChatFor(conv.id, activeModel, conv.workspaceId); + chatStores.set(conv.id, store); + void store.load(); + subscribeChat(conv.id); + tabsStore.openTab({ + conversationId: conv.id, + model: activeModel, + title: conv.title, + workspaceId: conv.workspaceId, + }); + } else { + // Already open — update the title from the backend if it differs. + tabsStore.setTitle(conv.id, conv.title); + } + } + + // Remove tabs for conversations no longer active/idle (closed elsewhere). + const backendIds = new Set(data.conversations.map((c) => c.id)); + for (const tab of tabsStore.tabs) { + if (!backendIds.has(tab.conversationId)) { + removeTabLocally(tab.conversationId); + } + } + } catch (err) { + reportError( + `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`, + err, + ); + } + } + + const socketOpts: SurfaceSocketOptions = { + url: wsUrl, + onMessage: handleServerMessage, + onChat: handleChatMessage, + onConversationOpen(msg: ConversationOpenMessage): void { + openConversation(msg.conversationId, msg.workspaceId); + }, + onConversationStatusChanged(msg: ConversationStatusChangedMessage): void { + const { conversationId, status, workspaceId } = msg; + if (status === "closed") { + // Closed on another device (or the backend) — remove the tab locally. + if (chatStores.has(conversationId)) { + removeTabLocally(conversationId); + } + return; + } + // active / queued / idle — update the status map (drives the tab spinner). + // `queued` = the turn is in flight but waiting for a concurrency slot + // (broadcast-only, never persisted — CR-13); the tab shows a ring. + conversationStatuses = new Map(conversationStatuses).set(conversationId, status); + // If this is a new active OR queued conversation we don't have a tab for, + // open one — so a cross-device turn (incl. one waiting in the concurrency + // queue) is visible. `idle` never opens a tab. + if ((status === "active" || status === "queued") && !chatStores.has(conversationId)) { + openConversation(conversationId, workspaceId); + } + }, + onConversationCompacted(msg: ConversationCompactedMessage): void { + // Compaction keeps the conversation ID — the old full history is forked + // to an archive (newConversationId). Just reload the same conversation's + // history (dispose stale store + cache + re-fetch). + const cid = msg.conversationId; + const wasActive = tabsStore.activeConversationId === cid; + const store = chatStores.get(cid); + if (store !== undefined) { + store.dispose(); + } + void cache.delete(cid); + const fresh = createChatFor(cid, activeModel, activeWorkspaceId); + chatStores.set(cid, fresh); + void fresh.load(); + if (wasActive) { + refreshActiveChat(); + } + }, + onReopen() { + // The server forgot our subscriptions on reconnect; re-send each with the + // conversation it was subscribed under (protocolSubscribe would no-op since + // they're still in our local map, so emit the wire messages directly). + for (const [surfaceId, sub] of protocol.subscriptions) { + const msg: SubscribeMessage = + sub.conversationId === undefined + ? { type: "subscribe", surfaceId } + : { type: "subscribe", surfaceId, conversationId: sub.conversationId }; + socket?.send(msg); + } + // Re-attach to every open conversation's turn stream. A turn that kept + // running while we were disconnected resumes streaming (server replays it + // from `turn-start`); one that sealed while we were gone is committed from + // history by `resync()` (which also clears a now-stale "generating"). + for (const tab of tabsStore.tabs) { + subscribeChat(tab.conversationId); + chatStores.get(tab.conversationId)?.resync(); + } + // Re-attach to every MODAL watch too (a run-chat modal open across a + // reconnect keeps streaming). Watch stores are separate from tabs. + for (const [watchId, watchStore] of watchStores) { + subscribeChat(watchId); + watchStore.resync(); + } + }, + }; + if (opts?.socketFactory !== undefined) { + socketOpts.socketFactory = opts.socketFactory; + } + socket = createSurfaceSocket(socketOpts); + + // Fetch model catalog + void fetchImpl(`${httpBase}/models`) + .then((res) => { + if (!res.ok) return; + return res.json() as Promise<ModelsResponse>; + }) + .then((data) => { + if (data === undefined) return; + models = data.models; + modelInfo = data.modelInfo ?? {}; + if (data.models.length > 0 && !data.models.includes(activeModel)) { + const first = data.models[0]; + if (first !== undefined) { + activeModel = first; + draftStore.setModel(first); + } + } + }) + .catch((err) => { + reportError("Failed to load model list", err); + }); + + // Fetch the discovered-computer catalog (global, like models). Empty until + // the `ssh` extension lands — a safe no-op until then (the selector shows + // "Local (none)" only). Non-fatal: a failure leaves an empty list. + void fetchImpl(`${httpBase}/computers`) + .then((res) => { + if (!res.ok) return { computers: [] } as ComputerListResponse; + return res.json() as Promise<ComputerListResponse>; + }) + .then((data) => { + computers = data?.computers ?? []; + }) + .catch((err) => { + reportError("Failed to load computer list", err); + }); + + // Restore persisted tabs + const persistedState = storageAdapter.load(); + if (persistedState !== null && persistedState.tabs.length > 0) { + for (const tab of persistedState.tabs) { + const store = createChatFor(tab.conversationId, tab.model, tab.workspaceId); + chatStores.set(tab.conversationId, store); + void store.load(); + // Watch each restored conversation's live turns: after a reload mid-turn the + // server replays the in-flight turn so we keep rendering it. Queued until the + // socket opens. + subscribeChat(tab.conversationId); + } + if (persistedState.activeConversationId !== null) { + const activeTab = persistedState.tabs.find( + (t) => t.conversationId === persistedState.activeConversationId, + ); + if (activeTab !== undefined) { + activeModel = activeTab.model; + } + } + } + + refreshActiveChat(); + void refreshCwd(); + void refreshComputer(); + void refreshModel(); + void refreshReasoningEffort(); + void refreshThinking(); + void refreshCompactPercent(); + void refreshVisionSettings(); + + // Fetch the authoritative open-conversation list from the backend (cross- + // device tab sync). Merges with the localStorage-restored tabs: opens new + // ones, removes closed ones, updates titles + statuses. + void fetchOpenConversations(); + + return { + get tabs(): readonly Tab[] { + return tabsStore.tabs.filter((t) => t.workspaceId === activeWorkspaceId); + }, + get activeConversationId(): string | null { + return tabsStore.activeConversationId; + }, + get activeWorkspaceId(): string { + return activeWorkspaceId; + }, + get httpBase(): string { + return httpBase; + }, + setActiveWorkspace(workspaceId: string): void { + activeWorkspaceId = workspaceId; + // Reset to a fresh draft scoped to the new workspace so a new chat is + // stamped with the right `workspaceId` on `chat.send`. + const nextDraftId = randomId(); + draftStore = createChatFor(nextDraftId, activeModel, workspaceId); + draftConversationId = nextDraftId; + tabsStore.newDraft(); + refreshActiveChat(); + syncSubscriptions(); + void refreshCwd(); + void refreshComputer(); + void refreshModel(); + void refreshReasoningEffort(); + void refreshThinking(); + void refreshCompactPercent(); + }, + get activeChat(): ChatStore { + return activeChat; + }, + get models(): readonly string[] { + return models; + }, + get modelInfo(): Readonly<Record<string, ModelMetadata>> { + return modelInfo; + }, + get activeModel(): string { + return activeModel; + }, + get catalog() { + return protocol.catalog; + }, + get surfaces(): readonly SurfaceSpec[] { + const out: SurfaceSpec[] = []; + for (const entry of protocol.catalog) { + const spec = getSurfaceSpec(protocol, entry.id); + if (spec) out.push(spec); + } + return out; + }, + get lastError() { + return protocol.lastError; + }, + get storage() { + return localStorageOpt; + }, + get cwd(): string | null { + return cwd; + }, + get computerId(): string | null { + return computerId; + }, + get computers(): readonly ComputerEntry[] { + return computers; + }, + get reasoningEffort(): ReasoningEffort | null { + return reasoningEffort; + }, + get thinking(): boolean | null { + return thinking; + }, + get compactPercent(): number | null { + return compactPercent; + }, + get visionSettings(): VisionSettings | null { + return visionSettings; + }, + async refreshVisionSettings(): Promise<void> { + await refreshVisionSettings(); + }, + get chatLimit(): number { + return chatLimit; + }, + conversationStatus(conversationId: string): ConversationStatus | undefined { + return conversationStatuses.get(conversationId); + }, + workspaceHasActiveConversations(workspaceId: string): boolean { + // O(1) lookup into the once-derived `activeWorkspaces` set; false when the + // workspace has no active/queued conversation (or none at all). + return activeWorkspaces.has(workspaceId); + }, + get currentConversationId(): string { + return workspaceConversationId(); + }, + + surface(surfaceId: string): SurfaceSpec | null { + return getSurfaceSpec(protocol, surfaceId); + }, + + send(text: string, images?: readonly ImageInput[]): void { + if (tabsStore.activeConversationId === null) { + // Draft: promote to tab on first send + const conversationId = draftConversationId; + const model = activeModel; + tabsStore.createTab({ + conversationId, + model, + title: deriveTitle(text), + workspaceId: activeWorkspaceId, + }); + chatStores.set(conversationId, draftStore); + void draftStore.load(); + + // Prepare next draft + const nextDraftId = randomId(); + draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId); + draftConversationId = nextDraftId; + + refreshActiveChat(); + // The draft became a real conversation: re-scope conversation-scoped + // surfaces (e.g. cache-warming) to its id. + syncSubscriptions(); + void refreshCwd(); + void refreshComputer(); + void refreshReasoningEffort(); + void refreshThinking(); + void refreshCompactPercent(); + // Now send on the promoted store + chatStores.get(conversationId)?.send(text, images); + } else { + activeChat.send(text, images); + } + }, + + queueMessage(text: string): void { + // Only offered while generating (Composer switches to `chat.queue` + // when `status === "running"`), so a draft (never generating) never + // reaches here. `chat.queue` auto-starts a turn if idle, so even a race + // (turn sealed between the status read and the send) is safe — the + // server starts a fresh turn with the message as its opening prompt. + activeChat.queueMessage(text); + }, + + cancelQueuedMessage(messageId: string): void { + // Fire-and-forget + idempotent. The message-queue surface (conversation- + // scoped) reconciles the queue UI — the cancelled row leaves the snapshot. + // A cancel of an already-drained / unknown message is a silent no-op, so + // there is no local state to roll back. Delegates to the focused + // conversation's chat store, which owns the conversationId + transport. + activeChat.cancelQueuedMessage(messageId); + }, + + selectModel(model: string): void { + activeModel = model; + const activeId = tabsStore.activeConversationId; + if (activeId !== null) { + tabsStore.setModel(activeId, model); + chatStores.get(activeId)?.setModel(model); + void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(activeId)}/model`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model } satisfies SetModelRequest), + }).catch((err) => { + reportError("Failed to persist model", err); + }); + } else { + draftStore.setModel(model); + } + }, + + newDraft(): void { + tabsStore.newDraft(); + const nextDraftId = randomId(); + draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId); + draftConversationId = nextDraftId; + refreshActiveChat(); + syncSubscriptions(); + void refreshCwd(); + void refreshComputer(); + void refreshModel(); + void refreshReasoningEffort(); + void refreshThinking(); + void refreshCompactPercent(); + }, + + selectTab(conversationId: string): void { + tabsStore.selectTab(conversationId); + const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId); + if (tab !== undefined) { + activeModel = tab.model; + } + refreshActiveChat(); + syncSubscriptions(); + void refreshCwd(); + void refreshComputer(); + void refreshModel(); + void refreshReasoningEffort(); + void refreshThinking(); + void refreshCompactPercent(); + }, + + closeTab(conversationId: string): void { + // The user is DONE with this chat: abort any in-flight turn + stop/disable + // its cache-warming, server-side (POST /close sets status → "closed"). + closeConversation(conversationId); + removeTabLocally(conversationId); + }, + + renameTab(conversationId: string, title: string): void { + tabsStore.setTitle(conversationId, title); + void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/title`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title } satisfies SetTitleRequest), + }).catch((err) => { + reportError("Failed to rename conversation", err); + }); + }, + + invoke(surfaceId: string, actionId: string, payload?: unknown): void { + const result = protocolInvoke( + protocol, + surfaceId, + actionId, + payload, + focusedConversationId(), + ); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + }, + + async warmNow(): Promise<WarmResult | null> { + const conversationId = tabsStore.activeConversationId; + if (conversationId === null) return null; + const body: WarmRequest = { conversationId, model: activeModel }; + try { + const res = await fetchImpl(`${httpBase}/chat/warm`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `Warm failed (HTTP ${res.status})` }; + } + return { ok: true, response: (await res.json()) as WarmResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" }; + } + }, + + async setCwd(value: string): Promise<CwdResult | null> { + const id = workspaceConversationId(); + const body: SetCwdRequest = { + cwd: value, + workspaceId: untrack(() => activeWorkspaceId), + }; + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `Set cwd failed (HTTP ${res.status})` }; + } + const data = (await res.json()) as CwdResponse; + const next = data.cwd ?? null; + if (workspaceConversationId() === id) cwd = next; + return { ok: true, cwd: next }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" }; + } + }, + + async setComputer(computerIdValue: string | null): Promise<ComputerResult | null> { + const id = workspaceConversationId(); + const body: SetConversationComputerRequest = { computerId: computerIdValue }; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/computer`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set computer failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as ConversationComputerResponse; + const next = data.computerId ?? null; + if (workspaceConversationId() === id) computerId = next; + return { ok: true, computerId: next }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set computer request failed", + }; + } + }, + + async computerStatus(alias: string): Promise<ComputerStatusResult | null> { + if (alias === "") return null; + try { + const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/status`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Computer status failed (HTTP ${res.status})`, + }; + } + const status = (await res.json()) as ComputerStatusResponse; + return { ok: true, response: status }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Computer status request failed", + }; + } + }, + + async testComputer(alias: string): Promise<TestComputerResult | null> { + if (alias === "") return null; + try { + const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/test`, { + method: "POST", + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Test computer failed (HTTP ${res.status})`, + }; + } + const response = (await res.json()) as TestComputerResponse; + return { ok: true, response }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Test computer request failed", + }; + } + }, + + async setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null> { + const id = workspaceConversationId(); + const body: SetReasoningEffortRequest = { reasoningEffort: level }; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set reasoning effort failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as ReasoningEffortResponse; + const next = data.reasoningEffort ?? level; + if (workspaceConversationId() === id) reasoningEffort = next; + return { ok: true, reasoningEffort: next }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set reasoning effort request failed", + }; + } + }, + + async setThinking(enabled: boolean): Promise<ThinkingResult | null> { + const id = workspaceConversationId(); + const body: SetThinkingRequest = { thinking: enabled }; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/thinking`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set thinking failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as ThinkingResponse; + const next = data.thinking ?? enabled; + if (workspaceConversationId() === id) thinking = next; + return { ok: true, thinking: next }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set thinking request failed", + }; + } + }, + + stopGeneration(): void { + const conversationId = tabsStore.activeConversationId; + if (conversationId === null) return; + void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, { + method: "POST", + }).catch((err) => { + reportError("Failed to stop generation", err); + }); + }, + + async compactNow(keepLastN?: number): Promise<CompactResult | null> { + const conversationId = tabsStore.activeConversationId; + if (conversationId === null) return null; + const body: Record<string, unknown> = {}; + if (keepLastN !== undefined) body.keepLastN = keepLastN; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(conversationId)}/compact`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Compact failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as CompactResponse; + return { ok: true, response: data }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Compact request failed", + }; + } + }, + + async setCompactPercent(percent: number): Promise<CompactPercentResult | null> { + const id = workspaceConversationId(); + const body: SetCompactPercentRequest = { threshold: percent }; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set compact percent failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as CompactPercentResponse; + if (workspaceConversationId() === id) compactPercent = data.threshold; + return { ok: true, percent: data.threshold }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set compact percent request failed", + }; + } + }, + + async setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | null> { + const body: SetVisionSettingsRequest = patch; + try { + const res = await fetchImpl(`${httpBase}/settings/vision`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set vision settings failed (HTTP ${res.status})`, + }; + } + const data = normalizeVisionSettings(await res.json()); + visionSettings = data; + return { ok: true, settings: data }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set vision settings request failed", + }; + } + }, + + async setChatLimit(limit: number): Promise<ChatLimitResult> { + const next = normalizeChatLimit(limit); + chatLimitStore.save(next); + chatLimit = next; + // Propagate to every live chat store. The ACTIVE one is awaited so its + // refill (on a raise) lands before the caller returns — letting the + // shell preserve scroll over the prepended older chunks. Background + // stores refill fire-and-forget. Future stores pick the new limit up at + // creation (via the persisted store). + const active = getActiveChat(); + await active.setChatLimit(next); + for (const s of chatStores.values()) { + if (s !== active) void s.setChatLimit(next); + } + if (draftStore !== active) void draftStore.setChatLimit(next); + return { ok: true, chatLimit: next }; + }, + + async lspStatus(): Promise<LspResult | null> { + const id = workspaceConversationId(); + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/lsp`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `LSP status failed (HTTP ${res.status})` }; + } + // Normalize the untyped body at this network seam so a malformed/partial + // response can never crash the renderer (servers is guaranteed an array). + const data = (await res.json()) as Partial<LspStatusResponse>; + const response: LspStatusResponse = { + conversationId: data.conversationId ?? id, + cwd: data.cwd ?? null, + servers: Array.isArray(data.servers) ? data.servers : [], + }; + return { ok: true, response }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "LSP status request failed", + }; + } + }, + + async mcpStatus(): Promise<McpResult | null> { + const id = workspaceConversationId(); + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/mcp`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `MCP status failed (HTTP ${res.status})` }; + } + // Normalize the untyped body at this network seam so a malformed/partial + // response can never crash the renderer (servers is guaranteed an array). + const data = (await res.json()) as Partial<McpStatusResponse>; + const response: McpStatusResponse = { + conversationId: data.conversationId ?? id, + cwd: data.cwd ?? null, + servers: Array.isArray(data.servers) ? data.servers : [], + }; + return { ok: true, response }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "MCP status request failed", + }; + } + }, + + async heartbeatConfig(): Promise<HeartbeatConfigResult> { + // Workspace-scoped (NOT per-conversation): use the active workspace id. + const wsId = untrack(() => activeWorkspaceId); + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Heartbeat config failed (HTTP ${res.status})`, + }; + } + // Normalize the untyped JSON at the network seam (pure helper) so a + // malformed/partial response can never crash the renderer. + const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json()); + return { ok: true, config }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Heartbeat config request failed", + }; + } + }, + + async setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult> { + const wsId = untrack(() => activeWorkspaceId); + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(patch), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set heartbeat config failed (HTTP ${res.status})`, + }; + } + const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json()); + return { ok: true, config }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set heartbeat config request failed", + }; + } + }, + + async heartbeatRuns(): Promise<HeartbeatRunsResult> { + const wsId = untrack(() => activeWorkspaceId); + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs`, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Heartbeat runs failed (HTTP ${res.status})`, + }; + } + const runs: readonly HeartbeatRun[] = normalizeHeartbeatRuns(await res.json()); + return { ok: true, runs }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Heartbeat runs request failed", + }; + } + }, + + async stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> { + const wsId = untrack(() => activeWorkspaceId); + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs/${encodeURIComponent(runId)}/stop`, + { method: "POST" }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Stop heartbeat run failed (HTTP ${res.status})`, + }; + } + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Stop heartbeat run request failed", + }; + } + }, + + async heartbeatNextRun(): Promise<HeartbeatNextRunResult> { + const wsId = untrack(() => activeWorkspaceId); + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/next-run`, + ); + if (!res.ok) { + // 404 = the backend hasn't shipped CR-HB-3 yet → the FE falls back to + // an approximation. Surface as ok:false (non-fatal). + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Heartbeat next-run failed (HTTP ${res.status})`, + }; + } + const data = (await res.json().catch(() => null)) as { nextRunAt?: string | null } | null; + // `null` (disabled / no run scheduled) passes through; anything non-string + // also becomes null so a malformed body can't crash the countdown. + const raw = data?.nextRunAt; + const nextRunAt = typeof raw === "string" ? raw : null; + return { ok: true, nextRunAt }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Heartbeat next-run request failed", + }; + } + }, + + watchConversation(conversationId: string): ChatStore { + return watchConversation(conversationId); + }, + + unwatchConversation(conversationId: string): void { + unwatchConversation(conversationId); + }, + + // ── Concurrency (per-provider limits + live status; GLOBAL, not workspace-scoped) + + async concurrencyLimits(): Promise<ConcurrencyLimitsResult> { + try { + const res = await fetchImpl(`${httpBase}/concurrency/limits`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Concurrency limits failed (HTTP ${res.status})`, + }; + } + // Normalize the untyped JSON at the network seam (pure helper) so a + // malformed/partial response (e.g. the extension returning `{}`) can + // never crash the renderer. + const limits = normalizeConcurrencyLimits(await res.json()); + return { ok: true, limits }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Concurrency limits request failed", + }; + } + }, + + async getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult> { + try { + const res = await fetchImpl( + `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Concurrency limit failed (HTTP ${res.status})`, + }; + } + const limit = normalizeConcurrencyLimit(await res.json()); + if (limit === null) { + return { ok: false, error: "Malformed concurrency limit response" }; + } + return { ok: true, providerId: limit.providerId, limit: limit.limit }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Concurrency limit request failed", + }; + } + }, + + async setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult> { + try { + const res = await fetchImpl( + `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ limit }), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set concurrency limit failed (HTTP ${res.status})`, + }; + } + const echoed = normalizeConcurrencyLimit(await res.json()); + if (echoed === null) { + return { ok: false, error: "Malformed concurrency limit response" }; + } + return { ok: true, providerId: echoed.providerId, limit: echoed.limit }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set concurrency limit request failed", + }; + } + }, + + async deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult> { + try { + const res = await fetchImpl( + `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`, + { method: "DELETE" }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Delete concurrency limit failed (HTTP ${res.status})`, + }; + } + return { ok: true, providerId }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Delete concurrency limit request failed", + }; + } + }, + + async concurrencyStatus(): Promise<ConcurrencyStatusResult> { + try { + const res = await fetchImpl(`${httpBase}/concurrency/status`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Concurrency status failed (HTTP ${res.status})`, + }; + } + const providers = normalizeConcurrencyStatus(await res.json()); + return { ok: true, providers }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Concurrency status request failed", + }; + } + }, + + async getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult> { + try { + const res = await fetchImpl( + `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Get concurrency cooldown failed (HTTP ${res.status})`, + }; + } + const data = normalizeConcurrencyCooldown(await res.json()); + if (data === null) { + return { ok: false, error: "Malformed concurrency cooldown response" }; + } + return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Get concurrency cooldown request failed", + }; + } + }, + + async setConcurrencyCooldown( + providerId: string, + cooldownMs: number, + ): Promise<ConcurrencyCooldownResult> { + try { + const res = await fetchImpl( + `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ cooldownMs }), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set concurrency cooldown failed (HTTP ${res.status})`, + }; + } + const data = normalizeConcurrencyCooldown(await res.json()); + if (data === null) { + return { ok: false, error: "Malformed concurrency cooldown response" }; + } + return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set concurrency cooldown request failed", + }; + } + }, + + async loadSystemPrompt(): Promise<SystemPromptLoadResult> { + try { + const res = await fetchImpl(`${httpBase}/system-prompt`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Load system prompt failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as SystemPromptTemplateResponse; + return { ok: true, template: data.template ?? "" }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Load system prompt request failed", + }; + } + }, + + async setSystemPrompt(template: string): Promise<SystemPromptSaveResult> { + try { + const body: SetSystemPromptTemplateRequest = { template }; + const res = await fetchImpl(`${httpBase}/system-prompt`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set system prompt failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as SystemPromptTemplateResponse; + return { ok: true, template: data.template }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set system prompt request failed", + }; + } + }, + + async loadSystemPromptVariables(): Promise<SystemPromptVariablesResult> { + try { + const res = await fetchImpl(`${httpBase}/system-prompt/variables`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Load system prompt variables failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as Partial<SystemPromptVariablesResponse>; + return { ok: true, variables: Array.isArray(data.variables) ? data.variables : [] }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Load system prompt variables request failed", + }; + } + }, + + attachUnloadGate(gate: () => boolean): void { + unloadGate = gate; + }, + + get fatalError(): string | null { + return fatalError; + }, + clearFatalError(): void { + fatalError = null; + }, + + dispose(): void { + for (const store of chatStores.values()) { + store.dispose(); + } + chatStores.clear(); + for (const store of watchStores.values()) { + store.dispose(); + } + watchStores.clear(); + draftStore.dispose(); + socket?.close(); + socket = null; + }, + }; } diff --git a/src/app/store.test.ts b/src/app/store.test.ts index db6fdaa..47c3977 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -5,50 +5,50 @@ import type { WebSocketLike } from "../adapters/ws"; import { createAppStore } from "./store.svelte"; interface FakeSocket extends WebSocketLike { - sent: string[]; - resolveOpen(): void; - feedServerMessage(data: WsServerMessage): void; - feedSurfaceMessage(data: SurfaceServerMessage): void; + sent: string[]; + resolveOpen(): void; + feedServerMessage(data: WsServerMessage): void; + feedSurfaceMessage(data: SurfaceServerMessage): void; } function fakeSocket(): FakeSocket { - let onopen: (() => void) | null = null; - let onmessage: ((ev: { data: string }) => void) | null = null; - const sent: string[] = []; - - const ws: FakeSocket = { - send(data: string) { - sent.push(data); - }, - close() {}, - get onopen() { - return onopen; - }, - set onopen(fn) { - onopen = fn; - }, - get onmessage() { - return onmessage; - }, - set onmessage(fn) { - onmessage = fn; - }, - get onclose() { - return null; - }, - set onclose(_fn) {}, - resolveOpen() { - onopen?.(); - }, - feedServerMessage(msg: WsServerMessage) { - onmessage?.({ data: JSON.stringify(msg) }); - }, - feedSurfaceMessage(msg: SurfaceServerMessage) { - onmessage?.({ data: JSON.stringify(msg) }); - }, - sent, - }; - return ws; + let onopen: (() => void) | null = null; + let onmessage: ((ev: { data: string }) => void) | null = null; + const sent: string[] = []; + + const ws: FakeSocket = { + send(data: string) { + sent.push(data); + }, + close() {}, + get onopen() { + return onopen; + }, + set onopen(fn) { + onopen = fn; + }, + get onmessage() { + return onmessage; + }, + set onmessage(fn) { + onmessage = fn; + }, + get onclose() { + return null; + }, + set onclose(_fn) {}, + resolveOpen() { + onopen?.(); + }, + feedServerMessage(msg: WsServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + feedSurfaceMessage(msg: SurfaceServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + sent, + }; + return ws; } /** @@ -57,1017 +57,2128 @@ function fakeSocket(): FakeSocket { * `sent` accumulates and `open()` can be driven again after `closeRemote()`. */ interface ReconnectableSocket extends WebSocketLike { - sent: string[]; - open(): void; - closeRemote(): void; + sent: string[]; + open(): void; + closeRemote(): void; } function reconnectableSocket(): ReconnectableSocket { - let onopen: (() => void) | null = null; - let onmessage: ((ev: { data: string }) => void) | null = null; - let onclose: ((ev: { code: number; reason: string }) => void) | null = null; - const sent: string[] = []; - return { - send(data: string) { - sent.push(data); - }, - close() {}, - get onopen() { - return onopen; - }, - set onopen(fn) { - onopen = fn; - }, - get onmessage() { - return onmessage; - }, - set onmessage(fn) { - onmessage = fn; - }, - get onclose() { - return onclose; - }, - set onclose(fn) { - onclose = fn; - }, - sent, - open() { - onopen?.(); - }, - closeRemote() { - onclose?.({ code: 1006, reason: "" }); - }, - }; + let onopen: (() => void) | null = null; + let onmessage: ((ev: { data: string }) => void) | null = null; + let onclose: ((ev: { code: number; reason: string }) => void) | null = null; + const sent: string[] = []; + return { + send(data: string) { + sent.push(data); + }, + close() {}, + get onopen() { + return onopen; + }, + set onopen(fn) { + onopen = fn; + }, + get onmessage() { + return onmessage; + }, + set onmessage(fn) { + onmessage = fn; + }, + get onclose() { + return onclose; + }, + set onclose(fn) { + onclose = fn; + }, + sent, + open() { + onopen?.(); + }, + closeRemote() { + onclose?.({ code: 1006, reason: "" }); + }, + }; } interface FakeFetchOptions { - models?: readonly string[]; - history?: Record<string, ConversationHistoryResponse>; + models?: readonly string[]; + history?: Record<string, ConversationHistoryResponse>; + model?: string | null; } function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch { - const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"]; - const history = opts?.history ?? {}; - return async (input: string | URL | Request): Promise<Response> => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models }), { status: 200 }); - } - const body = - history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse); - return new Response(JSON.stringify(body), { status: 200 }); - }; + const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"]; + const history = opts?.history ?? {}; + return async (input: string | URL | Request): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models }), { status: 200 }); + } + if (url.endsWith("/model")) { + return new Response( + JSON.stringify({ + conversationId: "ignored", + model: opts?.model ?? null, + }), + { status: 200 }, + ); + } + const body = + history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse); + return new Response(JSON.stringify(body), { status: 200 }); + }; } function parseSent(ws: { sent: string[] }): unknown[] { - return ws.sent.map((s) => JSON.parse(s)); + return ws.sent.map((s) => JSON.parse(s)); } function createFakeStorage(): Storage { - const map = new Map<string, string>(); - return { - get length() { - return map.size; - }, - clear() { - map.clear(); - }, - getItem(key: string): string | null { - return map.get(key) ?? null; - }, - key(_index: number): string | null { - return null; - }, - removeItem(key: string) { - map.delete(key); - }, - setItem(key: string, value: string) { - map.set(key, value); - }, - }; + const map = new Map<string, string>(); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key: string): string | null { + return map.get(key) ?? null; + }, + key(_index: number): string | null { + return null; + }, + removeItem(key: string) { + map.delete(key); + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + }; } function activeConversationId(store: ReturnType<typeof createAppStore>): string { - const id = store.activeConversationId; - expect(id).not.toBeNull(); - return id as string; + const id = store.activeConversationId; + expect(id).not.toBeNull(); + return id as string; } describe("createAppStore", () => { - it("starts with empty catalog and no surfaces", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - expect(store.catalog).toEqual([]); - expect(store.surfaces).toEqual([]); - expect(store.lastError).toBeNull(); - - store.dispose(); - }); - - it("updates catalog when catalog message arrives", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - expect(store.catalog).toHaveLength(2); - expect(store.catalog[0]?.id).toBe("s1"); - expect(store.catalog[1]?.id).toBe("s2"); - - store.dispose(); - }); - - it("auto-subscribes to every catalog entry when the catalog arrives", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.sent.length = 0; - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - const subscribed = ws.sent - .map((s) => JSON.parse(s)) - .filter((p) => p.type === "subscribe") - .map((p) => p.surfaceId); - expect(subscribed).toContain("s1"); - expect(subscribed).toContain("s2"); - - store.dispose(); - }); - - it("unsubscribes from entries that vanish from a new catalog", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - ws.sent.length = 0; - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], - }); - - const unsubscribed = ws.sent - .map((s) => JSON.parse(s)) - .filter((p) => p.type === "unsubscribe") - .map((p) => p.surfaceId); - expect(unsubscribed).toContain("s2"); - expect(unsubscribed).not.toContain("s1"); - - store.dispose(); - }); - - it("exposes received surface specs via `surfaces`, in catalog order", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - // Only s1's spec has arrived: surfaces reflects what's actually received. - ws.feedSurfaceMessage({ - type: "surface", - spec: { - id: "s1", - region: "sidebar", - title: "Surface One", - fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], - }, - }); - expect(store.surfaces.map((s) => s.id)).toEqual(["s1"]); - - ws.feedSurfaceMessage({ - type: "surface", - spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] }, - }); - // Catalog order preserved (s1 before s2). - expect(store.surfaces.map((s) => s.id)).toEqual(["s1", "s2"]); - expect(store.surfaces[0]?.fields).toHaveLength(1); - - store.dispose(); - }); - - it("invoke sends an invoke message", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.sent.length = 0; - store.invoke("s1", "toggle-dark", true); - - const invokeMsg = ws.sent.find((s) => { - const parsed = JSON.parse(s); - return ( - parsed.type === "invoke" && - parsed.surfaceId === "s1" && - parsed.actionId === "toggle-dark" && - parsed.payload === true - ); - }); - expect(invokeMsg).toBeTruthy(); - - store.dispose(); - }); - - it("error message updates lastError", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "error", - message: "Something went wrong", - }); - - expect(store.lastError).not.toBeNull(); - expect(store.lastError?.message).toBe("Something went wrong"); - - store.dispose(); - }); - - it("dispose closes the socket", () => { - const ws = fakeSocket(); - const closeSpy = { called: false }; - const origClose = ws.close.bind(ws); - ws.close = () => { - closeSpy.called = true; - origClose(); - }; - - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.dispose(); - expect(closeSpy.called).toBe(true); - }); - - it("exposes activeChat with empty initial messages", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - expect(store.activeChat).toBeDefined(); - expect(store.activeChat.messages).toEqual([]); - expect(store.activeChat.chunks).toEqual([]); - expect(store.activeChat.error).toBeNull(); - - store.dispose(); - }); - - it("sending a message from draft creates a tab and posts chat.send", () => { - const ws = fakeSocket(); - const storage = createFakeStorage(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: storage, - }); - ws.resolveOpen(); - - ws.sent.length = 0; - store.send("hello world"); - - expect(store.tabs).toHaveLength(1); - expect(store.tabs[0]?.title).toBe("hello world"); - expect(store.activeConversationId).not.toBeNull(); - - const msgs = parseSent(ws); - const chatSend = msgs.find((m) => (m as { type: string }).type === "chat.send") as - | { type: string; conversationId: string; message: string } - | undefined; - expect(chatSend).toBeTruthy(); - expect(chatSend?.message).toBe("hello world"); - - store.dispose(); - }); - - it("an incoming chat.delta renders in the transcript", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("test"); - const convId = activeConversationId(store); - - ws.feedServerMessage({ - type: "chat.delta", - event: { type: "turn-start", conversationId: convId, turnId: "turn-1" }, - }); - - ws.feedServerMessage({ - type: "chat.delta", - event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "Hello " }, - }); - - ws.feedServerMessage({ - type: "chat.delta", - event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "world" }, - }); - - expect(store.activeChat.chunks.length).toBeGreaterThan(0); - const assistantChunks = store.activeChat.chunks.filter( - (c) => c.role === "assistant" && c.chunk.type === "text", - ); - expect(assistantChunks).toHaveLength(1); - expect((assistantChunks[0]?.chunk as { type: "text"; text: string }).text).toBe("Hello world"); - - store.dispose(); - }); - - it("chat.error sets the chat error", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("test"); - const convId = activeConversationId(store); - - ws.feedServerMessage({ - type: "chat.error", - conversationId: convId, - message: "bad request", - }); - - expect(store.activeChat.error).toBe("bad request"); - - store.dispose(); - }); - - it("turn-sealed triggers a history fetch and synced chunks render", async () => { - const fetchedUrls: string[] = []; - const historyResponse: ConversationHistoryResponse = { - chunks: [ - { seq: 1, role: "user", chunk: { type: "text", text: "hi" } }, - { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } }, - ], - latestSeq: 2, - }; - const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - fetchedUrls.push(url); - if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { - status: 200, - }); - } - return new Response(JSON.stringify(historyResponse), { status: 200 }); - }; - - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl, - httpUrl: "http://localhost:24203", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("hi"); - const convId = activeConversationId(store); - - ws.feedServerMessage({ - type: "chat.delta", - event: { type: "turn-start", conversationId: convId, turnId: "turn-1" }, - }); - - ws.feedServerMessage({ - type: "chat.delta", - event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" }, - }); - - await new Promise((r) => setTimeout(r, 50)); - - expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); - - await new Promise((r) => setTimeout(r, 50)); - - expect(store.activeChat.chunks.length).toBeGreaterThan(0); - - store.dispose(); - }); - - it("fetches and exposes the model catalog", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl({ - models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"], - }), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - await new Promise((r) => setTimeout(r, 50)); - - expect(store.models).toEqual([ - "opencode/deepseek-v4-flash", - "openai/gpt-4o", - "anthropic/claude-3", - ]); - - store.dispose(); - }); - - it("default model is flash", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); - - store.dispose(); - }); - - it("draft: sending the first message creates a tab titled from the message", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - expect(store.tabs).toHaveLength(0); - expect(store.activeConversationId).toBeNull(); - - store.send("What is the meaning of life?"); - - expect(store.tabs).toHaveLength(1); - expect(store.tabs[0]?.title).toBe("What is the meaning of life?"); - expect(store.activeConversationId).toBe(store.tabs[0]?.conversationId); - - store.dispose(); - }); - - it("selecting a model updates the active tab", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("hello"); - - store.selectModel("openai/gpt-4o"); - - expect(store.activeModel).toBe("openai/gpt-4o"); - expect(store.tabs[0]?.model).toBe("openai/gpt-4o"); - - store.dispose(); - }); - - it("chat.delta routes to the matching tab only", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("first message"); - const convId1 = activeConversationId(store); - - store.newDraft(); - store.send("second message"); - const convId2 = activeConversationId(store); - - expect(convId1).not.toBe(convId2); - - ws.feedServerMessage({ - type: "chat.delta", - event: { type: "turn-start", conversationId: convId1, turnId: "turn-1" }, - }); - ws.feedServerMessage({ - type: "chat.delta", - event: { - type: "text-delta", - conversationId: convId1, - turnId: "turn-1", - delta: "response to first", - }, - }); - - store.selectTab(convId1); - const assistantChunks1 = store.activeChat.chunks.filter( - (c) => c.role === "assistant" && c.chunk.type === "text", - ); - expect(assistantChunks1).toHaveLength(1); - expect((assistantChunks1[0]?.chunk as { type: "text"; text: string }).text).toBe( - "response to first", - ); - - store.selectTab(convId2); - const assistantChunks2 = store.activeChat.chunks.filter( - (c) => c.role === "assistant" && c.chunk.type === "text", - ); - expect(assistantChunks2).toEqual([]); - - store.dispose(); - }); - - it("closing a tab evicts its cache and drops the tab", () => { - const ws = fakeSocket(); - const storage = createFakeStorage(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: storage, - }); - ws.resolveOpen(); - - store.send("first"); - const convId = activeConversationId(store); - expect(store.tabs).toHaveLength(1); - - store.closeTab(convId); - - expect(store.tabs).toHaveLength(0); - expect(store.activeConversationId).toBeNull(); - - store.dispose(); - }); - - it("closing a tab POSTs /conversations/:id/close (abort turn + stop warming)", async () => { - const calls: { url: string; method: string }[] = []; - const base = fakeFetchImpl(); - const fetchImpl: typeof fetch = async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - calls.push({ url, method: init?.method ?? "GET" }); - if (url.endsWith("/close")) { - return new Response( - JSON.stringify({ conversationId: url.split("/").at(-2), abortedTurn: false }), - { status: 200 }, - ); - } - return base(input, init); - }; - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl, - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("first"); - const convId = activeConversationId(store); - store.closeTab(convId); - await Promise.resolve(); // flush the fire-and-forget fetch - - const close = calls.find((c) => c.url.endsWith(`/conversations/${convId}/close`)); - expect(close).toBeDefined(); - expect(close?.method).toBe("POST"); - - store.dispose(); - }); - - it("seeds reasoningEffort from GET /conversations/:id/reasoning-effort (null = never set)", async () => { - const base = fakeFetchImpl(); - const fetchImpl: typeof fetch = async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/reasoning-effort")) { - return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: "xhigh" }), { - status: 200, - }); - } - return base(input, init); - }; - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl, - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - await vi.waitFor(() => { - expect(store.reasoningEffort).toBe("xhigh"); - }); - - store.dispose(); - }); - - it("setReasoningEffort PUTs the level and updates local state from the echo", async () => { - const calls: { url: string; method: string; body: string | undefined }[] = []; - const base = fakeFetchImpl(); - const fetchImpl: typeof fetch = async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - calls.push({ url, method: init?.method ?? "GET", body: init?.body as string | undefined }); - if (url.endsWith("/reasoning-effort") && init?.method === "PUT") { - const sent = JSON.parse(init.body as string) as { reasoningEffort: string }; - return new Response( - JSON.stringify({ conversationId: "x", reasoningEffort: sent.reasoningEffort }), - { status: 200 }, - ); - } - if (url.endsWith("/reasoning-effort")) { - return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), { - status: 200, - }); - } - return base(input, init); - }; - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl, - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - const result = await store.setReasoningEffort("max"); - expect(result).toEqual({ ok: true, reasoningEffort: "max" }); - expect(store.reasoningEffort).toBe("max"); - - const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/reasoning-effort")); - expect(put).toBeDefined(); - // The PUT targets the workspace conversation (draft id works too) and - // carries exactly the SetReasoningEffortRequest body. - expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`); - expect(JSON.parse(put?.body ?? "{}")).toEqual({ reasoningEffort: "max" }); - - store.dispose(); - }); - - it("setReasoningEffort surfaces a 400 error and leaves state unchanged", async () => { - const base = fakeFetchImpl(); - const fetchImpl: typeof fetch = async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/reasoning-effort") && init?.method === "PUT") { - return new Response(JSON.stringify({ error: "bad level" }), { status: 400 }); - } - if (url.endsWith("/reasoning-effort")) { - return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), { - status: 200, - }); - } - return base(input, init); - }; - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl, - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - const result = await store.setReasoningEffort("max"); - expect(result).toEqual({ ok: false, error: "bad level" }); - expect(store.reasoningEffort).toBeNull(); - - store.dispose(); - }); - - it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s-global", region: "side", title: "Global", scope: "global" }, - { id: "s-conv", region: "side", title: "Scoped", scope: "conversation" }, - ], - }); - - ws.sent.length = 0; - store.send("promote the draft"); // draft → real conversation: surfaces re-scope - const convId = activeConversationId(store); - - const surfaceMsgs = parseSent(ws).filter( - (p): p is { type: string; surfaceId: string; conversationId?: string } => - (p as { type: string }).type === "subscribe" || - (p as { type: string }).type === "unsubscribe", - ); - // The conversation-scoped surface re-scopes: unsubscribe old + subscribe new id. - expect( - surfaceMsgs.some( - (m) => m.type === "subscribe" && m.surfaceId === "s-conv" && m.conversationId === convId, - ), - ).toBe(true); - // The global surface is untouched — no redundant unsubscribe+subscribe round trip. - expect(surfaceMsgs.some((m) => m.surfaceId === "s-global")).toBe(false); - - store.dispose(); - }); - - it("tabs persist to the injected storage and restore on a new store", () => { - const ws = fakeSocket(); - const storage = createFakeStorage(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: storage, - }); - ws.resolveOpen(); - - store.send("persist me"); - const convId = store.tabs[0]?.conversationId; - const title = store.tabs[0]?.title; - expect(convId).toBeDefined(); - expect(title).toBeDefined(); - - const raw = storage.getItem("dispatch.tabs"); - expect(raw).not.toBeNull(); - const parsed = JSON.parse(raw as string); - expect(parsed.tabs).toHaveLength(1); - expect(parsed.tabs[0].conversationId).toBe(convId); - expect(parsed.tabs[0].title).toBe(title); - - const ws2 = fakeSocket(); - const store2 = createAppStore({ - socketFactory: () => ws2, - fetchImpl: fakeFetchImpl(), - localStorage: storage, - }); - ws2.resolveOpen(); - - expect(store2.tabs).toHaveLength(1); - expect(store2.tabs[0]?.conversationId).toBe(convId); - expect(store2.tabs[0]?.title).toBe(title); - expect(store2.activeConversationId).toBe(convId); - - store.dispose(); - store2.dispose(); - }); - - it("tabs persist to globalThis.localStorage when no storage is injected", () => { - const realLs = globalThis.localStorage; - const memLs = createFakeStorage(); - globalThis.localStorage = memLs; - try { - const ws1 = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws1, - fetchImpl: fakeFetchImpl(), - }); - ws1.resolveOpen(); - - store.send("persist via default"); - const convId = store.tabs[0]?.conversationId; - const title = store.tabs[0]?.title; - expect(convId).toBeDefined(); - expect(title).toBeDefined(); - - const raw = globalThis.localStorage.getItem("dispatch.tabs"); - expect(raw).not.toBeNull(); - const parsed = JSON.parse(raw as string); - expect(parsed.tabs).toHaveLength(1); - expect(parsed.tabs[0].conversationId).toBe(convId); - expect(parsed.tabs[0].title).toBe(title); - - store.dispose(); - - const ws2 = fakeSocket(); - const store2 = createAppStore({ - socketFactory: () => ws2, - fetchImpl: fakeFetchImpl(), - }); - ws2.resolveOpen(); - - expect(store2.tabs).toHaveLength(1); - expect(store2.tabs[0]?.conversationId).toBe(convId); - expect(store2.tabs[0]?.title).toBe(title); - expect(store2.activeConversationId).toBe(convId); - - store2.dispose(); - } finally { - globalThis.localStorage = realLs; - } - }); - - it("newDraft resets to draft mode", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("first"); - expect(store.tabs).toHaveLength(1); - - store.newDraft(); - expect(store.activeConversationId).toBeNull(); - - store.dispose(); - }); - - it("selectTab switches active tab", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("first"); - const convId1 = activeConversationId(store); - - store.newDraft(); - store.send("second"); - const convId2 = activeConversationId(store); - - store.selectTab(convId1); - expect(store.activeConversationId).toBe(convId1); - - store.selectTab(convId2); - expect(store.activeConversationId).toBe(convId2); - - store.dispose(); - }); - - it("subscribes to chat for each restored tab on page load", () => { - const storage = createFakeStorage(); - // First session: create a tab, then dispose. - const ws1 = fakeSocket(); - const store1 = createAppStore({ - socketFactory: () => ws1, - fetchImpl: fakeFetchImpl(), - localStorage: storage, - }); - ws1.resolveOpen(); - store1.send("persist me"); - const convId = store1.tabs[0]?.conversationId as string; - expect(convId).toBeDefined(); - store1.dispose(); - - // Second session: the restored tab must be re-subscribed for live turns. - const ws2 = fakeSocket(); - const store2 = createAppStore({ - socketFactory: () => ws2, - fetchImpl: fakeFetchImpl(), - localStorage: storage, - }); - ws2.resolveOpen(); // flush the queued chat.subscribe - - const subscribed = parseSent(ws2) - .filter((p) => (p as { type: string }).type === "chat.subscribe") - .map((p) => (p as { conversationId: string }).conversationId); - expect(subscribed).toContain(convId); - - store2.dispose(); - }); - - it("unsubscribes from chat when a tab is closed", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("first"); - const convId = activeConversationId(store); - - ws.sent.length = 0; - store.closeTab(convId); - - const unsubscribed = parseSent(ws) - .filter((p) => (p as { type: string }).type === "chat.unsubscribe") - .map((p) => (p as { conversationId: string }).conversationId); - expect(unsubscribed).toContain(convId); - - store.dispose(); - }); - - it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => { - const fetchedUrls: string[] = []; - const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - fetchedUrls.push(url); - if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { - status: 200, - }); - } - return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); - }; - - const ws = reconnectableSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl, - httpUrl: "http://localhost:24203", - localStorage: createFakeStorage(), - }); - ws.open(); - - store.send("hi"); - const convId = activeConversationId(store); - - // Drop the connection, wait past the reconnect backoff, then re-open. - ws.sent.length = 0; - fetchedUrls.length = 0; - ws.closeRemote(); - await new Promise((r) => setTimeout(r, 800)); - ws.open(); // reconnect → onReopen - - const subscribed = parseSent(ws) - .filter((p) => (p as { type: string }).type === "chat.subscribe") - .map((p) => (p as { conversationId: string }).conversationId); - expect(subscribed).toContain(convId); - - // resync() pulled the tail from history for the reconnected conversation. - await vi.waitFor(() => { - expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); - }); - - store.dispose(); - }); + it("starts with empty catalog and no surfaces", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + expect(store.catalog).toEqual([]); + expect(store.surfaces).toEqual([]); + expect(store.lastError).toBeNull(); + + store.dispose(); + }); + + it("updates catalog when catalog message arrives", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + expect(store.catalog).toHaveLength(2); + expect(store.catalog[0]?.id).toBe("s1"); + expect(store.catalog[1]?.id).toBe("s2"); + + store.dispose(); + }); + + it("auto-subscribes to every catalog entry when the catalog arrives", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.sent.length = 0; + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + const subscribed = ws.sent + .map((s) => JSON.parse(s)) + .filter((p) => p.type === "subscribe") + .map((p) => p.surfaceId); + expect(subscribed).toContain("s1"); + expect(subscribed).toContain("s2"); + + store.dispose(); + }); + + it("unsubscribes from entries that vanish from a new catalog", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + ws.sent.length = 0; + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + const unsubscribed = ws.sent + .map((s) => JSON.parse(s)) + .filter((p) => p.type === "unsubscribe") + .map((p) => p.surfaceId); + expect(unsubscribed).toContain("s2"); + expect(unsubscribed).not.toContain("s1"); + + store.dispose(); + }); + + it("exposes received surface specs via `surfaces`, in catalog order", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + // Only s1's spec has arrived: surfaces reflects what's actually received. + ws.feedSurfaceMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], + }, + }); + expect(store.surfaces.map((s) => s.id)).toEqual(["s1"]); + + ws.feedSurfaceMessage({ + type: "surface", + spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] }, + }); + // Catalog order preserved (s1 before s2). + expect(store.surfaces.map((s) => s.id)).toEqual(["s1", "s2"]); + expect(store.surfaces[0]?.fields).toHaveLength(1); + + store.dispose(); + }); + + it("invoke sends an invoke message", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.sent.length = 0; + store.invoke("s1", "toggle-dark", true); + + const invokeMsg = ws.sent.find((s) => { + const parsed = JSON.parse(s); + return ( + parsed.type === "invoke" && + parsed.surfaceId === "s1" && + parsed.actionId === "toggle-dark" && + parsed.payload === true + ); + }); + expect(invokeMsg).toBeTruthy(); + + store.dispose(); + }); + + it("error message updates lastError", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "error", + message: "Something went wrong", + }); + + expect(store.lastError).not.toBeNull(); + expect(store.lastError?.message).toBe("Something went wrong"); + + store.dispose(); + }); + + it("dispose closes the socket", () => { + const ws = fakeSocket(); + const closeSpy = { called: false }; + const origClose = ws.close.bind(ws); + ws.close = () => { + closeSpy.called = true; + origClose(); + }; + + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.dispose(); + expect(closeSpy.called).toBe(true); + }); + + it("exposes activeChat with empty initial messages", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + expect(store.activeChat).toBeDefined(); + expect(store.activeChat.messages).toEqual([]); + expect(store.activeChat.chunks).toEqual([]); + expect(store.activeChat.error).toBeNull(); + + store.dispose(); + }); + + it("sending a message from draft creates a tab and posts chat.send", () => { + const ws = fakeSocket(); + const storage = createFakeStorage(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + + ws.sent.length = 0; + store.send("hello world"); + + expect(store.tabs).toHaveLength(1); + expect(store.tabs[0]?.title).toBe("hello world"); + expect(store.activeConversationId).not.toBeNull(); + + const msgs = parseSent(ws); + const chatSend = msgs.find((m) => (m as { type: string }).type === "chat.send") as + | { type: string; conversationId: string; message: string } + | undefined; + expect(chatSend).toBeTruthy(); + expect(chatSend?.message).toBe("hello world"); + + store.dispose(); + }); + + it("sending from draft forwards staged images on chat.send", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + ws.sent.length = 0; + + const images = [ + { url: "data:image/png;base64,AAAA", mimeType: "image/png" }, + { url: "https://example.com/x.jpg" }, + ]; + store.send("describe these", images); + + const msgs = parseSent(ws); + const chatSend = msgs.find((m) => (m as { type: string }).type === "chat.send") as + | { type: string; message: string; images?: { url: string }[] } + | undefined; + expect(chatSend).toBeTruthy(); + expect(chatSend?.images).toEqual(images); + // The optimistic echo includes the image chunks. + expect(store.activeChat.chunks.some((c) => c.chunk.type === "image")).toBe(true); + + store.dispose(); + }); + + it("an incoming chat.delta renders in the transcript", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("test"); + const convId = activeConversationId(store); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId: convId, turnId: "turn-1" }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "Hello " }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "world" }, + }); + + expect(store.activeChat.chunks.length).toBeGreaterThan(0); + const assistantChunks = store.activeChat.chunks.filter( + (c) => c.role === "assistant" && c.chunk.type === "text", + ); + expect(assistantChunks).toHaveLength(1); + expect((assistantChunks[0]?.chunk as { type: "text"; text: string }).text).toBe("Hello world"); + + store.dispose(); + }); + + it("chat.error sets the chat error", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("test"); + const convId = activeConversationId(store); + + ws.feedServerMessage({ + type: "chat.error", + conversationId: convId, + message: "bad request", + }); + + expect(store.activeChat.error).toBe("bad request"); + + store.dispose(); + }); + + it("turn-sealed triggers a history fetch and synced chunks render", async () => { + const fetchedUrls: string[] = []; + const historyResponse: ConversationHistoryResponse = { + chunks: [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" } }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } }, + ], + latestSeq: 2, + }; + const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + fetchedUrls.push(url); + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + status: 200, + }); + } + return new Response(JSON.stringify(historyResponse), { status: 200 }); + }; + + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + httpUrl: "http://localhost:24203", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("hi"); + const convId = activeConversationId(store); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId: convId, turnId: "turn-1" }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" }, + }); + + // `turn-sealed` triggers an async `syncTail` (cache.sinceSeq → historySync + // → cache.commit → applyHistory). Poll for the side-effect rather than + // guessing a fixed delay — under suite load a fixed `setTimeout` raced the + // fetch chain and flaked here. + await vi.waitFor(() => { + expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); + }); + + await vi.waitFor(() => { + expect(store.activeChat.chunks.length).toBeGreaterThan(0); + }); + + store.dispose(); + }); + + it("fetches and exposes the model catalog", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ + models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"], + }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await new Promise((r) => setTimeout(r, 50)); + + expect(store.models).toEqual([ + "opencode/deepseek-v4-flash", + "openai/gpt-4o", + "anthropic/claude-3", + ]); + + store.dispose(); + }); + + it("default model is flash", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); + + store.dispose(); + }); + + it("draft: sending the first message creates a tab titled from the message", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + + store.send("What is the meaning of life?"); + + expect(store.tabs).toHaveLength(1); + expect(store.tabs[0]?.title).toBe("What is the meaning of life?"); + expect(store.activeConversationId).toBe(store.tabs[0]?.conversationId); + + store.dispose(); + }); + + it("selecting a model persists it to the backend", () => { + const ws = fakeSocket(); + const fetchImpl = fakeFetchImpl(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("hello"); + store.selectModel("openai/gpt-4o"); + + const put = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ conversationId: "ignored", model: "openai/gpt-4o" }), { + status: 200, + }), + ); + const capturingStore = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (init?.method === "PUT" && url.endsWith("/model")) { + put(url, init); + } + return fetchImpl(input, init); + }, + localStorage: createFakeStorage(), + }); + capturingStore.send("hello"); + capturingStore.selectModel("openai/gpt-4o"); + + expect(put).toHaveBeenCalledOnce(); + const [callUrl, callInit] = put.mock.calls[0] as [string, RequestInit]; + expect(callUrl.endsWith("/model")).toBe(true); + expect(JSON.parse(callInit.body as string)).toEqual({ model: "openai/gpt-4o" }); + + store.dispose(); + capturingStore.dispose(); + }); + + it("focuses a conversation with a persisted model", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ model: "openai/gpt-4o" }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first message"); + + // New tab opens with the default model until the persisted-model fetch resolves. + expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); + + // Wait for the persisted model fetch to resolve. + await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o")); + expect(store.tabs[0]?.model).toBe("openai/gpt-4o"); + + store.dispose(); + }); + + it("chat.delta routes to the matching tab only", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first message"); + const convId1 = activeConversationId(store); + + store.newDraft(); + store.send("second message"); + const convId2 = activeConversationId(store); + + expect(convId1).not.toBe(convId2); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId: convId1, turnId: "turn-1" }, + }); + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "text-delta", + conversationId: convId1, + turnId: "turn-1", + delta: "response to first", + }, + }); + + store.selectTab(convId1); + const assistantChunks1 = store.activeChat.chunks.filter( + (c) => c.role === "assistant" && c.chunk.type === "text", + ); + expect(assistantChunks1).toHaveLength(1); + expect((assistantChunks1[0]?.chunk as { type: "text"; text: string }).text).toBe( + "response to first", + ); + + store.selectTab(convId2); + const assistantChunks2 = store.activeChat.chunks.filter( + (c) => c.role === "assistant" && c.chunk.type === "text", + ); + expect(assistantChunks2).toEqual([]); + + store.dispose(); + }); + + it("closing a tab evicts its cache and drops the tab", () => { + const ws = fakeSocket(); + const storage = createFakeStorage(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + + store.send("first"); + const convId = activeConversationId(store); + expect(store.tabs).toHaveLength(1); + + store.closeTab(convId); + + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + + store.dispose(); + }); + + it("closing a tab POSTs /conversations/:id/close (abort turn + stop warming)", async () => { + const calls: { url: string; method: string }[] = []; + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + calls.push({ url, method: init?.method ?? "GET" }); + if (url.endsWith("/close")) { + return new Response( + JSON.stringify({ conversationId: url.split("/").at(-2), abortedTurn: false }), + { status: 200 }, + ); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + const convId = activeConversationId(store); + store.closeTab(convId); + await Promise.resolve(); // flush the fire-and-forget fetch + + const close = calls.find((c) => c.url.endsWith(`/conversations/${convId}/close`)); + expect(close).toBeDefined(); + expect(close?.method).toBe("POST"); + + store.dispose(); + }); + + it("seeds reasoningEffort from GET /conversations/:id/reasoning-effort (null = never set)", async () => { + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/reasoning-effort")) { + return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: "xhigh" }), { + status: 200, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await vi.waitFor(() => { + expect(store.reasoningEffort).toBe("xhigh"); + }); + + store.dispose(); + }); + + it("setReasoningEffort PUTs the level and updates local state from the echo", async () => { + const calls: { url: string; method: string; body: string | undefined }[] = []; + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + calls.push({ url, method: init?.method ?? "GET", body: init?.body as string | undefined }); + if (url.endsWith("/reasoning-effort") && init?.method === "PUT") { + const sent = JSON.parse(init.body as string) as { reasoningEffort: string }; + return new Response( + JSON.stringify({ conversationId: "x", reasoningEffort: sent.reasoningEffort }), + { status: 200 }, + ); + } + if (url.endsWith("/reasoning-effort")) { + return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), { + status: 200, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.setReasoningEffort("max"); + expect(result).toEqual({ ok: true, reasoningEffort: "max" }); + expect(store.reasoningEffort).toBe("max"); + + const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/reasoning-effort")); + expect(put).toBeDefined(); + // The PUT targets the workspace conversation (draft id works too) and + // carries exactly the SetReasoningEffortRequest body. + expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`); + expect(JSON.parse(put?.body ?? "{}")).toEqual({ reasoningEffort: "max" }); + + store.dispose(); + }); + + it("setReasoningEffort surfaces a 400 error and leaves state unchanged", async () => { + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/reasoning-effort") && init?.method === "PUT") { + return new Response(JSON.stringify({ error: "bad level" }), { status: 400 }); + } + if (url.endsWith("/reasoning-effort")) { + return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), { + status: 200, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.setReasoningEffort("max"); + expect(result).toEqual({ ok: false, error: "bad level" }); + expect(store.reasoningEffort).toBeNull(); + + store.dispose(); + }); + + it("seeds thinking from GET /conversations/:id/thinking (null = never set ⇒ ON)", async () => { + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/thinking")) { + return new Response(JSON.stringify({ conversationId: "x", thinking: false }), { + status: 200, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await vi.waitFor(() => { + expect(store.thinking).toBe(false); + }); + + store.dispose(); + }); + + it("treats a missing thinking endpoint (404) as 'never set' (null ⇒ ON)", async () => { + // The thinking endpoint is PROPOSED (backend-handoff.md); until the backend + // ships it, GET 404s and `thinking` stays null ⇒ the selector shows the + // effort level (thinking ON, the default) — graceful, never a crash. + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/thinking")) { + return new Response("not found", { status: 404 }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // give the (404ing) fetch a tick to settle + await vi.waitFor(() => { + expect(store.reasoningEffort).not.toBe(undefined); + }); + expect(store.thinking).toBeNull(); + + store.dispose(); + }); + + it("setThinking PUTs the flag and updates local state from the echo", async () => { + const calls: { url: string; method: string; body: string | undefined }[] = []; + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + calls.push({ url, method: init?.method ?? "GET", body: init?.body as string | undefined }); + if (url.endsWith("/thinking") && init?.method === "PUT") { + const sent = JSON.parse(init.body as string) as { thinking: boolean }; + return new Response(JSON.stringify({ conversationId: "x", thinking: sent.thinking }), { + status: 200, + }); + } + if (url.endsWith("/thinking")) { + return new Response(JSON.stringify({ conversationId: "x", thinking: null }), { + status: 200, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.setThinking(false); + expect(result).toEqual({ ok: true, thinking: false }); + expect(store.thinking).toBe(false); + + const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/thinking")); + expect(put).toBeDefined(); + expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`); + expect(JSON.parse(put?.body ?? "{}")).toEqual({ thinking: false }); + + store.dispose(); + }); + + it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s-global", region: "side", title: "Global", scope: "global" }, + { id: "s-conv", region: "side", title: "Scoped", scope: "conversation" }, + ], + }); + + ws.sent.length = 0; + store.send("promote the draft"); // draft → real conversation: surfaces re-scope + const convId = activeConversationId(store); + + const surfaceMsgs = parseSent(ws).filter( + (p): p is { type: string; surfaceId: string; conversationId?: string } => + (p as { type: string }).type === "subscribe" || + (p as { type: string }).type === "unsubscribe", + ); + // The conversation-scoped surface re-scopes: unsubscribe old + subscribe new id. + expect( + surfaceMsgs.some( + (m) => m.type === "subscribe" && m.surfaceId === "s-conv" && m.conversationId === convId, + ), + ).toBe(true); + // The global surface is untouched — no redundant unsubscribe+subscribe round trip. + expect(surfaceMsgs.some((m) => m.surfaceId === "s-global")).toBe(false); + + store.dispose(); + }); + + it("tabs persist to the injected storage and restore on a new store", () => { + const ws = fakeSocket(); + const storage = createFakeStorage(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + + store.send("persist me"); + const convId = store.tabs[0]?.conversationId; + const title = store.tabs[0]?.title; + expect(convId).toBeDefined(); + expect(title).toBeDefined(); + + const raw = storage.getItem("dispatch.tabs"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw as string); + expect(parsed.tabs).toHaveLength(1); + expect(parsed.tabs[0].conversationId).toBe(convId); + expect(parsed.tabs[0].title).toBe(title); + + const ws2 = fakeSocket(); + const store2 = createAppStore({ + socketFactory: () => ws2, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws2.resolveOpen(); + + expect(store2.tabs).toHaveLength(1); + expect(store2.tabs[0]?.conversationId).toBe(convId); + expect(store2.tabs[0]?.title).toBe(title); + expect(store2.activeConversationId).toBe(convId); + + store.dispose(); + store2.dispose(); + }); + + it("tabs persist to globalThis.localStorage when no storage is injected", () => { + const realLs = globalThis.localStorage; + const memLs = createFakeStorage(); + globalThis.localStorage = memLs; + try { + const ws1 = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws1, + fetchImpl: fakeFetchImpl(), + }); + ws1.resolveOpen(); + + store.send("persist via default"); + const convId = store.tabs[0]?.conversationId; + const title = store.tabs[0]?.title; + expect(convId).toBeDefined(); + expect(title).toBeDefined(); + + const raw = globalThis.localStorage.getItem("dispatch.tabs"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw as string); + expect(parsed.tabs).toHaveLength(1); + expect(parsed.tabs[0].conversationId).toBe(convId); + expect(parsed.tabs[0].title).toBe(title); + + store.dispose(); + + const ws2 = fakeSocket(); + const store2 = createAppStore({ + socketFactory: () => ws2, + fetchImpl: fakeFetchImpl(), + }); + ws2.resolveOpen(); + + expect(store2.tabs).toHaveLength(1); + expect(store2.tabs[0]?.conversationId).toBe(convId); + expect(store2.tabs[0]?.title).toBe(title); + expect(store2.activeConversationId).toBe(convId); + + store2.dispose(); + } finally { + globalThis.localStorage = realLs; + } + }); + + it("newDraft resets to draft mode", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + expect(store.tabs).toHaveLength(1); + + store.newDraft(); + expect(store.activeConversationId).toBeNull(); + + store.dispose(); + }); + + it("selectTab switches active tab", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + const convId1 = activeConversationId(store); + + store.newDraft(); + store.send("second"); + const convId2 = activeConversationId(store); + + store.selectTab(convId1); + expect(store.activeConversationId).toBe(convId1); + + store.selectTab(convId2); + expect(store.activeConversationId).toBe(convId2); + + store.dispose(); + }); + + it("subscribes to chat for each restored tab on page load", () => { + const storage = createFakeStorage(); + // First session: create a tab, then dispose. + const ws1 = fakeSocket(); + const store1 = createAppStore({ + socketFactory: () => ws1, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws1.resolveOpen(); + store1.send("persist me"); + const convId = store1.tabs[0]?.conversationId as string; + expect(convId).toBeDefined(); + store1.dispose(); + + // Second session: the restored tab must be re-subscribed for live turns. + const ws2 = fakeSocket(); + const store2 = createAppStore({ + socketFactory: () => ws2, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws2.resolveOpen(); // flush the queued chat.subscribe + + const subscribed = parseSent(ws2) + .filter((p) => (p as { type: string }).type === "chat.subscribe") + .map((p) => (p as { conversationId: string }).conversationId); + expect(subscribed).toContain(convId); + + store2.dispose(); + }); + + it("unsubscribes from chat when a tab is closed", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + const convId = activeConversationId(store); + + ws.sent.length = 0; + store.closeTab(convId); + + const unsubscribed = parseSent(ws) + .filter((p) => (p as { type: string }).type === "chat.unsubscribe") + .map((p) => (p as { conversationId: string }).conversationId); + expect(unsubscribed).toContain(convId); + + store.dispose(); + }); + + it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => { + const fetchedUrls: string[] = []; + const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + fetchedUrls.push(url); + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + status: 200, + }); + } + return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + }; + + const ws = reconnectableSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + httpUrl: "http://localhost:24203", + localStorage: createFakeStorage(), + }); + ws.open(); + + store.send("hi"); + const convId = activeConversationId(store); + + // Drop the connection, wait past the reconnect backoff, then re-open. + ws.sent.length = 0; + fetchedUrls.length = 0; + ws.closeRemote(); + await new Promise((r) => setTimeout(r, 800)); + ws.open(); // reconnect → onReopen + + const subscribed = parseSent(ws) + .filter((p) => (p as { type: string }).type === "chat.subscribe") + .map((p) => (p as { conversationId: string }).conversationId); + expect(subscribed).toContain(convId); + + // resync() pulled the tail from history for the reconnected conversation. + await vi.waitFor(() => { + expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); + }); + + store.dispose(); + }); + + // ── Heartbeat (workspace-scoped config + runs + watch) ─────────────────────── + // + // The heartbeat API is a plain REST surface (not a transport-contract type), + // so these tests fake the four endpoints + verify the store coerces the + // untyped JSON and routes live deltas to a watch store (the run-chat modal). + + function heartbeatFetchImpl(opts?: { + config?: Record<string, unknown>; + runs?: Record<string, unknown>; + }): typeof fetch { + const base = fakeFetchImpl(); + const config = opts?.config ?? { + enabled: true, + systemPrompt: "sys", + taskPrompt: "task", + intervalMinutes: 15, + model: "openai/gpt-4o", + reasoningEffort: "medium", + }; + const runs = opts?.runs ?? { + runs: [ + { + id: "run-1", + conversationId: "hb-conv-1", + triggeredAt: "2026-06-25T10:00:00Z", + status: "running", + }, + ], + }; + return async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.includes("/heartbeat/runs") && method === "GET") { + return new Response(JSON.stringify(runs), { status: 200 }); + } + if (url.includes("/heartbeat/runs/") && method === "POST") { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (url.endsWith("/heartbeat") && method === "GET") { + return new Response(JSON.stringify(config), { status: 200 }); + } + if (url.endsWith("/heartbeat") && method === "PUT") { + // Echo the patch merged onto the stored config so the round-trip is observable. + const patch = init?.body ? JSON.parse(init.body as string) : {}; + return new Response(JSON.stringify({ ...config, ...patch }), { + status: 200, + }); + } + if (url.includes("/heartbeat")) { + return new Response(JSON.stringify(config), { status: 200 }); + } + return base(input, init); + }; + } + + it("heartbeatConfig loads + coerces the workspace config", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: heartbeatFetchImpl(), + localStorage: createFakeStorage(), + }); + const result = await store.heartbeatConfig(); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.config).toEqual({ + enabled: true, + inactiveOnly: true, + systemPrompt: "sys", + taskPrompt: "task", + intervalMinutes: 15, + model: "openai/gpt-4o", + reasoningEffort: "medium", + }); + store.dispose(); + }); + + it("heartbeatConfig surfaces an HTTP error", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/heartbeat")) + return new Response(JSON.stringify({ error: "nope" }), { status: 500 }); + return fakeFetchImpl()(input); + }, + localStorage: createFakeStorage(), + }); + const result = await store.heartbeatConfig(); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("nope"); + store.dispose(); + }); + + it("setHeartbeatConfig PUTs a patch and returns the merged config", async () => { + const calls: { url: string; method: string; body: unknown }[] = []; + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.endsWith("/heartbeat") && method === "PUT") { + calls.push({ url, method, body: JSON.parse(init?.body as string) }); + } + return heartbeatFetchImpl()(input, init); + }, + localStorage: createFakeStorage(), + }); + const result = await store.setHeartbeatConfig({ enabled: false, intervalMinutes: 9999 }); + expect(result.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]?.body).toEqual({ enabled: false, intervalMinutes: 9999 }); + // The store normalizes the echoed response (interval clamped to the 1–1440 range). + if (!result.ok) throw new Error("unreachable"); + expect(result.config.intervalMinutes).toBe(1440); + store.dispose(); + }); + + it("heartbeatRuns loads + coerces the run list", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: heartbeatFetchImpl(), + localStorage: createFakeStorage(), + }); + const result = await store.heartbeatRuns(); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.runs).toHaveLength(1); + expect(result.runs[0]).toMatchObject({ + id: "run-1", + conversationId: "hb-conv-1", + status: "running", + }); + store.dispose(); + }); + + it("stopHeartbeatRun POSTs the stop endpoint", async () => { + const calls: { url: string; method: string }[] = []; + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.includes("/heartbeat/runs/") && method === "POST") { + calls.push({ url, method }); + } + return heartbeatFetchImpl()(input, init); + }, + localStorage: createFakeStorage(), + }); + const result = await store.stopHeartbeatRun("run-1"); + expect(result.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toContain("/heartbeat/runs/run-1/stop"); + expect(calls[0]?.method).toBe("POST"); + store.dispose(); + }); + + it("watchConversation subscribes + routes live deltas to the watch store", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // A heartbeat run's conversation that is NOT an open tab — watch it. + const watch = store.watchConversation("hb-conv-watch"); + // A chat.subscribe was sent for the watched conversation. + const subscribed = parseSent(ws).some( + (p) => + (p as { type: string; conversationId?: string }).type === "chat.subscribe" && + (p as { conversationId?: string }).conversationId === "hb-conv-watch", + ); + expect(subscribed).toBe(true); + + // Feed a live delta for the watched conversation → the watch store folds it. + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId: "hb-conv-watch", turnId: "t1" }, + }); + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "text-delta", + conversationId: "hb-conv-watch", + turnId: "t1", + delta: "hello from heartbeat", + }, + }); + + await vi.waitFor(() => { + const text = watch.chunks.find((c) => c.role === "assistant" && c.chunk.type === "text"); + expect((text?.chunk as { type: "text"; text: string } | undefined)?.text).toBe( + "hello from heartbeat", + ); + }); + expect(watch.generating).toBe(true); + + // Unwatch → unsubscribes (a chat.unsubscribe for this conversation is sent). + ws.sent.length = 0; + store.unwatchConversation("hb-conv-watch"); + const unsubscribed = parseSent(ws).some( + (p) => + (p as { type: string; conversationId?: string }).type === "chat.unsubscribe" && + (p as { conversationId?: string }).conversationId === "hb-conv-watch", + ); + expect(unsubscribed).toBe(true); + + store.dispose(); + }); + + it("watchConversation reuses an open tab's store; unwatch is a no-op for it", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + const convId = activeConversationId(store); + // The conversation is an open tab (already subscribed on send). Watching it + // must REUSE the tab's store + subscription — so no NEW chat.subscribe is + // sent (the watch path only subscribes when it creates an ephemeral store). + // (Note: `store.activeChat` is a Svelte `$state` PROXY of the tab store, so a + // reference-equality check is meaningless here — we assert behavior instead.) + ws.sent.length = 0; + store.watchConversation(convId); + const subscribed = parseSent(ws).some( + (p) => + (p as { type: string; conversationId?: string }).type === "chat.subscribe" && + (p as { conversationId?: string }).conversationId === convId, + ); + expect(subscribed).toBe(false); + + // Unwatching a tab conversation does NOT unsubscribe (the tab keeps its stream). + ws.sent.length = 0; + store.unwatchConversation(convId); + const unsubscribed = parseSent(ws).some( + (p) => (p as { type: string }).type === "chat.unsubscribe", + ); + expect(unsubscribed).toBe(false); + + store.dispose(); + }); + + // ── Concurrency (per-provider limits + live status; GLOBAL REST surface) ───── + // + // The concurrency API is a plain REST surface under /concurrency (provided by + // the `concurrency` extension). These tests fake all five endpoints + verify + // the store coerces the untyped JSON and routes the right method/URL. + + function concurrencyFetchImpl(opts?: { + limits?: Record<string, unknown>; + limit?: Record<string, unknown>; + status?: Record<string, unknown>; + }): typeof fetch { + const base = fakeFetchImpl(); + const limits = opts?.limits ?? { + limits: [ + { providerId: "umans", limit: 4 }, + { providerId: "openai-compat", limit: 5 }, + ], + }; + const limit = opts?.limit ?? { providerId: "umans", limit: 4 }; + const status = opts?.status ?? { + providers: [ + { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, + { + providerId: "openai-compat", + limit: 5, + inFlight: 5, + queued: 3, + paused: true, + pausedUntil: 1_719_408_000_000, + }, + ], + }; + return async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.endsWith("/concurrency/status") && method === "GET") { + return new Response(JSON.stringify(status), { status: 200 }); + } + if (url.endsWith("/concurrency/limits") && method === "GET") { + return new Response(JSON.stringify(limits), { status: 200 }); + } + if (url.includes("/concurrency/limits/") && method === "GET") { + return new Response(JSON.stringify(limit), { status: 200 }); + } + if (url.includes("/concurrency/limits/") && method === "PUT") { + const seg = url.slice( + url.lastIndexOf("/concurrency/limits/") + "/concurrency/limits/".length, + ); + const body = init?.body ? JSON.parse(init.body as string) : {}; + return new Response(JSON.stringify({ providerId: seg, ...body }), { status: 200 }); + } + if (url.includes("/concurrency/limits/") && method === "DELETE") { + return new Response(JSON.stringify({ ok: true, providerId: "umans" }), { status: 200 }); + } + if (url.includes("/concurrency/cooldown/") && method === "GET") { + return new Response(JSON.stringify({ providerId: "umans", cooldownMs: 350 }), { + status: 200, + }); + } + if (url.includes("/concurrency/cooldown/") && method === "PUT") { + const seg = url.slice( + url.lastIndexOf("/concurrency/cooldown/") + "/concurrency/cooldown/".length, + ); + const body = init?.body ? JSON.parse(init.body as string) : {}; + return new Response(JSON.stringify({ providerId: seg, cooldownMs: body.cooldownMs ?? 0 }), { + status: 200, + }); + } + return base(input, init); + }; + } + + it("concurrencyLimits loads + coerces the limits list", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: concurrencyFetchImpl(), + localStorage: createFakeStorage(), + }); + const result = await store.concurrencyLimits(); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.limits).toEqual([ + { providerId: "umans", limit: 4 }, + { providerId: "openai-compat", limit: 5 }, + ]); + store.dispose(); + }); + + it("concurrencyLimits tolerates a malformed/empty body (extension not loaded)", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: concurrencyFetchImpl({ limits: {} }), + localStorage: createFakeStorage(), + }); + const result = await store.concurrencyLimits(); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.limits).toEqual([]); + store.dispose(); + }); + + it("concurrencyLimits surfaces an HTTP error", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/concurrency/limits")) + return new Response(JSON.stringify({ error: "Concurrency service not available" }), { + status: 503, + }); + return fakeFetchImpl()(input); + }, + localStorage: createFakeStorage(), + }); + const result = await store.concurrencyLimits(); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("Concurrency service not available"); + store.dispose(); + }); + + it("getConcurrencyLimit loads one provider's limit", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: concurrencyFetchImpl(), + localStorage: createFakeStorage(), + }); + const result = await store.getConcurrencyLimit("umans"); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.providerId).toBe("umans"); + expect(result.limit).toBe(4); + store.dispose(); + }); + + it("getConcurrencyLimit surfaces a 404 (not configured)", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.includes("/concurrency/limits/") && method === "GET") + return new Response( + JSON.stringify({ error: "No concurrency limit configured for this provider" }), + { status: 404 }, + ); + return fakeFetchImpl()(input); + }, + localStorage: createFakeStorage(), + }); + const result = await store.getConcurrencyLimit("anthropic"); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("No concurrency limit configured"); + store.dispose(); + }); + + it("setConcurrencyLimit PUTs { limit } to the provider URL and returns the echoed limit", async () => { + const calls: { url: string; method: string; body: unknown }[] = []; + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.includes("/concurrency/limits/") && method === "PUT") { + calls.push({ url, method, body: JSON.parse(init?.body as string) }); + } + return concurrencyFetchImpl()(input, init); + }, + localStorage: createFakeStorage(), + }); + const result = await store.setConcurrencyLimit("anthropic", 8); + expect(result.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]?.method).toBe("PUT"); + expect(calls[0]?.url).toContain("/concurrency/limits/anthropic"); + expect(calls[0]?.body).toEqual({ limit: 8 }); + if (!result.ok) throw new Error("unreachable"); + expect(result.providerId).toBe("anthropic"); // echoed by the fake + expect(result.limit).toBe(8); + store.dispose(); + }); + + it("setConcurrencyLimit surfaces a 400 (non-positive body)", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes("/concurrency/limits/") && init?.method === "PUT") + return new Response( + JSON.stringify({ error: "Body must be { limit: <positive integer> }" }), + { + status: 400, + }, + ); + return fakeFetchImpl()(input); + }, + localStorage: createFakeStorage(), + }); + const result = await store.setConcurrencyLimit("umans", 0); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("Body must be"); + store.dispose(); + }); + + it("deleteConcurrencyLimit DELETEs the provider URL and returns ok", async () => { + const calls: { url: string; method: string }[] = []; + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.includes("/concurrency/limits/") && method === "DELETE") { + calls.push({ url, method }); + } + return concurrencyFetchImpl()(input, init); + }, + localStorage: createFakeStorage(), + }); + const result = await store.deleteConcurrencyLimit("umans"); + expect(result.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]?.method).toBe("DELETE"); + expect(calls[0]?.url).toContain("/concurrency/limits/umans"); + if (!result.ok) throw new Error("unreachable"); + expect(result.providerId).toBe("umans"); + store.dispose(); + }); + + it("concurrencyStatus loads + coerces the status list (incl. pausedUntil)", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: concurrencyFetchImpl(), + localStorage: createFakeStorage(), + }); + const result = await store.concurrencyStatus(); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.providers).toHaveLength(2); + expect(result.providers[0]).toEqual({ + providerId: "umans", + limit: 4, + inFlight: 2, + queued: 1, + paused: false, + cooldownMs: 350, + autoReduced: false, + }); + expect(result.providers[1]).toMatchObject({ + providerId: "openai-compat", + paused: true, + pausedUntil: 1_719_408_000_000, + cooldownMs: 350, + autoReduced: false, + }); + store.dispose(); + }); + + it("concurrencyStatus tolerates a malformed body (extension not loaded)", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: concurrencyFetchImpl({ status: {} }), + localStorage: createFakeStorage(), + }); + const result = await store.concurrencyStatus(); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.providers).toEqual([]); + store.dispose(); + }); + + it("getConcurrencyCooldown loads + coerces the cooldown", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: concurrencyFetchImpl(), + localStorage: createFakeStorage(), + }); + const result = await store.getConcurrencyCooldown("umans"); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.providerId).toBe("umans"); + expect(result.cooldownMs).toBe(350); + store.dispose(); + }); + + it("getConcurrencyCooldown surfaces a 404 (no concurrency config) as ok:false", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes("/concurrency/cooldown/")) { + return new Response( + JSON.stringify({ error: "No concurrency configuration for this provider" }), + { + status: 404, + }, + ); + } + return fakeFetchImpl()(input); + }, + localStorage: createFakeStorage(), + }); + const result = await store.getConcurrencyCooldown("ghost"); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("No concurrency configuration"); + store.dispose(); + }); + + it("setConcurrencyCooldown PUTs { cooldownMs } + returns the echoed value", async () => { + const calls: { url: string; method: string; body: unknown }[] = []; + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + calls.push({ url, method, body: init?.body ? JSON.parse(init.body as string) : null }); + if (url.includes("/concurrency/cooldown/") && method === "PUT") { + const body = init?.body ? JSON.parse(init.body as string) : {}; + return new Response( + JSON.stringify({ providerId: "umans", cooldownMs: body.cooldownMs }), + { status: 200 }, + ); + } + return fakeFetchImpl()(input, init); + }, + localStorage: createFakeStorage(), + }); + const result = await store.setConcurrencyCooldown("umans", 500); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.cooldownMs).toBe(500); + const cooldownCall = calls.find( + (c) => c.url.includes("/concurrency/cooldown/") && c.method === "PUT", + ); + expect(cooldownCall?.url).toContain("/concurrency/cooldown/umans"); + expect(cooldownCall?.body).toEqual({ cooldownMs: 500 }); + store.dispose(); + }); + + it("setConcurrencyCooldown surfaces a 400 (invalid body) as ok:false", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes("/concurrency/cooldown/") && init?.method === "PUT") { + return new Response( + JSON.stringify({ error: "Body must be { cooldownMs: <non-negative integer> }" }), + { status: 400 }, + ); + } + return fakeFetchImpl()(input, init); + }, + localStorage: createFakeStorage(), + }); + const result = await store.setConcurrencyCooldown("umans", -1); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("non-negative integer"); + store.dispose(); + }); + + // ── Conversation status: `queued` (CR-13 — waiting for a concurrency slot) ──── + + it("conversation.statusChanged 'queued' sets the status (tab spinner) without opening a duplicate tab", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + store.send("hello"); + const convId = activeConversationId(store); + const tabsBefore = store.tabs.length; + + // The backend broadcasts "queued" while the turn waits for a slot. + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "queued", + workspaceId: "default", + }); + + expect(store.conversationStatus(convId)).toBe("queued"); + // The tab already exists (opened on send) — no duplicate tab is opened. + expect(store.tabs.length).toBe(tabsBefore); + + // Granted → "active" (dots), then idle on turn seal. + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "active", + workspaceId: "default", + }); + expect(store.conversationStatus(convId)).toBe("active"); + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "idle", + workspaceId: "default", + }); + expect(store.conversationStatus(convId)).toBe("idle"); + store.dispose(); + }); + + it("conversation.statusChanged 'queued' opens a tab for a new cross-device conversation", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + expect(store.tabs.length).toBe(0); + + // Another device's turn is waiting for a slot — broadcast "queued". + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: "other-device-conv", + status: "queued", + workspaceId: "default", + }); + + expect(store.conversationStatus("other-device-conv")).toBe("queued"); + // A queued conversation we had no tab for opens one (like `active`). + expect(store.tabs.some((t) => t.conversationId === "other-device-conv")).toBe(true); + store.dispose(); + }); + + // ── workspaceHasActiveConversations (workspace-card active indicator) ───── + + it("workspaceHasActiveConversations is false when no conversation is active", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + store.send("hello"); + const convId = activeConversationId(store); + + // The conversation is freshly created — the backend hasn't reported it as + // active yet, so the workspace has no active conversation. + expect(store.conversationStatus(convId)).toBeUndefined(); + expect(store.workspaceHasActiveConversations("default")).toBe(false); + store.dispose(); + }); + + it("workspaceHasActiveConversations is true when a conversation in the workspace is active", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + store.send("hello"); + const convId = activeConversationId(store); + + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "active", + workspaceId: "default", + }); + + expect(store.workspaceHasActiveConversations("default")).toBe(true); + store.dispose(); + }); + + it("workspaceHasActiveConversations is true when a conversation is queued (waiting for a slot)", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + store.send("hello"); + const convId = activeConversationId(store); + + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "queued", + workspaceId: "default", + }); + + expect(store.workspaceHasActiveConversations("default")).toBe(true); + store.dispose(); + }); + + it("workspaceHasActiveConversations goes back to false when the conversation goes idle", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + store.send("hello"); + const convId = activeConversationId(store); + + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "active", + workspaceId: "default", + }); + expect(store.workspaceHasActiveConversations("default")).toBe(true); + + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "idle", + workspaceId: "default", + }); + expect(store.workspaceHasActiveConversations("default")).toBe(false); + store.dispose(); + }); + + it("workspaceHasActiveConversations scopes to the given workspace (ignores other workspaces)", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // A cross-device active conversation in workspace "proj-a". + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: "proj-a-conv", + status: "active", + workspaceId: "proj-a", + }); + + // proj-a is active; proj-b is not (no active conversation there). + expect(store.workspaceHasActiveConversations("proj-a")).toBe(true); + expect(store.workspaceHasActiveConversations("proj-b")).toBe(false); + store.dispose(); + }); +}); + +describe("createAppStore — vision settings (global)", () => { + function visionFetch(initial: { imageLimit: number; compactionModel: string | null }): { + fetchImpl: typeof fetch; + puts: { imageLimit?: number; compactionModel?: string | null }[]; + } { + let current = initial; + const puts: { imageLimit?: number; compactionModel?: string | null }[] = []; + return { + puts, + fetchImpl: async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/models")) { + return new Response( + JSON.stringify({ + models: ["kimi/k2", "umans/glm-5.2"], + modelInfo: { "kimi/k2": { vision: true } }, + }), + { status: 200 }, + ); + } + if (url.endsWith("/settings/vision")) { + if (init?.method === "PUT") { + const text = typeof init.body === "string" ? init.body : ""; + const body = text ? (JSON.parse(text) as object) : {}; + puts.push(body as { imageLimit?: number; compactionModel?: string | null }); + current = { ...current, ...(body as object) } as { + imageLimit: number; + compactionModel: string | null; + }; + } + return new Response(JSON.stringify(current), { status: 200 }); + } + // Default: empty history + no cwd for the other endpoints. + return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + }, + }; + } + + it("loads vision settings on boot (GET /settings/vision)", async () => { + const { fetchImpl } = visionFetch({ imageLimit: 7, compactionModel: "kimi/k2" }); + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl, + localStorage: createFakeStorage(), + }); + fakeSocket().resolveOpen(); // not strictly needed for HTTP + + await vi.waitFor(() => { + expect(store.visionSettings).toEqual({ imageLimit: 7, compactionModel: "kimi/k2" }); + }); + store.dispose(); + }); + + it("setVisionSettings PUTs a partial update and reflects the merged settings", async () => { + const ctx = visionFetch({ imageLimit: 10, compactionModel: null }); + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: ctx.fetchImpl, + localStorage: createFakeStorage(), + }); + + await vi.waitFor(() => { + expect(store.visionSettings?.imageLimit).toBe(10); + }); + + const result = await store.setVisionSettings({ imageLimit: 3 }); + expect(result?.ok).toBe(true); + if (result?.ok) { + expect(result.settings.imageLimit).toBe(3); + expect(result.settings.compactionModel).toBeNull(); + } + expect(ctx.puts).toEqual([{ imageLimit: 3 }]); + expect(store.visionSettings?.imageLimit).toBe(3); + + // A second save updates compactionModel only. + const result2 = await store.setVisionSettings({ compactionModel: "kimi/k2" }); + expect(result2?.ok).toBe(true); + expect(ctx.puts).toEqual([{ imageLimit: 3 }, { compactionModel: "kimi/k2" }]); + expect(store.visionSettings?.compactionModel).toBe("kimi/k2"); + store.dispose(); + }); + + it("refreshVisionSettings refetches (load adapter)", async () => { + const { fetchImpl } = visionFetch({ imageLimit: 5, compactionModel: null }); + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl, + localStorage: createFakeStorage(), + }); + + await store.refreshVisionSettings(); + expect(store.visionSettings).toEqual({ imageLimit: 5, compactionModel: null }); + store.dispose(); + }); + + it("surfaces a PUT error", async () => { + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/settings/vision")) { + if (init?.method === "PUT") { + return new Response(JSON.stringify({ error: "invalid imageLimit" }), { status: 400 }); + } + return new Response(JSON.stringify({ imageLimit: 10, compactionModel: null }), { + status: 200, + }); + } + return new Response(JSON.stringify({ models: [] }), { status: 200 }); + }; + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl, + localStorage: createFakeStorage(), + }); + + const result = await store.setVisionSettings({ imageLimit: -1 }); + expect(result?.ok).toBe(false); + if (result !== null && !result.ok) { + expect(result.error).toContain("invalid imageLimit"); + } + store.dispose(); + }); }); diff --git a/src/app/uuid.test.ts b/src/app/uuid.test.ts index bd8e306..7673db1 100644 --- a/src/app/uuid.test.ts +++ b/src/app/uuid.test.ts @@ -4,28 +4,28 @@ import { randomId } from "./uuid"; const V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; describe("randomId", () => { - it("returns a v4-shaped uuid", () => { - const id = randomId(); - expect(id).toMatch(V4_RE); - }); + it("returns a v4-shaped uuid", () => { + const id = randomId(); + expect(id).toMatch(V4_RE); + }); - it("returns distinct values across calls", () => { - const ids = new Set<string>(); - for (let i = 0; i < 200; i++) { - ids.add(randomId()); - } - expect(ids.size).toBe(200); - }); + it("returns distinct values across calls", () => { + const ids = new Set<string>(); + for (let i = 0; i < 200; i++) { + ids.add(randomId()); + } + expect(ids.size).toBe(200); + }); - it("works without crypto.randomUUID (getRandomValues branch)", () => { - const origRandomUUID = crypto.randomUUID; - try { - // Remove randomUUID so the getRandomValues branch is taken - delete (crypto as { randomUUID?: () => string }).randomUUID; - const id = randomId(); - expect(id).toMatch(V4_RE); - } finally { - crypto.randomUUID = origRandomUUID; - } - }); + it("works without crypto.randomUUID (getRandomValues branch)", () => { + const origRandomUUID = crypto.randomUUID; + try { + // Remove randomUUID so the getRandomValues branch is taken + delete (crypto as { randomUUID?: () => string }).randomUUID; + const id = randomId(); + expect(id).toMatch(V4_RE); + } finally { + crypto.randomUUID = origRandomUUID; + } + }); }); diff --git a/src/app/uuid.ts b/src/app/uuid.ts index ae39d4d..bdceefe 100644 --- a/src/app/uuid.ts +++ b/src/app/uuid.ts @@ -1,65 +1,65 @@ const HEX = "0123456789abcdef"; function hexChar(n: number): string { - return HEX.charAt(n & 0xf); + return HEX.charAt(n & 0xf); } function hexFromBytes(bytes: Uint8Array): string { - let out = ""; - for (let i = 0; i < bytes.length; i++) { - const b = bytes[i] as number; - out += hexChar(b >> 4); - out += hexChar(b); - } - return out; + let out = ""; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i] as number; + out += hexChar(b >> 4); + out += hexChar(b); + } + return out; } function formatV4(rand: Uint8Array): string { - const h = hexFromBytes(rand); - return ( - h.slice(0, 8) + - "-" + - h.slice(8, 12) + - "-4" + - h.slice(13, 16) + - "-" + - ((parseInt(h.slice(16, 18), 16) & 0x3f) | 0x80).toString(16).padStart(2, "0") + - h.slice(18, 20) + - "-" + - h.slice(20, 32) - ); + const h = hexFromBytes(rand); + return ( + h.slice(0, 8) + + "-" + + h.slice(8, 12) + + "-4" + + h.slice(13, 16) + + "-" + + ((parseInt(h.slice(16, 18), 16) & 0x3f) | 0x80).toString(16).padStart(2, "0") + + h.slice(18, 20) + + "-" + + h.slice(20, 32) + ); } function uuidFromGetRandomValues(): string { - const buf = new Uint8Array(16); - crypto.getRandomValues(buf); - buf[6] = ((buf[6] as number) & 0x0f) | 0x40; - buf[8] = ((buf[8] as number) & 0x3f) | 0x80; - return formatV4(buf); + const buf = new Uint8Array(16); + crypto.getRandomValues(buf); + buf[6] = ((buf[6] as number) & 0x0f) | 0x40; + buf[8] = ((buf[8] as number) & 0x3f) | 0x80; + return formatV4(buf); } function uuidFromMathRandom(): string { - let s = ""; - for (let i = 0; i < 36; i++) { - if (i === 8 || i === 13 || i === 18 || i === 23) { - s += "-"; - } else if (i === 14) { - s += "4"; - } else if (i === 19) { - s += hexChar(Math.floor(Math.random() * 4) + 8); - } else { - s += hexChar(Math.floor(Math.random() * 16)); - } - } - return s; + let s = ""; + for (let i = 0; i < 36; i++) { + if (i === 8 || i === 13 || i === 18 || i === 23) { + s += "-"; + } else if (i === 14) { + s += "4"; + } else if (i === 19) { + s += hexChar(Math.floor(Math.random() * 4) + 8); + } else { + s += hexChar(Math.floor(Math.random() * 16)); + } + } + return s; } export function randomId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { - return uuidFromGetRandomValues(); - } - return uuidFromMathRandom(); + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + return uuidFromGetRandomValues(); + } + return uuidFromMathRandom(); } diff --git a/src/components/Table.svelte b/src/components/Table.svelte index 7c56e69..b6f5c5a 100644 --- a/src/components/Table.svelte +++ b/src/components/Table.svelte @@ -1,42 +1,42 @@ <script lang="ts"> - // Generic, purely presentational table. Props in → markup out; zero logic, - // zero data-fetching. Shared by the surface custom-field "table" renderer and - // the frontend "Loaded Modules" view, so neither feature depends on the other. - let { - columns, - rows, - empty = "No data", - }: { - readonly columns: readonly string[]; - readonly rows: readonly (readonly string[])[]; - /** Text shown when there are no rows. */ - readonly empty?: string; - } = $props(); + // Generic, purely presentational table. Props in → markup out; zero logic, + // zero data-fetching. Shared by the surface custom-field "table" renderer and + // the frontend "Loaded Modules" view, so neither feature depends on the other. + let { + columns, + rows, + empty = "No data", + }: { + readonly columns: readonly string[]; + readonly rows: readonly (readonly string[])[]; + /** Text shown when there are no rows. */ + readonly empty?: string; + } = $props(); </script> <div class="overflow-x-auto"> - <table class="table table-sm"> - <thead> - <tr> - {#each columns as col, i (i)} - <th>{col}</th> - {/each} - </tr> - </thead> - <tbody> - {#if rows.length === 0} - <tr> - <td colspan={Math.max(columns.length, 1)} class="opacity-60">{empty}</td> - </tr> - {:else} - {#each rows as row, r (r)} - <tr> - {#each row as cell, c (c)} - <td>{cell}</td> - {/each} - </tr> - {/each} - {/if} - </tbody> - </table> + <table class="table table-sm"> + <thead> + <tr> + {#each columns as col, i (i)} + <th>{col}</th> + {/each} + </tr> + </thead> + <tbody> + {#if rows.length === 0} + <tr> + <td colspan={Math.max(columns.length, 1)} class="opacity-60">{empty}</td> + </tr> + {:else} + {#each rows as row, r (r)} + <tr> + {#each row as cell, c (c)} + <td>{cell}</td> + {/each} + </tr> + {/each} + {/if} + </tbody> + </table> </div> diff --git a/src/components/Table.test.ts b/src/components/Table.test.ts index 9fbecd3..f43a981 100644 --- a/src/components/Table.test.ts +++ b/src/components/Table.test.ts @@ -3,33 +3,33 @@ import { describe, expect, it } from "vitest"; import Table from "./Table.svelte"; describe("Table", () => { - it("renders a header cell per column", () => { - render(Table, { props: { columns: ["Name", "Version"], rows: [] } }); - const headers = screen.getAllByRole("columnheader"); - expect(headers.map((h) => h.textContent)).toEqual(["Name", "Version"]); - }); + it("renders a header cell per column", () => { + render(Table, { props: { columns: ["Name", "Version"], rows: [] } }); + const headers = screen.getAllByRole("columnheader"); + expect(headers.map((h) => h.textContent)).toEqual(["Name", "Version"]); + }); - it("renders one row per data row with aligned cells", () => { - render(Table, { - props: { - columns: ["Name", "Version"], - rows: [ - ["alpha", "1.0"], - ["beta", "2.3"], - ], - }, - }); - const body = screen.getAllByRole("rowgroup")[1]; - if (body === undefined) throw new Error("expected a tbody rowgroup"); - const rows = within(body).getAllByRole("row"); - expect(rows).toHaveLength(2); - expect(within(rows[0] as HTMLElement).getByText("alpha")).toBeInTheDocument(); - expect(within(rows[0] as HTMLElement).getByText("1.0")).toBeInTheDocument(); - expect(within(rows[1] as HTMLElement).getByText("beta")).toBeInTheDocument(); - }); + it("renders one row per data row with aligned cells", () => { + render(Table, { + props: { + columns: ["Name", "Version"], + rows: [ + ["alpha", "1.0"], + ["beta", "2.3"], + ], + }, + }); + const body = screen.getAllByRole("rowgroup")[1]; + if (body === undefined) throw new Error("expected a tbody rowgroup"); + const rows = within(body).getAllByRole("row"); + expect(rows).toHaveLength(2); + expect(within(rows[0] as HTMLElement).getByText("alpha")).toBeInTheDocument(); + expect(within(rows[0] as HTMLElement).getByText("1.0")).toBeInTheDocument(); + expect(within(rows[1] as HTMLElement).getByText("beta")).toBeInTheDocument(); + }); - it("shows the empty message when there are no rows", () => { - render(Table, { props: { columns: ["A"], rows: [], empty: "Nothing loaded" } }); - expect(screen.getByText("Nothing loaded")).toBeInTheDocument(); - }); + it("shows the empty message when there are no rows", () => { + render(Table, { props: { columns: ["A"], rows: [], empty: "Nothing loaded" } }); + expect(screen.getByText("Nothing loaded")).toBeInTheDocument(); + }); }); diff --git a/src/core/chunks/groups.test.ts b/src/core/chunks/groups.test.ts index fbfda83..c8b0fa2 100644 --- a/src/core/chunks/groups.test.ts +++ b/src/core/chunks/groups.test.ts @@ -4,122 +4,122 @@ import { groupRenderedChunks } from "./groups"; import type { RenderedChunk } from "./types"; const text = (seq: number, role: Role, t: string, provisional = false): RenderedChunk => ({ - seq, - role, - chunk: { type: "text", text: t }, - provisional, + seq, + role, + chunk: { type: "text", text: t }, + provisional, }); const call = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({ - seq, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: id, - toolName: `tool-${id}`, - input: { id }, - ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), - }, - provisional, + seq, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: id, + toolName: `tool-${id}`, + input: { id }, + ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), + }, + provisional, }); const result = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({ - seq, - role: "tool", - chunk: { - type: "tool-result", - toolCallId: id, - toolName: `tool-${id}`, - content: `result-${id}`, - isError: false, - ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), - }, - provisional, + seq, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: id, + toolName: `tool-${id}`, + content: `result-${id}`, + isError: false, + ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), + }, + provisional, }); describe("groupRenderedChunks", () => { - it("returns no groups for an empty stream", () => { - expect(groupRenderedChunks([])).toEqual([]); - }); + it("returns no groups for an empty stream", () => { + expect(groupRenderedChunks([])).toEqual([]); + }); - it("passes non-tool chunks through as single groups, in order", () => { - const groups = groupRenderedChunks([text(1, "user", "hi"), text(2, "assistant", "hello")]); - expect(groups).toHaveLength(2); - expect(groups.every((g) => g.kind === "single")).toBe(true); - }); + it("passes non-tool chunks through as single groups, in order", () => { + const groups = groupRenderedChunks([text(1, "user", "hi"), text(2, "assistant", "hello")]); + expect(groups).toHaveLength(2); + expect(groups.every((g) => g.kind === "single")).toBe(true); + }); - it("does NOT batch a single tool call (one per step) — call+result stay separate singles", () => { - const groups = groupRenderedChunks([call(1, "a", "s1"), result(2, "a", "s1")]); - expect(groups).toHaveLength(2); - expect(groups.map((g) => g.kind)).toEqual(["single", "single"]); - }); + it("does NOT batch a single tool call (one per step) — call+result stay separate singles", () => { + const groups = groupRenderedChunks([call(1, "a", "s1"), result(2, "a", "s1")]); + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.kind)).toEqual(["single", "single"]); + }); - it("does NOT batch tool calls that have no stepId (pre-0.2.0 replay)", () => { - const groups = groupRenderedChunks([ - call(1, "a"), - call(2, "b"), - result(3, "a"), - result(4, "b"), - ]); - expect(groups).toHaveLength(4); - expect(groups.every((g) => g.kind === "single")).toBe(true); - }); + it("does NOT batch tool calls that have no stepId (pre-0.2.0 replay)", () => { + const groups = groupRenderedChunks([ + call(1, "a"), + call(2, "b"), + result(3, "a"), + result(4, "b"), + ]); + expect(groups).toHaveLength(4); + expect(groups.every((g) => g.kind === "single")).toBe(true); + }); - it("batches 2+ calls sharing a stepId into one group, pairing each with its result", () => { - const groups = groupRenderedChunks([ - call(1, "a", "s1"), - call(2, "b", "s1"), - result(3, "a", "s1"), - result(4, "b", "s1"), - ]); - expect(groups).toHaveLength(1); - const g = groups[0]; - if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); - expect(g.stepId).toBe("s1"); - expect(g.entries).toHaveLength(2); - expect(g.entries[0]?.call.toolCallId).toBe("a"); - expect(g.entries[0]?.result?.content).toBe("result-a"); - expect(g.entries[1]?.call.toolCallId).toBe("b"); - expect(g.entries[1]?.result?.content).toBe("result-b"); - }); + it("batches 2+ calls sharing a stepId into one group, pairing each with its result", () => { + const groups = groupRenderedChunks([ + call(1, "a", "s1"), + call(2, "b", "s1"), + result(3, "a", "s1"), + result(4, "b", "s1"), + ]); + expect(groups).toHaveLength(1); + const g = groups[0]; + if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); + expect(g.stepId).toBe("s1"); + expect(g.entries).toHaveLength(2); + expect(g.entries[0]?.call.toolCallId).toBe("a"); + expect(g.entries[0]?.result?.content).toBe("result-a"); + expect(g.entries[1]?.call.toolCallId).toBe("b"); + expect(g.entries[1]?.result?.content).toBe("result-b"); + }); - it("positions the batch at the first call and keeps surrounding chunks in order", () => { - const groups = groupRenderedChunks([ - text(1, "assistant", "before"), - call(2, "a", "s1"), - call(3, "b", "s1"), - result(4, "a", "s1"), - result(5, "b", "s1"), - text(6, "assistant", "after"), - ]); - expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]); - }); + it("positions the batch at the first call and keeps surrounding chunks in order", () => { + const groups = groupRenderedChunks([ + text(1, "assistant", "before"), + call(2, "a", "s1"), + call(3, "b", "s1"), + result(4, "a", "s1"), + result(5, "b", "s1"), + text(6, "assistant", "after"), + ]); + expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]); + }); - it("marks the batch provisional when any of its calls/results is provisional", () => { - const groups = groupRenderedChunks([call(1, "a", "s1"), call(2, "b", "s1", true)]); - const g = groups[0]; - if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); - expect(g.provisional).toBe(true); - expect(g.entries).toHaveLength(2); - expect(g.entries[1]?.result).toBeNull(); // dangling call (no result yet) - }); + it("marks the batch provisional when any of its calls/results is provisional", () => { + const groups = groupRenderedChunks([call(1, "a", "s1"), call(2, "b", "s1", true)]); + const g = groups[0]; + if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); + expect(g.provisional).toBe(true); + expect(g.entries).toHaveLength(2); + expect(g.entries[1]?.result).toBeNull(); // dangling call (no result yet) + }); - it("batches one step while leaving a different single-call step ungrouped", () => { - const groups = groupRenderedChunks([ - call(1, "a", "s1"), - call(2, "b", "s1"), - call(3, "c", "s2"), - result(4, "a", "s1"), - result(5, "b", "s1"), - result(6, "c", "s2"), - ]); - expect(groups.map((g) => g.kind)).toEqual(["tool-batch", "single", "single"]); - const batch = groups[0]; - if (batch?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); - expect(batch.entries).toHaveLength(2); - // the s2 single call + its result remain as separate single groups - const singles = groups.slice(1); - expect(singles[0]?.kind === "single" && singles[0].chunk.chunk.type).toBe("tool-call"); - expect(singles[1]?.kind === "single" && singles[1].chunk.chunk.type).toBe("tool-result"); - }); + it("batches one step while leaving a different single-call step ungrouped", () => { + const groups = groupRenderedChunks([ + call(1, "a", "s1"), + call(2, "b", "s1"), + call(3, "c", "s2"), + result(4, "a", "s1"), + result(5, "b", "s1"), + result(6, "c", "s2"), + ]); + expect(groups.map((g) => g.kind)).toEqual(["tool-batch", "single", "single"]); + const batch = groups[0]; + if (batch?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); + expect(batch.entries).toHaveLength(2); + // the s2 single call + its result remain as separate single groups + const singles = groups.slice(1); + expect(singles[0]?.kind === "single" && singles[0].chunk.chunk.type).toBe("tool-call"); + expect(singles[1]?.kind === "single" && singles[1].chunk.chunk.type).toBe("tool-result"); + }); }); diff --git a/src/core/chunks/groups.ts b/src/core/chunks/groups.ts index 6dc7e10..53a2873 100644 --- a/src/core/chunks/groups.ts +++ b/src/core/chunks/groups.ts @@ -6,8 +6,8 @@ import type { RenderedChunk } from "./types"; * `result` is null while the call is still pending (no result chunk yet). */ export interface ToolBatchEntry { - readonly call: ToolCallChunk; - readonly result: ToolResultChunk | null; + readonly call: ToolCallChunk; + readonly result: ToolResultChunk | null; } /** @@ -16,13 +16,13 @@ export interface ToolBatchEntry { * rendered as one grouped unit. */ export type RenderGroup = - | { readonly kind: "single"; readonly chunk: RenderedChunk } - | { - readonly kind: "tool-batch"; - readonly stepId: string; - readonly entries: readonly ToolBatchEntry[]; - readonly provisional: boolean; - }; + | { readonly kind: "single"; readonly chunk: RenderedChunk } + | { + readonly kind: "tool-batch"; + readonly stepId: string; + readonly entries: readonly ToolBatchEntry[]; + readonly provisional: boolean; + }; /** * Group a flat rendered-chunk stream for display. Tool calls sharing a `stepId` @@ -35,61 +35,61 @@ export type RenderGroup = * Pure: input → output, no DOM, no Svelte. */ export function groupRenderedChunks(rendered: readonly RenderedChunk[]): readonly RenderGroup[] { - // 1. Steps that batched 2+ tool calls. - const callsPerStep = new Map<string, number>(); - for (const rc of rendered) { - if (rc.chunk.type === "tool-call" && rc.chunk.stepId !== undefined) { - callsPerStep.set(rc.chunk.stepId, (callsPerStep.get(rc.chunk.stepId) ?? 0) + 1); - } - } - const batchSteps = new Set<string>(); - for (const [stepId, count] of callsPerStep) { - if (count >= 2) batchSteps.add(stepId); - } + // 1. Steps that batched 2+ tool calls. + const callsPerStep = new Map<string, number>(); + for (const rc of rendered) { + if (rc.chunk.type === "tool-call" && rc.chunk.stepId !== undefined) { + callsPerStep.set(rc.chunk.stepId, (callsPerStep.get(rc.chunk.stepId) ?? 0) + 1); + } + } + const batchSteps = new Set<string>(); + for (const [stepId, count] of callsPerStep) { + if (count >= 2) batchSteps.add(stepId); + } - // 2. toolCallIds belonging to a batch (so their results are absorbed), and a - // lookup of result chunks by toolCallId for pairing. - const batchCallIds = new Set<string>(); - const resultByCallId = new Map<string, ToolResultChunk>(); - for (const rc of rendered) { - const chunk = rc.chunk; - if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) { - batchCallIds.add(chunk.toolCallId); - } else if (chunk.type === "tool-result" && !resultByCallId.has(chunk.toolCallId)) { - resultByCallId.set(chunk.toolCallId, chunk); - } - } + // 2. toolCallIds belonging to a batch (so their results are absorbed), and a + // lookup of result chunks by toolCallId for pairing. + const batchCallIds = new Set<string>(); + const resultByCallId = new Map<string, ToolResultChunk>(); + for (const rc of rendered) { + const chunk = rc.chunk; + if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) { + batchCallIds.add(chunk.toolCallId); + } else if (chunk.type === "tool-result" && !resultByCallId.has(chunk.toolCallId)) { + resultByCallId.set(chunk.toolCallId, chunk); + } + } - // 3. Emit groups in stream order; each batch lands at its first call. - const groups: RenderGroup[] = []; - const emittedSteps = new Set<string>(); - for (const rc of rendered) { - const chunk = rc.chunk; + // 3. Emit groups in stream order; each batch lands at its first call. + const groups: RenderGroup[] = []; + const emittedSteps = new Set<string>(); + for (const rc of rendered) { + const chunk = rc.chunk; - if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) { - const stepId = chunk.stepId; - if (emittedSteps.has(stepId)) continue; - emittedSteps.add(stepId); + if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) { + const stepId = chunk.stepId; + if (emittedSteps.has(stepId)) continue; + emittedSteps.add(stepId); - const entries: ToolBatchEntry[] = []; - let provisional = false; - for (const inner of rendered) { - if (inner.chunk.type === "tool-call" && inner.chunk.stepId === stepId) { - const result = resultByCallId.get(inner.chunk.toolCallId) ?? null; - entries.push({ call: inner.chunk, result }); - if (inner.provisional) provisional = true; - } - } - groups.push({ kind: "tool-batch", stepId, entries, provisional }); - continue; - } + const entries: ToolBatchEntry[] = []; + let provisional = false; + for (const inner of rendered) { + if (inner.chunk.type === "tool-call" && inner.chunk.stepId === stepId) { + const result = resultByCallId.get(inner.chunk.toolCallId) ?? null; + entries.push({ call: inner.chunk, result }); + if (inner.provisional) provisional = true; + } + } + groups.push({ kind: "tool-batch", stepId, entries, provisional }); + continue; + } - if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) { - continue; // absorbed into its batch - } + if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) { + continue; // absorbed into its batch + } - groups.push({ kind: "single", chunk: rc }); - } + groups.push({ kind: "single", chunk: rc }); + } - return groups; + return groups; } diff --git a/src/core/chunks/image-url.test.ts b/src/core/chunks/image-url.test.ts new file mode 100644 index 0000000..8a79c09 --- /dev/null +++ b/src/core/chunks/image-url.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { resolveImageUrl } from "./image-url"; + +const BASE = "http://localhost:24203"; + +describe("resolveImageUrl", () => { + it("returns a data URL as-is (the optimistic echo / a pasted image)", () => { + const dataUrl = "data:image/png;base64,iVBORw0KGgo="; + expect(resolveImageUrl(dataUrl, BASE)).toBe(dataUrl); + }); + + it("returns an absolute http URL as-is", () => { + const abs = "https://example.com/img.png"; + expect(resolveImageUrl(abs, BASE)).toBe(abs); + }); + + it("prepends the api base to a relative /images/ path", () => { + expect(resolveImageUrl("/images/conv-123/abc-456.png", BASE)).toBe( + "http://localhost:24203/images/conv-123/abc-456.png", + ); + }); + + it("does not double the slash when the base has a trailing slash", () => { + expect(resolveImageUrl("/images/c/x.png", "http://localhost:24203/")).toBe( + "http://localhost:24203/images/c/x.png", + ); + }); + + it("adds a leading slash to a path-relative url without one", () => { + expect(resolveImageUrl("images/c/x.png", BASE)).toBe("http://localhost:24203/images/c/x.png"); + }); + + it("returns the relative path as-is when apiBase is empty (root-relative)", () => { + // A browser resolves a root-relative `/images/…` against the document origin. + expect(resolveImageUrl("/images/c/x.png", "")).toBe("/images/c/x.png"); + }); + + it("handles a relative path with an empty apiBase (path-relative without slash)", () => { + expect(resolveImageUrl("images/c/x.png", "")).toBe("/images/c/x.png"); + }); + + it("returns a data URL as-is even with an empty apiBase", () => { + const dataUrl = "data:image/jpeg;base64,AAAA"; + expect(resolveImageUrl(dataUrl, "")).toBe(dataUrl); + }); +}); diff --git a/src/core/chunks/image-url.ts b/src/core/chunks/image-url.ts new file mode 100644 index 0000000..e5ec756 --- /dev/null +++ b/src/core/chunks/image-url.ts @@ -0,0 +1,35 @@ +/** + * Resolve an `ImageChunk.url` into a renderable `<img src>` value. + * + * Persisted image chunks now carry a COMPACT HTTP path + * (`/images/<conversationId>/<uuid>.png`) served by the backend — NOT a base64 + * data URL (images are stored on disk under tmp, not in the conversation store, + * to keep SQLite payloads small). The optimistic echo (what the FE just sent in + * `ChatRequest.images`) still carries a data URL, and a chunk could also carry + * an absolute `http(s)://` URL, so the resolution is format-aware: + * + * - `data:` URL → returned as-is (the optimistic echo / a pasted data URL). + * - `http(s)://` → returned as-is (an absolute URL already). + * - anything else (a relative path like `/images/…`) → `apiBase` is prepended + * (with no double slash). An empty `apiBase` leaves a root-relative path, + * which a browser resolves against the document origin. + * + * Pure: input → output, zero DOM, zero Svelte. + * + * @param url The chunk's `url` (data URL, absolute, or relative path). + * @param apiBase The HTTP API base URL (e.g. `http://localhost:24203`). + */ +export function resolveImageUrl(url: string, apiBase: string): string { + if (url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + // A relative path (e.g. `/images/…`) — normalize to a leading slash and + // prepend the api base. With an empty base this yields a root-relative path + // (a browser resolves `/images/…` against the document origin). + const path = url.startsWith("/") ? url : `/${url}`; + if (apiBase.length === 0) return path; + // Join without a double slash: strip a trailing slash from the base, then + // append the (leading-slash) path verbatim. + const base = apiBase.endsWith("/") ? apiBase.slice(0, -1) : apiBase; + return `${base}${path}`; +} diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts index 6ab0f35..bdd6ce4 100644 --- a/src/core/chunks/index.ts +++ b/src/core/chunks/index.ts @@ -1,28 +1,31 @@ export type { RenderGroup, ToolBatchEntry } from "./groups"; export { groupRenderedChunks } from "./groups"; +export { resolveImageUrl } from "./image-url"; export { - appendUserMessage, - applyHistory, - clearGenerating, - foldEvent, - initialState, + appendUserMessage, + applyHistory, + clearGenerating, + foldEvent, + initialState, } from "./reducer"; -export { selectChunks, selectGenerating, selectMessages } from "./selectors"; +export type { ProviderRetryView } from "./retry-banner"; +export { formatRetryDelay, viewProviderRetry } from "./retry-banner"; +export { selectChunks, selectGenerating, selectMessages, selectProviderRetry } from "./selectors"; export { - DEFAULT_CHAT_LIMIT, - initialWindowSize, - MAX_CHAT_LIMIT, - MIN_CHAT_LIMIT, - normalizeChatLimit, - restoreEarlier, - selectHasEarlier, - trimTranscript, - unloadCount, - windowTranscript, + DEFAULT_CHAT_LIMIT, + initialWindowSize, + MAX_CHAT_LIMIT, + MIN_CHAT_LIMIT, + normalizeChatLimit, + restoreEarlier, + selectHasEarlier, + trimTranscript, + unloadCount, + windowTranscript, } from "./trim"; export type { - AccumulatingChunk, - ProvisionalChunk, - RenderedChunk, - TranscriptState, + AccumulatingChunk, + ProvisionalChunk, + RenderedChunk, + TranscriptState, } from "./types"; diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts index a346545..058552e 100644 --- a/src/core/chunks/reducer.test.ts +++ b/src/core/chunks/reducer.test.ts @@ -1,700 +1,1024 @@ import type { - StepId, - StoredChunk, - TurnDoneEvent, - TurnErrorEvent, - TurnInputEvent, - TurnReasoningDeltaEvent, - TurnSealedEvent, - TurnStartEvent, - TurnSteeringEvent, - TurnTextDeltaEvent, - TurnToolCallEvent, - TurnToolResultEvent, - TurnUsageEvent, + ImageInput, + StepId, + StoredChunk, + TurnDoneEvent, + TurnErrorEvent, + TurnInputEvent, + TurnProviderRetryEvent, + TurnReasoningDeltaEvent, + TurnSealedEvent, + TurnStartEvent, + TurnSteeringEvent, + TurnTextDeltaEvent, + TurnToolCallEvent, + TurnToolResultEvent, + TurnUsageEvent, } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - appendUserMessage, - applyHistory, - clearGenerating, - foldEvent, - initialState, + appendUserMessage, + applyHistory, + clearGenerating, + foldEvent, + initialState, } from "./reducer"; -import { selectChunks, selectGenerating, selectMessages } from "./selectors"; +import { selectChunks, selectGenerating, selectMessages, selectProviderRetry } from "./selectors"; const turnStart = (turnId: string): TurnStartEvent => ({ - type: "turn-start", - conversationId: "c1", - turnId, + type: "turn-start", + conversationId: "c1", + turnId, }); const textDelta = (turnId: string, delta: string): TurnTextDeltaEvent => ({ - type: "text-delta", - conversationId: "c1", - turnId, - delta, + type: "text-delta", + conversationId: "c1", + turnId, + delta, }); const reasoningDelta = (turnId: string, delta: string): TurnReasoningDeltaEvent => ({ - type: "reasoning-delta", - conversationId: "c1", - turnId, - delta, + type: "reasoning-delta", + conversationId: "c1", + turnId, + delta, }); const toolCall = ( - turnId: string, - toolCallId: string, - toolName: string, - input: unknown, - stepId = "s0", + turnId: string, + toolCallId: string, + toolName: string, + input: unknown, + stepId = "s0", ): TurnToolCallEvent => ({ - type: "tool-call", - conversationId: "c1", - turnId, - toolCallId, - toolName, - input, - stepId: stepId as StepId, + type: "tool-call", + conversationId: "c1", + turnId, + toolCallId, + toolName, + input, + stepId: stepId as StepId, }); const toolResult = ( - turnId: string, - toolCallId: string, - toolName: string, - content: string, - stepId = "s0", + turnId: string, + toolCallId: string, + toolName: string, + content: string, + stepId = "s0", ): TurnToolResultEvent => ({ - type: "tool-result", - conversationId: "c1", - turnId, - toolCallId, - toolName, - content, - isError: false, - stepId: stepId as StepId, + type: "tool-result", + conversationId: "c1", + turnId, + toolCallId, + toolName, + content, + isError: false, + stepId: stepId as StepId, }); const usageEvent = (turnId: string, inputTokens: number, outputTokens: number): TurnUsageEvent => ({ - type: "usage", - conversationId: "c1", - turnId, - usage: { inputTokens, outputTokens }, + type: "usage", + conversationId: "c1", + turnId, + usage: { inputTokens, outputTokens }, }); const errorEvent = (turnId: string, message: string, code?: string): TurnErrorEvent => - code !== undefined - ? { type: "error", conversationId: "c1", turnId, message, code } - : { type: "error", conversationId: "c1", turnId, message }; + code !== undefined + ? { type: "error", conversationId: "c1", turnId, message, code } + : { type: "error", conversationId: "c1", turnId, message }; const doneEvent = (turnId: string): TurnDoneEvent => ({ - type: "done", - conversationId: "c1", - turnId, - reason: "stop", + type: "done", + conversationId: "c1", + turnId, + reason: "stop", }); const turnSealed = (turnId: string): TurnSealedEvent => ({ - type: "turn-sealed", - conversationId: "c1", - turnId, + type: "turn-sealed", + conversationId: "c1", + turnId, }); +const providerRetry = ( + turnId: string, + attempt: number, + delayMs: number, + message = "HTTP 429: overloaded", + code?: string, +): TurnProviderRetryEvent => + code !== undefined + ? { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message, code } + : { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message }; + const storedChunk = ( - seq: number, - role: "user" | "assistant" | "tool" | "system", - chunk: StoredChunk["chunk"], + seq: number, + role: "user" | "assistant" | "tool" | "system", + chunk: StoredChunk["chunk"], ): StoredChunk => ({ - seq, - role, - chunk, + seq, + role, + chunk, }); describe("initialState", () => { - it("initial state is empty", () => { - const s = initialState(); - expect(s.committed).toEqual([]); - expect(s.provisional).toEqual([]); - expect(s.accumulating).toBeNull(); - expect(s.currentTurnId).toBeNull(); - expect(s.latestUsage).toBeNull(); - expect(s.sealedTurnId).toBeNull(); - expect(s.generating).toBe(false); - }); + it("initial state is empty", () => { + const s = initialState(); + expect(s.committed).toEqual([]); + expect(s.provisional).toEqual([]); + expect(s.accumulating).toBeNull(); + expect(s.currentTurnId).toBeNull(); + expect(s.latestUsage).toBeNull(); + expect(s.sealedTurnId).toBeNull(); + expect(s.generating).toBe(false); + }); }); describe("foldEvent — generating (turn-running state)", () => { - it("turn-start sets generating true", () => { - let s = initialState(); - expect(selectGenerating(s)).toBe(false); - s = foldEvent(s, turnStart("t1")); - expect(s.generating).toBe(true); - expect(selectGenerating(s)).toBe(true); - }); - - it("a content delta sets generating true (e.g. a late-joiner replay missing turn-start)", () => { - let s = initialState(); - s = foldEvent(s, textDelta("t1", "hi")); - expect(s.generating).toBe(true); - s = initialState(); - s = foldEvent(s, reasoningDelta("t1", "hmm")); - expect(s.generating).toBe(true); - s = initialState(); - s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); - expect(s.generating).toBe(true); - }); - - it("stays generating across the turn's deltas", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "wor")); - s = foldEvent(s, textDelta("t1", "king")); - expect(s.generating).toBe(true); - }); - - it("done clears generating", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "answer")); - s = foldEvent(s, doneEvent("t1")); - expect(s.generating).toBe(false); - }); - - it("turn-sealed clears generating", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, turnSealed("t1")); - expect(s.generating).toBe(false); - }); - - it("error clears generating", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, errorEvent("t1", "boom")); - expect(s.generating).toBe(false); - }); - - it("a new turn re-asserts generating after the previous one finished", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, doneEvent("t1")); - s = foldEvent(s, turnSealed("t1")); - expect(s.generating).toBe(false); - s = foldEvent(s, turnStart("t2")); - expect(s.generating).toBe(true); - }); - - it("status does not change generating (free-form string, not inferred)", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - const next = foldEvent(s, { type: "status", conversationId: "c1", status: "idle" }); - expect(next.generating).toBe(true); - }); + it("turn-start sets generating true", () => { + let s = initialState(); + expect(selectGenerating(s)).toBe(false); + s = foldEvent(s, turnStart("t1")); + expect(s.generating).toBe(true); + expect(selectGenerating(s)).toBe(true); + }); + + it("a content delta sets generating true (e.g. a late-joiner replay missing turn-start)", () => { + let s = initialState(); + s = foldEvent(s, textDelta("t1", "hi")); + expect(s.generating).toBe(true); + s = initialState(); + s = foldEvent(s, reasoningDelta("t1", "hmm")); + expect(s.generating).toBe(true); + s = initialState(); + s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); + expect(s.generating).toBe(true); + }); + + it("stays generating across the turn's deltas", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "wor")); + s = foldEvent(s, textDelta("t1", "king")); + expect(s.generating).toBe(true); + }); + + it("done clears generating", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "answer")); + s = foldEvent(s, doneEvent("t1")); + expect(s.generating).toBe(false); + }); + + it("turn-sealed clears generating", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, turnSealed("t1")); + expect(s.generating).toBe(false); + }); + + it("error clears generating", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, errorEvent("t1", "boom")); + expect(s.generating).toBe(false); + }); + + it("a new turn re-asserts generating after the previous one finished", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, doneEvent("t1")); + s = foldEvent(s, turnSealed("t1")); + expect(s.generating).toBe(false); + s = foldEvent(s, turnStart("t2")); + expect(s.generating).toBe(true); + }); + + it("status does not change generating (free-form string, not inferred)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + const next = foldEvent(s, { type: "status", conversationId: "c1", status: "idle" }); + expect(next.generating).toBe(true); + }); }); describe("clearGenerating", () => { - it("clears a set generating flag", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - expect(s.generating).toBe(true); - const cleared = clearGenerating(s); - expect(cleared.generating).toBe(false); - }); - - it("returns the same object when already not generating (no-op)", () => { - const s = initialState(); - expect(clearGenerating(s)).toBe(s); - }); - - it("preserves transcript content while clearing generating", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "partial")); - const cleared = clearGenerating(s); - expect(cleared.generating).toBe(false); - expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); - expect(cleared.currentTurnId).toBe("t1"); - }); + it("clears a set generating flag", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + expect(s.generating).toBe(true); + const cleared = clearGenerating(s); + expect(cleared.generating).toBe(false); + }); + + it("returns the same object when already not generating (no-op)", () => { + const s = initialState(); + expect(clearGenerating(s)).toBe(s); + }); + + it("preserves transcript content while clearing generating", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial")); + const cleared = clearGenerating(s); + expect(cleared.generating).toBe(false); + expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(cleared.currentTurnId).toBe("t1"); + }); }); describe("foldEvent — text-delta", () => { - it("text-delta accumulates into one TextChunk", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "hello")); - expect(s.accumulating).toEqual({ kind: "text", text: "hello" }); - expect(s.provisional).toEqual([]); - }); - - it("successive text-deltas extend the same provisional chunk", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "hello ")); - s = foldEvent(s, textDelta("t1", "world")); - expect(s.accumulating).toEqual({ kind: "text", text: "hello world" }); - expect(s.provisional).toEqual([]); - }); - - it("text-delta after reasoning-delta flushes thinking and starts text", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, reasoningDelta("t1", "thinking...")); - s = foldEvent(s, textDelta("t1", "answer")); - expect(s.accumulating).toEqual({ kind: "text", text: "answer" }); - expect(s.provisional).toHaveLength(1); - expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." }); - expect(s.provisional[0]?.role).toBe("assistant"); - }); + it("text-delta accumulates into one TextChunk", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hello")); + expect(s.accumulating).toEqual({ kind: "text", text: "hello" }); + expect(s.provisional).toEqual([]); + }); + + it("successive text-deltas extend the same provisional chunk", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hello ")); + s = foldEvent(s, textDelta("t1", "world")); + expect(s.accumulating).toEqual({ kind: "text", text: "hello world" }); + expect(s.provisional).toEqual([]); + }); + + it("text-delta after reasoning-delta flushes thinking and starts text", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningDelta("t1", "thinking...")); + s = foldEvent(s, textDelta("t1", "answer")); + expect(s.accumulating).toEqual({ kind: "text", text: "answer" }); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." }); + expect(s.provisional[0]?.role).toBe("assistant"); + }); }); describe("foldEvent — reasoning-delta", () => { - it("reasoning-delta yields a thinking chunk", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, reasoningDelta("t1", "hmm")); - expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" }); - }); - - it("successive reasoning-deltas extend the same chunk", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, reasoningDelta("t1", "hmm ")); - s = foldEvent(s, reasoningDelta("t1", "ok")); - expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok" }); - }); + it("reasoning-delta yields a thinking chunk", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningDelta("t1", "hmm")); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" }); + }); + + it("successive reasoning-deltas extend the same chunk", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningDelta("t1", "hmm ")); + s = foldEvent(s, reasoningDelta("t1", "ok")); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok" }); + }); }); describe("foldEvent — tool-call then tool-result", () => { - it("tool-call then tool-result render in order", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, toolCall("t1", "tc1", "bash", { cmd: "ls" }, "t1#0")); - s = foldEvent(s, toolResult("t1", "tc1", "bash", "file.txt", "t1#0")); - expect(s.provisional).toHaveLength(2); - expect(s.provisional[0]?.role).toBe("assistant"); - // foldEvent copies the event's stepId onto the chunk (grouping key). - expect(s.provisional[0]?.chunk).toEqual({ - type: "tool-call", - toolCallId: "tc1", - toolName: "bash", - input: { cmd: "ls" }, - stepId: "t1#0", - }); - expect(s.provisional[1]?.role).toBe("tool"); - expect(s.provisional[1]?.chunk).toEqual({ - type: "tool-result", - toolCallId: "tc1", - toolName: "bash", - content: "file.txt", - isError: false, - stepId: "t1#0", - }); - }); - - it("tool-call flushes accumulating text", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "let me check")); - s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); - expect(s.provisional).toHaveLength(2); - expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "let me check" }); - expect(s.provisional[1]?.chunk).toMatchObject({ type: "tool-call", toolCallId: "tc1" }); - expect(s.accumulating).toBeNull(); - }); + it("tool-call then tool-result render in order", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, toolCall("t1", "tc1", "bash", { cmd: "ls" }, "t1#0")); + s = foldEvent(s, toolResult("t1", "tc1", "bash", "file.txt", "t1#0")); + expect(s.provisional).toHaveLength(2); + expect(s.provisional[0]?.role).toBe("assistant"); + // foldEvent copies the event's stepId onto the chunk (grouping key). + expect(s.provisional[0]?.chunk).toEqual({ + type: "tool-call", + toolCallId: "tc1", + toolName: "bash", + input: { cmd: "ls" }, + stepId: "t1#0", + }); + expect(s.provisional[1]?.role).toBe("tool"); + expect(s.provisional[1]?.chunk).toEqual({ + type: "tool-result", + toolCallId: "tc1", + toolName: "bash", + content: "file.txt", + isError: false, + stepId: "t1#0", + }); + }); + + it("tool-call flushes accumulating text", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "let me check")); + s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); + expect(s.provisional).toHaveLength(2); + expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "let me check" }); + expect(s.provisional[1]?.chunk).toMatchObject({ type: "tool-call", toolCallId: "tc1" }); + expect(s.accumulating).toBeNull(); + }); }); describe("foldEvent — turn-sealed", () => { - it("turn-sealed sets sealedTurnId", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "hi")); - s = foldEvent(s, turnSealed("t1")); - expect(s.sealedTurnId).toBe("t1"); - expect(s.accumulating).toBeNull(); - expect(s.provisional).toHaveLength(1); - expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hi" }); - }); + it("turn-sealed sets sealedTurnId", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hi")); + s = foldEvent(s, turnSealed("t1")); + expect(s.sealedTurnId).toBe("t1"); + expect(s.accumulating).toBeNull(); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hi" }); + }); }); describe("foldEvent — usage", () => { - it("stores latest usage", () => { - let s = initialState(); - s = foldEvent(s, usageEvent("t1", 100, 50)); - expect(s.latestUsage).toEqual({ inputTokens: 100, outputTokens: 50 }); - }); - - it("overwrites previous usage", () => { - let s = initialState(); - s = foldEvent(s, usageEvent("t1", 100, 50)); - s = foldEvent(s, usageEvent("t1", 200, 80)); - expect(s.latestUsage).toEqual({ inputTokens: 200, outputTokens: 80 }); - }); + it("stores latest usage", () => { + let s = initialState(); + s = foldEvent(s, usageEvent("t1", 100, 50)); + expect(s.latestUsage).toEqual({ inputTokens: 100, outputTokens: 50 }); + }); + + it("overwrites previous usage", () => { + let s = initialState(); + s = foldEvent(s, usageEvent("t1", 100, 50)); + s = foldEvent(s, usageEvent("t1", 200, 80)); + expect(s.latestUsage).toEqual({ inputTokens: 200, outputTokens: 80 }); + }); }); describe("foldEvent — error", () => { - it("creates error chunk with code", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, errorEvent("t1", "bad", "E001")); - expect(s.provisional).toHaveLength(1); - expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad", code: "E001" }); - expect(s.provisional[0]?.role).toBe("assistant"); - }); - - it("creates error chunk without code", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, errorEvent("t1", "bad")); - expect(s.provisional).toHaveLength(1); - expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad" }); - }); + it("creates error chunk with code", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, errorEvent("t1", "bad", "E001")); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad", code: "E001" }); + expect(s.provisional[0]?.role).toBe("assistant"); + }); + + it("creates error chunk without code", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, errorEvent("t1", "bad")); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad" }); + }); }); describe("foldEvent — done", () => { - it("flushes accumulating chunk on done", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "hello")); - s = foldEvent(s, doneEvent("t1")); - expect(s.accumulating).toBeNull(); - expect(s.provisional).toHaveLength(1); - expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hello" }); - }); + it("flushes accumulating chunk on done", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hello")); + s = foldEvent(s, doneEvent("t1")); + expect(s.accumulating).toBeNull(); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hello" }); + }); }); describe("foldEvent — status and tool-output", () => { - it("status is a no-op", () => { - const s = initialState(); - const next = foldEvent(s, { type: "status", conversationId: "c1", status: "running" }); - expect(next).toBe(s); - }); - - it("tool-output is a no-op", () => { - const s = initialState(); - const next = foldEvent(s, { - type: "tool-output", - conversationId: "c1", - turnId: "t1", - toolCallId: "tc1", - data: "output", - stream: "stdout", - }); - expect(next).toBe(s); - }); + it("status is a no-op", () => { + const s = initialState(); + const next = foldEvent(s, { type: "status", conversationId: "c1", status: "running" }); + expect(next).toBe(s); + }); + + it("tool-output is a no-op", () => { + const s = initialState(); + const next = foldEvent(s, { + type: "tool-output", + conversationId: "c1", + turnId: "t1", + toolCallId: "tc1", + data: "output", + stream: "stdout", + }); + expect(next).toBe(s); + }); +}); + +describe("foldEvent — provider-retry (transient retry banner)", () => { + it("sets the provider-retry banner on a provider-retry event", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000, "HTTP 429: overloaded", "429")); + const retry = selectProviderRetry(s); + expect(retry).not.toBeNull(); + expect(retry?.attempt).toBe(0); + expect(retry?.delayMs).toBe(5000); + expect(retry?.code).toBe("429"); + }); + + it("does NOT add a chunk (never persisted — never pollutes the prompt)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(selectChunks(s)).toHaveLength(0); + expect(s.provisional).toHaveLength(0); + expect(s.accumulating).toBeNull(); + }); + + it("coalesces: the latest attempt + delay replaces the previous", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000, "first", "429")); + s = foldEvent(s, providerRetry("t1", 1, 10000, "second", "429")); + const retry = selectProviderRetry(s); + expect(retry?.attempt).toBe(1); + expect(retry?.delayMs).toBe(10000); + expect(retry?.message).toBe("second"); + }); + + it("keeps generating true (the turn is still in flight, just retrying)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(s.generating).toBe(true); + }); + + it("clears when content resumes (text-delta)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, textDelta("t1", "here is the reply")); + expect(selectProviderRetry(s)).toBeNull(); + }); + + it("clears when content resumes (reasoning-delta / tool-call / tool-result)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, reasoningDelta("t1", "thinking")); + expect(selectProviderRetry(s)).toBeNull(); + + s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); + expect(selectProviderRetry(s)).toBeNull(); + }); + + it("clears when the turn ends (done / turn-sealed / error)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, errorEvent("t1", "exhausted")); + expect(selectProviderRetry(s)).toBeNull(); + + s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, doneEvent("t1")); + expect(selectProviderRetry(s)).toBeNull(); + + s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, turnSealed("t1")); + expect(selectProviderRetry(s)).toBeNull(); + }); + + it("clears on a new turn (turn-start)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, turnStart("t2")); + expect(selectProviderRetry(s)).toBeNull(); + }); + + it("leaves the banner untouched across metadata events (usage / step-complete / status)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, usageEvent("t1", 10, 20)); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, { + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: "t1#0" as StepId, + ttftMs: 100, + decodeMs: 200, + genTotalMs: 300, + }); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, { type: "status", conversationId: "c1", status: "running" }); + expect(selectProviderRetry(s)).not.toBeNull(); + }); + + it("is null in the initial state", () => { + expect(selectProviderRetry(initialState())).toBeNull(); + }); +}); + +describe("clearGenerating also clears a stale provider-retry banner (reconnect)", () => { + it("clears the retry banner alongside generating on reconnect", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(s.generating).toBe(true); + expect(selectProviderRetry(s)).not.toBeNull(); + const cleared = clearGenerating(s); + expect(cleared.generating).toBe(false); + expect(selectProviderRetry(cleared)).toBeNull(); + }); + + it("preserves transcript content while clearing the banner", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + const cleared = clearGenerating(s); + expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(selectProviderRetry(cleared)).toBeNull(); + }); }); describe("foldEvent — user-message (the turn's user prompt; backend CR-3)", () => { - const userMessage = (text: string): TurnInputEvent => ({ - type: "user-message", - conversationId: "c1", - turnId: "t1", - text, - }); - - it("a watcher renders the prompt: appends a provisional user chunk + marks generating", () => { - let s = initialState(); - s = foldEvent(s, userMessage("what is 2+2?")); - const chunks = selectChunks(s); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.role).toBe("user"); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "what is 2+2?" }); - expect(chunks[0]?.provisional).toBe(true); - expect(s.generating).toBe(true); - }); - - it("dedups the SENDER's optimistic echo (no duplicate user bubble)", () => { - let s = initialState(); - s = appendUserMessage(s, "hi"); // optimistic echo from the sender's send() - s = foldEvent(s, userMessage("hi")); // server echo for the same turn - const users = selectChunks(s).filter((c) => c.role === "user"); - expect(users).toHaveLength(1); - }); - - it("appends when the trailing provisional differs (no false dedup)", () => { - let s = initialState(); - s = appendUserMessage(s, "first"); - s = foldEvent(s, userMessage("second")); - const users = selectChunks(s).filter((c) => c.role === "user"); - expect(users).toHaveLength(2); - }); - - it("ignores an empty user-message", () => { - let s = initialState(); - s = foldEvent(s, userMessage("")); - expect(selectChunks(s)).toHaveLength(0); - expect(s.generating).toBe(false); - }); - - it("flushes an accumulating chunk before appending the prompt", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "partial")); - s = foldEvent(s, userMessage("new prompt")); - // the partial assistant text was flushed to provisional, then the user prompt appended - expect(s.accumulating).toBeNull(); - const roles = selectChunks(s).map((c) => c.role); - expect(roles).toEqual(["assistant", "user"]); - }); + const userMessage = (text: string): TurnInputEvent => ({ + type: "user-message", + conversationId: "c1", + turnId: "t1", + text, + }); + + it("a watcher renders the prompt: appends a provisional user chunk + marks generating", () => { + let s = initialState(); + s = foldEvent(s, userMessage("what is 2+2?")); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "what is 2+2?" }); + expect(chunks[0]?.provisional).toBe(true); + expect(s.generating).toBe(true); + }); + + it("dedups the SENDER's optimistic echo (no duplicate user bubble)", () => { + let s = initialState(); + s = appendUserMessage(s, "hi"); // optimistic echo from the sender's send() + s = foldEvent(s, userMessage("hi")); // server echo for the same turn + const users = selectChunks(s).filter((c) => c.role === "user"); + expect(users).toHaveLength(1); + }); + + it("appends when the trailing provisional differs (no false dedup)", () => { + let s = initialState(); + s = appendUserMessage(s, "first"); + s = foldEvent(s, userMessage("second")); + const users = selectChunks(s).filter((c) => c.role === "user"); + expect(users).toHaveLength(2); + }); + + it("ignores an empty user-message", () => { + let s = initialState(); + s = foldEvent(s, userMessage("")); + expect(selectChunks(s)).toHaveLength(0); + expect(s.generating).toBe(false); + }); + + it("flushes an accumulating chunk before appending the prompt", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial")); + s = foldEvent(s, userMessage("new prompt")); + // the partial assistant text was flushed to provisional, then the user prompt appended + expect(s.accumulating).toBeNull(); + const roles = selectChunks(s).map((c) => c.role); + expect(roles).toEqual(["assistant", "user"]); + }); }); describe("foldEvent — steering (mid-turn steering injection)", () => { - const steering = (text: string): TurnSteeringEvent => ({ - type: "steering", - conversationId: "c1", - turnId: "t1", - text, - }); - - it("appends a provisional user bubble + keeps generating", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, toolResult("t1", "tc1", "read", "output")); - s = foldEvent(s, steering("actually, use a different file")); - const chunks = selectChunks(s); - const last = chunks[chunks.length - 1]; - expect(last?.role).toBe("user"); - expect(last?.chunk).toEqual({ type: "text", text: "actually, use a different file" }); - expect(last?.provisional).toBe(true); - expect(s.generating).toBe(true); - }); - - it("does NOT dedup against the sender's queue (unlike user-message)", () => { - // The sender enqueued the message via `chat.queue` — the queue SURFACE - // showed it. The `steering` event places it in the transcript; the surface - // separately clears on drain. No de-dup here (the transcript never showed - // the queued message). - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, steering("steer once")); - s = foldEvent(s, steering("steer again")); - const users = selectChunks(s).filter((c) => c.role === "user"); - expect(users).toHaveLength(2); - }); - - it("ignores an empty steering event", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, steering("")); - expect(selectChunks(s)).toHaveLength(0); - expect(s.generating).toBe(true); // turn-start already set it - }); - - it("flushes an accumulating chunk before appending the steering bubble", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "partial response")); - s = foldEvent(s, steering("mid-turn correction")); - expect(s.accumulating).toBeNull(); - const roles = selectChunks(s).map((c) => c.role); - expect(roles).toEqual(["assistant", "user"]); - }); + const steering = (text: string): TurnSteeringEvent => ({ + type: "steering", + conversationId: "c1", + turnId: "t1", + text, + }); + + it("appends a provisional user bubble + keeps generating", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, toolResult("t1", "tc1", "read", "output")); + s = foldEvent(s, steering("actually, use a different file")); + const chunks = selectChunks(s); + const last = chunks[chunks.length - 1]; + expect(last?.role).toBe("user"); + expect(last?.chunk).toEqual({ type: "text", text: "actually, use a different file" }); + expect(last?.provisional).toBe(true); + expect(s.generating).toBe(true); + }); + + it("does NOT dedup against the sender's queue (unlike user-message)", () => { + // The sender enqueued the message via `chat.queue` — the queue SURFACE + // showed it. The `steering` event places it in the transcript; the surface + // separately clears on drain. No de-dup here (the transcript never showed + // the queued message). + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, steering("steer once")); + s = foldEvent(s, steering("steer again")); + const users = selectChunks(s).filter((c) => c.role === "user"); + expect(users).toHaveLength(2); + }); + + it("ignores an empty steering event", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, steering("")); + expect(selectChunks(s)).toHaveLength(0); + expect(s.generating).toBe(true); // turn-start already set it + }); + + it("flushes an accumulating chunk before appending the steering bubble", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial response")); + s = foldEvent(s, steering("mid-turn correction")); + expect(s.accumulating).toBeNull(); + const roles = selectChunks(s).map((c) => c.role); + expect(roles).toEqual(["assistant", "user"]); + }); }); describe("applyHistory", () => { - it("orders committed chunks by seq", () => { - const s = initialState(); - const chunks = [ - storedChunk(3, "assistant", { type: "text", text: "c" }), - storedChunk(1, "user", { type: "text", text: "a" }), - storedChunk(2, "assistant", { type: "text", text: "b" }), - ]; - const next = applyHistory(s, chunks); - expect(next.committed.map((c) => c.seq)).toEqual([1, 2, 3]); - }); - - it("is idempotent on duplicate seqs", () => { - let s = initialState(); - const batch1 = [ - storedChunk(1, "user", { type: "text", text: "a" }), - storedChunk(2, "assistant", { type: "text", text: "b" }), - ]; - s = applyHistory(s, batch1); - const batch2 = [ - storedChunk(2, "assistant", { type: "text", text: "b" }), - storedChunk(3, "assistant", { type: "text", text: "c" }), - ]; - s = applyHistory(s, batch2); - expect(s.committed.map((c) => c.seq)).toEqual([1, 2, 3]); - expect(s.committed).toHaveLength(3); - }); - - it("supersedes & clears provisional once committed", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "hello")); - s = foldEvent(s, turnSealed("t1")); - expect(s.provisional).toHaveLength(1); - expect(s.sealedTurnId).toBe("t1"); - - s = applyHistory(s, [storedChunk(1, "assistant", { type: "text", text: "hello" })]); - expect(s.provisional).toEqual([]); - expect(s.accumulating).toBeNull(); - expect(s.sealedTurnId).toBeNull(); - expect(s.committed).toHaveLength(1); - }); - - it("keeps provisional and accumulating when sealedTurnId is null", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "wip")); - s = foldEvent(s, doneEvent("t1")); - s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); - expect(s.provisional).toHaveLength(1); - expect(s.committed).toHaveLength(1); - }); - - it("merges new history into existing committed", () => { - let s = initialState(); - s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "a" })]); - s = applyHistory(s, [storedChunk(2, "assistant", { type: "text", text: "b" })]); - expect(s.committed).toHaveLength(2); - expect(s.committed.map((c) => c.seq)).toEqual([1, 2]); - }); + it("orders committed chunks by seq", () => { + const s = initialState(); + const chunks = [ + storedChunk(3, "assistant", { type: "text", text: "c" }), + storedChunk(1, "user", { type: "text", text: "a" }), + storedChunk(2, "assistant", { type: "text", text: "b" }), + ]; + const next = applyHistory(s, chunks); + expect(next.committed.map((c) => c.seq)).toEqual([1, 2, 3]); + }); + + it("is idempotent on duplicate seqs", () => { + let s = initialState(); + const batch1 = [ + storedChunk(1, "user", { type: "text", text: "a" }), + storedChunk(2, "assistant", { type: "text", text: "b" }), + ]; + s = applyHistory(s, batch1); + const batch2 = [ + storedChunk(2, "assistant", { type: "text", text: "b" }), + storedChunk(3, "assistant", { type: "text", text: "c" }), + ]; + s = applyHistory(s, batch2); + expect(s.committed.map((c) => c.seq)).toEqual([1, 2, 3]); + expect(s.committed).toHaveLength(3); + }); + + it("supersedes & clears provisional once committed", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hello")); + s = foldEvent(s, turnSealed("t1")); + expect(s.provisional).toHaveLength(1); + expect(s.sealedTurnId).toBe("t1"); + + s = applyHistory(s, [storedChunk(1, "assistant", { type: "text", text: "hello" })]); + expect(s.provisional).toEqual([]); + expect(s.accumulating).toBeNull(); + expect(s.sealedTurnId).toBeNull(); + expect(s.committed).toHaveLength(1); + }); + + it("keeps provisional and accumulating when sealedTurnId is null", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "wip")); + s = foldEvent(s, doneEvent("t1")); + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); + expect(s.provisional).toHaveLength(1); + expect(s.committed).toHaveLength(1); + }); + + it("merges new history into existing committed", () => { + let s = initialState(); + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "a" })]); + s = applyHistory(s, [storedChunk(2, "assistant", { type: "text", text: "b" })]); + expect(s.committed).toHaveLength(2); + expect(s.committed.map((c) => c.seq)).toEqual([1, 2]); + }); + + it("removes provisional duplicate when committed user message arrives during generation", () => { + // Simulate: send() appends provisional user message, then syncTail + // fetches the same message as committed (CR-6: persisted at turn start). + let s = initialState(); + s = appendUserMessage(s, "hello"); + s = foldEvent(s, turnStart("t1")); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.role).toBe("user"); + expect(s.generating).toBe(true); + + // syncTail fetches the persisted user message as committed + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "hello" })]); + + // The provisional duplicate is removed — no double render + expect(s.provisional).toEqual([]); + expect(s.committed).toHaveLength(1); + expect(s.committed[0]?.role).toBe("user"); + expect(s.committed[0]?.chunk).toEqual({ type: "text", text: "hello" }); + }); }); describe("selectChunks", () => { - it("selectChunks marks provisional with seq null", () => { - let s = initialState(); - s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "wip")); - const chunks = selectChunks(s); - expect(chunks).toHaveLength(2); - expect(chunks[0]?.seq).toBe(1); - expect(chunks[0]?.provisional).toBe(false); - expect(chunks[1]?.seq).toBeNull(); - expect(chunks[1]?.provisional).toBe(true); - }); - - it("returns empty for empty state", () => { - expect(selectChunks(initialState())).toEqual([]); - }); - - it("includes accumulating chunk as provisional", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "building...")); - const chunks = selectChunks(s); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.seq).toBeNull(); - expect(chunks[0]?.provisional).toBe(true); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "building..." }); - }); - - it("marks ONLY the actively-accumulating chunk as streaming", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - // A flushed-but-still-provisional thinking chunk, then a live accumulating one. - s = foldEvent(s, reasoningDelta("t1", "first thought")); - s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); // flushes the thinking - s = foldEvent(s, textDelta("t1", "now writing")); - const chunks = selectChunks(s); - const thinking = chunks.find((c) => c.chunk.type === "thinking"); - const accumulating = chunks.find((c) => c.streaming === true); - expect(thinking?.streaming).toBeFalsy(); // flushed → not streaming - expect(accumulating?.chunk).toEqual({ type: "text", text: "now writing" }); - expect(chunks.filter((c) => c.streaming === true)).toHaveLength(1); - }); + it("selectChunks marks provisional with seq null", () => { + let s = initialState(); + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "wip")); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(2); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[0]?.provisional).toBe(false); + expect(chunks[1]?.seq).toBeNull(); + expect(chunks[1]?.provisional).toBe(true); + }); + + it("returns empty for empty state", () => { + expect(selectChunks(initialState())).toEqual([]); + }); + + it("includes accumulating chunk as provisional", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "building...")); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.seq).toBeNull(); + expect(chunks[0]?.provisional).toBe(true); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "building..." }); + }); + + it("marks ONLY the actively-accumulating chunk as streaming", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + // A flushed-but-still-provisional thinking chunk, then a live accumulating one. + s = foldEvent(s, reasoningDelta("t1", "first thought")); + s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); // flushes the thinking + s = foldEvent(s, textDelta("t1", "now writing")); + const chunks = selectChunks(s); + const thinking = chunks.find((c) => c.chunk.type === "thinking"); + const accumulating = chunks.find((c) => c.streaming === true); + expect(thinking?.streaming).toBeFalsy(); // flushed → not streaming + expect(accumulating?.chunk).toEqual({ type: "text", text: "now writing" }); + expect(chunks.filter((c) => c.streaming === true)).toHaveLength(1); + }); }); describe("selectMessages", () => { - it("selectMessages groups consecutive same-role chunks", () => { - let s = initialState(); - s = applyHistory(s, [ - storedChunk(1, "user", { type: "text", text: "q1" }), - storedChunk(2, "user", { type: "text", text: "q2" }), - storedChunk(3, "assistant", { type: "text", text: "a1" }), - storedChunk(4, "assistant", { type: "text", text: "a2" }), - storedChunk(5, "user", { type: "text", text: "q3" }), - ]); - const msgs = selectMessages(s); - expect(msgs).toHaveLength(3); - expect(msgs[0]?.role).toBe("user"); - expect(msgs[0]?.chunks).toHaveLength(2); - expect(msgs[1]?.role).toBe("assistant"); - expect(msgs[1]?.chunks).toHaveLength(2); - expect(msgs[2]?.role).toBe("user"); - expect(msgs[2]?.chunks).toHaveLength(1); - }); - - it("returns empty for empty state", () => { - expect(selectMessages(initialState())).toEqual([]); - }); - - it("mixes committed and provisional in messages", () => { - let s = initialState(); - s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "a1")); - s = foldEvent(s, textDelta("t1", "a2")); - const msgs = selectMessages(s); - expect(msgs).toHaveLength(2); - expect(msgs[0]?.role).toBe("user"); - expect(msgs[0]?.chunks).toHaveLength(1); - expect(msgs[1]?.role).toBe("assistant"); - expect(msgs[1]?.chunks).toHaveLength(1); - expect(msgs[1]?.chunks[0]).toEqual({ type: "text", text: "a1a2" }); - }); + it("selectMessages groups consecutive same-role chunks", () => { + let s = initialState(); + s = applyHistory(s, [ + storedChunk(1, "user", { type: "text", text: "q1" }), + storedChunk(2, "user", { type: "text", text: "q2" }), + storedChunk(3, "assistant", { type: "text", text: "a1" }), + storedChunk(4, "assistant", { type: "text", text: "a2" }), + storedChunk(5, "user", { type: "text", text: "q3" }), + ]); + const msgs = selectMessages(s); + expect(msgs).toHaveLength(3); + expect(msgs[0]?.role).toBe("user"); + expect(msgs[0]?.chunks).toHaveLength(2); + expect(msgs[1]?.role).toBe("assistant"); + expect(msgs[1]?.chunks).toHaveLength(2); + expect(msgs[2]?.role).toBe("user"); + expect(msgs[2]?.chunks).toHaveLength(1); + }); + + it("returns empty for empty state", () => { + expect(selectMessages(initialState())).toEqual([]); + }); + + it("mixes committed and provisional in messages", () => { + let s = initialState(); + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "a1")); + s = foldEvent(s, textDelta("t1", "a2")); + const msgs = selectMessages(s); + expect(msgs).toHaveLength(2); + expect(msgs[0]?.role).toBe("user"); + expect(msgs[0]?.chunks).toHaveLength(1); + expect(msgs[1]?.role).toBe("assistant"); + expect(msgs[1]?.chunks).toHaveLength(1); + expect(msgs[1]?.chunks[0]).toEqual({ type: "text", text: "a1a2" }); + }); }); describe("appendUserMessage", () => { - it("adds a provisional user text chunk", () => { - let s = initialState(); - s = appendUserMessage(s, "hello from user"); - const chunks = selectChunks(s); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.seq).toBeNull(); - expect(chunks[0]?.role).toBe("user"); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello from user" }); - expect(chunks[0]?.provisional).toBe(true); - }); - - it("selectMessages includes the optimistic user message", () => { - let s = initialState(); - s = appendUserMessage(s, "what is 2+2?"); - const msgs = selectMessages(s); - expect(msgs).toHaveLength(1); - expect(msgs[0]?.role).toBe("user"); - expect(msgs[0]?.chunks).toHaveLength(1); - expect(msgs[0]?.chunks[0]).toEqual({ type: "text", text: "what is 2+2?" }); - }); - - it("user echo then turn-sealed + applyHistory supersedes the provisional user chunk", () => { - let s = initialState(); - s = appendUserMessage(s, "hi"); - expect(selectChunks(s)).toHaveLength(1); - - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "hello back")); - s = foldEvent(s, turnSealed("t1")); - s = applyHistory(s, [ - storedChunk(1, "user", { type: "text", text: "hi" }), - storedChunk(2, "assistant", { type: "text", text: "hello back" }), - ]); - const chunks = selectChunks(s); - expect(chunks).toHaveLength(2); - expect(chunks[0]?.seq).toBe(1); - expect(chunks[0]?.role).toBe("user"); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hi" }); - expect(chunks[0]?.provisional).toBe(false); - expect(chunks[1]?.seq).toBe(2); - expect(chunks[1]?.role).toBe("assistant"); - expect(chunks[1]?.provisional).toBe(false); - }); - - it("flushes accumulating chunk before appending user message", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "partial")); - expect(s.accumulating).toEqual({ kind: "text", text: "partial" }); - - s = appendUserMessage(s, "user msg"); - expect(s.accumulating).toBeNull(); - expect(s.provisional).toHaveLength(2); - expect(s.provisional[0]?.role).toBe("assistant"); - expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "partial" }); - expect(s.provisional[1]?.role).toBe("user"); - expect(s.provisional[1]?.chunk).toEqual({ type: "text", text: "user msg" }); - }); + it("adds a provisional user text chunk", () => { + let s = initialState(); + s = appendUserMessage(s, "hello from user"); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.seq).toBeNull(); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello from user" }); + expect(chunks[0]?.provisional).toBe(true); + }); + + it("selectMessages includes the optimistic user message", () => { + let s = initialState(); + s = appendUserMessage(s, "what is 2+2?"); + const msgs = selectMessages(s); + expect(msgs).toHaveLength(1); + expect(msgs[0]?.role).toBe("user"); + expect(msgs[0]?.chunks).toHaveLength(1); + expect(msgs[0]?.chunks[0]).toEqual({ type: "text", text: "what is 2+2?" }); + }); + + it("user echo then turn-sealed + applyHistory supersedes the provisional user chunk", () => { + let s = initialState(); + s = appendUserMessage(s, "hi"); + expect(selectChunks(s)).toHaveLength(1); + + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hello back")); + s = foldEvent(s, turnSealed("t1")); + s = applyHistory(s, [ + storedChunk(1, "user", { type: "text", text: "hi" }), + storedChunk(2, "assistant", { type: "text", text: "hello back" }), + ]); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(2); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hi" }); + expect(chunks[0]?.provisional).toBe(false); + expect(chunks[1]?.seq).toBe(2); + expect(chunks[1]?.role).toBe("assistant"); + expect(chunks[1]?.provisional).toBe(false); + }); + + it("flushes accumulating chunk before appending user message", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial")); + expect(s.accumulating).toEqual({ kind: "text", text: "partial" }); + + s = appendUserMessage(s, "user msg"); + expect(s.accumulating).toBeNull(); + expect(s.provisional).toHaveLength(2); + expect(s.provisional[0]?.role).toBe("assistant"); + expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "partial" }); + expect(s.provisional[1]?.role).toBe("user"); + expect(s.provisional[1]?.chunk).toEqual({ type: "text", text: "user msg" }); + }); +}); + +const PNG = "data:image/png;base64,AAAA"; +const JPG = "data:image/jpeg;base64,BBBB"; + +describe("appendUserMessage — images (vision handoff)", () => { + it("echoes text then image chunks in order", () => { + const images: ImageInput[] = [ + { url: PNG, mimeType: "image/png" }, + { url: JPG, mimeType: "image/jpeg" }, + ]; + let s = initialState(); + s = appendUserMessage(s, "what's this?", images); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(3); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "what's this?" }); + expect(chunks[1]?.chunk).toEqual({ type: "image", url: PNG, mimeType: "image/png" }); + expect(chunks[2]?.chunk).toEqual({ type: "image", url: JPG, mimeType: "image/jpeg" }); + expect(chunks.every((c) => c.provisional && c.seq === null)).toBe(true); + }); + + it("omits mimeType when not provided", () => { + let s = initialState(); + s = appendUserMessage(s, "look", [{ url: PNG }]); + const img = selectChunks(s)[1]?.chunk; + expect(img).toEqual({ type: "image", url: PNG }); + expect(img).not.toHaveProperty("mimeType"); + }); + + it("echoes images-only when text is empty (no text chunk)", () => { + let s = initialState(); + s = appendUserMessage(s, "", [{ url: PNG, mimeType: "image/png" }]); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.chunk.type).toBe("image"); + }); + + it("echoes nothing for empty text + empty images", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial")); + s = appendUserMessage(s, "", []); + // Defensive flush still happened; no user chunk added. + const users = selectChunks(s).filter((c) => c.role === "user"); + expect(users).toHaveLength(0); + expect(s.accumulating).toBeNull(); + }); + + it("skips images with an empty url", () => { + let s = initialState(); + s = appendUserMessage(s, "hi", [ + { url: "", mimeType: "image/png" }, + { url: PNG, mimeType: "image/png" }, + ]); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(2); // text + the one valid image + expect(chunks[1]?.chunk.type).toBe("image"); + }); + + it("groups text + images into one user ChatMessage", () => { + let s = initialState(); + s = appendUserMessage(s, "see this", [{ url: PNG }]); + const msgs = selectMessages(s); + expect(msgs).toHaveLength(1); + expect(msgs[0]?.role).toBe("user"); + expect(msgs[0]?.chunks).toHaveLength(2); + }); +}); + +describe("foldEvent — user-message dedups against a text+image echo", () => { + const userMessage = (text: string): TurnInputEvent => ({ + type: "user-message", + conversationId: "c1", + turnId: "t1", + text, + }); + + it("does not duplicate the text when images follow it in the echo", () => { + let s = initialState(); + s = appendUserMessage(s, "hi", [{ url: PNG, mimeType: "image/png" }]); + expect(selectChunks(s)).toHaveLength(2); // text + image + s = foldEvent(s, userMessage("hi")); + const users = selectChunks(s).filter((c) => c.role === "user"); + expect(users).toHaveLength(2); // unchanged — no duplicate text + expect(users[0]?.chunk.type).toBe("text"); + expect(users[1]?.chunk.type).toBe("image"); + expect(s.generating).toBe(true); + }); + + it("still appends when the echoed text differs", () => { + let s = initialState(); + s = appendUserMessage(s, "first", [{ url: PNG }]); + s = foldEvent(s, userMessage("second")); + const users = selectChunks(s).filter((c) => c.role === "user"); + expect(users.filter((c) => c.chunk.type === "text")).toHaveLength(2); + }); +}); + +describe("applyHistory — multi-chunk image echo is superseded by committed", () => { + it("drops the provisional [text, image] echo when committed arrives during generation", () => { + let s = initialState(); + s = appendUserMessage(s, "hi", [{ url: PNG, mimeType: "image/png" }]); + s = foldEvent(s, turnStart("t1")); + expect(s.provisional).toHaveLength(2); + + s = applyHistory(s, [ + storedChunk(1, "user", { type: "text", text: "hi" }), + storedChunk(2, "user", { type: "image", url: PNG, mimeType: "image/png" }), + ]); + expect(s.provisional).toEqual([]); + expect(s.committed).toHaveLength(2); + expect(s.committed[0]?.chunk).toEqual({ type: "text", text: "hi" }); + expect(s.committed[1]?.chunk).toEqual({ type: "image", url: PNG, mimeType: "image/png" }); + }); + + it("keeps the echo when committed is only a partial match (img not yet persisted)", () => { + let s = initialState(); + s = appendUserMessage(s, "hi", [ + { url: PNG, mimeType: "image/png" }, + { url: JPG, mimeType: "image/jpeg" }, + ]); + s = foldEvent(s, turnStart("t1")); + // Only the text + first image have been persisted so far. + s = applyHistory(s, [ + storedChunk(1, "user", { type: "text", text: "hi" }), + storedChunk(2, "user", { type: "image", url: PNG, mimeType: "image/png" }), + ]); + // Echo is [text, img1, img2]; committed run is [text, img1] — not a full + // match (echo is longer) → keep the echo (turn-seal will drop it wholesale). + expect(s.provisional.length).toBeGreaterThan(0); + }); + + it("renders committed image chunks from history (a non-vision transcription turn)", () => { + // The server persists the original image chunk AND the transcription text + // in the SAME user message: render both (image, then analysis text). + let s = initialState(); + s = applyHistory(s, [ + storedChunk(1, "user", { type: "text", text: "describe this" }), + storedChunk(2, "user", { type: "image", url: PNG, mimeType: "image/png" }), + storedChunk(3, "user", { + type: "text", + text: "[Image analysis (via kimi/k2)]: a red square", + }), + storedChunk(4, "assistant", { type: "text", text: "the square is red" }), + ]); + const msgs = selectMessages(s); + expect(msgs).toHaveLength(2); // one user message (3 chunks) + one assistant + expect(msgs[0]?.role).toBe("user"); + expect(msgs[0]?.chunks).toHaveLength(3); + expect(msgs[0]?.chunks[1]).toEqual({ type: "image", url: PNG, mimeType: "image/png" }); + }); }); diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts index 035846c..64e41b9 100644 --- a/src/core/chunks/reducer.ts +++ b/src/core/chunks/reducer.ts @@ -1,19 +1,21 @@ -import type { AgentEvent, Chunk, StoredChunk } from "@dispatch/wire"; +import type { AgentEvent, Chunk, ImageInput, Role, StoredChunk } from "@dispatch/wire"; +import { assertChunkExhaustive } from "../wire/conformance"; import type { AccumulatingChunk, ProvisionalChunk, TranscriptState } from "./types"; /** The initial empty transcript state. */ export function initialState(): TranscriptState { - return { - committed: [], - provisional: [], - accumulating: null, - currentTurnId: null, - latestUsage: null, - sealedTurnId: null, - hiddenBeforeSeq: 0, - hiddenThinkingCount: 0, - generating: false, - }; + return { + committed: [], + provisional: [], + accumulating: null, + currentTurnId: null, + latestUsage: null, + sealedTurnId: null, + hiddenBeforeSeq: 0, + hiddenThinkingCount: 0, + generating: false, + providerRetry: null, + }; } /** @@ -24,18 +26,78 @@ export function initialState(): TranscriptState { * server's replay re-asserts `generating` via the replayed `turn-start`. */ export function clearGenerating(state: TranscriptState): TranscriptState { - if (!state.generating) return state; - return { ...state, generating: false }; + if (!state.generating) return state; + // Also drop a stale `provider-retry` banner — a retry pending at disconnect + // is stale once we re-subscribe (provider-retry events are not replayed), so + // a finished turn must not keep showing a "retrying…" banner forever. + return { ...state, generating: false, providerRetry: null }; } function flushAccumulating( - provisional: readonly ProvisionalChunk[], - acc: AccumulatingChunk | null, + provisional: readonly ProvisionalChunk[], + acc: AccumulatingChunk | null, ): readonly ProvisionalChunk[] { - if (acc === null) return provisional; - const chunk: Chunk = - acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text }; - return [...provisional, { role: "assistant", chunk }]; + if (acc === null) return provisional; + const chunk: Chunk = + acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text }; + return [...provisional, { role: "assistant", chunk }]; +} + +/** + * Content equality for two chunks of the SAME role (used to de-dup an + * optimistic echo against the authoritative committed version). Compares the + * discriminating payload only — `stepId` (generation provenance) is ignored + * since it is absent on provisional echoes but present on committed tool chunks. + */ +function chunkContentEquals(a: Chunk, b: Chunk): boolean { + if (a.type !== b.type) return false; + switch (a.type) { + case "text": { + const o = b as Extract<Chunk, { type: "text" }>; + return a.text === o.text; + } + case "thinking": { + const o = b as Extract<Chunk, { type: "thinking" }>; + return a.text === o.text; + } + case "image": { + const o = b as Extract<Chunk, { type: "image" }>; + return a.url === o.url; + } + case "error": { + const o = b as Extract<Chunk, { type: "error" }>; + return a.message === o.message && a.code === o.code; + } + case "system": { + const o = b as Extract<Chunk, { type: "system" }>; + return a.text === o.text; + } + case "tool-call": { + const o = b as Extract<Chunk, { type: "tool-call" }>; + return a.toolCallId === o.toolCallId && a.toolName === o.toolName; + } + case "tool-result": { + const o = b as Extract<Chunk, { type: "tool-result" }>; + return a.toolCallId === o.toolCallId && a.toolName === o.toolName && a.isError === o.isError; + } + default: + return assertChunkExhaustive(a) === assertChunkExhaustive(b); + } +} + +/** + * The trailing run of consecutive same-role provisional chunks at the end of + * `provisional` (the optimistic echo of one message). Returns the start/end + * indices `[start, end)` into `provisional` (empty if none). + */ +function trailingRun( + provisional: readonly ProvisionalChunk[], + role: Role, +): readonly ProvisionalChunk[] { + const end = provisional.length; + let start = end; + while (start > 0 && provisional[start - 1]?.role === role) start--; + return provisional.slice(start, end); } /** @@ -49,28 +111,59 @@ function flushAccumulating( * unloaded. Restoring earlier history goes through `restoreEarlier` instead. */ export function applyHistory( - state: TranscriptState, - chunks: readonly StoredChunk[], + state: TranscriptState, + chunks: readonly StoredChunk[], ): TranscriptState { - const seqMap = new Map<number, StoredChunk>(); - for (const c of state.committed) seqMap.set(c.seq, c); - for (const c of chunks) { - if (c.seq < state.hiddenBeforeSeq) continue; - seqMap.set(c.seq, c); - } - const committed = Array.from(seqMap.values()).sort((a, b) => a.seq - b.seq); + const seqMap = new Map<number, StoredChunk>(); + for (const c of state.committed) seqMap.set(c.seq, c); + let addedNew = false; + for (const c of chunks) { + if (c.seq < state.hiddenBeforeSeq) continue; + if (!seqMap.has(c.seq)) addedNew = true; + seqMap.set(c.seq, c); + } + const committed = Array.from(seqMap.values()).sort((a, b) => a.seq - b.seq); - if (state.sealedTurnId !== null) { - return { - ...state, - committed, - provisional: [], - accumulating: null, - sealedTurnId: null, - }; - } + if (state.sealedTurnId !== null) { + return { + ...state, + committed, + provisional: [], + accumulating: null, + sealedTurnId: null, + }; + } - return { ...state, committed }; + // During generation: if new committed chunks arrived, the provisional + // array may contain duplicates — the optimistic echo from `appendUserMessage` + // is now backed by committed chunks (CR-6: user message persisted at turn + // start). A user message may be multi-chunk (`[text, image, image, …]`), so + // match the trailing provisional user-run against the trailing committed + // user-run by content equality and drop the whole echo when it is fully + // backed. Leaves the accumulating (streaming) chunk untouched. + if (addedNew && state.generating && state.provisional.length > 0) { + const provRun = trailingRun(state.provisional, "user"); + if (provRun.length > 0) { + const commRun = trailingRun(committed, "user"); + // Drop the provisional user echo iff every echoed chunk is content-equal + // to the corresponding committed chunk (the server persisted the same + // message). A partial match (echo longer than committed) keeps the echo + // — the not-yet-committed tail stays until the turn seals. + const fullyBacked = + commRun.length >= provRun.length && + provRun.every((p, i) => { + const c = commRun[i]; + return c !== undefined && chunkContentEquals(p.chunk, c.chunk); + }); + if (fullyBacked) { + const dropStart = state.provisional.length - provRun.length; + const provisional = state.provisional.slice(0, dropStart); + return { ...state, committed, provisional, accumulating: state.accumulating }; + } + } + } + + return { ...state, committed }; } /** @@ -94,185 +187,260 @@ export function applyHistory( * it true; `done` / `turn-sealed` / `error` clear it. This is what a watching * (or reconnected) client renders as "generating…", with no dependence on the * free-form `status` event string. + * + * NOTE: this is the inner reducer. The transient `provider-retry` banner is SET + * here (on a `provider-retry` event) but CLEARED by the `foldEvent` wrapper + * below, so the clearing logic stays centralized in one place. */ -export function foldEvent(state: TranscriptState, event: AgentEvent): TranscriptState { - switch (event.type) { - case "status": - case "tool-output": - return state; +function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState { + switch (event.type) { + case "status": + case "tool-output": + return state; + + case "turn-start": + return { ...state, currentTurnId: event.turnId, generating: true }; + + case "user-message": { + // The turn's USER prompt, surfaced on the event stream (backend CR-3) so a + // WATCHER/late-joiner renders it mid-turn instead of waiting for seal. The + // SENDER already echoed its own prompt optimistically (`appendUserMessage`), + // so DE-DUP: skip if the trailing provisional USER run already contains an + // identical user text chunk. The echo may be multi-chunk (`[text, image, + // image, …]` — `appendUserMessage` appends the text first, then images), so + // we scan the whole trailing user run, not just the last chunk (which would + // be an image when images were pasted). A pure watcher has no such echo → + // it appends and renders. The `user-message` event carries ONLY text (never + // images — images arrive via history/loadSince); an images-only send (empty + // text) emits no `user-message` and is not de-duped here. + if (event.text.length === 0) return state; + const run = trailingRun(state.provisional, "user"); + const alreadyEchoed = run.some((p) => p.chunk.type === "text" && p.chunk.text === event.text); + if (alreadyEchoed) { + return { ...state, generating: true }; + } + const provisional = flushAccumulating(state.provisional, state.accumulating); + return { + ...state, + provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }], + accumulating: null, + generating: true, + }; + } - case "turn-start": - return { ...state, currentTurnId: event.turnId, generating: true }; + case "text-delta": { + const acc = state.accumulating; + if (acc !== null && acc.kind === "text") { + return { + ...state, + accumulating: { kind: "text", text: acc.text + event.delta }, + generating: true, + }; + } + const provisional = flushAccumulating(state.provisional, acc); + return { + ...state, + provisional, + accumulating: { kind: "text", text: event.delta }, + generating: true, + }; + } - case "user-message": { - // The turn's USER prompt, surfaced on the event stream (backend CR-3) so a - // WATCHER/late-joiner renders it mid-turn instead of waiting for seal. The - // SENDER already echoed its own prompt optimistically (`appendUserMessage`), - // so DE-DUP: skip if the trailing provisional chunk is already an identical - // user text chunk. A pure watcher has no such echo → it appends and renders. - if (event.text.length === 0) return state; - const last = state.provisional[state.provisional.length - 1]; - if ( - last !== undefined && - last.role === "user" && - last.chunk.type === "text" && - last.chunk.text === event.text - ) { - return { ...state, generating: true }; - } - const provisional = flushAccumulating(state.provisional, state.accumulating); - return { - ...state, - provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }], - accumulating: null, - generating: true, - }; - } + case "reasoning-delta": { + const acc = state.accumulating; + if (acc !== null && acc.kind === "thinking") { + return { + ...state, + accumulating: { kind: "thinking", text: acc.text + event.delta }, + generating: true, + }; + } + const provisional = flushAccumulating(state.provisional, acc); + return { + ...state, + provisional, + accumulating: { kind: "thinking", text: event.delta }, + generating: true, + }; + } - case "text-delta": { - const acc = state.accumulating; - if (acc !== null && acc.kind === "text") { - return { - ...state, - accumulating: { kind: "text", text: acc.text + event.delta }, - generating: true, - }; - } - const provisional = flushAccumulating(state.provisional, acc); - return { - ...state, - provisional, - accumulating: { kind: "text", text: event.delta }, - generating: true, - }; - } + case "tool-call": { + const provisional = flushAccumulating(state.provisional, state.accumulating); + const chunk: Chunk = { + type: "tool-call", + toolCallId: event.toolCallId, + toolName: event.toolName, + input: event.input, + stepId: event.stepId, + }; + return { + ...state, + provisional: [...provisional, { role: "assistant", chunk }], + accumulating: null, + generating: true, + }; + } - case "reasoning-delta": { - const acc = state.accumulating; - if (acc !== null && acc.kind === "thinking") { - return { - ...state, - accumulating: { kind: "thinking", text: acc.text + event.delta }, - generating: true, - }; - } - const provisional = flushAccumulating(state.provisional, acc); - return { - ...state, - provisional, - accumulating: { kind: "thinking", text: event.delta }, - generating: true, - }; - } + case "tool-result": { + const provisional = flushAccumulating(state.provisional, state.accumulating); + const chunk: Chunk = { + type: "tool-result", + toolCallId: event.toolCallId, + toolName: event.toolName, + content: event.content, + isError: event.isError, + stepId: event.stepId, + }; + return { + ...state, + provisional: [...provisional, { role: "tool", chunk }], + accumulating: null, + generating: true, + }; + } - case "tool-call": { - const provisional = flushAccumulating(state.provisional, state.accumulating); - const chunk: Chunk = { - type: "tool-call", - toolCallId: event.toolCallId, - toolName: event.toolName, - input: event.input, - stepId: event.stepId, - }; - return { - ...state, - provisional: [...provisional, { role: "assistant", chunk }], - accumulating: null, - generating: true, - }; - } + case "error": { + const provisional = flushAccumulating(state.provisional, state.accumulating); + const chunk: Chunk = + event.code !== undefined + ? { type: "error", message: event.message, code: event.code } + : { type: "error", message: event.message }; + return { + ...state, + provisional: [...provisional, { role: "assistant", chunk }], + accumulating: null, + generating: false, + }; + } - case "tool-result": { - const provisional = flushAccumulating(state.provisional, state.accumulating); - const chunk: Chunk = { - type: "tool-result", - toolCallId: event.toolCallId, - toolName: event.toolName, - content: event.content, - isError: event.isError, - stepId: event.stepId, - }; - return { - ...state, - provisional: [...provisional, { role: "tool", chunk }], - accumulating: null, - generating: true, - }; - } + case "usage": + return { ...state, latestUsage: event.usage }; - case "error": { - const provisional = flushAccumulating(state.provisional, state.accumulating); - const chunk: Chunk = - event.code !== undefined - ? { type: "error", message: event.message, code: event.code } - : { type: "error", message: event.message }; - return { - ...state, - provisional: [...provisional, { role: "assistant", chunk }], - accumulating: null, - generating: false, - }; - } + case "step-complete": + // Timing metadata — no content chunk; handled by the telemetry reducer. + return state; - case "usage": - return { ...state, latestUsage: event.usage }; + case "done": { + const provisional = flushAccumulating(state.provisional, state.accumulating); + return { + ...state, + provisional, + accumulating: null, + generating: false, + }; + } - case "step-complete": - // Timing metadata — no content chunk; handled by the telemetry reducer. - return state; + case "turn-sealed": { + const provisional = flushAccumulating(state.provisional, state.accumulating); + return { + ...state, + provisional, + accumulating: null, + sealedTurnId: event.turnId, + generating: false, + }; + } - case "done": { - const provisional = flushAccumulating(state.provisional, state.accumulating); - return { - ...state, - provisional, - accumulating: null, - generating: false, - }; - } + case "steering": { + // A steering message drained from the queue at a tool-result boundary + // (the model sees it alongside the tool results). Append a user bubble + // to the provisional transcript; the turn is still in flight. The queue + // surface clears separately on drain (a different channel) — no de-dup + // here (unlike `user-message`, steering is never optimistically echoed + // into the transcript by the sender). + if (event.text.length === 0) return state; + const provisional = flushAccumulating(state.provisional, state.accumulating); + return { + ...state, + provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }], + accumulating: null, + generating: true, + }; + } + + case "provider-retry": { + // TRANSIENT: a retryable provider error is being retried with backoff. + // Coalesce — the latest attempt + delay replaces any previous, so a + // single updating "retrying…" banner shows the newest. NOT a chunk: it + // never enters provisional/committed, so it can never pollute the prompt + // or be replayed on a reload. The turn is still in flight, so `generating` + // (already true from `turn-start`) is left untouched. + return { ...state, providerRetry: event }; + } + } +} - case "turn-sealed": { - const provisional = flushAccumulating(state.provisional, state.accumulating); - return { - ...state, - provisional, - accumulating: null, - sealedTurnId: event.turnId, - generating: false, - }; - } +/** + * Fold one live AgentEvent into the transcript state. Wraps `reduceEvent` to + * centralize the TRANSIENT `provider-retry` banner's clearing: the banner is + * SET by `reduceEvent` on a `provider-retry` event (coalescing), and CLEARED + * here when the model's content resumes (the retry succeeded) or the turn ends + * (done/sealed/error) or a new turn starts. Metadata/no-op events + * (`status`/`tool-output`/`usage`/`step-complete`) leave a showing banner + * untouched. The `state.providerRetry !== null` guard keeps the common path + * (no banner pending) identity-stable — no needless new object. + */ +// Events that clear a showing provider-retry banner (content resumed or turn ended). +const RETRY_CLEARING_EVENTS: ReadonlySet<AgentEvent["type"]> = new Set([ + "turn-start", + "text-delta", + "reasoning-delta", + "tool-call", + "tool-result", + "error", + "done", + "turn-sealed", +]); - case "steering": { - // A steering message drained from the queue at a tool-result boundary - // (the model sees it alongside the tool results). Append a user bubble - // to the provisional transcript; the turn is still in flight. The queue - // surface clears separately on drain (a different channel) — no de-dup - // here (unlike `user-message`, steering is never optimistically echoed - // into the transcript by the sender). - if (event.text.length === 0) return state; - const provisional = flushAccumulating(state.provisional, state.accumulating); - return { - ...state, - provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }], - accumulating: null, - generating: true, - }; - } - } +export function foldEvent(state: TranscriptState, event: AgentEvent): TranscriptState { + const next = reduceEvent(state, event); + if (event.type === "provider-retry") return next; // set by reduceEvent; not a clearing event + if (RETRY_CLEARING_EVENTS.has(event.type) && state.providerRetry !== null) { + return { ...next, providerRetry: null }; + } + return next; } /** * Optimistically append a user message to the provisional list. * Flushes any in-progress accumulating chunk first (defensively). - * The provisional user chunk is superseded when applyHistory receives + * The provisional user chunks are superseded when applyHistory receives * the authoritative committed chunks after a turn seals. + * + * When `images` are provided, they are appended AFTER the text chunk (in + * order) as `image` chunks — matching the server's persisted layout + * (`[text, image, image, …]` in one user message). A text chunk is only + * appended when `text` is non-empty (an images-only send echoes just the + * images). The `user-message` event carries only text (never images), so its + * de-dup scans the trailing user run for the echoed text rather than just the + * last chunk. */ -export function appendUserMessage(state: TranscriptState, text: string): TranscriptState { - const provisional = flushAccumulating(state.provisional, state.accumulating); - const userChunk: Chunk = { type: "text", text }; - return { - ...state, - provisional: [...provisional, { role: "user", chunk: userChunk }], - accumulating: null, - }; +export function appendUserMessage( + state: TranscriptState, + text: string, + images?: readonly ImageInput[], +): TranscriptState { + const provisional = flushAccumulating(state.provisional, state.accumulating); + const userChunks: Chunk[] = []; + if (text.length > 0) userChunks.push({ type: "text", text }); + if (images !== undefined) { + for (const img of images) { + if (img.url.length === 0) continue; + const chunk: Chunk = + img.mimeType !== undefined + ? { type: "image", url: img.url, mimeType: img.mimeType } + : { type: "image", url: img.url }; + userChunks.push(chunk); + } + } + if (userChunks.length === 0) { + // Nothing to echo (empty text + no images) — leave state unchanged but + // still flush any accumulating chunk defensively. + return { ...state, provisional, accumulating: null }; + } + return { + ...state, + provisional: [...provisional, ...userChunks.map((chunk) => ({ role: "user", chunk }) as const)], + accumulating: null, + }; } diff --git a/src/core/chunks/retry-banner.test.ts b/src/core/chunks/retry-banner.test.ts new file mode 100644 index 0000000..1d8c1cd --- /dev/null +++ b/src/core/chunks/retry-banner.test.ts @@ -0,0 +1,63 @@ +import type { TurnProviderRetryEvent } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { formatRetryDelay, viewProviderRetry } from "./retry-banner"; + +const retry = ( + attempt: number, + delayMs: number, + message = "HTTP 429: overloaded", + code?: string, +): TurnProviderRetryEvent => + code !== undefined + ? { + type: "provider-retry", + conversationId: "c1", + turnId: "t1", + attempt, + delayMs, + message, + code, + } + : { type: "provider-retry", conversationId: "c1", turnId: "t1", attempt, delayMs, message }; + +describe("formatRetryDelay", () => { + it("formats sub-minute delays as seconds", () => { + expect(formatRetryDelay(5000)).toBe("5s"); + expect(formatRetryDelay(10000)).toBe("10s"); + expect(formatRetryDelay(30000)).toBe("30s"); + }); + + it("formats minute+ delays as minutes", () => { + expect(formatRetryDelay(60000)).toBe("1m"); + expect(formatRetryDelay(300000)).toBe("5m"); + expect(formatRetryDelay(1800000)).toBe("30m"); + }); + + it("rounds to the nearest whole unit", () => { + expect(formatRetryDelay(5500)).toBe("6s"); // 5.5s -> 6s + expect(formatRetryDelay(90000)).toBe("2m"); // 1.5m -> 2m + }); +}); + +describe("viewProviderRetry", () => { + it("labels the attempt 1-based (attempt 0 = Retry #1)", () => { + expect(viewProviderRetry(retry(0, 5000)).attemptLabel).toBe("Retry #1"); + expect(viewProviderRetry(retry(1, 10000)).attemptLabel).toBe("Retry #2"); + expect(viewProviderRetry(retry(7, 1800000)).attemptLabel).toBe("Retry #8"); + }); + + it("derives the delay label from delayMs", () => { + expect(viewProviderRetry(retry(0, 5000)).delayLabel).toBe("5s"); + expect(viewProviderRetry(retry(4, 300000)).delayLabel).toBe("5m"); + }); + + it("passes the endpoint error verbatim", () => { + const msg = 'HTTP 429: {"error":{"type":"overloaded_error","message":"overloaded"}}'; + expect(viewProviderRetry(retry(0, 5000, msg)).message).toBe(msg); + }); + + it("surfaces the code when present, null when absent", () => { + expect(viewProviderRetry(retry(0, 5000, "msg", "429")).code).toBe("429"); + expect(viewProviderRetry(retry(0, 5000, "msg")).code).toBeNull(); + }); +}); diff --git a/src/core/chunks/retry-banner.ts b/src/core/chunks/retry-banner.ts new file mode 100644 index 0000000..afa6a98 --- /dev/null +++ b/src/core/chunks/retry-banner.ts @@ -0,0 +1,51 @@ +import type { TurnProviderRetryEvent } from "@dispatch/wire"; + +/** + * Pure view-model for the transient `provider-retry` warning banner. Zero DOM, + * zero effects, zero Svelte — mirrors the `core/metrics` view-models the chat UI + * already imports. The banner state itself lives in `TranscriptState.providerRetry` + * (set/cleared by `foldEvent`); this module only formats an event into render data. + * + * The "countdown" is a STATIC label derived from `delayMs` (e.g. "retrying in + * 5s…"), matching the backend's examples — NOT a live ticking timer (which would + * be a component effect and re-render churn). Each new `provider-retry` event + * coalesces over the previous, so the banner always shows the newest attempt + delay. + */ + +/** The display shape for a provider-retry banner. */ +export interface ProviderRetryView { + /** "Retry #N" — `attempt` is 0-based, so +1 (attempt 0 = "Retry #1"). */ + readonly attemptLabel: string; + /** The scheduled sleep as a short duration: "5s" / "30s" / "1m" / "5m" / "30m". */ + readonly delayLabel: string; + /** The endpoint's error verbatim, e.g. "HTTP 429: {…overloaded_error…}". */ + readonly message: string; + /** The HTTP code when known (e.g. "429"), else null. */ + readonly code: string | null; +} + +/** + * Format a millisecond delay as a short, human-friendly duration. Matches the + * backend's backoff schedule (5s→10s→30s→60s→5m→10m→15m→30m): under a minute + * shows seconds, under an hour shows minutes, else hours. + */ +export function formatRetryDelay(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const totalMinutes = Math.round(totalSeconds / 60); + if (totalMinutes < 60) return `${totalMinutes}m`; + return `${Math.round(totalMinutes / 60)}h`; +} + +/** + * Map a `provider-retry` event to its banner view. `attempt` is 0-based (the Nth + * retry about to happen), so the label is 1-based for the user. + */ +export function viewProviderRetry(event: TurnProviderRetryEvent): ProviderRetryView { + return { + attemptLabel: `Retry #${event.attempt + 1}`, + delayLabel: formatRetryDelay(event.delayMs), + message: event.message, + code: event.code ?? null, + }; +} diff --git a/src/core/chunks/selectors.ts b/src/core/chunks/selectors.ts index 6929de2..e46d5f5 100644 --- a/src/core/chunks/selectors.ts +++ b/src/core/chunks/selectors.ts @@ -1,4 +1,4 @@ -import type { ChatMessage, Chunk } from "@dispatch/wire"; +import type { ChatMessage, Chunk, TurnProviderRetryEvent } from "@dispatch/wire"; import type { RenderedChunk, TranscriptState } from "./types"; /** @@ -6,21 +6,21 @@ import type { RenderedChunk, TranscriptState } from "./types"; * then provisional (seq: null). */ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] { - const result: RenderedChunk[] = []; - for (const c of state.committed) { - result.push({ seq: c.seq, role: c.role, chunk: c.chunk, provisional: false }); - } - for (const p of state.provisional) { - result.push({ seq: null, role: p.role, chunk: p.chunk, provisional: true }); - } - if (state.accumulating !== null) { - const chunk: Chunk = - state.accumulating.kind === "text" - ? { type: "text", text: state.accumulating.text } - : { type: "thinking", text: state.accumulating.text }; - result.push({ seq: null, role: "assistant", chunk, provisional: true, streaming: true }); - } - return result; + const result: RenderedChunk[] = []; + for (const c of state.committed) { + result.push({ seq: c.seq, role: c.role, chunk: c.chunk, provisional: false }); + } + for (const p of state.provisional) { + result.push({ seq: null, role: p.role, chunk: p.chunk, provisional: true }); + } + if (state.accumulating !== null) { + const chunk: Chunk = + state.accumulating.kind === "text" + ? { type: "text", text: state.accumulating.text } + : { type: "thinking", text: state.accumulating.text }; + result.push({ seq: null, role: "assistant", chunk, provisional: true, streaming: true }); + } + return result; } /** @@ -29,32 +29,41 @@ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] { * reconnected client whose in-flight turn was replayed. */ export function selectGenerating(state: TranscriptState): boolean { - return state.generating; + return state.generating; +} + +/** + * The latest `provider-retry` event for the current turn, or `null` when no retry + * is pending. Drives the transient yellow "retrying…" warning banner (rendered + * by ChatView). Never persisted — see `TranscriptState.providerRetry`. + */ +export function selectProviderRetry(state: TranscriptState): TurnProviderRetryEvent | null { + return state.providerRetry; } /** * Group consecutive same-role rendered chunks into ChatMessages. */ export function selectMessages(state: TranscriptState): readonly ChatMessage[] { - const rendered = selectChunks(state); - const first = rendered[0]; - if (first === undefined) return []; + const rendered = selectChunks(state); + const first = rendered[0]; + if (first === undefined) return []; - const messages: ChatMessage[] = []; - let role = first.role; - let chunks: Chunk[] = [first.chunk]; + const messages: ChatMessage[] = []; + let role = first.role; + let chunks: Chunk[] = [first.chunk]; - for (let i = 1; i < rendered.length; i++) { - const rc = rendered[i]; - if (rc === undefined) continue; - if (rc.role === role) { - chunks.push(rc.chunk); - } else { - messages.push({ role, chunks }); - role = rc.role; - chunks = [rc.chunk]; - } - } - messages.push({ role, chunks }); - return messages; + for (let i = 1; i < rendered.length; i++) { + const rc = rendered[i]; + if (rc === undefined) continue; + if (rc.role === role) { + chunks.push(rc.chunk); + } else { + messages.push({ role, chunks }); + role = rc.role; + chunks = [rc.chunk]; + } + } + messages.push({ role, chunks }); + return messages; } diff --git a/src/core/chunks/trim.test.ts b/src/core/chunks/trim.test.ts index aa4b0e3..7c4bbef 100644 --- a/src/core/chunks/trim.test.ts +++ b/src/core/chunks/trim.test.ts @@ -2,234 +2,234 @@ import type { StoredChunk } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { applyHistory, initialState } from "./reducer"; import { - DEFAULT_CHAT_LIMIT, - initialWindowSize, - MAX_CHAT_LIMIT, - MIN_CHAT_LIMIT, - normalizeChatLimit, - restoreEarlier, - selectHasEarlier, - trimTranscript, - unloadCount, - windowTranscript, + DEFAULT_CHAT_LIMIT, + initialWindowSize, + MAX_CHAT_LIMIT, + MIN_CHAT_LIMIT, + normalizeChatLimit, + restoreEarlier, + selectHasEarlier, + trimTranscript, + unloadCount, + windowTranscript, } from "./trim"; import type { TranscriptState } from "./types"; function chunk(seq: number, type: "text" | "thinking" = "text"): StoredChunk { - return { seq, role: "assistant", chunk: { type, text: `c${seq}` } }; + return { seq, role: "assistant", chunk: { type, text: `c${seq}` } }; } function chunks(from: number, to: number): StoredChunk[] { - const out: StoredChunk[] = []; - for (let seq = from; seq <= to; seq++) out.push(chunk(seq)); - return out; + const out: StoredChunk[] = []; + for (let seq = from; seq <= to; seq++) out.push(chunk(seq)); + return out; } function stateWith(committed: readonly StoredChunk[]): TranscriptState { - return { ...initialState(), committed }; + return { ...initialState(), committed }; } describe("normalizeChatLimit", () => { - it("defaults non-numeric / NaN / missing values", () => { - expect(normalizeChatLimit(undefined)).toBe(DEFAULT_CHAT_LIMIT); - expect(normalizeChatLimit(null)).toBe(DEFAULT_CHAT_LIMIT); - expect(normalizeChatLimit("100")).toBe(DEFAULT_CHAT_LIMIT); - expect(normalizeChatLimit(Number.NaN)).toBe(DEFAULT_CHAT_LIMIT); - expect(normalizeChatLimit(Number.POSITIVE_INFINITY)).toBe(DEFAULT_CHAT_LIMIT); - }); - - it("floors and clamps numeric values", () => { - expect(normalizeChatLimit(100.9)).toBe(100); - expect(normalizeChatLimit(0)).toBe(MIN_CHAT_LIMIT); - expect(normalizeChatLimit(-5)).toBe(MIN_CHAT_LIMIT); - expect(normalizeChatLimit(10_000_000)).toBe(MAX_CHAT_LIMIT); - expect(normalizeChatLimit(256)).toBe(256); - }); + it("defaults non-numeric / NaN / missing values", () => { + expect(normalizeChatLimit(undefined)).toBe(DEFAULT_CHAT_LIMIT); + expect(normalizeChatLimit(null)).toBe(DEFAULT_CHAT_LIMIT); + expect(normalizeChatLimit("100")).toBe(DEFAULT_CHAT_LIMIT); + expect(normalizeChatLimit(Number.NaN)).toBe(DEFAULT_CHAT_LIMIT); + expect(normalizeChatLimit(Number.POSITIVE_INFINITY)).toBe(DEFAULT_CHAT_LIMIT); + }); + + it("floors and clamps numeric values", () => { + expect(normalizeChatLimit(100.9)).toBe(100); + expect(normalizeChatLimit(0)).toBe(MIN_CHAT_LIMIT); + expect(normalizeChatLimit(-5)).toBe(MIN_CHAT_LIMIT); + expect(normalizeChatLimit(10_000_000)).toBe(MAX_CHAT_LIMIT); + expect(normalizeChatLimit(256)).toBe(256); + }); }); describe("unloadCount / initialWindowSize", () => { - it("unload is a quarter of the limit, rounded up", () => { - expect(unloadCount(100)).toBe(25); - expect(unloadCount(256)).toBe(64); - expect(unloadCount(10)).toBe(3); - }); - - it("initial window is 75% of the limit, rounded down", () => { - expect(initialWindowSize(100)).toBe(75); - expect(initialWindowSize(256)).toBe(192); - expect(initialWindowSize(1)).toBe(1); // never below 1 - }); + it("unload is a quarter of the limit, rounded up", () => { + expect(unloadCount(100)).toBe(25); + expect(unloadCount(256)).toBe(64); + expect(unloadCount(10)).toBe(3); + }); + + it("initial window is 75% of the limit, rounded down", () => { + expect(initialWindowSize(100)).toBe(75); + expect(initialWindowSize(256)).toBe(192); + expect(initialWindowSize(1)).toBe(1); // never below 1 + }); }); describe("trimTranscript", () => { - it("is the identity at or under the limit", () => { - const at = stateWith(chunks(1, 100)); - expect(trimTranscript(at, 100)).toBe(at); - const under = stateWith(chunks(1, 99)); - expect(trimTranscript(under, 100)).toBe(under); - }); - - it("unloads exactly a quarter when the limit is first exceeded (100 → 101 drops 25)", () => { - const state = stateWith(chunks(1, 101)); - const next = trimTranscript(state, 100); - expect(next.committed).toHaveLength(76); - expect(next.committed[0]?.seq).toBe(26); - expect(next.hiddenBeforeSeq).toBe(26); - }); - - it("unloads multiple quarters when trimming was deferred far past the limit", () => { - const state = stateWith(chunks(1, 130)); - const next = trimTranscript(state, 100); - // 130 → needs 2 quarters (25 each) to get to ≤ 100 → 80 remain. - expect(next.committed).toHaveLength(80); - expect(next.committed[0]?.seq).toBe(51); - expect(next.hiddenBeforeSeq).toBe(51); - }); - - it("counts provisional + accumulating toward the limit (drops committed first)", () => { - const base = stateWith(chunks(1, 98)); - const state: TranscriptState = { - ...base, - provisional: [ - { role: "user", chunk: { type: "text", text: "q" } }, - { role: "assistant", chunk: { type: "text", text: "a" } }, - ], - accumulating: { kind: "text", text: "stream" }, - }; - // 98 + 2 + 1 = 101 > 100 → drop 25 committed. - const next = trimTranscript(state, 100); - expect(next.committed).toHaveLength(73); - expect(next.provisional).toHaveLength(2); - expect(next.accumulating).not.toBeNull(); - }); - - it("drops oldest provisional when committed is exhausted", () => { - const base = stateWith(chunks(1, 2)); - const provisional = Array.from({ length: 20 }, (_, i) => ({ - role: "assistant" as const, - chunk: { type: "text" as const, text: `p${i}` }, - })); - const state: TranscriptState = { ...base, provisional }; - // 2 + 20 = 22 > 10. quarter = 3. Drop 2 committed, then drop - // ceil((20-10)/3)*3 = 12 provisional → 8 remain. - const next = trimTranscript(state, 10); - expect(next.committed).toHaveLength(0); - expect(next.provisional).toHaveLength(8); - expect(next.hiddenBeforeSeq).toBe(3); - }); - - it("accumulates the hidden thinking count for stable render keys", () => { - const committed = [chunk(1, "thinking"), ...chunks(2, 9), chunk(10, "thinking"), chunk(11)]; - const state = stateWith(committed); - const next = trimTranscript(state, 10); // 11 > 10 → drop ceil(10/4)=3 oldest - expect(next.committed[0]?.seq).toBe(4); - expect(next.hiddenThinkingCount).toBe(1); - }); - - it("ignores a nonsensical limit", () => { - const state = stateWith(chunks(1, 50)); - expect(trimTranscript(state, 0)).toBe(state); - expect(trimTranscript(state, Number.NaN)).toBe(state); - }); + it("is the identity at or under the limit", () => { + const at = stateWith(chunks(1, 100)); + expect(trimTranscript(at, 100)).toBe(at); + const under = stateWith(chunks(1, 99)); + expect(trimTranscript(under, 100)).toBe(under); + }); + + it("unloads exactly a quarter when the limit is first exceeded (100 → 101 drops 25)", () => { + const state = stateWith(chunks(1, 101)); + const next = trimTranscript(state, 100); + expect(next.committed).toHaveLength(76); + expect(next.committed[0]?.seq).toBe(26); + expect(next.hiddenBeforeSeq).toBe(26); + }); + + it("unloads multiple quarters when trimming was deferred far past the limit", () => { + const state = stateWith(chunks(1, 130)); + const next = trimTranscript(state, 100); + // 130 → needs 2 quarters (25 each) to get to ≤ 100 → 80 remain. + expect(next.committed).toHaveLength(80); + expect(next.committed[0]?.seq).toBe(51); + expect(next.hiddenBeforeSeq).toBe(51); + }); + + it("counts provisional + accumulating toward the limit (drops committed first)", () => { + const base = stateWith(chunks(1, 98)); + const state: TranscriptState = { + ...base, + provisional: [ + { role: "user", chunk: { type: "text", text: "q" } }, + { role: "assistant", chunk: { type: "text", text: "a" } }, + ], + accumulating: { kind: "text", text: "stream" }, + }; + // 98 + 2 + 1 = 101 > 100 → drop 25 committed. + const next = trimTranscript(state, 100); + expect(next.committed).toHaveLength(73); + expect(next.provisional).toHaveLength(2); + expect(next.accumulating).not.toBeNull(); + }); + + it("drops oldest provisional when committed is exhausted", () => { + const base = stateWith(chunks(1, 2)); + const provisional = Array.from({ length: 20 }, (_, i) => ({ + role: "assistant" as const, + chunk: { type: "text" as const, text: `p${i}` }, + })); + const state: TranscriptState = { ...base, provisional }; + // 2 + 20 = 22 > 10. quarter = 3. Drop 2 committed, then drop + // ceil((20-10)/3)*3 = 12 provisional → 8 remain. + const next = trimTranscript(state, 10); + expect(next.committed).toHaveLength(0); + expect(next.provisional).toHaveLength(8); + expect(next.hiddenBeforeSeq).toBe(3); + }); + + it("accumulates the hidden thinking count for stable render keys", () => { + const committed = [chunk(1, "thinking"), ...chunks(2, 9), chunk(10, "thinking"), chunk(11)]; + const state = stateWith(committed); + const next = trimTranscript(state, 10); // 11 > 10 → drop ceil(10/4)=3 oldest + expect(next.committed[0]?.seq).toBe(4); + expect(next.hiddenThinkingCount).toBe(1); + }); + + it("ignores a nonsensical limit", () => { + const state = stateWith(chunks(1, 50)); + expect(trimTranscript(state, 0)).toBe(state); + expect(trimTranscript(state, Number.NaN)).toBe(state); + }); }); describe("windowTranscript", () => { - it("keeps only the newest maxCommitted chunks and sets the watermark", () => { - const state = stateWith(chunks(1, 1000)); - const next = windowTranscript(state, 75); - expect(next.committed).toHaveLength(75); - expect(next.committed[0]?.seq).toBe(926); - expect(next.hiddenBeforeSeq).toBe(926); - expect(selectHasEarlier(next)).toBe(true); - }); - - it("is the identity within the window", () => { - const state = stateWith(chunks(1, 50)); - expect(windowTranscript(state, 75)).toBe(state); - expect(selectHasEarlier(state)).toBe(false); - }); + it("keeps only the newest maxCommitted chunks and sets the watermark", () => { + const state = stateWith(chunks(1, 1000)); + const next = windowTranscript(state, 75); + expect(next.committed).toHaveLength(75); + expect(next.committed[0]?.seq).toBe(926); + expect(next.hiddenBeforeSeq).toBe(926); + expect(selectHasEarlier(next)).toBe(true); + }); + + it("is the identity within the window", () => { + const state = stateWith(chunks(1, 50)); + expect(windowTranscript(state, 75)).toBe(state); + expect(selectHasEarlier(state)).toBe(false); + }); }); describe("applyHistory respects the watermark", () => { - it("does not resurrect chunks below hiddenBeforeSeq on a full-cache merge", () => { - const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100); - expect(trimmed.hiddenBeforeSeq).toBe(26); - // A later sync merges the FULL cache (seqs 1..101) — the unloaded prefix must stay out. - const merged = applyHistory(trimmed, chunks(1, 101)); - expect(merged.committed[0]?.seq).toBe(26); - expect(merged.committed).toHaveLength(76); - }); - - it("still merges the tail above the watermark", () => { - const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100); - const merged = applyHistory(trimmed, chunks(100, 110)); - expect(merged.committed[merged.committed.length - 1]?.seq).toBe(110); - expect(merged.committed[0]?.seq).toBe(26); - }); + it("does not resurrect chunks below hiddenBeforeSeq on a full-cache merge", () => { + const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100); + expect(trimmed.hiddenBeforeSeq).toBe(26); + // A later sync merges the FULL cache (seqs 1..101) — the unloaded prefix must stay out. + const merged = applyHistory(trimmed, chunks(1, 101)); + expect(merged.committed[0]?.seq).toBe(26); + expect(merged.committed).toHaveLength(76); + }); + + it("still merges the tail above the watermark", () => { + const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100); + const merged = applyHistory(trimmed, chunks(100, 110)); + expect(merged.committed[merged.committed.length - 1]?.seq).toBe(110); + expect(merged.committed[0]?.seq).toBe(26); + }); }); describe("restoreEarlier", () => { - it("pages the newest `count` earlier chunks back in and lowers the watermark", () => { - const windowed = windowTranscript(stateWith(chunks(1, 1000)), 75); // loaded 926..1000 - const restored = restoreEarlier(windowed, chunks(1, 1000), 64); - expect(restored.committed[0]?.seq).toBe(862); - expect(restored.committed).toHaveLength(75 + 64); - expect(restored.hiddenBeforeSeq).toBe(862); - expect(selectHasEarlier(restored)).toBe(true); - }); - - it("restoring down to seq 1 reaches the contractual origin (hasEarlier clears)", () => { - const windowed = windowTranscript(stateWith(chunks(1, 100)), 75); // hidden: 1..25 - const restored = restoreEarlier(windowed, chunks(1, 100), 64); - expect(restored.committed).toHaveLength(100); - expect(restored.committed[0]?.seq).toBe(1); - expect(restored.hiddenBeforeSeq).toBe(1); // floor at the origin — inert - expect(restored.hiddenThinkingCount).toBe(0); - expect(selectHasEarlier(restored)).toBe(false); - }); - - it("is the identity when nothing older is known locally (server may still hold more)", () => { - const windowed = windowTranscript(stateWith(chunks(50, 200)), 75); - const restored = restoreEarlier(windowed, [], 64); - expect(restored).toBe(windowed); - // seqs are 1-based gap-free: window starts at 126 ⇒ older chunks DO exist. - expect(selectHasEarlier(restored)).toBe(true); - }); - - it("is the identity when the window already starts at seq 1", () => { - const state = stateWith(chunks(1, 10)); - expect(restoreEarlier(state, chunks(1, 10), 5)).toBe(state); - }); - - it("works on a server-windowed transcript (no local watermark)", () => { - // A cold-cache fresh load with `?limit=` commits a suffix (seq 809..1000) - // with hiddenBeforeSeq still 0 — hasEarlier derives from seq > 1, and a - // backfilled run merges below it. - const state = stateWith(chunks(809, 1000)); - expect(state.hiddenBeforeSeq).toBe(0); - expect(selectHasEarlier(state)).toBe(true); - const restored = restoreEarlier(state, chunks(745, 808), 64); - expect(restored.committed[0]?.seq).toBe(745); - expect(restored.committed).toHaveLength(192 + 64); - expect(restored.hiddenBeforeSeq).toBe(745); - expect(selectHasEarlier(restored)).toBe(true); - }); - - it("decrements the hidden thinking count by the restored thinking chunks", () => { - const committed = [chunk(1, "thinking"), chunk(2), chunk(3, "thinking"), ...chunks(4, 12)]; - const trimmed = trimTranscript(stateWith(committed), 10); // drops 3: seqs 1..3 (2 thinking) - expect(trimmed.hiddenThinkingCount).toBe(2); - const restored = restoreEarlier(trimmed, committed, 2); // restores seqs 2..3 (1 thinking) - expect(restored.hiddenBeforeSeq).toBe(2); - expect(restored.hiddenThinkingCount).toBe(1); - }); - - it("round-trips with trim: trim → restore-all yields the original committed list", () => { - const original = chunks(1, 101); - const trimmed = trimTranscript(stateWith(original), 100); - const restored = restoreEarlier(trimmed, original, 1000); - expect(restored.committed).toEqual(original); - expect(restored.hiddenBeforeSeq).toBe(1); - expect(selectHasEarlier(restored)).toBe(false); - }); + it("pages the newest `count` earlier chunks back in and lowers the watermark", () => { + const windowed = windowTranscript(stateWith(chunks(1, 1000)), 75); // loaded 926..1000 + const restored = restoreEarlier(windowed, chunks(1, 1000), 64); + expect(restored.committed[0]?.seq).toBe(862); + expect(restored.committed).toHaveLength(75 + 64); + expect(restored.hiddenBeforeSeq).toBe(862); + expect(selectHasEarlier(restored)).toBe(true); + }); + + it("restoring down to seq 1 reaches the contractual origin (hasEarlier clears)", () => { + const windowed = windowTranscript(stateWith(chunks(1, 100)), 75); // hidden: 1..25 + const restored = restoreEarlier(windowed, chunks(1, 100), 64); + expect(restored.committed).toHaveLength(100); + expect(restored.committed[0]?.seq).toBe(1); + expect(restored.hiddenBeforeSeq).toBe(1); // floor at the origin — inert + expect(restored.hiddenThinkingCount).toBe(0); + expect(selectHasEarlier(restored)).toBe(false); + }); + + it("is the identity when nothing older is known locally (server may still hold more)", () => { + const windowed = windowTranscript(stateWith(chunks(50, 200)), 75); + const restored = restoreEarlier(windowed, [], 64); + expect(restored).toBe(windowed); + // seqs are 1-based gap-free: window starts at 126 ⇒ older chunks DO exist. + expect(selectHasEarlier(restored)).toBe(true); + }); + + it("is the identity when the window already starts at seq 1", () => { + const state = stateWith(chunks(1, 10)); + expect(restoreEarlier(state, chunks(1, 10), 5)).toBe(state); + }); + + it("works on a server-windowed transcript (no local watermark)", () => { + // A cold-cache fresh load with `?limit=` commits a suffix (seq 809..1000) + // with hiddenBeforeSeq still 0 — hasEarlier derives from seq > 1, and a + // backfilled run merges below it. + const state = stateWith(chunks(809, 1000)); + expect(state.hiddenBeforeSeq).toBe(0); + expect(selectHasEarlier(state)).toBe(true); + const restored = restoreEarlier(state, chunks(745, 808), 64); + expect(restored.committed[0]?.seq).toBe(745); + expect(restored.committed).toHaveLength(192 + 64); + expect(restored.hiddenBeforeSeq).toBe(745); + expect(selectHasEarlier(restored)).toBe(true); + }); + + it("decrements the hidden thinking count by the restored thinking chunks", () => { + const committed = [chunk(1, "thinking"), chunk(2), chunk(3, "thinking"), ...chunks(4, 12)]; + const trimmed = trimTranscript(stateWith(committed), 10); // drops 3: seqs 1..3 (2 thinking) + expect(trimmed.hiddenThinkingCount).toBe(2); + const restored = restoreEarlier(trimmed, committed, 2); // restores seqs 2..3 (1 thinking) + expect(restored.hiddenBeforeSeq).toBe(2); + expect(restored.hiddenThinkingCount).toBe(1); + }); + + it("round-trips with trim: trim → restore-all yields the original committed list", () => { + const original = chunks(1, 101); + const trimmed = trimTranscript(stateWith(original), 100); + const restored = restoreEarlier(trimmed, original, 1000); + expect(restored.committed).toEqual(original); + expect(restored.hiddenBeforeSeq).toBe(1); + expect(selectHasEarlier(restored)).toBe(false); + }); }); diff --git a/src/core/chunks/trim.ts b/src/core/chunks/trim.ts index 7791721..9846357 100644 --- a/src/core/chunks/trim.ts +++ b/src/core/chunks/trim.ts @@ -29,54 +29,54 @@ export const MAX_CHAT_LIMIT = 100_000; * [MIN_CHAT_LIMIT, MAX_CHAT_LIMIT]. */ export function normalizeChatLimit(value: unknown): number { - if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_CHAT_LIMIT; - const n = Math.floor(value); - if (n < MIN_CHAT_LIMIT) return MIN_CHAT_LIMIT; - if (n > MAX_CHAT_LIMIT) return MAX_CHAT_LIMIT; - return n; + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_CHAT_LIMIT; + const n = Math.floor(value); + if (n < MIN_CHAT_LIMIT) return MIN_CHAT_LIMIT; + if (n > MAX_CHAT_LIMIT) return MAX_CHAT_LIMIT; + return n; } /** The bulk-unload unit: a quarter of the limit, rounded up. */ export function unloadCount(limit: number): number { - return Math.ceil(limit / 4); + return Math.ceil(limit / 4); } /** The fresh-load window: 75% of the limit, rounded down (≥ 1). */ export function initialWindowSize(limit: number): number { - return Math.max(1, Math.floor(limit * 0.75)); + return Math.max(1, Math.floor(limit * 0.75)); } /** Total loaded (rendered) chunk count: committed + provisional + accumulating. */ function totalCount(state: TranscriptState): number { - return state.committed.length + state.provisional.length + (state.accumulating !== null ? 1 : 0); + return state.committed.length + state.provisional.length + (state.accumulating !== null ? 1 : 0); } function countThinking(chunks: readonly StoredChunk[]): number { - let n = 0; - for (const c of chunks) { - if (c.chunk.type === "thinking") n++; - } - return n; + let n = 0; + for (const c of chunks) { + if (c.chunk.type === "thinking") n++; + } + return n; } /** Drop the `drop` oldest committed chunks, advancing the watermark + thinking base. */ function dropOldest(state: TranscriptState, drop: number): TranscriptState { - const dropped = state.committed.slice(0, drop); - const kept = state.committed.slice(drop); - const first = kept[0]; - const lastDropped = dropped[dropped.length - 1]; - let hiddenBeforeSeq = state.hiddenBeforeSeq; - if (first !== undefined) { - hiddenBeforeSeq = first.seq; - } else if (lastDropped !== undefined) { - hiddenBeforeSeq = lastDropped.seq + 1; - } - return { - ...state, - committed: kept, - hiddenBeforeSeq, - hiddenThinkingCount: state.hiddenThinkingCount + countThinking(dropped), - }; + const dropped = state.committed.slice(0, drop); + const kept = state.committed.slice(drop); + const first = kept[0]; + const lastDropped = dropped[dropped.length - 1]; + let hiddenBeforeSeq = state.hiddenBeforeSeq; + if (first !== undefined) { + hiddenBeforeSeq = first.seq; + } else if (lastDropped !== undefined) { + hiddenBeforeSeq = lastDropped.seq + 1; + } + return { + ...state, + committed: kept, + hiddenBeforeSeq, + hiddenThinkingCount: state.hiddenThinkingCount + countThinking(dropped), + }; } /** @@ -89,36 +89,36 @@ function dropOldest(state: TranscriptState, drop: number): TranscriptState { * to keep the browser responsive during very long turns. */ export function trimTranscript(state: TranscriptState, limit: number): TranscriptState { - if (!Number.isFinite(limit) || limit <= 0) return state; - const total = totalCount(state); - if (total <= limit) return state; - const quarter = unloadCount(limit); - const passes = Math.ceil((total - limit) / quarter); - - // First, drop oldest committed chunks (the usual path). - const committedDrop = Math.min(passes * quarter, state.committed.length); - let next = committedDrop > 0 ? dropOldest(state, committedDrop) : state; - - // If still over the limit and committed is exhausted, drop oldest - // provisional chunks (the in-flight turn). These chunks have no seq - // (not yet persisted) and can't be "Show earlier" — but dropping them - // keeps the browser responsive. They'll come back as committed when - // the turn seals and syncTail fetches them from the server. - const remaining = totalCount(next); - if (remaining > limit && next.provisional.length > 0) { - const provisionalDrop = Math.min( - Math.ceil((remaining - limit) / quarter) * quarter, - next.provisional.length, - ); - if (provisionalDrop > 0) { - next = { - ...next, - provisional: next.provisional.slice(provisionalDrop), - }; - } - } - - return next; + if (!Number.isFinite(limit) || limit <= 0) return state; + const total = totalCount(state); + if (total <= limit) return state; + const quarter = unloadCount(limit); + const passes = Math.ceil((total - limit) / quarter); + + // First, drop oldest committed chunks (the usual path). + const committedDrop = Math.min(passes * quarter, state.committed.length); + let next = committedDrop > 0 ? dropOldest(state, committedDrop) : state; + + // If still over the limit and committed is exhausted, drop oldest + // provisional chunks (the in-flight turn). These chunks have no seq + // (not yet persisted) and can't be "Show earlier" — but dropping them + // keeps the browser responsive. They'll come back as committed when + // the turn seals and syncTail fetches them from the server. + const remaining = totalCount(next); + if (remaining > limit && next.provisional.length > 0) { + const provisionalDrop = Math.min( + Math.ceil((remaining - limit) / quarter) * quarter, + next.provisional.length, + ); + if (provisionalDrop > 0) { + next = { + ...next, + provisional: next.provisional.slice(provisionalDrop), + }; + } + } + + return next; } /** @@ -127,10 +127,10 @@ export function trimTranscript(state: TranscriptState, limit: number): Transcrip * already within the window. */ export function windowTranscript(state: TranscriptState, maxCommitted: number): TranscriptState { - if (!Number.isFinite(maxCommitted) || maxCommitted < 0) return state; - const drop = state.committed.length - maxCommitted; - if (drop <= 0) return state; - return dropOldest(state, drop); + if (!Number.isFinite(maxCommitted) || maxCommitted < 0) return state; + const drop = state.committed.length - maxCommitted; + if (drop <= 0) return state; + return dropOldest(state, drop); } /** @@ -139,7 +139,7 @@ export function windowTranscript(state: TranscriptState, maxCommitted: number): * committed list (all-provisional overflow). 0 = window start unknown/origin. */ function oldestLoadedSeq(state: TranscriptState): number { - return state.committed[0]?.seq ?? state.hiddenBeforeSeq; + return state.committed[0]?.seq ?? state.hiddenBeforeSeq; } /** @@ -154,22 +154,22 @@ function oldestLoadedSeq(state: TranscriptState): number { * contractual origin) or nothing older is known locally. */ export function restoreEarlier( - state: TranscriptState, - earlier: readonly StoredChunk[], - count: number, + state: TranscriptState, + earlier: readonly StoredChunk[], + count: number, ): TranscriptState { - const oldest = oldestLoadedSeq(state); - if (oldest <= 1) return state; - const below = earlier.filter((c) => c.seq < oldest).sort((a, b) => a.seq - b.seq); - if (below.length === 0) return state; - const keep = below.slice(-Math.max(1, count)); - const firstKept = keep[0]; - return { - ...state, - committed: [...keep, ...state.committed], - hiddenBeforeSeq: firstKept?.seq ?? state.hiddenBeforeSeq, - hiddenThinkingCount: Math.max(0, state.hiddenThinkingCount - countThinking(keep)), - }; + const oldest = oldestLoadedSeq(state); + if (oldest <= 1) return state; + const below = earlier.filter((c) => c.seq < oldest).sort((a, b) => a.seq - b.seq); + if (below.length === 0) return state; + const keep = below.slice(-Math.max(1, count)); + const firstKept = keep[0]; + return { + ...state, + committed: [...keep, ...state.committed], + hiddenBeforeSeq: firstKept?.seq ?? state.hiddenBeforeSeq, + hiddenThinkingCount: Math.max(0, state.hiddenThinkingCount - countThinking(keep)), + }; } /** @@ -181,5 +181,5 @@ export function restoreEarlier( * fresh load. */ export function selectHasEarlier(state: TranscriptState): boolean { - return oldestLoadedSeq(state) > 1; + return oldestLoadedSeq(state) > 1; } diff --git a/src/core/chunks/types.ts b/src/core/chunks/types.ts index 14619bd..2ced736 100644 --- a/src/core/chunks/types.ts +++ b/src/core/chunks/types.ts @@ -1,63 +1,77 @@ -import type { Chunk, Role, StoredChunk, Usage } from "@dispatch/wire"; +import type { Chunk, Role, StoredChunk, TurnProviderRetryEvent, Usage } from "@dispatch/wire"; /** A chunk being accumulated from streaming deltas (text or thinking). */ export interface AccumulatingChunk { - readonly kind: "text" | "thinking"; - readonly text: string; + readonly kind: "text" | "thinking"; + readonly text: string; } /** A provisional chunk that has no authoritative seq yet. */ export interface ProvisionalChunk { - readonly role: Role; - readonly chunk: Chunk; + readonly role: Role; + readonly chunk: Chunk; } /** The transcript reducer state. Holds committed history + live in-flight turn. */ export interface TranscriptState { - readonly committed: readonly StoredChunk[]; - readonly provisional: readonly ProvisionalChunk[]; - readonly accumulating: AccumulatingChunk | null; - readonly currentTurnId: string | null; - readonly latestUsage: Usage | null; - readonly sealedTurnId: string | null; - /** - * The chat-limit UNLOAD watermark: committed chunks with `seq <` this are - * unloaded (not in `committed`, not rendered) to keep long transcripts cheap. - * `0` = nothing unloaded. `applyHistory` refuses chunks below it (a cache/tail - * merge must not resurrect what the trim dropped); "Show earlier messages" - * lowers it via `restoreEarlier`. See `trim.ts`. - */ - readonly hiddenBeforeSeq: number; - /** - * How many thinking-type chunks are currently unloaded below the watermark. - * Pure render-key bookkeeping: the UI keys thinking collapses by ORDINAL (so - * the key survives the provisional→committed seal transition), and this base - * keeps those ordinals stable when a trim removes older thinking chunks — - * otherwise every remaining collapse would shift keys and swap/lose its - * open state mid-stream. - */ - readonly hiddenThinkingCount: number; - /** - * True while a turn is generating on the server — derived STRUCTURALLY from the - * event stream: a `turn-start` (or any turn delta) with no matching `done` / - * `turn-sealed` / `error` yet. A late-joiner that subscribes mid-turn gets the - * in-flight turn replayed from its `turn-start`, so this lights up for any - * watching client. NOT inferred from the free-form `status` event string. - */ - readonly generating: boolean; + readonly committed: readonly StoredChunk[]; + readonly provisional: readonly ProvisionalChunk[]; + readonly accumulating: AccumulatingChunk | null; + readonly currentTurnId: string | null; + readonly latestUsage: Usage | null; + readonly sealedTurnId: string | null; + /** + * The chat-limit UNLOAD watermark: committed chunks with `seq <` this are + * unloaded (not in `committed`, not rendered) to keep long transcripts cheap. + * `0` = nothing unloaded. `applyHistory` refuses chunks below it (a cache/tail + * merge must not resurrect what the trim dropped); "Show earlier messages" + * lowers it via `restoreEarlier`. See `trim.ts`. + */ + readonly hiddenBeforeSeq: number; + /** + * How many thinking-type chunks are currently unloaded below the watermark. + * Pure render-key bookkeeping: the UI keys thinking collapses by ORDINAL (so + * the key survives the provisional→committed seal transition), and this base + * keeps those ordinals stable when a trim removes older thinking chunks — + * otherwise every remaining collapse would shift keys and swap/lose its + * open state mid-stream. + */ + readonly hiddenThinkingCount: number; + /** + * True while a turn is generating on the server — derived STRUCTURALLY from the + * event stream: a `turn-start` (or any turn delta) with no matching `done` / + * `turn-sealed` / `error` yet. A late-joiner that subscribes mid-turn gets the + * in-flight turn replayed from its `turn-start`, so this lights up for any + * watching client. NOT inferred from the free-form `status` event string. + */ + readonly generating: boolean; + /** + * The latest `provider-retry` event for the current turn, or `null` when no + * retry is pending. TRANSIENT UI state (never a Chunk — never committed or + * provisional, so it can NEVER pollute the model's prompt or be replayed on a + * reload/replay of past turns: only committed seq'd chunks are history). Set + * by `foldEvent` on each `provider-retry` (the latest coalesces over previous + * so a single updating "retrying…" banner shows the newest attempt + delay); + * cleared when the model's content resumes (`text-delta`/`reasoning-delta`/ + * `tool-call`/`tool-result`), the turn ends (`done`/`turn-sealed`/`error`), + * or a new turn starts (`turn-start`). Also cleared on a WS reconnect + * (`clearGenerating`) — a retry pending at disconnect is stale once we + * re-subscribe (provider-retry events are not replayed). + */ + readonly providerRetry: TurnProviderRetryEvent | null; } /** A chunk ready for rendering: either committed (with seq) or provisional. */ export interface RenderedChunk { - readonly seq: number | null; - readonly role: Role; - readonly chunk: Chunk; - readonly provisional: boolean; - /** - * True only for the single chunk currently being accumulated from live deltas - * (the in-flight text/thinking the model is actively generating). Absent/false - * once flushed or committed. Lets the UI show a live indicator (e.g. loading - * dots on streaming thinking) and drop it the moment generation moves on. - */ - readonly streaming?: boolean; + readonly seq: number | null; + readonly role: Role; + readonly chunk: Chunk; + readonly provisional: boolean; + /** + * True only for the single chunk currently being accumulated from live deltas + * (the in-flight text/thinking the model is actively generating). Absent/false + * once flushed or committed. Lets the UI show a live indicator (e.g. loading + * dots on streaming thinking) and drop it the moment generation moves on. + */ + readonly streaming?: boolean; } diff --git a/src/core/metrics/format.test.ts b/src/core/metrics/format.test.ts index 6a4bd38..97170d0 100644 --- a/src/core/metrics/format.test.ts +++ b/src/core/metrics/format.test.ts @@ -1,369 +1,375 @@ import type { StepId, StepMetrics, TurnMetrics } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - computeCachePct, - computeContextUsage, - computeExpectedCachePct, - computeTps, - formatCompactTokens, - formatContextSize, - viewCacheRate, - viewExpectedCache, - viewStepMetrics, - viewTurnMetrics, + computeCachePct, + computeContextUsage, + computeExpectedCachePct, + computeTps, + formatCompactTokens, + formatContextSize, + viewCacheRate, + viewExpectedCache, + viewStepMetrics, + viewTurnMetrics, } from "./format"; describe("computeTps", () => { - it("null when elapsed missing", () => { - expect(computeTps(100, undefined)).toBeNull(); - }); + it("null when elapsed missing", () => { + expect(computeTps(100, undefined)).toBeNull(); + }); - it("null when elapsed is zero", () => { - expect(computeTps(100, 0)).toBeNull(); - }); + it("null when elapsed is zero", () => { + expect(computeTps(100, 0)).toBeNull(); + }); - it("null when elapsed is negative", () => { - expect(computeTps(100, -100)).toBeNull(); - }); + it("null when elapsed is negative", () => { + expect(computeTps(100, -100)).toBeNull(); + }); - it("computes tokens per second", () => { - expect(computeTps(1000, 2000)).toBe(500); - }); + it("computes tokens per second", () => { + expect(computeTps(1000, 2000)).toBe(500); + }); - it("computes fractional tps", () => { - expect(computeTps(100, 3000)).toBeCloseTo(33.33, 1); - }); + it("computes fractional tps", () => { + expect(computeTps(100, 3000)).toBeCloseTo(33.33, 1); + }); }); describe("viewStepMetrics", () => { - it("formats tokens with thousands separator, tps, and durations", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 1234, outputTokens: 567 }, - ttftMs: 820, - decodeMs: 1200, - genTotalMs: 2020, - }; - const view = viewStepMetrics(step, 0); - expect(view.label).toBe("step 1"); - expect(view.tokensLabel).toBe("1,801 tok"); - expect(view.tps).toBe("473 tok/s"); - expect(view.ttft).toBe("820ms"); - expect(view.decode).toBe("1.2s"); - expect(view.genTotal).toBe("2.0s"); - }); - - it("handles missing timing fields", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }; - const view = viewStepMetrics(step, 0); - expect(view.tps).toBeNull(); - expect(view.ttft).toBeNull(); - expect(view.decode).toBeNull(); - expect(view.genTotal).toBeNull(); - }); - - it("formats duration < 1s as ms", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - ttftMs: 42, - }; - const view = viewStepMetrics(step, 0); - expect(view.ttft).toBe("42ms"); - }); - - it("formats duration >= 1s as seconds", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - genTotalMs: 3200, - }; - const view = viewStepMetrics(step, 0); - expect(view.genTotal).toBe("3.2s"); - }); - - it("uses step index for label", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - }; - expect(viewStepMetrics(step, 2).label).toBe("step 3"); - }); - - it("tps uses decodeMs (not genTotalMs)", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - decodeMs: 500, - genTotalMs: 800, - }; - const view = viewStepMetrics(step, 0); - // 50 / (500/1000) = 100 tok/s, NOT 50/(800/1000)=62.5 - expect(view.tps).toBe("100 tok/s"); - }); - - it("tps falls back to genTotalMs when decodeMs absent", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 800, - }; - const view = viewStepMetrics(step, 0); - // 50 / (800/1000) = 62.5 → rounds to 63 - expect(view.tps).toBe("63 tok/s"); - }); + it("formats tokens with thousands separator, tps, and durations", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 1234, outputTokens: 567 }, + ttftMs: 820, + decodeMs: 1200, + genTotalMs: 2020, + }; + const view = viewStepMetrics(step, 0); + expect(view.label).toBe("step 1"); + expect(view.tokensLabel).toBe("1,801 tok"); + expect(view.tps).toBe("473 tok/s"); + expect(view.ttft).toBe("820ms"); + expect(view.decode).toBe("1.2s"); + expect(view.genTotal).toBe("2.0s"); + }); + + it("handles missing timing fields", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }; + const view = viewStepMetrics(step, 0); + expect(view.tps).toBeNull(); + expect(view.ttft).toBeNull(); + expect(view.decode).toBeNull(); + expect(view.genTotal).toBeNull(); + }); + + it("formats duration < 1s as ms", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + ttftMs: 42, + }; + const view = viewStepMetrics(step, 0); + expect(view.ttft).toBe("42ms"); + }); + + it("formats duration >= 1s as seconds", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + genTotalMs: 3200, + }; + const view = viewStepMetrics(step, 0); + expect(view.genTotal).toBe("3.2s"); + }); + + it("uses step index for label", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + }; + expect(viewStepMetrics(step, 2).label).toBe("step 3"); + }); + + it("tps uses decodeMs (not genTotalMs)", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + decodeMs: 500, + genTotalMs: 800, + }; + const view = viewStepMetrics(step, 0); + // 50 / (500/1000) = 100 tok/s, NOT 50/(800/1000)=62.5 + expect(view.tps).toBe("100 tok/s"); + }); + + it("tps falls back to genTotalMs when decodeMs absent", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 800, + }; + const view = viewStepMetrics(step, 0); + // 50 / (800/1000) = 62.5 → rounds to 63 + expect(view.tps).toBe("63 tok/s"); + }); }); describe("viewTurnMetrics", () => { - it("formats total tokens and breakdown", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 1000, outputTokens: 234 }, - durationMs: 5000, - steps: [ - { - stepId: "s1" as StepId, - usage: { inputTokens: 1000, outputTokens: 234 }, - decodeMs: 3000, - genTotalMs: 4000, - }, - ], - }; - const view = viewTurnMetrics(turn); - expect(view.tokensLabel).toBe("1,234 tok"); - expect(view.breakdown).toBe("1,000 in / 234 out"); - expect(view.tps).toBe("78 tok/s"); - expect(view.duration).toBe("5.0s"); - }); - - it("breakdown includes cache only when present", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 1000, outputTokens: 234, cacheReadTokens: 500 }, - steps: [], - }; - const view = viewTurnMetrics(turn); - expect(view.breakdown).toBe("1,000 in / 234 out / 500 cache"); - }); - - it("breakdown omits cache when not present", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - const view = viewTurnMetrics(turn); - expect(view.breakdown).toBe("100 in / 50 out"); - }); - - it("tps is null when no step has decodeMs or genTotalMs", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [ - { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }, - ], - }; - const view = viewTurnMetrics(turn); - expect(view.tps).toBeNull(); - }); - - it("duration is null when durationMs absent", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - const view = viewTurnMetrics(turn); - expect(view.duration).toBeNull(); - }); - - it("sums decodeMs across steps (fallback genTotalMs per step) for tps", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 300, outputTokens: 150 }, - steps: [ - { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - decodeMs: 800, - genTotalMs: 1000, - }, - { - stepId: "s2" as StepId, - usage: { inputTokens: 200, outputTokens: 100 }, - genTotalMs: 2000, - }, - ], - }; - const view = viewTurnMetrics(turn); - // step1 uses decodeMs=800, step2 falls back to genTotalMs=2000 → total=2800ms - // 150 / (2800/1000) = 53.57 → rounds to 54 - expect(view.tps).toBe("54 tok/s"); - }); + it("formats total tokens and breakdown", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 1000, outputTokens: 234 }, + durationMs: 5000, + steps: [ + { + stepId: "s1" as StepId, + usage: { inputTokens: 1000, outputTokens: 234 }, + decodeMs: 3000, + genTotalMs: 4000, + }, + ], + }; + const view = viewTurnMetrics(turn); + expect(view.tokensLabel).toBe("1,234 tok"); + expect(view.breakdown).toBe("1,000 in / 234 out"); + expect(view.tps).toBe("78 tok/s"); + expect(view.duration).toBe("5.0s"); + }); + + it("breakdown includes cache only when present", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 1000, outputTokens: 234, cacheReadTokens: 500 }, + steps: [], + }; + const view = viewTurnMetrics(turn); + expect(view.breakdown).toBe("1,000 in / 234 out / 500 cache"); + }); + + it("breakdown omits cache when not present", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + const view = viewTurnMetrics(turn); + expect(view.breakdown).toBe("100 in / 50 out"); + }); + + it("tps is null when no step has decodeMs or genTotalMs", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [ + { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }, + ], + }; + const view = viewTurnMetrics(turn); + expect(view.tps).toBeNull(); + }); + + it("duration is null when durationMs absent", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + const view = viewTurnMetrics(turn); + expect(view.duration).toBeNull(); + }); + + it("sums decodeMs across steps (fallback genTotalMs per step) for tps", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 300, outputTokens: 150 }, + steps: [ + { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + decodeMs: 800, + genTotalMs: 1000, + }, + { + stepId: "s2" as StepId, + usage: { inputTokens: 200, outputTokens: 100 }, + genTotalMs: 2000, + }, + ], + }; + const view = viewTurnMetrics(turn); + // step1 uses decodeMs=800, step2 falls back to genTotalMs=2000 → total=2800ms + // 150 / (2800/1000) = 53.57 → rounds to 54 + expect(view.tps).toBe("54 tok/s"); + }); }); describe("computeCachePct", () => { - it("is cacheReadTokens / inputTokens as a rounded percentage", () => { - expect(computeCachePct({ inputTokens: 2737, outputTokens: 10, cacheReadTokens: 2560 })).toBe( - 94, - ); - expect(computeCachePct({ inputTokens: 2669, outputTokens: 10, cacheReadTokens: 384 })).toBe(14); - }); - - it("is 0 when cacheReadTokens absent (legitimate miss, not missing data)", () => { - expect(computeCachePct({ inputTokens: 1000, outputTokens: 50 })).toBe(0); - }); - - it("is 0 when there are no input tokens (guard divide-by-zero)", () => { - expect(computeCachePct({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 5 })).toBe(0); - }); - - it("clamps to 100 if read somehow exceeds input", () => { - expect(computeCachePct({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 })).toBe(100); - }); + it("is cacheReadTokens / inputTokens as a rounded percentage", () => { + expect(computeCachePct({ inputTokens: 2737, outputTokens: 10, cacheReadTokens: 2560 })).toBe( + 94, + ); + expect(computeCachePct({ inputTokens: 2669, outputTokens: 10, cacheReadTokens: 384 })).toBe(14); + }); + + it("is 0 when cacheReadTokens absent (legitimate miss, not missing data)", () => { + expect(computeCachePct({ inputTokens: 1000, outputTokens: 50 })).toBe(0); + }); + + it("is 0 when there are no input tokens (guard divide-by-zero)", () => { + expect(computeCachePct({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 5 })).toBe(0); + }); + + it("clamps to 100 if read somehow exceeds input", () => { + expect(computeCachePct({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 })).toBe(100); + }); }); describe("viewCacheRate", () => { - it("success level for a high hit rate (>= 66)", () => { - const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 93 }); - expect(v.pct).toBe(93); - expect(v.level).toBe("success"); - expect(v.isHit).toBe(true); - }); - - it("warning level for a mid hit rate (33..65)", () => { - const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 54 }); - expect(v.pct).toBe(54); - expect(v.level).toBe("warning"); - }); - - it("error level for a low hit rate (< 33), including a legitimate 0%", () => { - expect(viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 14 }).level).toBe( - "error", - ); - const miss = viewCacheRate({ inputTokens: 1000, outputTokens: 50 }); - expect(miss.pct).toBe(0); - expect(miss.level).toBe("error"); - expect(miss.isHit).toBe(false); - }); + it("success level for a high hit rate (>= 66)", () => { + const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 93 }); + expect(v.pct).toBe(93); + expect(v.level).toBe("success"); + expect(v.isHit).toBe(true); + }); + + it("warning level for a mid hit rate (33..65)", () => { + const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 54 }); + expect(v.pct).toBe(54); + expect(v.level).toBe("warning"); + }); + + it("error level for a low hit rate (< 33), including a legitimate 0%", () => { + expect(viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 14 }).level).toBe( + "error", + ); + const miss = viewCacheRate({ inputTokens: 1000, outputTokens: 50 }); + expect(miss.pct).toBe(0); + expect(miss.level).toBe("error"); + expect(miss.isHit).toBe(false); + }); }); describe("computeExpectedCachePct", () => { - it("null when there is no prior turn (first turn has no baseline)", () => { - expect(computeExpectedCachePct({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); - }); - - it("null when the prior turn cached nothing (denominator 0)", () => { - const prev = { inputTokens: 100, outputTokens: 0 }; - const current = { inputTokens: 200, outputTokens: 0, cacheReadTokens: 50 }; - expect(computeExpectedCachePct(current, prev)).toBeNull(); - }); - - it("100% when the whole prior cached prefix was read back (backend worked example)", () => { - // turn 1: cacheRead 0, cacheWrite 5146 → prefix 5146; turn 2 reads 5146 back. - const prev = { inputTokens: 5149, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 5146 }; - const current = { - inputTokens: 8462, - outputTokens: 0, - cacheReadTokens: 5146, - cacheWriteTokens: 3313, - }; - expect(computeExpectedCachePct(current, prev)).toBe(100); - }); - - it("drops below 100% when the cache busted (read < prior prefix)", () => { - const prev = { - inputTokens: 1000, - outputTokens: 0, - cacheReadTokens: 100, - cacheWriteTokens: 900, - }; - const current = { inputTokens: 1000, outputTokens: 0, cacheReadTokens: 500 }; - // 500 / (100 + 900) = 50% - expect(computeExpectedCachePct(current, prev)).toBe(50); - }); - - it("clamps to 100 if read somehow exceeds the prior prefix", () => { - const prev = { inputTokens: 100, outputTokens: 0, cacheWriteTokens: 100 }; - const current = { inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 }; - expect(computeExpectedCachePct(current, prev)).toBe(100); - }); + it("null when there is no prior turn (first turn has no baseline)", () => { + expect(computeExpectedCachePct({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); + }); + + it("null when the prior turn cached nothing (denominator 0)", () => { + const prev = { inputTokens: 100, outputTokens: 0 }; + const current = { inputTokens: 200, outputTokens: 0, cacheReadTokens: 50 }; + expect(computeExpectedCachePct(current, prev)).toBeNull(); + }); + + it("100% when the whole prior cached prefix was read back (backend worked example)", () => { + // turn 1: cacheRead 0, cacheWrite 5146 → prefix 5146; turn 2 reads 5146 back. + const prev = { inputTokens: 5149, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 5146 }; + const current = { + inputTokens: 8462, + outputTokens: 0, + cacheReadTokens: 5146, + cacheWriteTokens: 3313, + }; + expect(computeExpectedCachePct(current, prev)).toBe(100); + }); + + it("drops below 100% when the cache busted (read < prior prefix)", () => { + const prev = { + inputTokens: 1000, + outputTokens: 0, + cacheReadTokens: 100, + cacheWriteTokens: 900, + }; + const current = { inputTokens: 1000, outputTokens: 0, cacheReadTokens: 500 }; + // 500 / (100 + 900) = 50% + expect(computeExpectedCachePct(current, prev)).toBe(50); + }); + + it("clamps to 100 if read somehow exceeds the prior prefix", () => { + const prev = { inputTokens: 100, outputTokens: 0, cacheWriteTokens: 100 }; + const current = { inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 }; + expect(computeExpectedCachePct(current, prev)).toBe(100); + }); }); describe("viewExpectedCache", () => { - it("null view when it cannot be derived (no prior turn)", () => { - expect(viewExpectedCache({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); - }); - - it("success level + hit flag for full retention", () => { - const prev = { inputTokens: 5149, outputTokens: 0, cacheWriteTokens: 5146 }; - const current = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5146 }; - const v = viewExpectedCache(current, prev); - expect(v?.pct).toBe(100); - expect(v?.level).toBe("success"); - expect(v?.isHit).toBe(true); - }); + it("null view when it cannot be derived (no prior turn)", () => { + expect(viewExpectedCache({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); + }); + + it("success level + hit flag for full retention", () => { + const prev = { inputTokens: 5149, outputTokens: 0, cacheWriteTokens: 5146 }; + const current = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5146 }; + const v = viewExpectedCache(current, prev); + expect(v?.pct).toBe(100); + expect(v?.level).toBe("success"); + expect(v?.isHit).toBe(true); + }); }); describe("formatContextSize", () => { - it("formats a defined count with thousands separators", () => { - expect(formatContextSize(34102)).toBe("34,102 tokens in context"); - }); + it("formats a defined count with thousands separators", () => { + expect(formatContextSize(34102)).toBe("34,102 tokens in context"); + }); - it("renders a placeholder for undefined (never 0)", () => { - expect(formatContextSize(undefined)).toBe("context size unknown"); - }); + it("renders a placeholder for undefined (never 0)", () => { + expect(formatContextSize(undefined)).toBe("context size unknown"); + }); - it("renders an explicit 0 as zero tokens (a real reported value)", () => { - expect(formatContextSize(0)).toBe("0 tokens in context"); - }); + it("renders an explicit 0 as zero tokens (a real reported value)", () => { + expect(formatContextSize(0)).toBe("0 tokens in context"); + }); }); describe("formatCompactTokens", () => { - it("renders sub-1k counts as-is", () => { - expect(formatCompactTokens(0)).toBe("0"); - expect(formatCompactTokens(812)).toBe("812"); - }); - - it("renders thousands with one decimal (rounded ≥100k)", () => { - expect(formatCompactTokens(12300)).toBe("12.3k"); - expect(formatCompactTokens(150000)).toBe("150k"); - }); - - it("renders millions with one decimal", () => { - expect(formatCompactTokens(1_200_000)).toBe("1.2M"); - expect(formatCompactTokens(1_000_000)).toBe("1.0M"); - }); + it("renders sub-1k counts as-is", () => { + expect(formatCompactTokens(0)).toBe("0"); + expect(formatCompactTokens(812)).toBe("812"); + }); + + it("renders thousands with one decimal (rounded ≥100k)", () => { + expect(formatCompactTokens(12300)).toBe("12.3k"); + expect(formatCompactTokens(150000)).toBe("150k"); + }); + + it("renders millions with one decimal", () => { + expect(formatCompactTokens(1_200_000)).toBe("1.2M"); + expect(formatCompactTokens(1_000_000)).toBe("1.0M"); + }); }); describe("computeContextUsage", () => { - it("computes an unrounded clamped percent against the limit", () => { - const u = computeContextUsage(34102, 1_000_000); - expect(u.current).toBe(34102); - expect(u.max).toBe(1_000_000); - expect(u.percent).toBeCloseTo(3.4102, 4); - }); - - it("treats unknown contextSize as current 0", () => { - const u = computeContextUsage(undefined, 1_000_000); - expect(u.current).toBe(0); - expect(u.percent).toBe(0); - }); - - it("clamps percent to [0,100] and over-limit reads 100", () => { - expect(computeContextUsage(2_000_000, 1_000_000).percent).toBe(100); - }); - - it("max null (no/zero limit) ⇒ percent null", () => { - expect(computeContextUsage(5000, null).percent).toBeNull(); - expect(computeContextUsage(5000, 0).percent).toBeNull(); - expect(computeContextUsage(5000, null).max).toBeNull(); - }); + it("computes an unrounded clamped percent against the limit", () => { + const u = computeContextUsage(34102, 1_000_000); + expect(u.current).toBe(34102); + expect(u.max).toBe(1_000_000); + expect(u.percent).toBeCloseTo(3.4102, 4); + }); + + it("treats unknown contextSize as current null (never 0)", () => { + const u = computeContextUsage(undefined, 1_000_000); + expect(u.current).toBeNull(); + expect(u.percent).toBeNull(); + }); + + it("an explicit 0 context size is a real reported value (current 0)", () => { + const u = computeContextUsage(0, 1_000_000); + expect(u.current).toBe(0); + expect(u.percent).toBe(0); + }); + + it("clamps percent to [0,100] and over-limit reads 100", () => { + expect(computeContextUsage(2_000_000, 1_000_000).percent).toBe(100); + }); + + it("max null (no/zero limit) ⇒ percent null", () => { + expect(computeContextUsage(5000, null).percent).toBeNull(); + expect(computeContextUsage(5000, 0).percent).toBeNull(); + expect(computeContextUsage(5000, null).max).toBeNull(); + }); }); diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts index 534277c..894bd54 100644 --- a/src/core/metrics/format.ts +++ b/src/core/metrics/format.ts @@ -2,19 +2,19 @@ import type { StepMetrics, TurnMetrics, Usage } from "@dispatch/wire"; import type { CacheRateView, StepMetricsView, TurnMetricsView } from "./types"; function formatTokens(n: number): string { - return n.toLocaleString("en-US"); + return n.toLocaleString("en-US"); } function formatDuration(ms: number | undefined): string | null { - if (ms === undefined || ms <= 0) return null; - if (ms < 1000) return `${Math.round(ms)}ms`; - return `${(ms / 1000).toFixed(1)}s`; + if (ms === undefined || ms <= 0) return null; + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; } function formatTps(tps: number | null): string | null { - if (tps === null) return null; - if (tps < 10) return `${tps.toFixed(1)} tok/s`; - return `${Math.round(tps)} tok/s`; + if (tps === null) return null; + if (tps < 10) return `${tps.toFixed(1)} tok/s`; + return `${Math.round(tps)} tok/s`; } /** @@ -24,8 +24,8 @@ function formatTps(tps: number | null): string | null { * Never renders `0` for the unknown case. */ export function formatContextSize(n: number | undefined): string { - if (n === undefined) return "context size unknown"; - return `${formatTokens(n)} tokens in context`; + if (n === undefined) return "context size unknown"; + return `${formatTokens(n)} tokens in context`; } /** @@ -33,70 +33,74 @@ export function formatContextSize(n: number | undefined): string { * thousands-separated numbers live elsewhere; this trades precision for width. */ export function formatCompactTokens(n: number): string { - if (n < 1000) return `${n}`; - if (n < 1_000_000) { - const k = n / 1000; - return `${k >= 100 ? Math.round(k) : k.toFixed(1)}k`; - } - const m = n / 1_000_000; - return `${m >= 100 ? Math.round(m) : m.toFixed(1)}M`; + if (n < 1000) return `${n}`; + if (n < 1_000_000) { + const k = n / 1000; + return `${k >= 100 ? Math.round(k) : k.toFixed(1)}k`; + } + const m = n / 1_000_000; + return `${m >= 100 ? Math.round(m) : m.toFixed(1)}M`; } /** * Context-window occupancy: the current size against a max window limit. * - * `current` is the latest turn's context size (0 when unknown); `max` is the - * model's window limit (or `null` when unknown). `percent` is - * `current / max * 100` clamped to [0, 100], UNROUNDED (the UI picks the - * precision) — so a few-thousand-token context against a 1,000,000 window still - * reads non-zero. `percent` is `null` when `max` is unknown (no bar/denominator). + * `current` is the latest turn's context size, or `null` when unknown (no + * per-step usage reported yet) — NEVER coerced to `0`, so a consumer cannot + * silently render "0 tokens / 1M"; it must branch on `current === null` and show + * a placeholder instead. `max` is the model's window limit (or `null` when + * unknown). `percent` is `current / max * 100` clamped to [0, 100], UNROUNDED + * (the UI picks the precision) — so a few-thousand-token context against a + * 1,000,000 window still reads non-zero. `percent` is `null` when `current` OR + * `max` is unknown (no bar/denominator). */ export interface ContextUsage { - readonly current: number; - readonly max: number | null; - readonly percent: number | null; + readonly current: number | null; + readonly max: number | null; + readonly percent: number | null; } export function computeContextUsage( - contextSize: number | undefined, - contextLimit: number | null | undefined, + contextSize: number | undefined, + contextLimit: number | null | undefined, ): ContextUsage { - const current = contextSize ?? 0; - const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null; - const percent = max === null ? null : Math.max(0, Math.min(100, (current / max) * 100)); - return { current, max, percent }; + const current = contextSize ?? null; + const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null; + const percent = + current === null || max === null ? null : Math.max(0, Math.min(100, (current / max) * 100)); + return { current, max, percent }; } /** Compute tokens-per-second. Returns null when elapsed time is absent or zero. */ export function computeTps(outputTokens: number, elapsedMs: number | undefined): number | null { - if (elapsedMs === undefined || elapsedMs <= 0) return null; - return outputTokens / (elapsedMs / 1000); + if (elapsedMs === undefined || elapsedMs <= 0) return null; + return outputTokens / (elapsedMs / 1000); } function totalTokens(u: Usage): number { - return u.inputTokens + u.outputTokens; + return u.inputTokens + u.outputTokens; } function formatBreakdown(u: Usage): string { - let s = `${formatTokens(u.inputTokens)} in / ${formatTokens(u.outputTokens)} out`; - if (u.cacheReadTokens !== undefined && u.cacheReadTokens > 0) { - s += ` / ${formatTokens(u.cacheReadTokens)} cache`; - } - return s; + let s = `${formatTokens(u.inputTokens)} in / ${formatTokens(u.outputTokens)} out`; + if (u.cacheReadTokens !== undefined && u.cacheReadTokens > 0) { + s += ` / ${formatTokens(u.cacheReadTokens)} cache`; + } + return s; } /** Build a formatted view of a single step's metrics. */ export function viewStepMetrics(step: StepMetrics, index: number): StepMetricsView { - const total = totalTokens(step.usage); - const tps = computeTps(step.usage.outputTokens, step.decodeMs ?? step.genTotalMs); - return { - label: `step ${index + 1}`, - tokensLabel: `${formatTokens(total)} tok`, - tps: formatTps(tps), - ttft: formatDuration(step.ttftMs), - decode: formatDuration(step.decodeMs), - genTotal: formatDuration(step.genTotalMs), - }; + const total = totalTokens(step.usage); + const tps = computeTps(step.usage.outputTokens, step.decodeMs ?? step.genTotalMs); + return { + label: `step ${index + 1}`, + tokensLabel: `${formatTokens(total)} tok`, + tps: formatTps(tps), + ttft: formatDuration(step.ttftMs), + decode: formatDuration(step.decodeMs), + genTotal: formatDuration(step.genTotalMs), + }; } /** @@ -105,24 +109,24 @@ export function viewStepMetrics(step: StepMetrics, index: number): StepMetricsVi * missing data). Returns 0 when there are no input tokens. */ export function computeCachePct(u: Usage): number { - const read = u.cacheReadTokens ?? 0; - if (u.inputTokens <= 0) return 0; - const rate = read / u.inputTokens; - const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; - return Math.round(clamped * 100); + const read = u.cacheReadTokens ?? 0; + if (u.inputTokens <= 0) return 0; + const rate = read / u.inputTokens; + const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; + return Math.round(clamped * 100); } /** Colour severity for a cache hit percentage (badge colour). */ function cacheLevel(pct: number): "success" | "warning" | "error" { - if (pct >= 66) return "success"; - if (pct >= 33) return "warning"; - return "error"; + if (pct >= 66) return "success"; + if (pct >= 33) return "warning"; + return "error"; } /** Build a view of a cache hit rate (percentage + colour level + hit flag). */ export function viewCacheRate(u: Usage): CacheRateView { - const pct = computeCachePct(u); - return { pct, level: cacheLevel(pct), isHit: (u.cacheReadTokens ?? 0) > 0 }; + const pct = computeCachePct(u); + return { pct, level: cacheLevel(pct), isHit: (u.cacheReadTokens ?? 0) > 0 }; } /** @@ -135,13 +139,13 @@ export function viewCacheRate(u: Usage): CacheRateView { * prior turn cached nothing (denominator <= 0) — distinct from a real 0%. */ export function computeExpectedCachePct(current: Usage, prev: Usage | null): number | null { - if (prev === null) return null; - const denom = (prev.cacheReadTokens ?? 0) + (prev.cacheWriteTokens ?? 0); - if (denom <= 0) return null; - const read = current.cacheReadTokens ?? 0; - const rate = read / denom; - const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; - return Math.round(clamped * 100); + if (prev === null) return null; + const denom = (prev.cacheReadTokens ?? 0) + (prev.cacheWriteTokens ?? 0); + if (denom <= 0) return null; + const read = current.cacheReadTokens ?? 0; + const rate = read / denom; + const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; + return Math.round(clamped * 100); } /** @@ -149,27 +153,27 @@ export function computeExpectedCachePct(current: Usage, prev: Usage | null): num * or `null` when it can't be derived (see `computeExpectedCachePct`). */ export function viewExpectedCache(current: Usage, prev: Usage | null): CacheRateView | null { - const pct = computeExpectedCachePct(current, prev); - if (pct === null) return null; - return { pct, level: cacheLevel(pct), isHit: (current.cacheReadTokens ?? 0) > 0 }; + const pct = computeExpectedCachePct(current, prev); + if (pct === null) return null; + return { pct, level: cacheLevel(pct), isHit: (current.cacheReadTokens ?? 0) > 0 }; } /** Build a formatted view of a turn's aggregate metrics. */ export function viewTurnMetrics(turn: TurnMetrics, turnNumber?: number): TurnMetricsView { - const total = totalTokens(turn.usage); - let totalGenMs: number | undefined; - for (const step of turn.steps) { - const stepMs = step.decodeMs ?? step.genTotalMs; - if (stepMs !== undefined) { - totalGenMs = (totalGenMs ?? 0) + stepMs; - } - } - const tps = computeTps(turn.usage.outputTokens, totalGenMs); - return { - label: turnNumber !== undefined ? `turn ${turnNumber}` : "turn", - tokensLabel: `${formatTokens(total)} tok`, - breakdown: formatBreakdown(turn.usage), - tps: formatTps(tps), - duration: formatDuration(turn.durationMs), - }; + const total = totalTokens(turn.usage); + let totalGenMs: number | undefined; + for (const step of turn.steps) { + const stepMs = step.decodeMs ?? step.genTotalMs; + if (stepMs !== undefined) { + totalGenMs = (totalGenMs ?? 0) + stepMs; + } + } + const tps = computeTps(turn.usage.outputTokens, totalGenMs); + return { + label: turnNumber !== undefined ? `turn ${turnNumber}` : "turn", + tokensLabel: `${formatTokens(total)} tok`, + breakdown: formatBreakdown(turn.usage), + tps: formatTps(tps), + duration: formatDuration(turn.durationMs), + }; } diff --git a/src/core/metrics/index.ts b/src/core/metrics/index.ts index 36cd96f..d3c9669 100644 --- a/src/core/metrics/index.ts +++ b/src/core/metrics/index.ts @@ -1,31 +1,31 @@ export { - type ContextUsage, - computeCachePct, - computeContextUsage, - computeExpectedCachePct, - computeTps, - formatCompactTokens, - formatContextSize, - viewCacheRate, - viewExpectedCache, - viewStepMetrics, - viewTurnMetrics, + type ContextUsage, + computeCachePct, + computeContextUsage, + computeExpectedCachePct, + computeTps, + formatCompactTokens, + formatContextSize, + viewCacheRate, + viewExpectedCache, + viewStepMetrics, + viewTurnMetrics, } from "./format"; export { interleaveTurnMetrics } from "./place"; export { - applyDurableMetrics, - foldMetricsEvent, - initialMetricsState, - selectCurrentContextSize, - selectOrderedTurnMetrics, + applyDurableMetrics, + foldMetricsEvent, + initialMetricsState, + selectCurrentContextSize, + selectOrderedTurnMetrics, } from "./reducer"; export type { - CacheRateView, - MetricsRow, - MetricsState, - StepMetrics, - StepMetricsView, - TurnMetrics, - TurnMetricsEntry, - TurnMetricsView, + CacheRateView, + MetricsRow, + MetricsState, + StepMetrics, + StepMetricsView, + TurnMetrics, + TurnMetricsEntry, + TurnMetricsView, } from "./types"; diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts index 22f8639..9c925a3 100644 --- a/src/core/metrics/place.test.ts +++ b/src/core/metrics/place.test.ts @@ -5,536 +5,617 @@ import { interleaveTurnMetrics } from "./place"; import type { MetricsRow, TurnMetricsEntry } from "./types"; function userGroup(seq: number, text: string): RenderGroup { - return { - kind: "single", - chunk: { - seq, - role: "user", - chunk: { type: "text", text }, - provisional: false, - }, - }; + return { + kind: "single", + chunk: { + seq, + role: "user", + chunk: { type: "text", text }, + provisional: false, + }, + }; } function assistantGroup(seq: number, text: string): RenderGroup { - return { - kind: "single", - chunk: { - seq, - role: "assistant", - chunk: { type: "text", text }, - provisional: false, - }, - }; + return { + kind: "single", + chunk: { + seq, + role: "assistant", + chunk: { type: "text", text }, + provisional: false, + }, + }; } function toolCallGroup(seq: number, stepId: string, toolCallId: string): RenderGroup { - return { - kind: "single", - chunk: { - seq, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId, - toolName: "test", - input: {}, - stepId: stepId as StepId, - }, - provisional: false, - }, - }; + return { + kind: "single", + chunk: { + seq, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId, + toolName: "test", + input: {}, + stepId: stepId as StepId, + }, + provisional: false, + }, + }; } function toolResultGroup(seq: number, stepId: string, toolCallId: string): RenderGroup { - return { - kind: "single", - chunk: { - seq, - role: "tool", - chunk: { - type: "tool-result", - toolCallId, - toolName: "test", - content: "", - isError: false, - stepId: stepId as StepId, - }, - provisional: false, - }, - }; + return { + kind: "single", + chunk: { + seq, + role: "tool", + chunk: { + type: "tool-result", + toolCallId, + toolName: "test", + content: "", + isError: false, + stepId: stepId as StepId, + }, + provisional: false, + }, + }; } function toolBatchGroup(stepId: string, toolCallIds: string[]): RenderGroup { - return { - kind: "tool-batch", - stepId, - entries: toolCallIds.map((id) => ({ - call: { - type: "tool-call" as const, - toolCallId: id, - toolName: "test", - input: {}, - stepId: stepId as StepId, - }, - result: null, - })), - provisional: false, - }; + return { + kind: "tool-batch", + stepId, + entries: toolCallIds.map((id) => ({ + call: { + type: "tool-call" as const, + toolCallId: id, + toolName: "test", + input: {}, + stepId: stepId as StepId, + }, + result: null, + })), + provisional: false, + }; } function makeStep(stepId: string, inputTokens: number, outputTokens: number): StepMetrics { - return { - stepId: stepId as StepId, - usage: { inputTokens, outputTokens }, - }; + return { + stepId: stepId as StepId, + usage: { inputTokens, outputTokens }, + }; } function makeTurn( - turnId: string, - inputTokens: number, - outputTokens: number, - steps: StepMetrics[] = [], + turnId: string, + inputTokens: number, + outputTokens: number, + steps: StepMetrics[] = [], ): TurnMetrics { - return { - turnId, - usage: { inputTokens, outputTokens }, - steps, - }; + return { + turnId, + usage: { inputTokens, outputTokens }, + steps, + }; } function makeEntry( - turnId: string, - inputTokens: number, - outputTokens: number, - steps: StepMetrics[] = [], + turnId: string, + inputTokens: number, + outputTokens: number, + steps: StepMetrics[] = [], ): TurnMetricsEntry { - return { - turnId, - steps, - total: makeTurn(turnId, inputTokens, outputTokens, steps), - }; + return { + turnId, + steps, + total: makeTurn(turnId, inputTokens, outputTokens, steps), + }; } function makeProgressiveEntry(turnId: string, steps: StepMetrics[]): TurnMetricsEntry { - return { - turnId, - steps, - total: null, - }; + return { + turnId, + steps, + total: null, + }; } function expectGroupAt( - rows: readonly { readonly kind: string }[], - index: number, - expected: RenderGroup, + rows: readonly { readonly kind: string }[], + index: number, + expected: RenderGroup, ): void { - const row = rows[index]; - expect(row?.kind).toBe("group"); - expect((row as { readonly group: RenderGroup } | undefined)?.group).toBe(expected); + const row = rows[index]; + expect(row?.kind).toBe("group"); + expect((row as { readonly group: RenderGroup } | undefined)?.group).toBe(expected); } function expectStepMetricsAt( - rows: readonly { readonly kind: string }[], - index: number, - expectedStepId: string, - expectedIndex: number, + rows: readonly { readonly kind: string }[], + index: number, + expectedStepId: string, + expectedIndex: number, ): void { - const row = rows[index]; - expect(row?.kind).toBe("step-metrics"); - const sm = row as { readonly step: StepMetrics; readonly index: number } | undefined; - expect(sm?.step.stepId).toBe(expectedStepId); - expect(sm?.index).toBe(expectedIndex); + const row = rows[index]; + expect(row?.kind).toBe("step-metrics"); + const sm = row as { readonly step: StepMetrics; readonly index: number } | undefined; + expect(sm?.step.stepId).toBe(expectedStepId); + expect(sm?.index).toBe(expectedIndex); } function expectTurnMetricsAt( - rows: readonly { readonly kind: string }[], - index: number, - expectedTurnId: string, + rows: readonly { readonly kind: string }[], + index: number, + expectedTurnId: string, ): void { - const row = rows[index]; - expect(row?.kind).toBe("turn-metrics"); - expect((row as { readonly turn: TurnMetrics } | undefined)?.turn.turnId).toBe(expectedTurnId); + const row = rows[index]; + expect(row?.kind).toBe("turn-metrics"); + expect((row as { readonly turn: TurnMetrics } | undefined)?.turn.turnId).toBe(expectedTurnId); } describe("interleaveTurnMetrics", () => { - it("no metrics: rows are all groups, unchanged order", () => { - const g1 = userGroup(1, "q"); - const g2 = assistantGroup(2, "a"); - const rows = interleaveTurnMetrics([g1, g2], []); - expect(rows).toHaveLength(2); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - }); - - it("head-aligned: segment i gets entries[i]", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const g3 = userGroup(3, "q2"); - const g4 = toolCallGroup(4, "s2", "c2"); - const step1 = makeStep("s1", 100, 50); - const step2 = makeStep("s2", 200, 80); - const rows = interleaveTurnMetrics( - [g1, g2, g3, g4], - [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], - ); - - expect(rows).toHaveLength(8); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectTurnMetricsAt(rows, 3, "t1"); - expectGroupAt(rows, 4, g3); - expectGroupAt(rows, 5, g4); - expectStepMetricsAt(rows, 6, "s2", 0); - expectTurnMetricsAt(rows, 7, "t2"); - }); - - it("a trailing segment with no entry (in-flight turn) renders no metrics", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const g3 = userGroup(3, "q2"); - const g4 = assistantGroup(4, "a2"); - const step = makeStep("s1", 100, 50); - const rows = interleaveTurnMetrics([g1, g2, g3, g4], [makeEntry("t1", 100, 50, [step])]); - - expect(rows).toHaveLength(6); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectTurnMetricsAt(rows, 3, "t1"); - expectGroupAt(rows, 4, g3); - expectGroupAt(rows, 5, g4); - }); - - it("single text-only turn: no step row (unanchored), turn-metrics at tail", () => { - const g1 = userGroup(1, "q1"); - const g2 = assistantGroup(2, "a1"); - const step = makeStep("s1", 100, 50); - const turn = makeEntry("t1", 100, 50, [step]); - const rows = interleaveTurnMetrics([g1, g2], [turn]); - - expect(rows).toHaveLength(3); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectTurnMetricsAt(rows, 2, "t1"); - }); - - it("tool step anchors inline after its tool-batch group", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("t#0", ["c1", "c2"]); - const g3 = assistantGroup(3, "a1"); - const step0 = makeStep("t#0", 100, 50); - const step1 = makeStep("t#1", 200, 80); - const turn = makeEntry("t1", 300, 130, [step0, step1]); - const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "t#0", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("single tool-call group anchors its step", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const g3 = assistantGroup(3, "a1"); - const step = makeStep("s1", 100, 50); - const turn = makeEntry("t1", 100, 50, [step]); - const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("single tool-result group anchors its step", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolResultGroup(2, "s1", "c1"); - const g3 = assistantGroup(3, "a1"); - const step = makeStep("s1", 100, 50); - const turn = makeEntry("t1", 100, 50, [step]); - const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("multi-step: each tool step inline, unanchored text step skipped", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("t#0", ["c1"]); - const g3 = assistantGroup(2, "thinking"); - const g4 = toolBatchGroup("t#1", ["c2", "c3"]); - const g5 = assistantGroup(3, "a1"); - const step0 = makeStep("t#0", 100, 50); - const step1 = makeStep("t#1", 200, 80); - const step2 = makeStep("t#2", 50, 20); - const turn = makeEntry("t1", 350, 150, [step0, step1, step2]); - const rows = interleaveTurnMetrics([g1, g2, g3, g4, g5], [turn]); - - expect(rows).toHaveLength(8); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "t#0", 0); - expectGroupAt(rows, 3, g3); - expectGroupAt(rows, 4, g4); - expectStepMetricsAt(rows, 5, "t#1", 1); - expectGroupAt(rows, 6, g5); - expectTurnMetricsAt(rows, 7, "t1"); - }); - - it("multiple turns head-aligned with inline steps", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); - const g3 = assistantGroup(2, "a1"); - const g4 = userGroup(3, "q2"); - const g5 = toolCallGroup(4, "s2", "c2"); - const step1 = makeStep("s1", 100, 50); - const step2 = makeStep("s2", 200, 80); - const rows = interleaveTurnMetrics( - [g1, g2, g3, g4, g5], - [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], - ); - - expect(rows).toHaveLength(9); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - expectGroupAt(rows, 5, g4); - expectGroupAt(rows, 6, g5); - expectStepMetricsAt(rows, 7, "s2", 0); - expectTurnMetricsAt(rows, 8, "t2"); - }); - - it("unanchored step (stepId not in groups) is skipped — only turn-metrics", () => { - const g1 = userGroup(1, "q1"); - const g2 = assistantGroup(2, "a1"); - const step0 = makeStep("orphan", 100, 50); - const turn = makeEntry("t1", 100, 50, [step0]); - const rows = interleaveTurnMetrics([g1, g2], [turn]); - - expect(rows).toHaveLength(3); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectTurnMetricsAt(rows, 2, "t1"); - }); - - it("fewer metrics than segments: trailing segments are bare", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const g3 = userGroup(3, "q2"); - const g4 = assistantGroup(4, "a2"); - const g5 = userGroup(5, "q3"); - const g6 = assistantGroup(6, "a3"); - const step = makeStep("s1", 300, 120); - const rows = interleaveTurnMetrics( - [g1, g2, g3, g4, g5, g6], - [makeEntry("t1", 300, 120, [step])], - ); - - expect(rows).toHaveLength(8); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectTurnMetricsAt(rows, 3, "t1"); - expectGroupAt(rows, 4, g3); - expectGroupAt(rows, 5, g4); - expectGroupAt(rows, 6, g5); - expectGroupAt(rows, 7, g6); - }); - - it("in-flight turn (no durationMs) still produces turn row", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const step = makeStep("s1", 100, 50); - const turn: TurnMetricsEntry = { - turnId: "t1", - steps: [step], - total: { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [step], - }, - }; - const rows = interleaveTurnMetrics([g1, g2], [turn]); - - expect(rows).toHaveLength(4); - expectStepMetricsAt(rows, 2, "s1", 0); - expectTurnMetricsAt(rows, 3, "t1"); - const metricsRow = rows[3] as { readonly turn: TurnMetrics } | undefined; - expect(metricsRow?.turn.durationMs).toBeUndefined(); - }); - - it("leading non-turn groups emit as plain group rows", () => { - const g0 = assistantGroup(1, "system msg"); - const g1 = userGroup(2, "q1"); - const g2 = toolCallGroup(3, "s1", "c1"); - const step = makeStep("s1", 100, 50); - const rows = interleaveTurnMetrics([g0, g1, g2], [makeEntry("t1", 100, 50, [step])]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g0); - expect(rows[1]?.kind).toBe("group"); - expect(rows[2]?.kind).toBe("group"); - expectStepMetricsAt(rows, 3, "s1", 0); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("more metrics than segments: unmatched entry emits standalone turn-metrics", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const step1 = makeStep("s1", 100, 50); - const step2 = makeStep("s2", 200, 80); - const rows = interleaveTurnMetrics( - [g1, g2], - [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], - ); - - // Unmatched entry (t2) emits a standalone turn-metrics row at the top. - expect(rows).toHaveLength(5); - expectTurnMetricsAt(rows, 0, "t2"); - expectGroupAt(rows, 1, g1); - expectGroupAt(rows, 2, g2); - expectStepMetricsAt(rows, 3, "s1", 0); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("turn with no steps emits only turn-metrics (no step-metrics)", () => { - const g1 = userGroup(1, "q1"); - const g2 = assistantGroup(2, "a1"); - const rows = interleaveTurnMetrics([g1, g2], [makeEntry("t1", 100, 50)]); - - expect(rows).toHaveLength(3); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectTurnMetricsAt(rows, 2, "t1"); - }); - - it("progressive: entry with steps but total=null emits step rows and NO turn-metrics row", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); - const g3 = assistantGroup(2, "a1"); - const step1 = makeStep("s1", 100, 50); - const entry = makeProgressiveEntry("t1", [step1]); - const rows = interleaveTurnMetrics([g1, g2, g3], [entry]); - - expect(rows).toHaveLength(4); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - }); - - it("entry with total emits step rows + a turn-metrics row", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); - const g3 = assistantGroup(2, "a1"); - const step1 = makeStep("s1", 100, 50); - const entry = makeEntry("t1", 100, 50, [step1]); - const rows = interleaveTurnMetrics([g1, g2, g3], [entry]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("progressive multi-step: unanchored steps skipped, no turn-metrics", () => { - const g1 = userGroup(1, "q1"); - const g2 = assistantGroup(2, "a1"); - const step0 = makeStep("s1", 100, 50); - const step1 = makeStep("s2", 200, 80); - const entry = makeProgressiveEntry("t1", [step0, step1]); - const rows = interleaveTurnMetrics([g1, g2], [entry]); - - expect(rows).toHaveLength(2); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - }); + it("no metrics: rows are all groups, unchanged order", () => { + const g1 = userGroup(1, "q"); + const g2 = assistantGroup(2, "a"); + const rows = interleaveTurnMetrics([g1, g2], []); + expect(rows).toHaveLength(2); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + }); + + it("head-aligned: segment i gets entries[i]", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const g3 = userGroup(3, "q2"); + const g4 = toolCallGroup(4, "s2", "c2"); + const step1 = makeStep("s1", 100, 50); + const step2 = makeStep("s2", 200, 80); + const rows = interleaveTurnMetrics( + [g1, g2, g3, g4], + [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], + ); + + expect(rows).toHaveLength(8); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + expectGroupAt(rows, 4, g3); + expectGroupAt(rows, 5, g4); + expectStepMetricsAt(rows, 6, "s2", 0); + expectTurnMetricsAt(rows, 7, "t2"); + }); + + it("a trailing segment with no entry (in-flight turn) renders no metrics", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const g3 = userGroup(3, "q2"); + const g4 = assistantGroup(4, "a2"); + const step = makeStep("s1", 100, 50); + const rows = interleaveTurnMetrics([g1, g2, g3, g4], [makeEntry("t1", 100, 50, [step])]); + + expect(rows).toHaveLength(6); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + expectGroupAt(rows, 4, g3); + expectGroupAt(rows, 5, g4); + }); + + it("single text-only turn: no step row (unanchored), turn-metrics at tail", () => { + const g1 = userGroup(1, "q1"); + const g2 = assistantGroup(2, "a1"); + const step = makeStep("s1", 100, 50); + const turn = makeEntry("t1", 100, 50, [step]); + const rows = interleaveTurnMetrics([g1, g2], [turn]); + + expect(rows).toHaveLength(3); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectTurnMetricsAt(rows, 2, "t1"); + }); + + it("tool step anchors inline after its tool-batch group", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("t#0", ["c1", "c2"]); + const g3 = assistantGroup(3, "a1"); + const step0 = makeStep("t#0", 100, 50); + const step1 = makeStep("t#1", 200, 80); + const turn = makeEntry("t1", 300, 130, [step0, step1]); + const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "t#0", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("single tool-call group anchors its step", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const g3 = assistantGroup(3, "a1"); + const step = makeStep("s1", 100, 50); + const turn = makeEntry("t1", 100, 50, [step]); + const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("single tool-result group anchors its step", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolResultGroup(2, "s1", "c1"); + const g3 = assistantGroup(3, "a1"); + const step = makeStep("s1", 100, 50); + const turn = makeEntry("t1", 100, 50, [step]); + const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("multi-step: each tool step inline, unanchored text step skipped", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("t#0", ["c1"]); + const g3 = assistantGroup(2, "thinking"); + const g4 = toolBatchGroup("t#1", ["c2", "c3"]); + const g5 = assistantGroup(3, "a1"); + const step0 = makeStep("t#0", 100, 50); + const step1 = makeStep("t#1", 200, 80); + const step2 = makeStep("t#2", 50, 20); + const turn = makeEntry("t1", 350, 150, [step0, step1, step2]); + const rows = interleaveTurnMetrics([g1, g2, g3, g4, g5], [turn]); + + expect(rows).toHaveLength(8); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "t#0", 0); + expectGroupAt(rows, 3, g3); + expectGroupAt(rows, 4, g4); + expectStepMetricsAt(rows, 5, "t#1", 1); + expectGroupAt(rows, 6, g5); + expectTurnMetricsAt(rows, 7, "t1"); + }); + + it("multiple turns head-aligned with inline steps", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("s1", ["c1"]); + const g3 = assistantGroup(2, "a1"); + const g4 = userGroup(3, "q2"); + const g5 = toolCallGroup(4, "s2", "c2"); + const step1 = makeStep("s1", 100, 50); + const step2 = makeStep("s2", 200, 80); + const rows = interleaveTurnMetrics( + [g1, g2, g3, g4, g5], + [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], + ); + + expect(rows).toHaveLength(9); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + expectGroupAt(rows, 5, g4); + expectGroupAt(rows, 6, g5); + expectStepMetricsAt(rows, 7, "s2", 0); + expectTurnMetricsAt(rows, 8, "t2"); + }); + + it("unanchored step (stepId not in groups) is skipped — only turn-metrics", () => { + const g1 = userGroup(1, "q1"); + const g2 = assistantGroup(2, "a1"); + const step0 = makeStep("orphan", 100, 50); + const turn = makeEntry("t1", 100, 50, [step0]); + const rows = interleaveTurnMetrics([g1, g2], [turn]); + + expect(rows).toHaveLength(3); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectTurnMetricsAt(rows, 2, "t1"); + }); + + it("fewer metrics than segments: trailing segments are bare", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const g3 = userGroup(3, "q2"); + const g4 = assistantGroup(4, "a2"); + const g5 = userGroup(5, "q3"); + const g6 = assistantGroup(6, "a3"); + const step = makeStep("s1", 300, 120); + const rows = interleaveTurnMetrics( + [g1, g2, g3, g4, g5, g6], + [makeEntry("t1", 300, 120, [step])], + ); + + expect(rows).toHaveLength(8); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + expectGroupAt(rows, 4, g3); + expectGroupAt(rows, 5, g4); + expectGroupAt(rows, 6, g5); + expectGroupAt(rows, 7, g6); + }); + + it("trimmed leading turns: a mixed tool+text transcript tail-aligns text-only turns to their OWN (newest) entries, not stale trimmed ones", () => { + // A long conversation where the chat limit unloaded the oldest turn (t1). + // Metrics still hold all three turns; the loaded transcript is turns 2-3. + // Turn 2 is a tool turn (matched by stepId); turn 3 is text-only (no + // stepId groups) — the failure case. The text-only turn MUST get its OWN + // entry (t3), NOT the trimmed t1's stale metrics. + const g3 = userGroup(3, "q2"); + const g4 = toolBatchGroup("s2", ["c2"]); + const g5 = assistantGroup(4, "tool-reply"); + const g6 = userGroup(5, "q3"); + const g7 = assistantGroup(6, "text-reply"); + const step1 = makeStep("s1", 11, 1); // t1 (trimmed) + const step2 = makeStep("s2", 22, 2); // t2 (loaded, tool) + const step3 = makeStep("s3", 33, 3); // t3 (loaded, text-only — unanchored) + const entries = [ + makeEntry("t1", 11, 1, [step1]), + makeEntry("t2", 22, 2, [step2]), + makeEntry("t3", 33, 3, [step3]), + ]; + const rows = interleaveTurnMetrics([g3, g4, g5, g6, g7], entries); + + const tmRows = rows.filter( + (r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => r.kind === "turn-metrics", + ); + // Two loaded turns → two turn-metrics rows. The trimmed t1 does NOT render. + expect(tmRows).toHaveLength(2); + // CRITICAL: the text-only turn (segment 1) got t3 (its own newest entry), + // not t1 (the stale trimmed one). A misaligned head-align would show t1. + expect(tmRows[1]?.turn.turnId).toBe("t3"); + expect(tmRows[0]?.turn.turnId).toBe("t2"); + // And t1 never appears as a rendered row. + expect(tmRows.some((r) => r.turn.turnId === "t1")).toBe(false); + }); + + it("trimmed turn still counts toward the cumulative 'chat total' on the first visible turn", () => { + // t1 is trimmed (no segment) but finalized; t2 is the loaded visible turn. + // t2's "Chat Total" cumulative must INCLUDE t1's usage (the whole chat), + // even though t1 renders no row of its own. + const g1 = userGroup(2, "q2"); + const g2 = assistantGroup(3, "a2"); + const entries = [ + { + turnId: "t1", + steps: [], + total: { + turnId: "t1", + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 500 }, + steps: [], + }, + }, + { + turnId: "t2", + steps: [], + total: { + turnId: "t2", + usage: { inputTokens: 2000, outputTokens: 20, cacheReadTokens: 1600 }, + steps: [], + }, + }, + ]; + const rows = interleaveTurnMetrics([g1, g2], entries); + const tmRows = rows.filter( + (r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => r.kind === "turn-metrics", + ); + // Only the loaded turn renders a row; the trimmed t1 does not. + expect(tmRows).toHaveLength(1); + expect(tmRows[0]?.turn.turnId).toBe("t2"); + // Cumulative includes BOTH turns (t1 + t2): input 3000, cacheRead 2100. + expect(tmRows[0]?.cumulativeUsage.inputTokens).toBe(3000); + expect(tmRows[0]?.cumulativeUsage.cacheReadTokens).toBe(2100); + // Retention baseline is the prior finalized turn (t1, even though trimmed). + expect(tmRows[0]?.prevTurnUsage?.inputTokens).toBe(1000); + expect(tmRows[0]?.prevTurnUsage?.cacheReadTokens).toBe(500); + }); + + it("in-flight turn (no durationMs) still produces turn row", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const step = makeStep("s1", 100, 50); + const turn: TurnMetricsEntry = { + turnId: "t1", + steps: [step], + total: { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [step], + }, + }; + const rows = interleaveTurnMetrics([g1, g2], [turn]); + + expect(rows).toHaveLength(4); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + const metricsRow = rows[3] as { readonly turn: TurnMetrics } | undefined; + expect(metricsRow?.turn.durationMs).toBeUndefined(); + }); + + it("leading non-turn groups emit as plain group rows", () => { + const g0 = assistantGroup(1, "system msg"); + const g1 = userGroup(2, "q1"); + const g2 = toolCallGroup(3, "s1", "c1"); + const step = makeStep("s1", 100, 50); + const rows = interleaveTurnMetrics([g0, g1, g2], [makeEntry("t1", 100, 50, [step])]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g0); + expect(rows[1]?.kind).toBe("group"); + expect(rows[2]?.kind).toBe("group"); + expectStepMetricsAt(rows, 3, "s1", 0); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("trimmed turn (more metrics than segments) does NOT emit a standalone row at the top", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const step1 = makeStep("s1", 100, 50); + const step2 = makeStep("s2", 200, 80); + const rows = interleaveTurnMetrics( + [g1, g2], + [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], + ); + + // t2's content was unloaded by the chat limit (no segment for it); its + // metrics must NOT render a standalone row piled at the top. Only the + // loaded turn's content + its matched metrics appear. (t2 still counts + // toward the cumulative "chat total" — see the cache-total tests.) + expect(rows).toHaveLength(4); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + // No standalone turn-metrics row for t2 anywhere. + const tmRows = rows.filter((r) => r.kind === "turn-metrics"); + expect(tmRows).toHaveLength(1); + expect((tmRows[0] as { readonly turn: TurnMetrics }).turn.turnId).toBe("t1"); + }); + + it("turn with no steps emits only turn-metrics (no step-metrics)", () => { + const g1 = userGroup(1, "q1"); + const g2 = assistantGroup(2, "a1"); + const rows = interleaveTurnMetrics([g1, g2], [makeEntry("t1", 100, 50)]); + + expect(rows).toHaveLength(3); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectTurnMetricsAt(rows, 2, "t1"); + }); + + it("progressive: entry with steps but total=null emits step rows and NO turn-metrics row", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("s1", ["c1"]); + const g3 = assistantGroup(2, "a1"); + const step1 = makeStep("s1", 100, 50); + const entry = makeProgressiveEntry("t1", [step1]); + const rows = interleaveTurnMetrics([g1, g2, g3], [entry]); + + expect(rows).toHaveLength(4); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + }); + + it("entry with total emits step rows + a turn-metrics row", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("s1", ["c1"]); + const g3 = assistantGroup(2, "a1"); + const step1 = makeStep("s1", 100, 50); + const entry = makeEntry("t1", 100, 50, [step1]); + const rows = interleaveTurnMetrics([g1, g2, g3], [entry]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("progressive multi-step: unanchored steps skipped, no turn-metrics", () => { + const g1 = userGroup(1, "q1"); + const g2 = assistantGroup(2, "a1"); + const step0 = makeStep("s1", 100, 50); + const step1 = makeStep("s2", 200, 80); + const entry = makeProgressiveEntry("t1", [step0, step1]); + const rows = interleaveTurnMetrics([g1, g2], [entry]); + + expect(rows).toHaveLength(2); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + }); }); describe("interleaveTurnMetrics — cumulative usage (cache total)", () => { - function turnMetricsRows(rows: readonly MetricsRow[]) { - return rows.filter((r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => { - return r.kind === "turn-metrics"; - }); - } - - function cacheEntry( - turnId: string, - inputTokens: number, - outputTokens: number, - cacheReadTokens: number, - ): TurnMetricsEntry { - const total: TurnMetrics = { - turnId, - usage: { inputTokens, outputTokens, cacheReadTokens }, - steps: [], - }; - return { turnId, steps: [], total }; - } - - it("turn-metrics row carries this turn's usage and the running cumulative", () => { - const rows = interleaveTurnMetrics( - [userGroup(1, "q1"), assistantGroup(2, "a1")], - [makeEntry("t1", 1000, 100)], - ); - const tm = turnMetricsRows(rows); - expect(tm).toHaveLength(1); - expect(tm[0]?.turn.turnId).toBe("t1"); - expect(tm[0]?.cumulativeUsage).toEqual({ inputTokens: 1000, outputTokens: 100 }); - }); - - it("accumulates cache read + input across turns (chat total)", () => { - const rows = interleaveTurnMetrics( - [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], - [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], - ); - const tm = turnMetricsRows(rows); - expect(tm).toHaveLength(2); - // turn 1: only its own usage - expect(tm[0]?.cumulativeUsage.inputTokens).toBe(2669); - expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(384); - // turn 2: sum of both (input 5406, cacheRead 2944 → matches the backend's 54% example) - expect(tm[1]?.cumulativeUsage.inputTokens).toBe(5406); - expect(tm[1]?.cumulativeUsage.cacheReadTokens).toBe(2944); - }); - - it("an in-flight (total=null) turn does not contribute to the cumulative", () => { - const rows = interleaveTurnMetrics( - [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], - [cacheEntry("t1", 1000, 10, 500), makeProgressiveEntry("t2", [makeStep("s1", 200, 5)])], - ); - const tm = turnMetricsRows(rows); - // only the finalized turn emits a turn-metrics row; its cumulative is just itself - expect(tm).toHaveLength(1); - expect(tm[0]?.cumulativeUsage.inputTokens).toBe(1000); - expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(500); - }); - - it("carries the prior finalized turn's usage as the retention baseline", () => { - const rows = interleaveTurnMetrics( - [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], - [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], - ); - const tm = turnMetricsRows(rows); - // first finalized turn has no earlier baseline - expect(tm[0]?.prevTurnUsage).toBeNull(); - // second turn's baseline is the first turn's usage - expect(tm[1]?.prevTurnUsage?.inputTokens).toBe(2669); - expect(tm[1]?.prevTurnUsage?.cacheReadTokens).toBe(384); - }); + function turnMetricsRows(rows: readonly MetricsRow[]) { + return rows.filter((r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => { + return r.kind === "turn-metrics"; + }); + } + + function cacheEntry( + turnId: string, + inputTokens: number, + outputTokens: number, + cacheReadTokens: number, + ): TurnMetricsEntry { + const total: TurnMetrics = { + turnId, + usage: { inputTokens, outputTokens, cacheReadTokens }, + steps: [], + }; + return { turnId, steps: [], total }; + } + + it("turn-metrics row carries this turn's usage and the running cumulative", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1")], + [makeEntry("t1", 1000, 100)], + ); + const tm = turnMetricsRows(rows); + expect(tm).toHaveLength(1); + expect(tm[0]?.turn.turnId).toBe("t1"); + expect(tm[0]?.cumulativeUsage).toEqual({ inputTokens: 1000, outputTokens: 100 }); + }); + + it("accumulates cache read + input across turns (chat total)", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], + [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], + ); + const tm = turnMetricsRows(rows); + expect(tm).toHaveLength(2); + // turn 1: only its own usage + expect(tm[0]?.cumulativeUsage.inputTokens).toBe(2669); + expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(384); + // turn 2: sum of both (input 5406, cacheRead 2944 → matches the backend's 54% example) + expect(tm[1]?.cumulativeUsage.inputTokens).toBe(5406); + expect(tm[1]?.cumulativeUsage.cacheReadTokens).toBe(2944); + }); + + it("an in-flight (total=null) turn does not contribute to the cumulative", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], + [cacheEntry("t1", 1000, 10, 500), makeProgressiveEntry("t2", [makeStep("s1", 200, 5)])], + ); + const tm = turnMetricsRows(rows); + // only the finalized turn emits a turn-metrics row; its cumulative is just itself + expect(tm).toHaveLength(1); + expect(tm[0]?.cumulativeUsage.inputTokens).toBe(1000); + expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(500); + }); + + it("carries the prior finalized turn's usage as the retention baseline", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], + [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], + ); + const tm = turnMetricsRows(rows); + // first finalized turn has no earlier baseline + expect(tm[0]?.prevTurnUsage).toBeNull(); + // second turn's baseline is the first turn's usage + expect(tm[1]?.prevTurnUsage?.inputTokens).toBe(2669); + expect(tm[1]?.prevTurnUsage?.cacheReadTokens).toBe(384); + }); }); diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts index 0048fa0..7122b09 100644 --- a/src/core/metrics/place.ts +++ b/src/core/metrics/place.ts @@ -3,22 +3,22 @@ import type { RenderGroup } from "../chunks"; import type { MetricsRow, TurnMetricsEntry } from "./types"; function groupStepId(g: RenderGroup): string | undefined { - if (g.kind === "tool-batch") return g.stepId; - const c = g.chunk.chunk; - return c.type === "tool-call" || c.type === "tool-result" ? c.stepId : undefined; + if (g.kind === "tool-batch") return g.stepId; + const c = g.chunk.chunk; + return c.type === "tool-call" || c.type === "tool-result" ? c.stepId : undefined; } /** Element-wise sum of two token usages (cache fields included only when nonzero). */ function addUsage(a: Usage, b: Usage): Usage { - const out: Usage = { - inputTokens: a.inputTokens + b.inputTokens, - outputTokens: a.outputTokens + b.outputTokens, - }; - const read = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0); - const write = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0); - if (read > 0) (out as { cacheReadTokens?: number }).cacheReadTokens = read; - if (write > 0) (out as { cacheWriteTokens?: number }).cacheWriteTokens = write; - return out; + const out: Usage = { + inputTokens: a.inputTokens + b.inputTokens, + outputTokens: a.outputTokens + b.outputTokens, + }; + const read = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0); + const write = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0); + if (read > 0) (out as { cacheReadTokens?: number }).cacheReadTokens = read; + if (write > 0) (out as { cacheWriteTokens?: number }).cacheWriteTokens = write; + return out; } /** @@ -27,10 +27,11 @@ function addUsage(a: Usage, b: Usage): Usage { * Splits groups into per-turn segments: a new segment begins at each `single` * group with `group.chunk.role === "user"`. Segments are matched to entries * by `stepId` presence when possible (robust against chat-limit trimming: when - * a turn's user message is trimmed, head-alignment would be off by one, but + * a turn's user message is trimmed, positional alignment would be off, but * stepId matching still finds the right entry). Segments with no stepId-bearing - * groups (text-only turns) fall back to sequential matching against unused - * entries. + * groups (text-only turns) fall back to POSITIONAL tail-alignment: since the + * loaded transcript is always a SUFFIX of the full turn history (the chat limit + * keeps the newest and unloads the oldest), segment `seg` ↔ entry `K - T + seg`. * * Within a segment that has a matched entry, each completed step's metrics * are placed INLINE right after the last group bearing that step's `stepId`. @@ -44,248 +45,254 @@ function addUsage(a: Usage, b: Usage): Usage { * is finalized via `done` or durable data). A still-generating turn emits no * turn-total row. * - * Cumulative usage is computed across finalized turns in entry-array order - * (turn order), so the per-turn "chat total" cache rate is correct regardless - * of which turns were trimmed. + * Fully trimmed turns (entries whose content was unloaded by the chat limit and + * which match no segment) are NOT rendered as standalone rows — that previously + * piled a wall of stale cache badges at the top of a long, trimmed transcript. + * Their usage still counts toward the per-turn "chat total" cumulative (computed + * across ALL finalized turns in entry-array order), so the running cache rate + * stays correct regardless of which turns were trimmed; paging earlier history + * back in ("Show earlier messages") re-matches them and re-renders their rows. */ export function interleaveTurnMetrics( - groups: readonly RenderGroup[], - entries: readonly TurnMetricsEntry[], + groups: readonly RenderGroup[], + entries: readonly TurnMetricsEntry[], ): readonly MetricsRow[] { - if (entries.length === 0) { - return groups.map((g) => ({ kind: "group" as const, group: g })); - } + if (entries.length === 0) { + return groups.map((g) => ({ kind: "group" as const, group: g })); + } - const segmentStarts: number[] = []; - for (let i = 0; i < groups.length; i++) { - const g = groups[i]; - if (g !== undefined && g.kind === "single" && g.chunk.role === "user") { - segmentStarts.push(i); - } - } + const segmentStarts: number[] = []; + for (let i = 0; i < groups.length; i++) { + const g = groups[i]; + if (g !== undefined && g.kind === "single" && g.chunk.role === "user") { + segmentStarts.push(i); + } + } - let T = segmentStarts.length; + let T = segmentStarts.length; - // No user messages — e.g. a compacted conversation whose history starts - // with a system summary. Treat the entire transcript as one segment so - // turn/step metrics can still be placed. - if (T === 0 && entries.length > 0) { - segmentStarts.push(0); - T = 1; - } + // No user messages — e.g. a compacted conversation whose history starts + // with a system summary. Treat the entire transcript as one segment so + // turn/step metrics can still be placed. + if (T === 0 && entries.length > 0) { + segmentStarts.push(0); + T = 1; + } - if (T === 0) { - return groups.map((g) => ({ kind: "group" as const, group: g })); - } + if (T === 0) { + return groups.map((g) => ({ kind: "group" as const, group: g })); + } - const K = entries.length; + const K = entries.length; - // Build stepId → entry-index lookup for matching. - const entryStepIds: Set<string>[] = entries.map((e) => new Set(e.steps.map((s) => s.stepId))); + // Build stepId → entry-index lookup for matching. + const entryStepIds: Set<string>[] = entries.map((e) => new Set(e.steps.map((s) => s.stepId))); - // Match segments to entries. Pass 1: match by stepId overlap (handles - // trimming where head-alignment would be wrong). Pass 2: sequential fallback - // for unmatched segments (text-only turns with no stepId-bearing groups). - const usedEntries = new Set<number>(); - const segmentEntry = new Map<number, TurnMetricsEntry>(); - const segmentEntryIndex = new Map<number, number>(); + // Match segments to entries. Pass 1: match by stepId overlap (handles + // trimming where positional alignment alone could be ambiguous). Pass 2: + // positional tail-alignment fallback for unmatched segments (text-only turns + // with no stepId-bearing groups). + const usedEntries = new Set<number>(); + const segmentEntry = new Map<number, TurnMetricsEntry>(); + const segmentEntryIndex = new Map<number, number>(); - // Pass 1: stepId matching. - for (let seg = 0; seg < T; seg++) { - const start = segmentStarts[seg] ?? 0; - const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; + // Pass 1: stepId matching. + for (let seg = 0; seg < T; seg++) { + const start = segmentStarts[seg] ?? 0; + const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; - const segStepIds = new Set<string>(); - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g === undefined) continue; - const sid = groupStepId(g); - if (sid !== undefined) segStepIds.add(sid); - } - if (segStepIds.size === 0) continue; // text-only — defer to pass 2 + const segStepIds = new Set<string>(); + for (let i = start; i < end; i++) { + const g = groups[i]; + if (g === undefined) continue; + const sid = groupStepId(g); + if (sid !== undefined) segStepIds.add(sid); + } + if (segStepIds.size === 0) continue; // text-only — defer to pass 2 - let bestEntry = -1; - let bestMatch = 0; - for (let i = 0; i < K; i++) { - if (usedEntries.has(i)) continue; - let match = 0; - for (const sid of segStepIds) { - if (entryStepIds[i]?.has(sid)) match++; - } - if (match > bestMatch) { - bestMatch = match; - bestEntry = i; - } - } - if (bestEntry >= 0) { - usedEntries.add(bestEntry); - const e = entries[bestEntry]; - if (e !== undefined) { - segmentEntry.set(seg, e); - segmentEntryIndex.set(seg, bestEntry); - } - } - } + let bestEntry = -1; + let bestMatch = 0; + for (let i = 0; i < K; i++) { + if (usedEntries.has(i)) continue; + let match = 0; + for (const sid of segStepIds) { + if (entryStepIds[i]?.has(sid)) match++; + } + if (match > bestMatch) { + bestMatch = match; + bestEntry = i; + } + } + if (bestEntry >= 0) { + usedEntries.add(bestEntry); + const e = entries[bestEntry]; + if (e !== undefined) { + segmentEntry.set(seg, e); + segmentEntryIndex.set(seg, bestEntry); + } + } + } - // Pass 2: sequential fallback for unmatched segments. - // If NO segments were matched by stepId (pass 1), use TAIL-ALIGNMENT: - // the loaded chunks are always the NEWEST (chat-limit/windowing keeps the - // newest and trims the oldest), so match the LAST T entries to the T - // segments. This prevents misaligning oldest (trimmed) entries to newest - // segments — which would show "turn 1" on turn 20's content. - const pass1Matches = segmentEntry.size; - if (pass1Matches === 0 && K >= T) { - // Tail-align: skip the first K-T entries (trimmed turns). - for (let seg = 0; seg < T; seg++) { - if (segmentEntry.has(seg)) continue; - const entryIdx = K - T + seg; - if (entryIdx < K && !usedEntries.has(entryIdx)) { - usedEntries.add(entryIdx); - const e = entries[entryIdx]; - if (e !== undefined) { - segmentEntry.set(seg, e); - segmentEntryIndex.set(seg, entryIdx); - } - } - } - } else { - // Head-align fallback for remaining unmatched segments. - let nextUnused = 0; - for (let seg = 0; seg < T; seg++) { - if (segmentEntry.has(seg)) continue; - while (nextUnused < K && usedEntries.has(nextUnused)) nextUnused++; - if (nextUnused < K) { - usedEntries.add(nextUnused); - const e = entries[nextUnused]; - if (e !== undefined) { - segmentEntry.set(seg, e); - segmentEntryIndex.set(seg, nextUnused); - } - nextUnused++; - } - } - } + // Pass 2: positional fallback for segments pass 1 left unmatched + // (text-only turns with no stepId-bearing groups to anchor on). + // + // The loaded transcript is always a SUFFIX of the full turn history — + // chat-limit/windowing keeps the NEWEST chunks and unloads the OLDEST — so + // the T loaded segments correspond to the LAST T entries. TAIL-ALIGNMENT + // (segment `seg` ↔ entry `K - T + seg`) is therefore correct whenever the + // metrics hold at least as many turns as there are loaded segments + // (`K >= T`): the leading `K - T` entries are TRIMMED turns (their content + // was unloaded) and must be skipped, never matched to a newer segment. + // + // This MUST run even when pass 1 matched SOME segments (tool turns). The + // earlier code only tail-aligned when pass 1 matched NONE, falling back to + // HEAD-alignment otherwise — which, with leading trimmed entries, matched a + // brand-new text-only turn to an old (trimmed) entry's STALE metrics (the + // "new steps show no / wrong cache" failure). Tail-aligning by position is + // safe alongside pass 1: stepIds are unique per turn, so pass 1 already + // grabbed each tool turn's positionally-correct entry, leaving the right + // entry free for each text-only turn. + // + // Only when `K < T` (fewer entries than segments — some loaded turns have no + // metrics yet, e.g. a metrics sync still pending or a freshly loaded + // transcript) do we head-align, assigning the first K entries to the first K + // unmatched segments (the turns that DO have metrics sit at the front). + if (K >= T) { + // Tail-align: skip the first K-T entries (trimmed turns). + for (let seg = 0; seg < T; seg++) { + if (segmentEntry.has(seg)) continue; + const entryIdx = K - T + seg; + if (entryIdx >= 0 && entryIdx < K && !usedEntries.has(entryIdx)) { + usedEntries.add(entryIdx); + const e = entries[entryIdx]; + if (e !== undefined) { + segmentEntry.set(seg, e); + segmentEntryIndex.set(seg, entryIdx); + } + } + } + } else { + // Head-align fallback (K < T): first K entries to first K unmatched segments. + let nextUnused = 0; + for (let seg = 0; seg < T; seg++) { + if (segmentEntry.has(seg)) continue; + while (nextUnused < K && usedEntries.has(nextUnused)) nextUnused++; + if (nextUnused < K) { + usedEntries.add(nextUnused); + const e = entries[nextUnused]; + if (e !== undefined) { + segmentEntry.set(seg, e); + segmentEntryIndex.set(seg, nextUnused); + } + nextUnused++; + } + } + } - // Running cumulative usage across ALL finalized turns (in entry order), for - // the per-turn "chat total" cache rate. Alongside it, the previous finalized - // turn's usage at each index — the baseline for cross-turn retention. - const cumulativeByEntry: Usage[] = []; - const prevUsageByEntry: (Usage | null)[] = []; - let runningUsage: Usage = { inputTokens: 0, outputTokens: 0 }; - let lastFinalizedUsage: Usage | null = null; - for (const e of entries) { - prevUsageByEntry.push(lastFinalizedUsage); - if (e.total !== null) { - runningUsage = addUsage(runningUsage, e.total.usage); - lastFinalizedUsage = e.total.usage; - } - cumulativeByEntry.push(runningUsage); - } + // Running cumulative usage across ALL finalized turns (in entry order), for + // the per-turn "chat total" cache rate. Alongside it, the previous finalized + // turn's usage at each index — the baseline for cross-turn retention. + const cumulativeByEntry: Usage[] = []; + const prevUsageByEntry: (Usage | null)[] = []; + let runningUsage: Usage = { inputTokens: 0, outputTokens: 0 }; + let lastFinalizedUsage: Usage | null = null; + for (const e of entries) { + prevUsageByEntry.push(lastFinalizedUsage); + if (e.total !== null) { + runningUsage = addUsage(runningUsage, e.total.usage); + lastFinalizedUsage = e.total.usage; + } + cumulativeByEntry.push(runningUsage); + } - const rows: MetricsRow[] = []; + const rows: MetricsRow[] = []; - const firstUserIdx = segmentStarts[0] ?? 0; + const firstUserIdx = segmentStarts[0] ?? 0; - // Emit turn-metrics rows for entries that weren't matched to any segment - // (fully trimmed turns — their content was unloaded by the chat limit, but - // their aggregate metrics still show so the user knows what was trimmed). - for (let i = 0; i < entries.length; i++) { - if (usedEntries.has(i)) continue; - const e = entries[i]; - if (e === undefined || e.total === null) continue; - rows.push({ - kind: "turn-metrics", - turn: e.total, - turnNumber: i + 1, - cumulativeUsage: cumulativeByEntry[i] ?? e.total.usage, - prevTurnUsage: prevUsageByEntry[i] ?? null, - }); - } + for (let i = 0; i < firstUserIdx; i++) { + const g = groups[i]; + if (g !== undefined) { + rows.push({ kind: "group", group: g }); + } + } - for (let i = 0; i < firstUserIdx; i++) { - const g = groups[i]; - if (g !== undefined) { - rows.push({ kind: "group", group: g }); - } - } + for (let seg = 0; seg < T; seg++) { + const start = segmentStarts[seg] ?? 0; + const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; - for (let seg = 0; seg < T; seg++) { - const start = segmentStarts[seg] ?? 0; - const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; + const entry = segmentEntry.get(seg); - const entry = segmentEntry.get(seg); + if (entry === undefined) { + for (let i = start; i < end; i++) { + const g = groups[i]; + if (g !== undefined) { + rows.push({ kind: "group", group: g }); + } + } + continue; + } - if (entry === undefined) { - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g !== undefined) { - rows.push({ kind: "group", group: g }); - } - } - continue; - } + const entryIdx = segmentEntryIndex.get(seg) ?? 0; - const entryIdx = segmentEntryIndex.get(seg) ?? 0; + // Build anchor map: for each stepId, the LAST group index in this segment. + const anchorByStepId = new Map<string, number>(); + for (let i = start; i < end; i++) { + const g = groups[i]; + if (g === undefined) continue; + const sid = groupStepId(g); + if (sid !== undefined) { + anchorByStepId.set(sid, i); + } + } - // Build anchor map: for each stepId, the LAST group index in this segment. - const anchorByStepId = new Map<string, number>(); - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g === undefined) continue; - const sid = groupStepId(g); - if (sid !== undefined) { - anchorByStepId.set(sid, i); - } - } + // Classify each step as anchored or unanchored. Unanchored steps + // (content trimmed, or text-only steps with no tool chunks) are SKIPPED — + // step-metrics are only shown inline next to the content they describe. + const anchored: Map<number, { stepIndex: number; step: (typeof entry.steps)[number] }[]> = + new Map(); - // Classify each step as anchored or unanchored. Unanchored steps - // (content trimmed, or text-only steps with no tool chunks) are SKIPPED — - // step-metrics are only shown inline next to the content they describe. - const anchored: Map<number, { stepIndex: number; step: (typeof entry.steps)[number] }[]> = - new Map(); + for (let i = 0; i < entry.steps.length; i++) { + const step = entry.steps[i]; + if (step === undefined) continue; + const anchorGroupIdx = anchorByStepId.get(step.stepId); + if (anchorGroupIdx !== undefined) { + let arr = anchored.get(anchorGroupIdx); + if (arr === undefined) { + arr = []; + anchored.set(anchorGroupIdx, arr); + } + arr.push({ stepIndex: i, step }); + } + // Unanchored steps (no matching group) are skipped — no tail bubbles. + } - for (let i = 0; i < entry.steps.length; i++) { - const step = entry.steps[i]; - if (step === undefined) continue; - const anchorGroupIdx = anchorByStepId.get(step.stepId); - if (anchorGroupIdx !== undefined) { - let arr = anchored.get(anchorGroupIdx); - if (arr === undefined) { - arr = []; - anchored.set(anchorGroupIdx, arr); - } - arr.push({ stepIndex: i, step }); - } - // Unanchored steps (no matching group) are skipped — no tail bubbles. - } + // Emit groups; after each anchored group, emit its step-metrics rows. + for (let i = start; i < end; i++) { + const g = groups[i]; + if (g !== undefined) { + rows.push({ kind: "group", group: g }); + } + const stepsHere = anchored.get(i); + if (stepsHere !== undefined) { + stepsHere.sort((a, b) => a.stepIndex - b.stepIndex); + for (const { step, stepIndex } of stepsHere) { + rows.push({ kind: "step-metrics", step, index: stepIndex }); + } + } + } - // Emit groups; after each anchored group, emit its step-metrics rows. - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g !== undefined) { - rows.push({ kind: "group", group: g }); - } - const stepsHere = anchored.get(i); - if (stepsHere !== undefined) { - stepsHere.sort((a, b) => a.stepIndex - b.stepIndex); - for (const { step, stepIndex } of stepsHere) { - rows.push({ kind: "step-metrics", step, index: stepIndex }); - } - } - } + // Turn-metrics row (only when the turn is finalized). Unanchored steps + // are skipped — no tail bubbles. + if (entry.total !== null) { + rows.push({ + kind: "turn-metrics", + turn: entry.total, + turnNumber: entryIdx + 1, + cumulativeUsage: cumulativeByEntry[entryIdx] ?? entry.total.usage, + prevTurnUsage: prevUsageByEntry[entryIdx] ?? null, + }); + } + } - // Turn-metrics row (only when the turn is finalized). Unanchored steps - // are skipped — no tail bubbles. - if (entry.total !== null) { - rows.push({ - kind: "turn-metrics", - turn: entry.total, - turnNumber: entryIdx + 1, - cumulativeUsage: cumulativeByEntry[entryIdx] ?? entry.total.usage, - prevTurnUsage: prevUsageByEntry[entryIdx] ?? null, - }); - } - } - - return rows; + return rows; } diff --git a/src/core/metrics/reducer.test.ts b/src/core/metrics/reducer.test.ts index cd9f673..581a8b7 100644 --- a/src/core/metrics/reducer.test.ts +++ b/src/core/metrics/reducer.test.ts @@ -1,442 +1,689 @@ import type { StepId, TurnDoneEvent, TurnStepCompleteEvent, TurnUsageEvent } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - applyDurableMetrics, - foldMetricsEvent, - initialMetricsState, - selectCurrentContextSize, - selectOrderedTurnMetrics, + applyDurableMetrics, + foldMetricsEvent, + initialMetricsState, + selectCurrentContextSize, + selectOrderedTurnMetrics, } from "./reducer"; const usageEvent = ( - turnId: string, - inputTokens: number, - outputTokens: number, - stepId?: string, + turnId: string, + inputTokens: number, + outputTokens: number, + stepId?: string, ): TurnUsageEvent => { - const base = { - type: "usage" as const, - conversationId: "c1", - turnId, - usage: { inputTokens, outputTokens }, - }; - if (stepId !== undefined) { - return { ...base, stepId: stepId as StepId }; - } - return base; + const base = { + type: "usage" as const, + conversationId: "c1", + turnId, + usage: { inputTokens, outputTokens }, + }; + if (stepId !== undefined) { + return { ...base, stepId: stepId as StepId }; + } + return base; }; const stepCompleteEvent = ( - turnId: string, - stepId: string, - timing: { ttftMs?: number; decodeMs?: number; genTotalMs?: number } = {}, + turnId: string, + stepId: string, + timing: { ttftMs?: number; decodeMs?: number; genTotalMs?: number } = {}, ): TurnStepCompleteEvent => ({ - type: "step-complete", - conversationId: "c1", - turnId, - stepId: stepId as StepId, - ...timing, + type: "step-complete", + conversationId: "c1", + turnId, + stepId: stepId as StepId, + ...timing, }); const doneEvent = ( - turnId: string, - extra: { - durationMs?: number; - usage?: { inputTokens: number; outputTokens: number }; - contextSize?: number; - } = {}, + turnId: string, + extra: { + durationMs?: number; + usage?: { inputTokens: number; outputTokens: number }; + contextSize?: number; + } = {}, ): TurnDoneEvent => ({ - type: "done", - conversationId: "c1", - turnId, - reason: "stop", - ...extra, + type: "done", + conversationId: "c1", + turnId, + reason: "stop", + ...extra, }); describe("initialMetricsState", () => { - it("starts empty", () => { - const s = initialMetricsState(); - expect(s.live.size).toBe(0); - expect(s.liveOrder).toEqual([]); - expect(s.durable.size).toBe(0); - expect(s.durableOrder).toEqual([]); - }); + it("starts empty", () => { + const s = initialMetricsState(); + expect(s.live.size).toBe(0); + expect(s.liveOrder).toEqual([]); + expect(s.durable.size).toBe(0); + expect(s.durableOrder).toEqual([]); + }); }); describe("foldMetricsEvent", () => { - it("folds per-step usage by stepId into a turn", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(1); - expect(ordered[0]?.turnId).toBe("t1"); - expect(ordered[0]?.steps).toHaveLength(2); - expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); - expect(ordered[0]?.steps[0]?.usage).toEqual({ inputTokens: 100, outputTokens: 50 }); - expect(ordered[0]?.steps[1]?.stepId).toBe("s2"); - expect(ordered[0]?.steps[1]?.usage).toEqual({ inputTokens: 200, outputTokens: 80 }); - }); - - it("folds step-complete timing and merges with same-step usage", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent( - s, - stepCompleteEvent("t1", "s1", { ttftMs: 200, decodeMs: 800, genTotalMs: 1000 }), - ); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(1); - const step = ordered[0]?.steps[0]; - expect(step?.usage).toEqual({ inputTokens: 100, outputTokens: 50 }); - expect(step?.ttftMs).toBe(200); - expect(step?.decodeMs).toBe(800); - expect(step?.genTotalMs).toBe(1000); - }); - - it("step-complete before usage defaults usage to zeros", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 500 })); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - const step = ordered[0]?.steps[0]; - expect(step?.usage).toEqual({ inputTokens: 0, outputTokens: 0 }); - expect(step?.genTotalMs).toBe(500); - }); - - it("done sets durationMs and aggregate usage", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent( - s, - doneEvent("t1", { - durationMs: 5000, - usage: { inputTokens: 300, outputTokens: 150 }, - }), - ); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.total?.durationMs).toBe(5000); - expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 150 }); - }); - - it("aggregate usage sums steps when done.usage absent", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 130 }); - }); - - it("aggregate usage includes cache only when a step had cache", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, { - type: "usage", - conversationId: "c1", - turnId: "t1", - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 30 }, - }); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.total?.usage.cacheReadTokens).toBe(30); - expect(ordered[0]?.total?.usage.cacheWriteTokens).toBeUndefined(); - }); - - it("tolerates missing clock (no genTotalMs/ttft/decode)", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - const step = ordered[0]?.steps[0]; - expect(step?.ttftMs).toBeUndefined(); - expect(step?.decodeMs).toBeUndefined(); - expect(step?.genTotalMs).toBeUndefined(); - expect(ordered[0]?.total?.durationMs).toBeUndefined(); - }); - - it("usage without stepId does not create a turn", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50)); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(0); - }); - - it("ignores non-metrics events", () => { - const s = initialMetricsState(); - const next = foldMetricsEvent(s, { - type: "status", - conversationId: "c1", - status: "running", - }); - expect(next).toBe(s); - }); - - it("preserves first-seen order of steps", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 10, 5, "s2")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); - s = foldMetricsEvent(s, usageEvent("t1", 20, 8, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.steps[0]?.stepId).toBe("s2"); - expect(ordered[0]?.steps[1]?.stepId).toBe("s1"); - }); - - it("preserves first-seen order of turns", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t2", 10, 5, "s1")); - s = foldMetricsEvent(s, usageEvent("t1", 20, 8, "s1")); - s = foldMetricsEvent(s, doneEvent("t2")); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.turnId).toBe("t2"); - expect(ordered[1]?.turnId).toBe("t1"); - }); + it("folds per-step usage by stepId into a turn", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(1); + expect(ordered[0]?.turnId).toBe("t1"); + expect(ordered[0]?.steps).toHaveLength(2); + expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); + expect(ordered[0]?.steps[0]?.usage).toEqual({ inputTokens: 100, outputTokens: 50 }); + expect(ordered[0]?.steps[1]?.stepId).toBe("s2"); + expect(ordered[0]?.steps[1]?.usage).toEqual({ inputTokens: 200, outputTokens: 80 }); + }); + + it("folds step-complete timing and merges with same-step usage", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent( + s, + stepCompleteEvent("t1", "s1", { ttftMs: 200, decodeMs: 800, genTotalMs: 1000 }), + ); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(1); + const step = ordered[0]?.steps[0]; + expect(step?.usage).toEqual({ inputTokens: 100, outputTokens: 50 }); + expect(step?.ttftMs).toBe(200); + expect(step?.decodeMs).toBe(800); + expect(step?.genTotalMs).toBe(1000); + }); + + it("step-complete before usage defaults usage to zeros", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 500 })); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + const step = ordered[0]?.steps[0]; + expect(step?.usage).toEqual({ inputTokens: 0, outputTokens: 0 }); + expect(step?.genTotalMs).toBe(500); + }); + + it("done sets durationMs and aggregate usage", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent( + s, + doneEvent("t1", { + durationMs: 5000, + usage: { inputTokens: 300, outputTokens: 150 }, + }), + ); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.total?.durationMs).toBe(5000); + expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 150 }); + }); + + it("aggregate usage sums steps when done.usage absent", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 130 }); + }); + + it("aggregate usage includes cache only when a step had cache", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 30 }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.total?.usage.cacheReadTokens).toBe(30); + expect(ordered[0]?.total?.usage.cacheWriteTokens).toBeUndefined(); + }); + + it("tolerates missing clock (no genTotalMs/ttft/decode)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + const step = ordered[0]?.steps[0]; + expect(step?.ttftMs).toBeUndefined(); + expect(step?.decodeMs).toBeUndefined(); + expect(step?.genTotalMs).toBeUndefined(); + expect(ordered[0]?.total?.durationMs).toBeUndefined(); + }); + + it("usage without stepId does not create a turn", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50)); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(0); + }); + + it("ignores non-metrics events", () => { + const s = initialMetricsState(); + const next = foldMetricsEvent(s, { + type: "status", + conversationId: "c1", + status: "running", + }); + expect(next).toBe(s); + }); + + it("preserves first-seen order of steps", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 10, 5, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + s = foldMetricsEvent(s, usageEvent("t1", 20, 8, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.steps[0]?.stepId).toBe("s2"); + expect(ordered[0]?.steps[1]?.stepId).toBe("s1"); + }); + + it("preserves first-seen order of turns", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t2", 10, 5, "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 20, 8, "s1")); + s = foldMetricsEvent(s, doneEvent("t2")); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.turnId).toBe("t2"); + expect(ordered[1]?.turnId).toBe("t1"); + }); }); describe("selectOrderedTurnMetrics", () => { - it("durable wins over live by turnId, live-done appended last", () => { - let s = initialMetricsState(); - - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, usageEvent("t2", 200, 80, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); - s = foldMetricsEvent(s, doneEvent("t2")); - - s = applyDurableMetrics(s, [ - { - turnId: "t1", - usage: { inputTokens: 999, outputTokens: 999 }, - durationMs: 3000, - steps: [ - { - stepId: "s1" as StepId, - usage: { inputTokens: 999, outputTokens: 999 }, - genTotalMs: 3000, - }, - ], - }, - ]); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(2); - expect(ordered[0]?.turnId).toBe("t1"); - expect(ordered[0]?.total?.usage.inputTokens).toBe(999); - expect(ordered[0]?.total?.durationMs).toBe(3000); - expect(ordered[1]?.turnId).toBe("t2"); - expect(ordered[1]?.total?.durationMs).toBeUndefined(); - }); - - it("empty state returns empty", () => { - const s = initialMetricsState(); - expect(selectOrderedTurnMetrics(s)).toEqual([]); - }); - - it("selectOrderedTurnMetrics: in-flight turn exposes only completed steps and total=null", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 1000 })); - s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(1); - expect(ordered[0]?.turnId).toBe("t1"); - expect(ordered[0]?.steps).toHaveLength(1); - expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); - expect(ordered[0]?.total).toBeNull(); - }); - - it("selectOrderedTurnMetrics: a turn with no complete step and not done is omitted", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(0); - }); - - it("selectOrderedTurnMetrics: after done, total is present", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 1000 })); - s = foldMetricsEvent(s, doneEvent("t1", { durationMs: 2000 })); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(1); - expect(ordered[0]?.turnId).toBe("t1"); - expect(ordered[0]?.total?.durationMs).toBe(2000); - expect(ordered[0]?.steps).toHaveLength(1); - }); - - it("step-complete marks the step complete", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 500 })); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(1); - expect(ordered[0]?.steps).toHaveLength(1); - expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); - expect(ordered[0]?.steps[0]?.genTotalMs).toBe(500); - }); - - it("selectOrderedTurnMetrics: durable turn → steps + total present", () => { - let s = initialMetricsState(); - s = applyDurableMetrics(s, [ - { - turnId: "t1", - usage: { inputTokens: 300, outputTokens: 150 }, - durationMs: 5000, - steps: [ - { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 1000, - }, - { - stepId: "s2" as StepId, - usage: { inputTokens: 200, outputTokens: 100 }, - genTotalMs: 2000, - }, - ], - }, - ]); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(1); - expect(ordered[0]?.turnId).toBe("t1"); - expect(ordered[0]?.steps).toHaveLength(2); - expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); - expect(ordered[0]?.steps[1]?.stepId).toBe("s2"); - expect(ordered[0]?.total?.usage.inputTokens).toBe(300); - expect(ordered[0]?.total?.durationMs).toBe(5000); - }); + it("durable wins over live by turnId, live-done appended last", () => { + let s = initialMetricsState(); + + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, usageEvent("t2", 200, 80, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + s = foldMetricsEvent(s, doneEvent("t2")); + + s = applyDurableMetrics(s, [ + { + turnId: "t1", + usage: { inputTokens: 999, outputTokens: 999 }, + durationMs: 3000, + steps: [ + { + stepId: "s1" as StepId, + usage: { inputTokens: 999, outputTokens: 999 }, + genTotalMs: 3000, + }, + ], + }, + ]); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(2); + expect(ordered[0]?.turnId).toBe("t1"); + expect(ordered[0]?.total?.usage.inputTokens).toBe(999); + expect(ordered[0]?.total?.durationMs).toBe(3000); + expect(ordered[1]?.turnId).toBe("t2"); + expect(ordered[1]?.total?.durationMs).toBeUndefined(); + }); + + it("empty state returns empty", () => { + const s = initialMetricsState(); + expect(selectOrderedTurnMetrics(s)).toEqual([]); + }); + + it("selectOrderedTurnMetrics: in-flight turn exposes only completed steps and total=null", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 1000 })); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(1); + expect(ordered[0]?.turnId).toBe("t1"); + expect(ordered[0]?.steps).toHaveLength(1); + expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); + expect(ordered[0]?.total).toBeNull(); + }); + + it("selectOrderedTurnMetrics: a turn with no complete step and not done is omitted", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(0); + }); + + it("selectOrderedTurnMetrics: after done, total is present", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 1000 })); + s = foldMetricsEvent(s, doneEvent("t1", { durationMs: 2000 })); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(1); + expect(ordered[0]?.turnId).toBe("t1"); + expect(ordered[0]?.total?.durationMs).toBe(2000); + expect(ordered[0]?.steps).toHaveLength(1); + }); + + it("step-complete marks the step complete", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 500 })); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(1); + expect(ordered[0]?.steps).toHaveLength(1); + expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); + expect(ordered[0]?.steps[0]?.genTotalMs).toBe(500); + }); + + it("selectOrderedTurnMetrics: durable turn → steps + total present", () => { + let s = initialMetricsState(); + s = applyDurableMetrics(s, [ + { + turnId: "t1", + usage: { inputTokens: 300, outputTokens: 150 }, + durationMs: 5000, + steps: [ + { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 1000, + }, + { + stepId: "s2" as StepId, + usage: { inputTokens: 200, outputTokens: 100 }, + genTotalMs: 2000, + }, + ], + }, + ]); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(1); + expect(ordered[0]?.turnId).toBe("t1"); + expect(ordered[0]?.steps).toHaveLength(2); + expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); + expect(ordered[0]?.steps[1]?.stepId).toBe("s2"); + expect(ordered[0]?.total?.usage.inputTokens).toBe(300); + expect(ordered[0]?.total?.durationMs).toBe(5000); + }); }); describe("applyDurableMetrics", () => { - it("stores durable turns in order", () => { - let s = initialMetricsState(); - s = applyDurableMetrics(s, [ - { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [] }, - { turnId: "t2", usage: { inputTokens: 20, outputTokens: 8 }, steps: [] }, - ]); - expect(s.durableOrder).toEqual(["t1", "t2"]); - expect(s.durable.size).toBe(2); - }); - - it("is idempotent for same turnId", () => { - let s = initialMetricsState(); - const turn = { - turnId: "t1", - usage: { inputTokens: 10, outputTokens: 5 }, - steps: [], - }; - s = applyDurableMetrics(s, [turn]); - s = applyDurableMetrics(s, [turn]); - expect(s.durableOrder).toEqual(["t1"]); - expect(s.durable.size).toBe(1); - }); - - it("overwrites durable turn data for same turnId", () => { - let s = initialMetricsState(); - s = applyDurableMetrics(s, [ - { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [] }, - ]); - s = applyDurableMetrics(s, [ - { turnId: "t1", usage: { inputTokens: 99, outputTokens: 99 }, steps: [] }, - ]); - expect(s.durable.get("t1")?.usage.inputTokens).toBe(99); - }); + it("stores durable turns in order", () => { + let s = initialMetricsState(); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [] }, + { turnId: "t2", usage: { inputTokens: 20, outputTokens: 8 }, steps: [] }, + ]); + expect(s.durableOrder).toEqual(["t1", "t2"]); + expect(s.durable.size).toBe(2); + }); + + it("is idempotent for same turnId", () => { + let s = initialMetricsState(); + const turn = { + turnId: "t1", + usage: { inputTokens: 10, outputTokens: 5 }, + steps: [], + }; + s = applyDurableMetrics(s, [turn]); + s = applyDurableMetrics(s, [turn]); + expect(s.durableOrder).toEqual(["t1"]); + expect(s.durable.size).toBe(1); + }); + + it("overwrites durable turn data for same turnId", () => { + let s = initialMetricsState(); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [] }, + ]); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 99, outputTokens: 99 }, steps: [] }, + ]); + expect(s.durable.get("t1")?.usage.inputTokens).toBe(99); + }); }); describe("contextSize / selectCurrentContextSize", () => { - it("live done carries contextSize onto the turn total", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 1234 })); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.total?.contextSize).toBe(1234); - expect(selectCurrentContextSize(s)).toBe(1234); - }); - - it("contextSize is NOT the aggregate usage sum (multi-step turn)", () => { - let s = initialMetricsState(); - // Two steps: usage sums to 300 in / 130 out = 430, but contextSize is the - // backend-stamped final-step occupancy, independent of the sum. - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); - s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 250 })); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 130 }); - expect(ordered[0]?.total?.contextSize).toBe(250); - expect(selectCurrentContextSize(s)).toBe(250); - }); - - it("persisted (durable) contextSize is preserved and selected", () => { - let s = initialMetricsState(); - s = applyDurableMetrics(s, [ - { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [], contextSize: 4096 }, - ]); - expect(s.durable.get("t1")?.contextSize).toBe(4096); - expect(selectCurrentContextSize(s)).toBe(4096); - }); - - it("selectCurrentContextSize returns the LATEST turn's value", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 100 })); - s = foldMetricsEvent(s, doneEvent("t2", { contextSize: 900 })); - expect(selectCurrentContextSize(s)).toBe(900); - }); - - it("selectCurrentContextSize skips a later turn that lacks contextSize", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); - // t2 finishes but the provider reported no per-step usage → no contextSize. - s = foldMetricsEvent(s, doneEvent("t2")); - expect(selectCurrentContextSize(s)).toBe(700); - }); - - it("selectCurrentContextSize is undefined (not 0) when nothing reported", () => { - let s = initialMetricsState(); - expect(selectCurrentContextSize(s)).toBeUndefined(); - s = foldMetricsEvent(s, doneEvent("t1")); - expect(selectCurrentContextSize(s)).toBeUndefined(); - }); - - it("durable contextSize wins over live for a shared turnId", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 111 })); - s = applyDurableMetrics(s, [ - { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [], contextSize: 222 }, - ]); - expect(selectCurrentContextSize(s)).toBe(222); - }); + it("live done carries contextSize onto the turn total", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 1234 })); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.total?.contextSize).toBe(1234); + expect(selectCurrentContextSize(s)).toBe(1234); + }); + + it("contextSize is NOT the aggregate usage sum (multi-step turn)", () => { + let s = initialMetricsState(); + // Two steps: usage sums to 300 in / 130 out = 430, but contextSize is the + // backend-stamped final-step occupancy, independent of the sum. + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 250 })); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 130 }); + expect(ordered[0]?.total?.contextSize).toBe(250); + expect(selectCurrentContextSize(s)).toBe(250); + }); + + it("persisted (durable) contextSize is preserved and selected", () => { + let s = initialMetricsState(); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [], contextSize: 4096 }, + ]); + expect(s.durable.get("t1")?.contextSize).toBe(4096); + expect(selectCurrentContextSize(s)).toBe(4096); + }); + + it("selectCurrentContextSize returns the LATEST turn's value", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 100 })); + s = foldMetricsEvent(s, doneEvent("t2", { contextSize: 900 })); + expect(selectCurrentContextSize(s)).toBe(900); + }); + + it("selectCurrentContextSize skips a later turn that lacks contextSize", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + // t2 finishes but the provider reported no per-step usage → no contextSize. + s = foldMetricsEvent(s, doneEvent("t2")); + expect(selectCurrentContextSize(s)).toBe(700); + }); + + it("selectCurrentContextSize is undefined (not 0) when nothing reported", () => { + let s = initialMetricsState(); + expect(selectCurrentContextSize(s)).toBeUndefined(); + s = foldMetricsEvent(s, doneEvent("t1")); + expect(selectCurrentContextSize(s)).toBeUndefined(); + }); + + it("durable contextSize wins over live for a shared turnId", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 111 })); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [], contextSize: 222 }, + ]); + expect(selectCurrentContextSize(s)).toBe(222); + }); + + it("in-flight turn updates context size after the first step completes", () => { + // Before the requirement: an in-flight turn had total=null so its step usage + // was ignored until `done`. Now the latest step's input+output is used. + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + + // Still generating (no done) — context = step 1 input+output = 5200. + expect(selectCurrentContextSize(s)).toBe(5200); + }); + + it("in-flight turn updates progressively as each step reports usage", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + expect(selectCurrentContextSize(s)).toBe(5200); + + // Step 2 reports usage mid-stream (before its step-complete): each step's + // input already includes all prior context, so the last step's input+output + // is the current occupancy. + s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + }); + + it("in-flight context size is the latest step with usage, NOT the aggregate sum", () => { + // Mirrors the finalized-turn test: contextSize is the FINAL step's + // input+output, not the sum across steps (which would overcount a + // multi-step turn because every step re-prefills the growing prompt). + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + // Aggregate would be 300+130=430; the latest step is 200+80=280. + expect(selectCurrentContextSize(s)).toBe(280); + }); + + it("in-flight turn with a step-complete but no usage falls back to older turn", () => { + // step-complete before usage → the step has no usage yet, so the in-flight + // turn exposes no context size and the display falls back to the prior + // finalized turn's value (never 0). + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1", { genTotalMs: 500 })); + + expect(selectCurrentContextSize(s)).toBe(700); + }); + + it("in-flight turn with no steps/usage returns undefined (falls back)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + // t2 just started — no usage, no complete step — omitted entirely. + s = foldMetricsEvent(s, { type: "turn-start", conversationId: "c1", turnId: "t2" }); + expect(selectCurrentContextSize(s)).toBe(700); + + // t2's first step reports usage → the display jumps to t2's live value. + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + expect(selectCurrentContextSize(s)).toBe(810); + }); + + it("done finalizes the in-flight progressive value with the authoritative contextSize", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + expect(selectCurrentContextSize(s)).toBe(5200); + + s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + + // done stamps the authoritative contextSize (the final step's input+output). + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 5350 })); + expect(selectCurrentContextSize(s)).toBe(5350); + }); + + it("in-flight context size excludes cache tokens (they are a subset of inputTokens)", () => { + // cacheReadTokens / cacheWriteTokens are portions of inputTokens already + // counted — adding them would double-count. Only input+output is occupancy. + let s = initialMetricsState(); + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s1" as StepId, + usage: { + inputTokens: 5000, + outputTokens: 200, + cacheReadTokens: 4000, + cacheWriteTokens: 1000, + }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + // 5000+200=5200, NOT 9200 (with cacheRead) or 10200 (with both). + expect(selectCurrentContextSize(s)).toBe(5200); + }); + + it("multiple in-flight turns: the newest turn's live value wins", () => { + let s = initialMetricsState(); + // t1 (older) in-flight with one completed step → 5200. + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + // t2 (newer, seen later → last in liveOrder) in-flight → 8000. + s = foldMetricsEvent(s, usageEvent("t2", 7800, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + expect(selectCurrentContextSize(s)).toBe(8000); + }); + + it("out-of-order step IDs: usage for step 2 before step 1's step-complete still scans newest-first", () => { + // stepOrder is FIRST-SEEN: s1 (its usage arrived first), then s2. So s2 is + // the newest step regardless of when each step's step-complete arrives. + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2")); + // Neither step complete yet → the turn is omitted (no complete step), so the + // display can't update until the first step completes. + expect(selectCurrentContextSize(s)).toBeUndefined(); + + // s1 completes AFTER s2's usage was reported. The turn is now visible; the + // newest-first scan picks s2 (the later step), not s1 (the just-completed one). + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + expect(selectCurrentContextSize(s)).toBe(5350); + + // s2 completes — still s2, unchanged. + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + }); + + it("done turn without contextSize falls back to an older turn (even with step usage)", () => { + // Contract lock-in: a done turn's step usage is NOT consulted for the + // context display — only its authoritative total.contextSize is. When that + // is absent, the display falls back to the next older finalized turn rather + // than synthesizing a value from the step usage. + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + // t2 done WITH step usage but NO done.contextSize (edge case: the done event + // omitted contextSize despite per-step usage). + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + s = foldMetricsEvent(s, doneEvent("t2")); + expect(selectCurrentContextSize(s)).toBe(700); + }); + + it("in-flight context size skips a step with unsafe usage (NaN / negative)", () => { + // A corrupt provider report must never reach the status bar. The newest + // step with invalid counters is skipped, falling back to the prior valid one. + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + // s2 reports NaN input (e.g. a non-numeric provider field coerced). + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s2" as StepId, + usage: { inputTokens: Number.NaN, outputTokens: 150 }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + // s2 skipped (NaN) → falls back to s1's 5200, NOT NaN. + expect(selectCurrentContextSize(s)).toBe(5200); + + // Negative tokens are likewise skipped. + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s3" as StepId, + usage: { inputTokens: -10, outputTokens: 5 }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s3")); + expect(selectCurrentContextSize(s)).toBe(5200); + }); +}); + +describe("applyDurableMetrics pruning", () => { + it("prunes a live turn once durable data covers it (no unbounded growth)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 })); + expect(s.live.has("t1")).toBe(true); + expect(s.liveOrder).toContain("t1"); + + s = applyDurableMetrics(s, [ + { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [{ stepId: "s1" as StepId, usage: { inputTokens: 100, outputTokens: 50 } }], + contextSize: 150, + }, + ]); + // The live copy is gone; the durable (authoritative) entry replaces it. + expect(s.live.has("t1")).toBe(false); + expect(s.liveOrder).not.toContain("t1"); + expect(s.durable.has("t1")).toBe(true); + // The display still reads the durable value atomically (no gap). + expect(selectCurrentContextSize(s)).toBe(150); + }); + + it("prunes only the turns present in the durable batch (leaves other live turns)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 })); + // t2 still in flight — must NOT be pruned when only t1 seals. + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 100, outputTokens: 50 }, steps: [], contextSize: 150 }, + ]); + expect(s.live.has("t1")).toBe(false); + expect(s.live.has("t2")).toBe(true); + expect(s.liveOrder).toEqual(["t2"]); + // The newest (in-flight) turn's live value still wins. + expect(selectCurrentContextSize(s)).toBe(810); + }); + + it("is a no-op when no incoming turn is live (no live mutation)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + const before = s; + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [] }, + ]); + // t1 was never live → the live map/order are unchanged (same reference). + expect(s.live).toBe(before.live); + expect(s.liveOrder).toBe(before.liveOrder); + // t1 (durable) is older; the in-flight t2 still wins. + expect(selectCurrentContextSize(s)).toBe(810); + }); + + it("durable wins over live for a shared turnId (pruned live no longer consulted)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 111 })); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [], contextSize: 222 }, + ]); + // The live (111) copy is pruned; only durable (222) remains. + expect(s.live.has("t1")).toBe(false); + expect(selectCurrentContextSize(s)).toBe(222); + }); }); diff --git a/src/core/metrics/reducer.ts b/src/core/metrics/reducer.ts index 1e66cc8..39fc5ee 100644 --- a/src/core/metrics/reducer.ts +++ b/src/core/metrics/reducer.ts @@ -2,127 +2,172 @@ import type { AgentEvent, StepId, StepMetrics, TurnMetrics, Usage } from "@dispa import type { BuildingStep, LiveTurn, MetricsState, TurnMetricsEntry } from "./types"; function sumStepUsages(steps: readonly BuildingStep[]): Usage { - let inputTokens = 0; - let outputTokens = 0; - let hasCacheRead = false; - let hasCacheWrite = false; - let cacheReadTokens = 0; - let cacheWriteTokens = 0; + let inputTokens = 0; + let outputTokens = 0; + let hasCacheRead = false; + let hasCacheWrite = false; + let cacheReadTokens = 0; + let cacheWriteTokens = 0; - for (const step of steps) { - if (step.usage === undefined) continue; - inputTokens += step.usage.inputTokens; - outputTokens += step.usage.outputTokens; - if (step.usage.cacheReadTokens !== undefined && step.usage.cacheReadTokens > 0) { - hasCacheRead = true; - cacheReadTokens += step.usage.cacheReadTokens; - } - if (step.usage.cacheWriteTokens !== undefined && step.usage.cacheWriteTokens > 0) { - hasCacheWrite = true; - cacheWriteTokens += step.usage.cacheWriteTokens; - } - } + for (const step of steps) { + if (step.usage === undefined) continue; + inputTokens += step.usage.inputTokens; + outputTokens += step.usage.outputTokens; + if (step.usage.cacheReadTokens !== undefined && step.usage.cacheReadTokens > 0) { + hasCacheRead = true; + cacheReadTokens += step.usage.cacheReadTokens; + } + if (step.usage.cacheWriteTokens !== undefined && step.usage.cacheWriteTokens > 0) { + hasCacheWrite = true; + cacheWriteTokens += step.usage.cacheWriteTokens; + } + } - const base: Usage = { inputTokens, outputTokens }; - if (hasCacheRead) { - (base as { cacheReadTokens?: number }).cacheReadTokens = cacheReadTokens; - } - if (hasCacheWrite) { - (base as { cacheWriteTokens?: number }).cacheWriteTokens = cacheWriteTokens; - } - return base; + const base: Usage = { inputTokens, outputTokens }; + if (hasCacheRead) { + (base as { cacheReadTokens?: number }).cacheReadTokens = cacheReadTokens; + } + if (hasCacheWrite) { + (base as { cacheWriteTokens?: number }).cacheWriteTokens = cacheWriteTokens; + } + return base; } function buildingStepToMetrics(bs: BuildingStep): StepMetrics { - const usage: Usage = bs.usage ?? { inputTokens: 0, outputTokens: 0 }; - const base: StepMetrics = { stepId: bs.stepId as StepId, usage }; - if (bs.ttftMs !== undefined) { - (base as { ttftMs?: number }).ttftMs = bs.ttftMs; - } - if (bs.decodeMs !== undefined) { - (base as { decodeMs?: number }).decodeMs = bs.decodeMs; - } - if (bs.genTotalMs !== undefined) { - (base as { genTotalMs?: number }).genTotalMs = bs.genTotalMs; - } - return base; + const usage: Usage = bs.usage ?? { inputTokens: 0, outputTokens: 0 }; + const base: StepMetrics = { stepId: bs.stepId as StepId, usage }; + if (bs.ttftMs !== undefined) { + (base as { ttftMs?: number }).ttftMs = bs.ttftMs; + } + if (bs.decodeMs !== undefined) { + (base as { decodeMs?: number }).decodeMs = bs.decodeMs; + } + if (bs.genTotalMs !== undefined) { + (base as { genTotalMs?: number }).genTotalMs = bs.genTotalMs; + } + return base; } function getStep(lt: LiveTurn, id: string): BuildingStep { - const step = lt.stepMap.get(id); - if (step === undefined) throw new Error(`Missing step ${id} in live turn`); - return step; + const step = lt.stepMap.get(id); + if (step === undefined) throw new Error(`Missing step ${id} in live turn`); + return step; } function liveTurnToMetrics(lt: LiveTurn): TurnMetrics { - const buildingSteps = lt.stepOrder.map((id) => getStep(lt, id)); - const steps = buildingSteps.map((bs) => buildingStepToMetrics(bs)); - const usage = lt.doneUsage ?? sumStepUsages(buildingSteps); - const base: TurnMetrics = { turnId: lt.turnId, usage, steps }; - if (lt.durationMs !== undefined) { - (base as { durationMs?: number }).durationMs = lt.durationMs; - } - if (lt.doneContextSize !== undefined) { - (base as { contextSize?: number }).contextSize = lt.doneContextSize; - } - return base; + const buildingSteps = lt.stepOrder.map((id) => getStep(lt, id)); + const steps = buildingSteps.map((bs) => buildingStepToMetrics(bs)); + const usage = lt.doneUsage ?? sumStepUsages(buildingSteps); + const base: TurnMetrics = { turnId: lt.turnId, usage, steps }; + if (lt.durationMs !== undefined) { + (base as { durationMs?: number }).durationMs = lt.durationMs; + } + if (lt.doneContextSize !== undefined) { + (base as { contextSize?: number }).contextSize = lt.doneContextSize; + } + return base; +} + +/** + * A step's contribution to the live context size: `inputTokens + outputTokens`, + * or `undefined` when the step has no usage yet OR its counters are not safe to + * sum (non-finite / negative — defensive: a corrupt provider report must never + * reach the status bar as NaN/Infinity). Cache tokens are deliberately NOT + * included: `cacheReadTokens` / `cacheWriteTokens` are a SUBSET of + * `inputTokens`, so adding them would double-count. + */ +function stepContextSize(usage: Usage | undefined): number | undefined { + if (usage === undefined) return undefined; + const { inputTokens, outputTokens } = usage; + if (!Number.isFinite(inputTokens) || !Number.isFinite(outputTokens)) return undefined; + if (inputTokens < 0 || outputTokens < 0) return undefined; + return inputTokens + outputTokens; +} + +/** + * The context size an IN-FLIGHT (not-done) turn occupies right now — for + * progressive display DURING a turn (before it seals), so the indicator updates + * after each step instead of waiting for `done`. + * + * CONTRACT: only call this on a turn whose `done` event has NOT arrived (the + * caller, `selectCurrentContextSize`, reaches it solely for entries with + * `total === null`, i.e. `lt.done === false`). Finalized turns use their + * authoritative `contextSize` instead; `doneContextSize` is read on the + * `total` path, never here. + * + * Returns the most recent step WITH USABLE USAGE's `inputTokens + outputTokens` + * (scanning newest → oldest by first-seen step order): each step's input + * already includes all prior context (the prompt is re-prefilled every step), so + * the last step's input+output is the true occupancy — the same definition + * `TurnDoneEvent.contextSize` stamps at turn end. A just-reported step's usage + * wins immediately, even mid-stream. Steps with no usage or unsafe usage are + * skipped, falling back to the next older usable step. `undefined` when no step + * has reported usable usage yet. + */ +function liveTurnContextSize(lt: LiveTurn): number | undefined { + for (let i = lt.stepOrder.length - 1; i >= 0; i--) { + const step = lt.stepMap.get(lt.stepOrder[i] ?? ""); + const ctx = stepContextSize(step?.usage); + if (ctx !== undefined) return ctx; + } + return undefined; } function ensureLiveTurn(state: MetricsState, turnId: string): [MetricsState, LiveTurn] { - const existing = state.live.get(turnId); - if (existing !== undefined) return [state, existing]; + const existing = state.live.get(turnId); + if (existing !== undefined) return [state, existing]; - const newTurn: LiveTurn = { - turnId, - done: false, - durationMs: undefined, - doneUsage: undefined, - doneContextSize: undefined, - stepMap: new Map(), - stepOrder: [], - }; - const newLive = new Map(state.live); - newLive.set(turnId, newTurn); - return [{ ...state, live: newLive, liveOrder: [...state.liveOrder, turnId] }, newTurn]; + const newTurn: LiveTurn = { + turnId, + done: false, + durationMs: undefined, + doneUsage: undefined, + doneContextSize: undefined, + stepMap: new Map(), + stepOrder: [], + }; + const newLive = new Map(state.live); + newLive.set(turnId, newTurn); + return [{ ...state, live: newLive, liveOrder: [...state.liveOrder, turnId] }, newTurn]; } function upsertStep(lt: LiveTurn, stepId: string, update: Partial<BuildingStep>): LiveTurn { - const existing = lt.stepMap.get(stepId); - if (existing !== undefined) { - const merged: BuildingStep = { - stepId, - usage: update.usage ?? existing.usage, - ttftMs: update.ttftMs ?? existing.ttftMs, - decodeMs: update.decodeMs ?? existing.decodeMs, - genTotalMs: update.genTotalMs ?? existing.genTotalMs, - complete: update.complete ?? existing.complete, - }; - const newMap = new Map(lt.stepMap); - newMap.set(stepId, merged); - return { ...lt, stepMap: newMap }; - } + const existing = lt.stepMap.get(stepId); + if (existing !== undefined) { + const merged: BuildingStep = { + stepId, + usage: update.usage ?? existing.usage, + ttftMs: update.ttftMs ?? existing.ttftMs, + decodeMs: update.decodeMs ?? existing.decodeMs, + genTotalMs: update.genTotalMs ?? existing.genTotalMs, + complete: update.complete ?? existing.complete, + }; + const newMap = new Map(lt.stepMap); + newMap.set(stepId, merged); + return { ...lt, stepMap: newMap }; + } - const fresh: BuildingStep = { - stepId, - usage: update.usage, - ttftMs: update.ttftMs, - decodeMs: update.decodeMs, - genTotalMs: update.genTotalMs, - complete: update.complete ?? false, - }; - const newMap = new Map(lt.stepMap); - newMap.set(stepId, fresh); - return { ...lt, stepMap: newMap, stepOrder: [...lt.stepOrder, stepId] }; + const fresh: BuildingStep = { + stepId, + usage: update.usage, + ttftMs: update.ttftMs, + decodeMs: update.decodeMs, + genTotalMs: update.genTotalMs, + complete: update.complete ?? false, + }; + const newMap = new Map(lt.stepMap); + newMap.set(stepId, fresh); + return { ...lt, stepMap: newMap, stepOrder: [...lt.stepOrder, stepId] }; } /** The initial empty metrics state. */ export function initialMetricsState(): MetricsState { - return { - live: new Map(), - liveOrder: [], - durable: new Map(), - durableOrder: [], - }; + return { + live: new Map(), + liveOrder: [], + durable: new Map(), + durableOrder: [], + }; } /** @@ -135,69 +180,88 @@ export function initialMetricsState(): MetricsState { * - All other event types: return state unchanged. */ export function foldMetricsEvent(state: MetricsState, event: AgentEvent): MetricsState { - switch (event.type) { - case "usage": { - if (event.stepId === undefined) return state; - const [s1, lt] = ensureLiveTurn(state, event.turnId); - const updated = upsertStep(lt, event.stepId, { usage: event.usage }); - const newLive = new Map(s1.live); - newLive.set(event.turnId, updated); - return { ...s1, live: newLive }; - } + switch (event.type) { + case "usage": { + if (event.stepId === undefined) return state; + const [s1, lt] = ensureLiveTurn(state, event.turnId); + const updated = upsertStep(lt, event.stepId, { usage: event.usage }); + const newLive = new Map(s1.live); + newLive.set(event.turnId, updated); + return { ...s1, live: newLive }; + } - case "step-complete": { - const [s1, lt] = ensureLiveTurn(state, event.turnId); - const updated = upsertStep(lt, event.stepId, { - ttftMs: event.ttftMs, - decodeMs: event.decodeMs, - genTotalMs: event.genTotalMs, - complete: true, - }); - const newLive = new Map(s1.live); - newLive.set(event.turnId, updated); - return { ...s1, live: newLive }; - } + case "step-complete": { + const [s1, lt] = ensureLiveTurn(state, event.turnId); + const updated = upsertStep(lt, event.stepId, { + ttftMs: event.ttftMs, + decodeMs: event.decodeMs, + genTotalMs: event.genTotalMs, + complete: true, + }); + const newLive = new Map(s1.live); + newLive.set(event.turnId, updated); + return { ...s1, live: newLive }; + } - case "done": { - const [s1, lt] = ensureLiveTurn(state, event.turnId); - const updated: LiveTurn = { - ...lt, - done: true, - durationMs: event.durationMs ?? lt.durationMs, - doneUsage: event.usage ?? lt.doneUsage, - doneContextSize: event.contextSize ?? lt.doneContextSize, - }; - const newLive = new Map(s1.live); - newLive.set(event.turnId, updated); - return { ...s1, live: newLive }; - } + case "done": { + const [s1, lt] = ensureLiveTurn(state, event.turnId); + const updated: LiveTurn = { + ...lt, + done: true, + durationMs: event.durationMs ?? lt.durationMs, + doneUsage: event.usage ?? lt.doneUsage, + doneContextSize: event.contextSize ?? lt.doneContextSize, + }; + const newLive = new Map(s1.live); + newLive.set(event.turnId, updated); + return { ...s1, live: newLive }; + } - default: - return state; - } + default: + return state; + } } /** * Store durable (sealed) metrics from the backend. These win over live data * for any shared `turnId`. + * + * Once durable (authoritative) data covers a turn, its live (in-memory) copy + * is REDUNDANT and is pruned from `state.live` / `liveOrder` so the live map + * doesn't grow unbounded over a long conversation. There is no display gap: + * the durable entry replaces the live one atomically in the same fold, and + * `selectOrderedTurnMetrics` / `selectCurrentContextSize` read durable for it. */ export function applyDurableMetrics( - state: MetricsState, - turns: readonly TurnMetrics[], + state: MetricsState, + turns: readonly TurnMetrics[], ): MetricsState { - const newDurable = new Map(state.durable); - const newDurableOrder = [...state.durableOrder]; - for (const turn of turns) { - if (!newDurable.has(turn.turnId)) { - newDurableOrder.push(turn.turnId); - } - newDurable.set(turn.turnId, turn); - } - return { - ...state, - durable: newDurable, - durableOrder: newDurableOrder, - }; + const newDurable = new Map(state.durable); + const newDurableOrder = [...state.durableOrder]; + const prunedIds = new Set<string>(); + for (const turn of turns) { + if (!newDurable.has(turn.turnId)) { + newDurableOrder.push(turn.turnId); + } + newDurable.set(turn.turnId, turn); + if (state.live.has(turn.turnId)) prunedIds.add(turn.turnId); + } + + if (prunedIds.size === 0) { + return { ...state, durable: newDurable, durableOrder: newDurableOrder }; + } + + const newLive = new Map(state.live); + for (const id of prunedIds) newLive.delete(id); + const newLiveOrder = state.liveOrder.filter((id) => !prunedIds.has(id)); + + return { + ...state, + live: newLive, + liveOrder: newLiveOrder, + durable: newDurable, + durableOrder: newDurableOrder, + }; } /** @@ -210,54 +274,72 @@ export function applyDurableMetrics( * Live turns with no completed steps and not done are omitted. */ export function selectOrderedTurnMetrics(state: MetricsState): readonly TurnMetricsEntry[] { - const result: TurnMetricsEntry[] = []; - const seen = new Set<string>(); + const result: TurnMetricsEntry[] = []; + const seen = new Set<string>(); - for (const turnId of state.durableOrder) { - const tm = state.durable.get(turnId); - if (tm !== undefined) { - result.push({ turnId, steps: tm.steps, total: tm }); - seen.add(turnId); - } - } + for (const turnId of state.durableOrder) { + const tm = state.durable.get(turnId); + if (tm !== undefined) { + result.push({ turnId, steps: tm.steps, total: tm }); + seen.add(turnId); + } + } - for (const turnId of state.liveOrder) { - if (seen.has(turnId)) continue; - const lt = state.live.get(turnId); - if (lt === undefined) continue; + for (const turnId of state.liveOrder) { + if (seen.has(turnId)) continue; + const lt = state.live.get(turnId); + if (lt === undefined) continue; - const completeSteps = lt.stepOrder - .map((id) => lt.stepMap.get(id)) - .filter((s): s is BuildingStep => s?.complete === true) - .map((s) => buildingStepToMetrics(s)); + const completeSteps = lt.stepOrder + .map((id) => lt.stepMap.get(id)) + .filter((s): s is BuildingStep => s?.complete === true) + .map((s) => buildingStepToMetrics(s)); - if (completeSteps.length === 0 && !lt.done) continue; + if (completeSteps.length === 0 && !lt.done) continue; - result.push({ - turnId, - steps: completeSteps, - total: lt.done ? liveTurnToMetrics(lt) : null, - }); - } + result.push({ + turnId, + steps: completeSteps, + total: lt.done ? liveTurnToMetrics(lt) : null, + }); + } - return result; + return result; } /** * Select the conversation's CURRENT context size — the tokens it occupies right * now. Per the wire contract a client reads the LATEST turn's `contextSize`; we * scan the merged ordered turns NEWEST → OLDEST and return the first DEFINED - * `contextSize` (a finalized turn whose provider reported per-step usage). + * value. + * + * For a FINALIZED turn (`done` event or durable data) we use its authoritative + * `contextSize`. For an IN-FLIGHT (not-done) turn we compute it PROGRESSIVELY + * from the most recent step WITH USAGE — its `inputTokens + outputTokens` is the + * current occupancy (mirroring `TurnDoneEvent.contextSize`'s definition) — so + * the indicator updates after each step completes instead of waiting for the + * turn to seal. An in-flight turn with no step usage yet is skipped, falling + * back to the next older finalized turn. * - * Returns `undefined` ("unknown") when no finalized turn carries a context size — - * the caller renders a placeholder, NEVER `0`. Durable (sealed) data wins over + * Returns `undefined` ("unknown") when no turn carries a context size — the + * caller renders a placeholder, NEVER `0`. Durable (sealed) data wins over * live for a shared `turnId` (it is the persisted, authoritative value). */ export function selectCurrentContextSize(state: MetricsState): number | undefined { - const ordered = selectOrderedTurnMetrics(state); - for (let i = ordered.length - 1; i >= 0; i--) { - const total = ordered[i]?.total; - if (total?.contextSize !== undefined) return total.contextSize; - } - return undefined; + const ordered = selectOrderedTurnMetrics(state); + for (let i = ordered.length - 1; i >= 0; i--) { + const entry = ordered[i]; + if (entry === undefined) continue; + if (entry.total !== null) { + if (entry.total.contextSize !== undefined) return entry.total.contextSize; + continue; + } + // In-flight turn: progressive context size from the latest step with usage. + const lt = state.live.get(entry.turnId); + if (lt !== undefined) { + const live = liveTurnContextSize(lt); + if (live !== undefined) return live; + } + } + return undefined; } diff --git a/src/core/metrics/types.ts b/src/core/metrics/types.ts index 5b96e0f..84d1904 100644 --- a/src/core/metrics/types.ts +++ b/src/core/metrics/types.ts @@ -5,28 +5,28 @@ export type { StepMetrics, TurnMetrics }; /** A step being built from live events (may be incomplete). */ export interface BuildingStep { - readonly stepId: string; - readonly usage: Usage | undefined; - readonly ttftMs: number | undefined; - readonly decodeMs: number | undefined; - readonly genTotalMs: number | undefined; - readonly complete: boolean; + readonly stepId: string; + readonly usage: Usage | undefined; + readonly ttftMs: number | undefined; + readonly decodeMs: number | undefined; + readonly genTotalMs: number | undefined; + readonly complete: boolean; } /** A turn being built from live events (in-flight). */ export interface LiveTurn { - readonly turnId: string; - readonly done: boolean; - readonly durationMs: number | undefined; - readonly doneUsage: Usage | undefined; - /** - * Context size carried on the turn's `done` event (the turn's FINAL step - * `inputTokens + outputTokens` — current context occupancy). `undefined` when - * the provider reported no per-step usage; never coerced to `0`. - */ - readonly doneContextSize: number | undefined; - readonly stepMap: ReadonlyMap<string, BuildingStep>; - readonly stepOrder: readonly string[]; + readonly turnId: string; + readonly done: boolean; + readonly durationMs: number | undefined; + readonly doneUsage: Usage | undefined; + /** + * Context size carried on the turn's `done` event (the turn's FINAL step + * `inputTokens + outputTokens` — current context occupancy). `undefined` when + * the provider reported no per-step usage; never coerced to `0`. + */ + readonly doneContextSize: number | undefined; + readonly stepMap: ReadonlyMap<string, BuildingStep>; + readonly stepOrder: readonly string[]; } /** @@ -36,62 +36,62 @@ export interface LiveTurn { * - `durable`: sealed turns keyed by `turnId` in the order they arrived. */ export interface MetricsState { - readonly live: ReadonlyMap<string, LiveTurn>; - readonly liveOrder: readonly string[]; - readonly durable: ReadonlyMap<string, TurnMetrics>; - readonly durableOrder: readonly string[]; + readonly live: ReadonlyMap<string, LiveTurn>; + readonly liveOrder: readonly string[]; + readonly durable: ReadonlyMap<string, TurnMetrics>; + readonly durableOrder: readonly string[]; } /** Per-turn placement entry: completed steps so far + optional turn total. */ export interface TurnMetricsEntry { - readonly turnId: string; - readonly steps: readonly StepMetrics[]; - readonly total: TurnMetrics | null; + readonly turnId: string; + readonly steps: readonly StepMetrics[]; + readonly total: TurnMetrics | null; } /** A row in the interleaved transcript: a render group, per-step metrics, or turn metrics. */ export type MetricsRow = - | { readonly kind: "group"; readonly group: RenderGroup } - | { readonly kind: "step-metrics"; readonly step: StepMetrics; readonly index: number } - | { - readonly kind: "turn-metrics"; - readonly turn: TurnMetrics; - /** 1-based turn number (the entry's position in the metrics array + 1). */ - readonly turnNumber: number; - /** Cumulative usage across all finalized turns up to and including this one. */ - readonly cumulativeUsage: Usage; - /** - * Usage of the most recent EARLIER finalized turn, or `null` when this is the - * first finalized turn. The baseline for cross-turn retention (expected cache). - */ - readonly prevTurnUsage: Usage | null; - }; + | { readonly kind: "group"; readonly group: RenderGroup } + | { readonly kind: "step-metrics"; readonly step: StepMetrics; readonly index: number } + | { + readonly kind: "turn-metrics"; + readonly turn: TurnMetrics; + /** 1-based turn number (the entry's position in the metrics array + 1). */ + readonly turnNumber: number; + /** Cumulative usage across all finalized turns up to and including this one. */ + readonly cumulativeUsage: Usage; + /** + * Usage of the most recent EARLIER finalized turn, or `null` when this is the + * first finalized turn. The baseline for cross-turn retention (expected cache). + */ + readonly prevTurnUsage: Usage | null; + }; /** Formatted cache hit-rate view: percentage + colour severity + hit flag. */ export interface CacheRateView { - /** Cache hit rate as a 0..100 integer percentage (`cacheReadTokens / inputTokens`). */ - readonly pct: number; - /** Colour severity for a badge (maps to DaisyUI `badge-{level}`). */ - readonly level: "success" | "warning" | "error"; - /** Whether any input tokens were served from cache. */ - readonly isHit: boolean; + /** Cache hit rate as a 0..100 integer percentage (`cacheReadTokens / inputTokens`). */ + readonly pct: number; + /** Colour severity for a badge (maps to DaisyUI `badge-{level}`). */ + readonly level: "success" | "warning" | "error"; + /** Whether any input tokens were served from cache. */ + readonly isHit: boolean; } /** Formatted per-step view for display. */ export interface StepMetricsView { - readonly label: string; - readonly tokensLabel: string; - readonly tps: string | null; - readonly ttft: string | null; - readonly decode: string | null; - readonly genTotal: string | null; + readonly label: string; + readonly tokensLabel: string; + readonly tps: string | null; + readonly ttft: string | null; + readonly decode: string | null; + readonly genTotal: string | null; } /** Formatted per-turn view for display. */ export interface TurnMetricsView { - readonly label: string; - readonly tokensLabel: string; - readonly breakdown: string; - readonly tps: string | null; - readonly duration: string | null; + readonly label: string; + readonly tokensLabel: string; + readonly breakdown: string; + readonly tps: string | null; + readonly duration: string | null; } diff --git a/src/core/protocol/index.ts b/src/core/protocol/index.ts index e7fd161..2c1c290 100644 --- a/src/core/protocol/index.ts +++ b/src/core/protocol/index.ts @@ -1,9 +1,9 @@ export { - applyServerMessage, - getSurfaceSpec, - initialState, - invoke, - subscribe, - unsubscribe, + applyServerMessage, + getSurfaceSpec, + initialState, + invoke, + subscribe, + unsubscribe, } from "./reducer"; export type { ProtocolResult, ProtocolState, Subscription } from "./types"; diff --git a/src/core/protocol/reducer.test.ts b/src/core/protocol/reducer.test.ts index c8e517a..d42dce7 100644 --- a/src/core/protocol/reducer.test.ts +++ b/src/core/protocol/reducer.test.ts @@ -1,245 +1,245 @@ import { describe, expect, it } from "vitest"; import { - applyServerMessage, - getSurfaceSpec, - initialState, - invoke, - subscribe, - unsubscribe, + applyServerMessage, + getSurfaceSpec, + initialState, + invoke, + subscribe, + unsubscribe, } from "./reducer"; const makeSpec = (id: string, title = id) => ({ - id, - region: "test", - title, - fields: [], + id, + region: "test", + title, + fields: [], }); describe("initialState", () => { - it("returns empty catalog, no subscriptions, no error", () => { - const s = initialState(); - expect(s.catalog).toEqual([]); - expect(s.subscriptions.size).toBe(0); - expect(s.lastError).toBeNull(); - }); + it("returns empty catalog, no subscriptions, no error", () => { + const s = initialState(); + expect(s.catalog).toEqual([]); + expect(s.subscriptions.size).toBe(0); + expect(s.lastError).toBeNull(); + }); }); describe("applyServerMessage — catalog", () => { - it("replaces the catalog", () => { - const s = initialState(); - const catalog = [ - { id: "a", region: "r", title: "A" }, - { id: "b", region: "r", title: "B" }, - ]; - const next = applyServerMessage(s, { type: "catalog", catalog }); - expect(next.catalog).toEqual(catalog); - }); + it("replaces the catalog", () => { + const s = initialState(); + const catalog = [ + { id: "a", region: "r", title: "A" }, + { id: "b", region: "r", title: "B" }, + ]; + const next = applyServerMessage(s, { type: "catalog", catalog }); + expect(next.catalog).toEqual(catalog); + }); }); describe("applyServerMessage — surface", () => { - it("sets the spec for a subscribed surface", () => { - let s = initialState(); - s = subscribe(s, "s1").state; - const spec = makeSpec("s1", "Surface 1"); - const next = applyServerMessage(s, { type: "surface", spec }); - expect(getSurfaceSpec(next, "s1")).toEqual(spec); - }); - - it("ignores a surface message for a non-subscribed surface", () => { - const s = initialState(); - const spec = makeSpec("unknown"); - const next = applyServerMessage(s, { type: "surface", spec }); - expect(next.subscriptions.has("unknown")).toBe(false); - }); + it("sets the spec for a subscribed surface", () => { + let s = initialState(); + s = subscribe(s, "s1").state; + const spec = makeSpec("s1", "Surface 1"); + const next = applyServerMessage(s, { type: "surface", spec }); + expect(getSurfaceSpec(next, "s1")).toEqual(spec); + }); + + it("ignores a surface message for a non-subscribed surface", () => { + const s = initialState(); + const spec = makeSpec("unknown"); + const next = applyServerMessage(s, { type: "surface", spec }); + expect(next.subscriptions.has("unknown")).toBe(false); + }); }); describe("applyServerMessage — update", () => { - it("replaces spec for a subscribed surface", () => { - let s = initialState(); - s = subscribe(s, "s1").state; - s = applyServerMessage(s, { type: "surface", spec: makeSpec("s1", "V1") }); - const next = applyServerMessage(s, { - type: "update", - update: { surfaceId: "s1", spec: makeSpec("s1", "V2") }, - }); - expect(getSurfaceSpec(next, "s1")?.title).toBe("V2"); - }); - - it("ignores an update for a non-subscribed surface", () => { - const s = initialState(); - const next = applyServerMessage(s, { - type: "update", - update: { surfaceId: "nope", spec: makeSpec("nope") }, - }); - expect(next.subscriptions.has("nope")).toBe(false); - }); + it("replaces spec for a subscribed surface", () => { + let s = initialState(); + s = subscribe(s, "s1").state; + s = applyServerMessage(s, { type: "surface", spec: makeSpec("s1", "V1") }); + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "s1", spec: makeSpec("s1", "V2") }, + }); + expect(getSurfaceSpec(next, "s1")?.title).toBe("V2"); + }); + + it("ignores an update for a non-subscribed surface", () => { + const s = initialState(); + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "nope", spec: makeSpec("nope") }, + }); + expect(next.subscriptions.has("nope")).toBe(false); + }); }); describe("applyServerMessage — error", () => { - it("records the error without throwing", () => { - const s = initialState(); - const err = { type: "error" as const, surfaceId: "s1", message: "boom" }; - const next = applyServerMessage(s, err); - expect(next.lastError).toEqual(err); - }); - - it("records error without surfaceId", () => { - const s = initialState(); - const err = { type: "error" as const, message: "global boom" }; - const next = applyServerMessage(s, err); - expect(next.lastError).toEqual(err); - }); + it("records the error without throwing", () => { + const s = initialState(); + const err = { type: "error" as const, surfaceId: "s1", message: "boom" }; + const next = applyServerMessage(s, err); + expect(next.lastError).toEqual(err); + }); + + it("records error without surfaceId", () => { + const s = initialState(); + const err = { type: "error" as const, message: "global boom" }; + const next = applyServerMessage(s, err); + expect(next.lastError).toEqual(err); + }); }); describe("subscribe", () => { - it("emits exactly one subscribe message (global, no conversationId)", () => { - const s = initialState(); - const result = subscribe(s, "s1"); - expect(result.outgoing).toEqual([{ type: "subscribe", surfaceId: "s1" }]); - expect(result.outgoing).toHaveLength(1); - }); - - it("adds the surface to subscriptions with null spec", () => { - const s = initialState(); - const result = subscribe(s, "s1"); - expect(result.state.subscriptions.get("s1")).toEqual({ - conversationId: undefined, - spec: null, - }); - expect(getSurfaceSpec(result.state, "s1")).toBeNull(); - }); - - it("is idempotent — second subscribe with the same scope is a no-op", () => { - let s = initialState(); - s = subscribe(s, "s1").state; - const result = subscribe(s, "s1"); - expect(result.outgoing).toEqual([]); - expect(result.state).toBe(s); - }); + it("emits exactly one subscribe message (global, no conversationId)", () => { + const s = initialState(); + const result = subscribe(s, "s1"); + expect(result.outgoing).toEqual([{ type: "subscribe", surfaceId: "s1" }]); + expect(result.outgoing).toHaveLength(1); + }); + + it("adds the surface to subscriptions with null spec", () => { + const s = initialState(); + const result = subscribe(s, "s1"); + expect(result.state.subscriptions.get("s1")).toEqual({ + conversationId: undefined, + spec: null, + }); + expect(getSurfaceSpec(result.state, "s1")).toBeNull(); + }); + + it("is idempotent — second subscribe with the same scope is a no-op", () => { + let s = initialState(); + s = subscribe(s, "s1").state; + const result = subscribe(s, "s1"); + expect(result.outgoing).toEqual([]); + expect(result.state).toBe(s); + }); }); describe("subscribe — conversation-scoped", () => { - it("includes conversationId in the subscribe message", () => { - const s = initialState(); - const result = subscribe(s, "cache-warming", "conv-A"); - expect(result.outgoing).toEqual([ - { type: "subscribe", surfaceId: "cache-warming", conversationId: "conv-A" }, - ]); - expect(result.state.subscriptions.get("cache-warming")?.conversationId).toBe("conv-A"); - }); - - it("re-scopes on conversation switch: unsubscribe old pair then subscribe new", () => { - let s = initialState(); - s = subscribe(s, "cw", "conv-A").state; - s = applyServerMessage(s, { - type: "surface", - spec: makeSpec("cw", "A-spec"), - conversationId: "conv-A", - }); - const result = subscribe(s, "cw", "conv-B"); - expect(result.outgoing).toEqual([ - { type: "unsubscribe", surfaceId: "cw", conversationId: "conv-A" }, - { type: "subscribe", surfaceId: "cw", conversationId: "conv-B" }, - ]); - // previous spec retained until the new one arrives (no flicker) - expect(getSurfaceSpec(result.state, "cw")?.title).toBe("A-spec"); - expect(result.state.subscriptions.get("cw")?.conversationId).toBe("conv-B"); - }); - - it("drops a stale update echoing the previous conversationId", () => { - let s = initialState(); - s = subscribe(s, "cw", "conv-A").state; - s = subscribe(s, "cw", "conv-B").state; // re-scoped to B - const next = applyServerMessage(s, { - type: "update", - update: { surfaceId: "cw", spec: makeSpec("cw", "STALE-A"), conversationId: "conv-A" }, - }); - expect(getSurfaceSpec(next, "cw")).toBeNull(); // stale ignored, no spec yet for B - }); - - it("accepts an update echoing the current conversationId", () => { - let s = initialState(); - s = subscribe(s, "cw", "conv-B").state; - const next = applyServerMessage(s, { - type: "update", - update: { surfaceId: "cw", spec: makeSpec("cw", "B-spec"), conversationId: "conv-B" }, - }); - expect(getSurfaceSpec(next, "cw")?.title).toBe("B-spec"); - }); - - it("accepts a global (no-echo) surface message even when subscribed with a conversationId", () => { - // loaded-extensions is global: server ignores our conversationId and echoes none. - let s = initialState(); - s = subscribe(s, "loaded-extensions", "conv-A").state; - const next = applyServerMessage(s, { - type: "surface", - spec: makeSpec("loaded-extensions", "Ext"), - }); - expect(getSurfaceSpec(next, "loaded-extensions")?.title).toBe("Ext"); - }); + it("includes conversationId in the subscribe message", () => { + const s = initialState(); + const result = subscribe(s, "cache-warming", "conv-A"); + expect(result.outgoing).toEqual([ + { type: "subscribe", surfaceId: "cache-warming", conversationId: "conv-A" }, + ]); + expect(result.state.subscriptions.get("cache-warming")?.conversationId).toBe("conv-A"); + }); + + it("re-scopes on conversation switch: unsubscribe old pair then subscribe new", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-A").state; + s = applyServerMessage(s, { + type: "surface", + spec: makeSpec("cw", "A-spec"), + conversationId: "conv-A", + }); + const result = subscribe(s, "cw", "conv-B"); + expect(result.outgoing).toEqual([ + { type: "unsubscribe", surfaceId: "cw", conversationId: "conv-A" }, + { type: "subscribe", surfaceId: "cw", conversationId: "conv-B" }, + ]); + // previous spec retained until the new one arrives (no flicker) + expect(getSurfaceSpec(result.state, "cw")?.title).toBe("A-spec"); + expect(result.state.subscriptions.get("cw")?.conversationId).toBe("conv-B"); + }); + + it("drops a stale update echoing the previous conversationId", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-A").state; + s = subscribe(s, "cw", "conv-B").state; // re-scoped to B + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "cw", spec: makeSpec("cw", "STALE-A"), conversationId: "conv-A" }, + }); + expect(getSurfaceSpec(next, "cw")).toBeNull(); // stale ignored, no spec yet for B + }); + + it("accepts an update echoing the current conversationId", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-B").state; + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "cw", spec: makeSpec("cw", "B-spec"), conversationId: "conv-B" }, + }); + expect(getSurfaceSpec(next, "cw")?.title).toBe("B-spec"); + }); + + it("accepts a global (no-echo) surface message even when subscribed with a conversationId", () => { + // loaded-extensions is global: server ignores our conversationId and echoes none. + let s = initialState(); + s = subscribe(s, "loaded-extensions", "conv-A").state; + const next = applyServerMessage(s, { + type: "surface", + spec: makeSpec("loaded-extensions", "Ext"), + }); + expect(getSurfaceSpec(next, "loaded-extensions")?.title).toBe("Ext"); + }); }); describe("unsubscribe", () => { - it("emits unsubscribe and drops the spec", () => { - let s = initialState(); - s = subscribe(s, "s1").state; - s = applyServerMessage(s, { type: "surface", spec: makeSpec("s1") }); - const result = unsubscribe(s, "s1"); - expect(result.outgoing).toEqual([{ type: "unsubscribe", surfaceId: "s1" }]); - expect(result.state.subscriptions.has("s1")).toBe(false); - }); - - it("includes conversationId for a scoped subscription", () => { - let s = initialState(); - s = subscribe(s, "cw", "conv-A").state; - const result = unsubscribe(s, "cw"); - expect(result.outgoing).toEqual([ - { type: "unsubscribe", surfaceId: "cw", conversationId: "conv-A" }, - ]); - }); - - it("is a no-op if not subscribed", () => { - const s = initialState(); - const result = unsubscribe(s, "nope"); - expect(result.outgoing).toEqual([]); - expect(result.state).toBe(s); - }); + it("emits unsubscribe and drops the spec", () => { + let s = initialState(); + s = subscribe(s, "s1").state; + s = applyServerMessage(s, { type: "surface", spec: makeSpec("s1") }); + const result = unsubscribe(s, "s1"); + expect(result.outgoing).toEqual([{ type: "unsubscribe", surfaceId: "s1" }]); + expect(result.state.subscriptions.has("s1")).toBe(false); + }); + + it("includes conversationId for a scoped subscription", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-A").state; + const result = unsubscribe(s, "cw"); + expect(result.outgoing).toEqual([ + { type: "unsubscribe", surfaceId: "cw", conversationId: "conv-A" }, + ]); + }); + + it("is a no-op if not subscribed", () => { + const s = initialState(); + const result = unsubscribe(s, "nope"); + expect(result.outgoing).toEqual([]); + expect(result.state).toBe(s); + }); }); describe("invoke", () => { - it("emits the correct InvokeMessage", () => { - const s = initialState(); - const result = invoke(s, "s1", "toggle", true); - expect(result.outgoing).toEqual([ - { type: "invoke", surfaceId: "s1", actionId: "toggle", payload: true }, - ]); - }); - - it("omits payload when not provided", () => { - const s = initialState(); - const result = invoke(s, "s1", "click"); - expect(result.outgoing).toEqual([ - { type: "invoke", surfaceId: "s1", actionId: "click", payload: undefined }, - ]); - }); - - it("includes conversationId when provided", () => { - const s = initialState(); - const result = invoke(s, "cw", "cache-warming/set-interval", 120, "conv-A"); - expect(result.outgoing).toEqual([ - { - type: "invoke", - surfaceId: "cw", - actionId: "cache-warming/set-interval", - payload: 120, - conversationId: "conv-A", - }, - ]); - }); - - it("does not mutate state", () => { - const s = initialState(); - const result = invoke(s, "s1", "a1"); - expect(result.state).toBe(s); - }); + it("emits the correct InvokeMessage", () => { + const s = initialState(); + const result = invoke(s, "s1", "toggle", true); + expect(result.outgoing).toEqual([ + { type: "invoke", surfaceId: "s1", actionId: "toggle", payload: true }, + ]); + }); + + it("omits payload when not provided", () => { + const s = initialState(); + const result = invoke(s, "s1", "click"); + expect(result.outgoing).toEqual([ + { type: "invoke", surfaceId: "s1", actionId: "click", payload: undefined }, + ]); + }); + + it("includes conversationId when provided", () => { + const s = initialState(); + const result = invoke(s, "cw", "cache-warming/set-interval", 120, "conv-A"); + expect(result.outgoing).toEqual([ + { + type: "invoke", + surfaceId: "cw", + actionId: "cache-warming/set-interval", + payload: 120, + conversationId: "conv-A", + }, + ]); + }); + + it("does not mutate state", () => { + const s = initialState(); + const result = invoke(s, "s1", "a1"); + expect(result.state).toBe(s); + }); }); diff --git a/src/core/protocol/reducer.ts b/src/core/protocol/reducer.ts index 3d6b1c8..976eb88 100644 --- a/src/core/protocol/reducer.ts +++ b/src/core/protocol/reducer.ts @@ -1,34 +1,34 @@ import type { - InvokeMessage, - SubscribeMessage, - SurfaceServerMessage, - SurfaceSpec, - UnsubscribeMessage, + InvokeMessage, + SubscribeMessage, + SurfaceServerMessage, + SurfaceSpec, + UnsubscribeMessage, } from "@dispatch/ui-contract"; import type { ProtocolResult, ProtocolState } from "./types"; /** The initial protocol state: empty catalog, no subscriptions, no error. */ export function initialState(): ProtocolState { - return { - catalog: [], - subscriptions: new Map(), - lastError: null, - }; + return { + catalog: [], + subscriptions: new Map(), + lastError: null, + }; } // ── Message builders (respect exactOptionalPropertyTypes: omit `conversationId` // entirely for a global subscription rather than setting it to `undefined`). ── function subMsg(surfaceId: string, conversationId: string | undefined): SubscribeMessage { - return conversationId === undefined - ? { type: "subscribe", surfaceId } - : { type: "subscribe", surfaceId, conversationId }; + return conversationId === undefined + ? { type: "subscribe", surfaceId } + : { type: "subscribe", surfaceId, conversationId }; } function unsubMsg(surfaceId: string, conversationId: string | undefined): UnsubscribeMessage { - return conversationId === undefined - ? { type: "unsubscribe", surfaceId } - : { type: "unsubscribe", surfaceId, conversationId }; + return conversationId === undefined + ? { type: "unsubscribe", surfaceId } + : { type: "unsubscribe", surfaceId, conversationId }; } /** @@ -38,37 +38,37 @@ function unsubMsg(surfaceId: string, conversationId: string | undefined): Unsubs * surface echoes nothing (`undefined`) and is always current. */ function isCurrent(desiredId: string | undefined, echoedId: string | undefined): boolean { - return echoedId === undefined || echoedId === desiredId; + return echoedId === undefined || echoedId === desiredId; } /** Fold an inbound server message into the next protocol state. */ export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessage): ProtocolState { - switch (msg.type) { - case "catalog": - return { ...state, catalog: msg.catalog }; + switch (msg.type) { + case "catalog": + return { ...state, catalog: msg.catalog }; - case "surface": { - const sub = state.subscriptions.get(msg.spec.id); - if (sub === undefined) return state; - if (!isCurrent(sub.conversationId, msg.conversationId)) return state; - const subs = new Map(state.subscriptions); - subs.set(msg.spec.id, { conversationId: sub.conversationId, spec: msg.spec }); - return { ...state, subscriptions: subs }; - } + case "surface": { + const sub = state.subscriptions.get(msg.spec.id); + if (sub === undefined) return state; + if (!isCurrent(sub.conversationId, msg.conversationId)) return state; + const subs = new Map(state.subscriptions); + subs.set(msg.spec.id, { conversationId: sub.conversationId, spec: msg.spec }); + return { ...state, subscriptions: subs }; + } - case "update": { - const { surfaceId, spec, conversationId } = msg.update; - const sub = state.subscriptions.get(surfaceId); - if (sub === undefined) return state; - if (!isCurrent(sub.conversationId, conversationId)) return state; - const subs = new Map(state.subscriptions); - subs.set(surfaceId, { conversationId: sub.conversationId, spec }); - return { ...state, subscriptions: subs }; - } + case "update": { + const { surfaceId, spec, conversationId } = msg.update; + const sub = state.subscriptions.get(surfaceId); + if (sub === undefined) return state; + if (!isCurrent(sub.conversationId, conversationId)) return state; + const subs = new Map(state.subscriptions); + subs.set(surfaceId, { conversationId: sub.conversationId, spec }); + return { ...state, subscriptions: subs }; + } - case "error": - return { ...state, lastError: msg }; - } + case "error": + return { ...state, lastError: msg }; + } } /** @@ -82,23 +82,23 @@ export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessa * one, retaining the previous spec until the new one arrives (no flicker). */ export function subscribe( - state: ProtocolState, - surfaceId: string, - conversationId?: string, + state: ProtocolState, + surfaceId: string, + conversationId?: string, ): ProtocolResult { - const existing = state.subscriptions.get(surfaceId); - if (existing !== undefined && existing.conversationId === conversationId) { - return { state, outgoing: [] }; - } - const subs = new Map(state.subscriptions); - const outgoing: (SubscribeMessage | UnsubscribeMessage)[] = []; - const priorSpec: SurfaceSpec | null = existing?.spec ?? null; - if (existing !== undefined) { - outgoing.push(unsubMsg(surfaceId, existing.conversationId)); - } - subs.set(surfaceId, { conversationId, spec: priorSpec }); - outgoing.push(subMsg(surfaceId, conversationId)); - return { state: { ...state, subscriptions: subs }, outgoing }; + const existing = state.subscriptions.get(surfaceId); + if (existing !== undefined && existing.conversationId === conversationId) { + return { state, outgoing: [] }; + } + const subs = new Map(state.subscriptions); + const outgoing: (SubscribeMessage | UnsubscribeMessage)[] = []; + const priorSpec: SurfaceSpec | null = existing?.spec ?? null; + if (existing !== undefined) { + outgoing.push(unsubMsg(surfaceId, existing.conversationId)); + } + subs.set(surfaceId, { conversationId, spec: priorSpec }); + outgoing.push(subMsg(surfaceId, conversationId)); + return { state: { ...state, subscriptions: subs }, outgoing }; } /** @@ -107,16 +107,16 @@ export function subscribe( * not subscribed. */ export function unsubscribe(state: ProtocolState, surfaceId: string): ProtocolResult { - const existing = state.subscriptions.get(surfaceId); - if (existing === undefined) { - return { state, outgoing: [] }; - } - const subs = new Map(state.subscriptions); - subs.delete(surfaceId); - return { - state: { ...state, subscriptions: subs }, - outgoing: [unsubMsg(surfaceId, existing.conversationId)], - }; + const existing = state.subscriptions.get(surfaceId); + if (existing === undefined) { + return { state, outgoing: [] }; + } + const subs = new Map(state.subscriptions); + subs.delete(surfaceId); + return { + state: { ...state, subscriptions: subs }, + outgoing: [unsubMsg(surfaceId, existing.conversationId)], + }; } /** @@ -124,20 +124,20 @@ export function unsubscribe(state: ProtocolState, surfaceId: string): ProtocolRe * `conversationId` for a scoped surface); no state change. */ export function invoke( - state: ProtocolState, - surfaceId: string, - actionId: string, - payload?: unknown, - conversationId?: string, + state: ProtocolState, + surfaceId: string, + actionId: string, + payload?: unknown, + conversationId?: string, ): ProtocolResult { - const outgoing: InvokeMessage = - conversationId === undefined - ? { type: "invoke", surfaceId, actionId, payload } - : { type: "invoke", surfaceId, actionId, payload, conversationId }; - return { state, outgoing: [outgoing] }; + const outgoing: InvokeMessage = + conversationId === undefined + ? { type: "invoke", surfaceId, actionId, payload } + : { type: "invoke", surfaceId, actionId, payload, conversationId }; + return { state, outgoing: [outgoing] }; } /** The current spec for a subscribed surface, or `null` if absent/unsubscribed. */ export function getSurfaceSpec(state: ProtocolState, surfaceId: string): SurfaceSpec | null { - return state.subscriptions.get(surfaceId)?.spec ?? null; + return state.subscriptions.get(surfaceId)?.spec ?? null; } diff --git a/src/core/protocol/types.ts b/src/core/protocol/types.ts index db8886a..6debb1d 100644 --- a/src/core/protocol/types.ts +++ b/src/core/protocol/types.ts @@ -1,8 +1,8 @@ import type { - SurfaceCatalog, - SurfaceClientMessage, - SurfaceErrorMessage, - SurfaceSpec, + SurfaceCatalog, + SurfaceClientMessage, + SurfaceErrorMessage, + SurfaceSpec, } from "@dispatch/ui-contract"; /** @@ -16,22 +16,22 @@ import type { * is always accepted. `spec` is `null` until the first `surface` arrives. */ export interface Subscription { - readonly conversationId: string | undefined; - readonly spec: SurfaceSpec | null; + readonly conversationId: string | undefined; + readonly spec: SurfaceSpec | null; } /** The client-side view of the surface protocol state. */ export interface ProtocolState { - /** The latest catalog received from the server (empty until first CatalogMessage). */ - readonly catalog: SurfaceCatalog; - /** Surfaces the client intends to be subscribed to, keyed by surfaceId. */ - readonly subscriptions: ReadonlyMap<string, Subscription>; - /** The last error received from the server, if any. */ - readonly lastError: SurfaceErrorMessage | null; + /** The latest catalog received from the server (empty until first CatalogMessage). */ + readonly catalog: SurfaceCatalog; + /** Surfaces the client intends to be subscribed to, keyed by surfaceId. */ + readonly subscriptions: ReadonlyMap<string, Subscription>; + /** The last error received from the server, if any. */ + readonly lastError: SurfaceErrorMessage | null; } /** A state transition result: the next state plus any outgoing messages to send. */ export interface ProtocolResult { - readonly state: ProtocolState; - readonly outgoing: readonly SurfaceClientMessage[]; + readonly state: ProtocolState; + readonly outgoing: readonly SurfaceClientMessage[]; } diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts index c50cbf4..2b98ca6 100644 --- a/src/core/wire/conformance.test.ts +++ b/src/core/wire/conformance.test.ts @@ -2,230 +2,281 @@ import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/tra import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - assertAgentEventExhaustive, - assertChunkExhaustive, - assertWsClientMessageExhaustive, - assertWsServerMessageExhaustive, + assertAgentEventExhaustive, + assertChunkExhaustive, + assertWsClientMessageExhaustive, + assertWsServerMessageExhaustive, } from "./conformance"; describe("StoredChunk round-trips JSON", () => { - it("preserves shape through JSON serialize/deserialize", () => { - const original: StoredChunk = { - seq: 42, - role: "assistant", - chunk: { type: "text", text: "hello" }, - }; - const roundTripped: StoredChunk = JSON.parse(JSON.stringify(original)) as StoredChunk; - expect(roundTripped).toEqual(original); - expect(roundTripped.seq).toBe(42); - expect(roundTripped.role).toBe("assistant"); - expect(roundTripped.chunk.type).toBe("text"); - }); + it("preserves shape through JSON serialize/deserialize", () => { + const original: StoredChunk = { + seq: 42, + role: "assistant", + chunk: { type: "text", text: "hello" }, + }; + const roundTripped: StoredChunk = JSON.parse(JSON.stringify(original)) as StoredChunk; + expect(roundTripped).toEqual(original); + expect(roundTripped.seq).toBe(42); + expect(roundTripped.role).toBe("assistant"); + expect(roundTripped.chunk.type).toBe("text"); + }); }); describe("classifies every AgentEvent type", () => { - const samples: AgentEvent[] = [ - { type: "status", conversationId: "c1", status: "idle" }, - { type: "turn-start", conversationId: "c1", turnId: "t1" }, - { type: "user-message", conversationId: "c1", turnId: "t1", text: "hi" }, - { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" }, - { type: "reasoning-delta", conversationId: "c1", turnId: "t1", delta: "thinking" }, - { - type: "tool-call", - conversationId: "c1", - turnId: "t1", - toolCallId: "tc1", - toolName: "read", - input: {}, - stepId: "t1#0" as StepId, - }, - { - type: "tool-result", - conversationId: "c1", - turnId: "t1", - toolCallId: "tc1", - toolName: "read", - content: "ok", - isError: false, - stepId: "t1#0" as StepId, - }, - { - type: "tool-output", - conversationId: "c1", - turnId: "t1", - toolCallId: "tc1", - data: "out", - stream: "stdout", - }, - { - type: "usage", - conversationId: "c1", - turnId: "t1", - usage: { inputTokens: 10, outputTokens: 20 }, - }, - { - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: "t1#0" as StepId, - ttftMs: 300, - decodeMs: 700, - genTotalMs: 1000, - }, - { type: "error", conversationId: "c1", turnId: "t1", message: "oops" }, - { type: "done", conversationId: "c1", turnId: "t1", reason: "complete" }, - { type: "turn-sealed", conversationId: "c1", turnId: "t1" }, - { type: "steering", conversationId: "c1", turnId: "t1", text: "steer mid-turn" }, - ]; + const samples: AgentEvent[] = [ + { type: "status", conversationId: "c1", status: "idle" }, + { type: "turn-start", conversationId: "c1", turnId: "t1" }, + { type: "user-message", conversationId: "c1", turnId: "t1", text: "hi" }, + { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" }, + { type: "reasoning-delta", conversationId: "c1", turnId: "t1", delta: "thinking" }, + { + type: "tool-call", + conversationId: "c1", + turnId: "t1", + toolCallId: "tc1", + toolName: "read", + input: {}, + stepId: "t1#0" as StepId, + }, + { + type: "tool-result", + conversationId: "c1", + turnId: "t1", + toolCallId: "tc1", + toolName: "read", + content: "ok", + isError: false, + stepId: "t1#0" as StepId, + }, + { + type: "tool-output", + conversationId: "c1", + turnId: "t1", + toolCallId: "tc1", + data: "out", + stream: "stdout", + }, + { + type: "usage", + conversationId: "c1", + turnId: "t1", + usage: { inputTokens: 10, outputTokens: 20 }, + }, + { + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: "t1#0" as StepId, + ttftMs: 300, + decodeMs: 700, + genTotalMs: 1000, + }, + { type: "error", conversationId: "c1", turnId: "t1", message: "oops" }, + { type: "done", conversationId: "c1", turnId: "t1", reason: "complete" }, + { type: "turn-sealed", conversationId: "c1", turnId: "t1" }, + { type: "steering", conversationId: "c1", turnId: "t1", text: "steer mid-turn" }, + { + type: "provider-retry", + conversationId: "c1", + turnId: "t1", + attempt: 0, + delayMs: 5000, + message: "HTTP 429: overloaded", + code: "429", + }, + ]; - it("returns a stable label for every AgentEvent.type variant", () => { - const labels = samples.map(assertAgentEventExhaustive); - expect(labels).toEqual([ - "status", - "turn-start", - "user-message", - "text-delta", - "reasoning-delta", - "tool-call", - "tool-result", - "tool-output", - "usage", - "step-complete", - "error", - "done", - "turn-sealed", - "steering", - ]); - }); + it("returns a stable label for every AgentEvent.type variant", () => { + const labels = samples.map(assertAgentEventExhaustive); + expect(labels).toEqual([ + "status", + "turn-start", + "user-message", + "text-delta", + "reasoning-delta", + "tool-call", + "tool-result", + "tool-output", + "usage", + "step-complete", + "error", + "done", + "turn-sealed", + "steering", + "provider-retry", + ]); + }); - it("covers all 14 AgentEvent variants", () => { - expect(samples).toHaveLength(14); - }); + it("covers all 15 AgentEvent variants", () => { + expect(samples).toHaveLength(15); + }); }); describe("classifies every Chunk type", () => { - it("returns a stable label for each Chunk.type variant", () => { - const chunks = [ - { type: "text" as const, text: "a" }, - { type: "thinking" as const, text: "b" }, - { type: "tool-call" as const, toolCallId: "tc", toolName: "n", input: null }, - { - type: "tool-result" as const, - toolCallId: "tc", - toolName: "n", - content: "c", - isError: false, - }, - { type: "error" as const, message: "e" }, - { type: "system" as const, text: "s" }, - ]; - const labels = chunks.map(assertChunkExhaustive); - expect(labels).toEqual(["text", "thinking", "tool-call", "tool-result", "error", "system"]); - }); + it("returns a stable label for each Chunk.type variant", () => { + const chunks = [ + { type: "text" as const, text: "a" }, + { type: "thinking" as const, text: "b" }, + { type: "tool-call" as const, toolCallId: "tc", toolName: "n", input: null }, + { + type: "tool-result" as const, + toolCallId: "tc", + toolName: "n", + content: "c", + isError: false, + }, + { type: "error" as const, message: "e" }, + { type: "system" as const, text: "s" }, + { type: "image" as const, url: "data:image/png;base64,AAAA", mimeType: "image/png" }, + ]; + const labels = chunks.map(assertChunkExhaustive); + expect(labels).toEqual([ + "text", + "thinking", + "tool-call", + "tool-result", + "error", + "system", + "image", + ]); + }); + + it("covers all 7 Chunk variants", () => { + // Keeps the exhaustive guard honest: a new Chunk.type variant must be added + // both here and to `assertChunkExhaustive` or the `satisfies never` errors. + expect([ + "text", + "thinking", + "tool-call", + "tool-result", + "error", + "system", + "image", + ] as const).toHaveLength(7); + }); }); describe("classifies every WsServerMessage type", () => { - it("returns a stable label for each variant", () => { - const msgs = [ - { type: "catalog" as const, catalog: [] }, - { type: "surface" as const, spec: { id: "s", region: "r", title: "S", fields: [] } }, - { - type: "update" as const, - update: { surfaceId: "s", spec: { id: "s", region: "r", title: "S", fields: [] } }, - }, - { type: "error" as const, message: "e" }, - { - type: "chat.delta" as const, - event: { type: "done" as const, conversationId: "c", turnId: "t", reason: "r" }, - }, - { type: "chat.error" as const, message: "e" }, - { type: "conversation.open" as const, conversationId: "c1" }, - { - type: "conversation.statusChanged" as const, - conversationId: "c1", - status: "active" as const, - }, - { - type: "conversation.compacted" as const, - conversationId: "c1", - newConversationId: "c2", - messagesSummarized: 10, - messagesKept: 5, - }, - ]; - const labels = msgs.map(assertWsServerMessageExhaustive); - expect(labels).toEqual([ - "catalog", - "surface", - "update", - "error", - "chat.delta", - "chat.error", - "conversation.open", - "conversation.statusChanged", - "conversation.compacted", - ]); - }); + it("returns a stable label for each variant", () => { + const msgs = [ + { type: "catalog" as const, catalog: [] }, + { type: "surface" as const, spec: { id: "s", region: "r", title: "S", fields: [] } }, + { + type: "update" as const, + update: { surfaceId: "s", spec: { id: "s", region: "r", title: "S", fields: [] } }, + }, + { type: "error" as const, message: "e" }, + { + type: "chat.delta" as const, + event: { type: "done" as const, conversationId: "c", turnId: "t", reason: "r" }, + }, + { type: "chat.error" as const, message: "e" }, + { type: "conversation.open" as const, conversationId: "c1", workspaceId: "w1" }, + { + type: "conversation.statusChanged" as const, + conversationId: "c1", + status: "active" as const, + workspaceId: "w1", + }, + { + type: "conversation.compacted" as const, + conversationId: "c1", + newConversationId: "c2", + messagesSummarized: 10, + messagesKept: 5, + }, + ]; + const labels = msgs.map(assertWsServerMessageExhaustive); + expect(labels).toEqual([ + "catalog", + "surface", + "update", + "error", + "chat.delta", + "chat.error", + "conversation.open", + "conversation.statusChanged", + "conversation.compacted", + ]); + }); }); describe("classifies every WsClientMessage type", () => { - it("returns a stable label for each variant", () => { - const msgs = [ - { type: "subscribe" as const, surfaceId: "s" }, - { type: "unsubscribe" as const, surfaceId: "s" }, - { type: "invoke" as const, surfaceId: "s", actionId: "a" }, - { type: "chat.send" as const, message: "hi" }, - { type: "chat.subscribe" as const, conversationId: "c1" }, - { type: "chat.unsubscribe" as const, conversationId: "c1" }, - { type: "chat.queue" as const, conversationId: "c1", text: "steer" }, - ]; - const labels = msgs.map(assertWsClientMessageExhaustive); - expect(labels).toEqual([ - "subscribe", - "unsubscribe", - "invoke", - "chat.send", - "chat.subscribe", - "chat.unsubscribe", - "chat.queue", - ]); - }); + it("returns a stable label for each variant", () => { + const msgs = [ + { type: "subscribe" as const, surfaceId: "s" }, + { type: "unsubscribe" as const, surfaceId: "s" }, + { type: "invoke" as const, surfaceId: "s", actionId: "a" }, + { type: "chat.send" as const, message: "hi" }, + { type: "chat.subscribe" as const, conversationId: "c1" }, + { type: "chat.unsubscribe" as const, conversationId: "c1" }, + { type: "chat.queue" as const, conversationId: "c1", text: "steer" }, + { type: "chat.queue.cancel" as const, conversationId: "c1", messageId: "m1" }, + ]; + const labels = msgs.map(assertWsClientMessageExhaustive); + expect(labels).toEqual([ + "subscribe", + "unsubscribe", + "invoke", + "chat.send", + "chat.subscribe", + "chat.unsubscribe", + "chat.queue", + "chat.queue.cancel", + ]); + }); }); describe("ChatSendMessage shape is constructible", () => { - it("constructs a minimal ChatSendMessage", () => { - const msg: ChatSendMessage = { type: "chat.send", message: "hello" }; - expect(msg.type).toBe("chat.send"); - expect(msg.message).toBe("hello"); - }); + it("constructs a minimal ChatSendMessage", () => { + const msg: ChatSendMessage = { type: "chat.send", message: "hello" }; + expect(msg.type).toBe("chat.send"); + expect(msg.message).toBe("hello"); + }); + + it("constructs a full ChatSendMessage", () => { + const msg: ChatSendMessage = { + type: "chat.send", + conversationId: "c1", + message: "hello", + model: "default/gpt-4", + cwd: "/tmp", + }; + expect(msg.conversationId).toBe("c1"); + expect(msg.model).toBe("default/gpt-4"); + expect(msg.cwd).toBe("/tmp"); + }); - it("constructs a full ChatSendMessage", () => { - const msg: ChatSendMessage = { - type: "chat.send", - conversationId: "c1", - message: "hello", - model: "default/gpt-4", - cwd: "/tmp", - }; - expect(msg.conversationId).toBe("c1"); - expect(msg.model).toBe("default/gpt-4"); - expect(msg.cwd).toBe("/tmp"); - }); + it("constructs a ChatSendMessage with pasted images", () => { + const msg: ChatSendMessage = { + type: "chat.send", + conversationId: "c1", + message: "what's in this image?", + images: [ + { url: "data:image/png;base64,AAAA", mimeType: "image/png" }, + { url: "https://example.com/cat.jpg" }, + ], + }; + expect(msg.images).toHaveLength(2); + expect(msg.images?.[0]?.mimeType).toBe("image/png"); + expect(msg.images?.[1]?.mimeType).toBeUndefined(); + }); }); describe("ConversationHistoryResponse shape is constructible", () => { - it("constructs a response with chunks", () => { - const resp: ConversationHistoryResponse = { - chunks: [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }], - latestSeq: 1, - }; - expect(resp.chunks).toHaveLength(1); - expect(resp.latestSeq).toBe(1); - }); + it("constructs a response with chunks", () => { + const resp: ConversationHistoryResponse = { + chunks: [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }], + latestSeq: 1, + }; + expect(resp.chunks).toHaveLength(1); + expect(resp.latestSeq).toBe(1); + }); - it("constructs an empty (caught-up) response", () => { - const resp: ConversationHistoryResponse = { chunks: [], latestSeq: 5 }; - expect(resp.chunks).toHaveLength(0); - expect(resp.latestSeq).toBe(5); - }); + it("constructs an empty (caught-up) response", () => { + const resp: ConversationHistoryResponse = { chunks: [], latestSeq: 5 }; + expect(resp.chunks).toHaveLength(0); + expect(resp.latestSeq).toBe(5); + }); }); diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts index 07808fc..16558cd 100644 --- a/src/core/wire/conformance.ts +++ b/src/core/wire/conformance.ts @@ -7,60 +7,64 @@ import type { AgentEvent, Chunk } from "@dispatch/wire"; * default branch becomes reachable → TypeScript error at build time. */ export function assertAgentEventExhaustive(event: AgentEvent): string { - switch (event.type) { - case "status": - return "status"; - case "turn-start": - return "turn-start"; - case "user-message": - return "user-message"; - case "text-delta": - return "text-delta"; - case "reasoning-delta": - return "reasoning-delta"; - case "tool-call": - return "tool-call"; - case "tool-result": - return "tool-result"; - case "tool-output": - return "tool-output"; - case "usage": - return "usage"; - case "error": - return "error"; - case "done": - return "done"; - case "turn-sealed": - return "turn-sealed"; - case "step-complete": - return "step-complete"; - case "steering": - return "steering"; - default: - return event satisfies never; - } + switch (event.type) { + case "status": + return "status"; + case "turn-start": + return "turn-start"; + case "user-message": + return "user-message"; + case "text-delta": + return "text-delta"; + case "reasoning-delta": + return "reasoning-delta"; + case "tool-call": + return "tool-call"; + case "tool-result": + return "tool-result"; + case "tool-output": + return "tool-output"; + case "usage": + return "usage"; + case "error": + return "error"; + case "done": + return "done"; + case "turn-sealed": + return "turn-sealed"; + case "step-complete": + return "step-complete"; + case "steering": + return "steering"; + case "provider-retry": + return "provider-retry"; + default: + return event satisfies never; + } } /** * Compile-time exhaustiveness guard for `Chunk.type`. */ export function assertChunkExhaustive(chunk: Chunk): string { - switch (chunk.type) { - case "text": - return "text"; - case "thinking": - return "thinking"; - case "tool-call": - return "tool-call"; - case "tool-result": - return "tool-result"; - case "error": - return "error"; - case "system": - return "system"; - default: - return chunk satisfies never; - } + switch (chunk.type) { + case "text": + return "text"; + case "thinking": + return "thinking"; + case "tool-call": + return "tool-call"; + case "tool-result": + return "tool-result"; + case "error": + return "error"; + case "system": + return "system"; + case "image": + return "image"; + default: + return chunk satisfies never; + } } /** @@ -68,28 +72,28 @@ export function assertChunkExhaustive(chunk: Chunk): string { * Covers both surface ops and chat ops. */ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string { - switch (msg.type) { - case "catalog": - return "catalog"; - case "surface": - return "surface"; - case "update": - return "update"; - case "error": - return "error"; - case "chat.delta": - return "chat.delta"; - case "chat.error": - return "chat.error"; - case "conversation.open": - return "conversation.open"; - case "conversation.statusChanged": - return "conversation.statusChanged"; - case "conversation.compacted": - return "conversation.compacted"; - default: - return msg satisfies never; - } + switch (msg.type) { + case "catalog": + return "catalog"; + case "surface": + return "surface"; + case "update": + return "update"; + case "error": + return "error"; + case "chat.delta": + return "chat.delta"; + case "chat.error": + return "chat.error"; + case "conversation.open": + return "conversation.open"; + case "conversation.statusChanged": + return "conversation.statusChanged"; + case "conversation.compacted": + return "conversation.compacted"; + default: + return msg satisfies never; + } } /** @@ -97,22 +101,24 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string { * Covers both surface ops and chat ops. */ export function assertWsClientMessageExhaustive(msg: WsClientMessage): string { - switch (msg.type) { - case "subscribe": - return "subscribe"; - case "unsubscribe": - return "unsubscribe"; - case "invoke": - return "invoke"; - case "chat.send": - return "chat.send"; - case "chat.subscribe": - return "chat.subscribe"; - case "chat.unsubscribe": - return "chat.unsubscribe"; - case "chat.queue": - return "chat.queue"; - default: - return msg satisfies never; - } + switch (msg.type) { + case "subscribe": + return "subscribe"; + case "unsubscribe": + return "unsubscribe"; + case "invoke": + return "invoke"; + case "chat.send": + return "chat.send"; + case "chat.subscribe": + return "chat.subscribe"; + case "chat.unsubscribe": + return "chat.unsubscribe"; + case "chat.queue": + return "chat.queue"; + case "chat.queue.cancel": + return "chat.queue.cancel"; + default: + return msg satisfies never; + } } diff --git a/src/core/wire/index.ts b/src/core/wire/index.ts index ae6b3e6..d4215cf 100644 --- a/src/core/wire/index.ts +++ b/src/core/wire/index.ts @@ -1,6 +1,6 @@ export { - assertAgentEventExhaustive, - assertChunkExhaustive, - assertWsClientMessageExhaustive, - assertWsServerMessageExhaustive, + assertAgentEventExhaustive, + assertChunkExhaustive, + assertWsClientMessageExhaustive, + assertWsServerMessageExhaustive, } from "./conformance"; diff --git a/src/features/cache-warming/index.ts b/src/features/cache-warming/index.ts index c432de6..976844b 100644 --- a/src/features/cache-warming/index.ts +++ b/src/features/cache-warming/index.ts @@ -3,6 +3,6 @@ export { default as CacheWarmingView } from "./ui/CacheWarmingView.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "cache-warming", - description: "Prompt-cache warming controls, history, and countdown", + name: "cache-warming", + description: "Prompt-cache warming controls, history, and countdown", } as const; diff --git a/src/features/cache-warming/logic/view-model.test.ts b/src/features/cache-warming/logic/view-model.test.ts index d5ea901..39fec80 100644 --- a/src/features/cache-warming/logic/view-model.test.ts +++ b/src/features/cache-warming/logic/view-model.test.ts @@ -1,228 +1,228 @@ import type { SurfaceSpec } from "@dispatch/ui-contract"; import { describe, expect, it } from "vitest"; import { - clampMinutes, - clampSeconds, - colorClass, - formatCountdown, - formatWarmLabel, - fromMinSec, - initialWarmingState, - observeWarm, - parseControls, - parsePct, - secondsUntilNext, - statusForPct, - toMinSec, + clampMinutes, + clampSeconds, + colorClass, + formatCountdown, + formatWarmLabel, + fromMinSec, + initialWarmingState, + observeWarm, + parseControls, + parsePct, + secondsUntilNext, + statusForPct, + toMinSec, } from "./view-model"; const spec = (fields: SurfaceSpec["fields"]): SurfaceSpec => ({ - id: "cache-warming", - region: "side", - title: "Cache Warming", - fields, + id: "cache-warming", + region: "side", + title: "Cache Warming", + fields, }); describe("parsePct", () => { - it("parses a percentage string", () => { - expect(parsePct("100%")).toBe(100); - expect(parsePct("93 %")).toBe(93); - expect(parsePct("0%")).toBe(0); - }); - it("returns null for a dash / non-numeric", () => { - expect(parsePct("—")).toBeNull(); - expect(parsePct("n/a")).toBeNull(); - }); + it("parses a percentage string", () => { + expect(parsePct("100%")).toBe(100); + expect(parsePct("93 %")).toBe(93); + expect(parsePct("0%")).toBe(0); + }); + it("returns null for a dash / non-numeric", () => { + expect(parsePct("—")).toBeNull(); + expect(parsePct("n/a")).toBeNull(); + }); }); describe("parseControls", () => { - it("returns empty defaults for a null spec", () => { - const c = parseControls(null); - expect(c).toEqual({ - enabled: false, - toggleActionId: null, - intervalSeconds: 0, - setIntervalActionId: null, - lastPct: null, - retentionPct: null, - nextWarmAt: null, - lastWarmAt: null, - }); - }); - - it("extracts toggle / number / both stats / timer by kind", () => { - const c = parseControls( - spec([ - { - kind: "toggle", - label: "Enabled", - value: true, - action: { actionId: "cache-warming/toggle" }, - }, - { - kind: "number", - label: "Interval", - value: 240, - unit: "s", - action: { actionId: "cache-warming/set-interval" }, - }, - { kind: "stat", label: "Last cache rate", value: "61%" }, - { kind: "stat", label: "Cache retention", value: "100%" }, - { - kind: "custom", - rendererId: "cache-warming-timer", - payload: { nextWarmAt: 1_700_000_240_000, lastWarmAt: 1_700_000_000_000 }, - }, - ]), - ); - expect(c).toEqual({ - enabled: true, - toggleActionId: "cache-warming/toggle", - intervalSeconds: 240, - setIntervalActionId: "cache-warming/set-interval", - lastPct: 61, - retentionPct: 100, - nextWarmAt: 1_700_000_240_000, - lastWarmAt: 1_700_000_000_000, - }); - }); - - it("tells the retention stat apart from the rate stat by label", () => { - const c = parseControls( - spec([ - { kind: "stat", label: "Cache retention", value: "100%" }, - { kind: "stat", label: "Last cache rate", value: "61%" }, - ]), - ); - expect(c.retentionPct).toBe(100); - expect(c.lastPct).toBe(61); - }); - - it("treats a '—' stat as no pct", () => { - const c = parseControls(spec([{ kind: "stat", label: "Last cache rate", value: "—" }])); - expect(c.lastPct).toBeNull(); - }); - - it("ignores an unknown custom renderer and a malformed timer payload", () => { - const c = parseControls( - spec([ - { kind: "custom", rendererId: "something-else", payload: { nextWarmAt: 5 } }, - { kind: "custom", rendererId: "cache-warming-timer", payload: "nope" }, - ]), - ); - expect(c.nextWarmAt).toBeNull(); - expect(c.lastWarmAt).toBeNull(); - }); + it("returns empty defaults for a null spec", () => { + const c = parseControls(null); + expect(c).toEqual({ + enabled: false, + toggleActionId: null, + intervalSeconds: 0, + setIntervalActionId: null, + lastPct: null, + retentionPct: null, + nextWarmAt: null, + lastWarmAt: null, + }); + }); + + it("extracts toggle / number / both stats / timer by kind", () => { + const c = parseControls( + spec([ + { + kind: "toggle", + label: "Enabled", + value: true, + action: { actionId: "cache-warming/toggle" }, + }, + { + kind: "number", + label: "Interval", + value: 240, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }, + { kind: "stat", label: "Last cache rate", value: "61%" }, + { kind: "stat", label: "Cache retention", value: "100%" }, + { + kind: "custom", + rendererId: "cache-warming-timer", + payload: { nextWarmAt: 1_700_000_240_000, lastWarmAt: 1_700_000_000_000 }, + }, + ]), + ); + expect(c).toEqual({ + enabled: true, + toggleActionId: "cache-warming/toggle", + intervalSeconds: 240, + setIntervalActionId: "cache-warming/set-interval", + lastPct: 61, + retentionPct: 100, + nextWarmAt: 1_700_000_240_000, + lastWarmAt: 1_700_000_000_000, + }); + }); + + it("tells the retention stat apart from the rate stat by label", () => { + const c = parseControls( + spec([ + { kind: "stat", label: "Cache retention", value: "100%" }, + { kind: "stat", label: "Last cache rate", value: "61%" }, + ]), + ); + expect(c.retentionPct).toBe(100); + expect(c.lastPct).toBe(61); + }); + + it("treats a '—' stat as no pct", () => { + const c = parseControls(spec([{ kind: "stat", label: "Last cache rate", value: "—" }])); + expect(c.lastPct).toBeNull(); + }); + + it("ignores an unknown custom renderer and a malformed timer payload", () => { + const c = parseControls( + spec([ + { kind: "custom", rendererId: "something-else", payload: { nextWarmAt: 5 } }, + { kind: "custom", rendererId: "cache-warming-timer", payload: "nope" }, + ]), + ); + expect(c.nextWarmAt).toBeNull(); + expect(c.lastWarmAt).toBeNull(); + }); }); describe("interval ↔ min/sec", () => { - it("clampSeconds caps at 0..59", () => { - expect(clampSeconds(75)).toBe(59); - expect(clampSeconds(-3)).toBe(0); - expect(clampSeconds(30)).toBe(30); - expect(clampSeconds(Number.NaN)).toBe(0); - }); - it("clampMinutes floors at 0", () => { - expect(clampMinutes(-1)).toBe(0); - expect(clampMinutes(4)).toBe(4); - }); - it("toMinSec splits total seconds", () => { - expect(toMinSec(240)).toEqual({ minutes: 4, seconds: 0 }); - expect(toMinSec(125)).toEqual({ minutes: 2, seconds: 5 }); - expect(toMinSec(45)).toEqual({ minutes: 0, seconds: 45 }); - }); - it("fromMinSec combines (clamping seconds to 59)", () => { - expect(fromMinSec(4, 0)).toBe(240); - expect(fromMinSec(2, 5)).toBe(125); - expect(fromMinSec(1, 75)).toBe(119); // 75s clamped to 59 - }); + it("clampSeconds caps at 0..59", () => { + expect(clampSeconds(75)).toBe(59); + expect(clampSeconds(-3)).toBe(0); + expect(clampSeconds(30)).toBe(30); + expect(clampSeconds(Number.NaN)).toBe(0); + }); + it("clampMinutes floors at 0", () => { + expect(clampMinutes(-1)).toBe(0); + expect(clampMinutes(4)).toBe(4); + }); + it("toMinSec splits total seconds", () => { + expect(toMinSec(240)).toEqual({ minutes: 4, seconds: 0 }); + expect(toMinSec(125)).toEqual({ minutes: 2, seconds: 5 }); + expect(toMinSec(45)).toEqual({ minutes: 0, seconds: 45 }); + }); + it("fromMinSec combines (clamping seconds to 59)", () => { + expect(fromMinSec(4, 0)).toBe(240); + expect(fromMinSec(2, 5)).toBe(125); + expect(fromMinSec(1, 75)).toBe(119); // 75s clamped to 59 + }); }); describe("status + formatting", () => { - it("statusForPct buckets high/mid/low", () => { - expect(statusForPct(100)).toBe("success"); - expect(statusForPct(80)).toBe("success"); - expect(statusForPct(60)).toBe("warning"); - expect(statusForPct(40)).toBe("warning"); - expect(statusForPct(10)).toBe("error"); - }); - it("colorClass maps to literal DaisyUI classes", () => { - expect(colorClass("success")).toBe("text-success"); - expect(colorClass("warning")).toBe("text-warning"); - expect(colorClass("error")).toBe("text-error"); - }); - it("formatWarmLabel matches the manual-warm phrasing", () => { - expect(formatWarmLabel(100)).toBe("Warmed — 100% cache hit"); - expect(formatWarmLabel(92.6)).toBe("Warmed — 93% cache hit"); - }); - it("formatCountdown renders s and m:ss", () => { - expect(formatCountdown(9)).toBe("9s"); - expect(formatCountdown(59)).toBe("59s"); - expect(formatCountdown(60)).toBe("1:00"); - expect(formatCountdown(185)).toBe("3:05"); - expect(formatCountdown(-5)).toBe("0s"); - }); + it("statusForPct buckets high/mid/low", () => { + expect(statusForPct(100)).toBe("success"); + expect(statusForPct(80)).toBe("success"); + expect(statusForPct(60)).toBe("warning"); + expect(statusForPct(40)).toBe("warning"); + expect(statusForPct(10)).toBe("error"); + }); + it("colorClass maps to literal DaisyUI classes", () => { + expect(colorClass("success")).toBe("text-success"); + expect(colorClass("warning")).toBe("text-warning"); + expect(colorClass("error")).toBe("text-error"); + }); + it("formatWarmLabel matches the manual-warm phrasing", () => { + expect(formatWarmLabel(100)).toBe("Warmed — 100% cache hit"); + expect(formatWarmLabel(92.6)).toBe("Warmed — 93% cache hit"); + }); + it("formatCountdown renders s and m:ss", () => { + expect(formatCountdown(9)).toBe("9s"); + expect(formatCountdown(59)).toBe("59s"); + expect(formatCountdown(60)).toBe("1:00"); + expect(formatCountdown(185)).toBe("3:05"); + expect(formatCountdown(-5)).toBe("0s"); + }); }); describe("warming history reducer (observeWarm)", () => { - it("starts empty", () => { - const s = initialWarmingState(); - expect(s.history).toEqual([]); - expect(s.lastWarmAt).toBeNull(); - }); - - it("records a new entry on each new authoritative lastWarmAt", () => { - let s = initialWarmingState(); - s = observeWarm(s, 1000, 100); - s = observeWarm(s, 2000, 90); - expect(s.history).toEqual([ - { pct: 90, at: 2000 }, - { pct: 100, at: 1000 }, - ]); - expect(s.lastWarmAt).toBe(2000); - }); - - it("de-duplicates on the timestamp, not the pct (a re-pushed surface → no dup)", () => { - let s = initialWarmingState(); - s = observeWarm(s, 1000, 100); // warm - s = observeWarm(s, 1000, 100); // toggle/interval re-push, same lastWarmAt → skip - expect(s.history).toHaveLength(1); - }); - - it("records two warms with the SAME pct (distinct timestamps both count)", () => { - let s = initialWarmingState(); - s = observeWarm(s, 1000, 100); - s = observeWarm(s, 2000, 100); - expect(s.history.map((e) => e.at)).toEqual([2000, 1000]); - }); - - it("ignores a null lastWarmAt; a null pct advances the key without an entry", () => { - let s = initialWarmingState(); - s = observeWarm(s, null, 100); - expect(s.history).toEqual([]); - s = observeWarm(s, 1000, null); - expect(s.history).toEqual([]); - expect(s.lastWarmAt).toBe(1000); - }); + it("starts empty", () => { + const s = initialWarmingState(); + expect(s.history).toEqual([]); + expect(s.lastWarmAt).toBeNull(); + }); + + it("records a new entry on each new authoritative lastWarmAt", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); + s = observeWarm(s, 2000, 90); + expect(s.history).toEqual([ + { pct: 90, at: 2000 }, + { pct: 100, at: 1000 }, + ]); + expect(s.lastWarmAt).toBe(2000); + }); + + it("de-duplicates on the timestamp, not the pct (a re-pushed surface → no dup)", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); // warm + s = observeWarm(s, 1000, 100); // toggle/interval re-push, same lastWarmAt → skip + expect(s.history).toHaveLength(1); + }); + + it("records two warms with the SAME pct (distinct timestamps both count)", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); + s = observeWarm(s, 2000, 100); + expect(s.history.map((e) => e.at)).toEqual([2000, 1000]); + }); + + it("ignores a null lastWarmAt; a null pct advances the key without an entry", () => { + let s = initialWarmingState(); + s = observeWarm(s, null, 100); + expect(s.history).toEqual([]); + s = observeWarm(s, 1000, null); + expect(s.history).toEqual([]); + expect(s.lastWarmAt).toBe(1000); + }); }); describe("secondsUntilNext (authoritative, from nextWarmAt)", () => { - it("is null when nothing is scheduled (nextWarmAt null)", () => { - expect(secondsUntilNext(null, 5000)).toBeNull(); - }); - - it("counts down to nextWarmAt, floored at 0", () => { - expect(secondsUntilNext(10_000, 10_000)).toBe(0); - expect(secondsUntilNext(250_000, 10_000)).toBe(240); - expect(secondsUntilNext(70_000, 10_000)).toBe(60); - }); - - it("treats a nextWarmAt past the stale grace as not scheduled (belt-and-braces)", () => { - // Within the 3s grace an on-time warm may briefly read "0s"… - expect(secondsUntilNext(10_000, 11_000)).toBe(0); - expect(secondsUntilNext(10_000, 13_000)).toBe(0); - // …but beyond it the value is stale → null (the "waiting…" state). - expect(secondsUntilNext(10_000, 13_001)).toBeNull(); - expect(secondsUntilNext(5_000, 999_999)).toBeNull(); - }); + it("is null when nothing is scheduled (nextWarmAt null)", () => { + expect(secondsUntilNext(null, 5000)).toBeNull(); + }); + + it("counts down to nextWarmAt, floored at 0", () => { + expect(secondsUntilNext(10_000, 10_000)).toBe(0); + expect(secondsUntilNext(250_000, 10_000)).toBe(240); + expect(secondsUntilNext(70_000, 10_000)).toBe(60); + }); + + it("treats a nextWarmAt past the stale grace as not scheduled (belt-and-braces)", () => { + // Within the 3s grace an on-time warm may briefly read "0s"… + expect(secondsUntilNext(10_000, 11_000)).toBe(0); + expect(secondsUntilNext(10_000, 13_000)).toBe(0); + // …but beyond it the value is stale → null (the "waiting…" state). + expect(secondsUntilNext(10_000, 13_001)).toBeNull(); + expect(secondsUntilNext(5_000, 999_999)).toBeNull(); + }); }); diff --git a/src/features/cache-warming/logic/view-model.ts b/src/features/cache-warming/logic/view-model.ts index eb105f6..bc8cf2e 100644 --- a/src/features/cache-warming/logic/view-model.ts +++ b/src/features/cache-warming/logic/view-model.ts @@ -15,37 +15,37 @@ import type { SurfaceSpec } from "@dispatch/ui-contract"; // ── Manual-warm port (consumer-defines-port; the composition root adapts the // store's `POST /chat/warm` result to this shape). ────────────────────────── export type WarmFeedback = - | { readonly ok: true; readonly cachePct: number; readonly expectedCacheRate: number } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly cachePct: number; readonly expectedCacheRate: number } + | { readonly ok: false; readonly error: string }; export type WarmNow = () => Promise<WarmFeedback | null>; // ── Parsed surface controls ─────────────────────────────────────────────────── export interface ParsedControls { - readonly enabled: boolean; - readonly toggleActionId: string | null; - readonly intervalSeconds: number; - readonly setIntervalActionId: string | null; - /** Most recent warm's cache-hit %, from the "last cache rate" stat (`null` when "—"/absent). */ - readonly lastPct: number | null; - /** Cross-turn retention %, from the "cache retention" stat (`null` when "—"/absent). */ - readonly retentionPct: number | null; - /** Authoritative epoch-ms the next AUTOMATIC warm fires, or `null` when not scheduled. */ - readonly nextWarmAt: number | null; - /** Authoritative epoch-ms of the most recent completed warm, or `null` if none. */ - readonly lastWarmAt: number | null; + readonly enabled: boolean; + readonly toggleActionId: string | null; + readonly intervalSeconds: number; + readonly setIntervalActionId: string | null; + /** Most recent warm's cache-hit %, from the "last cache rate" stat (`null` when "—"/absent). */ + readonly lastPct: number | null; + /** Cross-turn retention %, from the "cache retention" stat (`null` when "—"/absent). */ + readonly retentionPct: number | null; + /** Authoritative epoch-ms the next AUTOMATIC warm fires, or `null` when not scheduled. */ + readonly nextWarmAt: number | null; + /** Authoritative epoch-ms of the most recent completed warm, or `null` if none. */ + readonly lastWarmAt: number | null; } const EMPTY_CONTROLS: ParsedControls = { - enabled: false, - toggleActionId: null, - intervalSeconds: 0, - setIntervalActionId: null, - lastPct: null, - retentionPct: null, - nextWarmAt: null, - lastWarmAt: null, + enabled: false, + toggleActionId: null, + intervalSeconds: 0, + setIntervalActionId: null, + lastPct: null, + retentionPct: null, + nextWarmAt: null, + lastWarmAt: null, }; /** The `cache-warming-timer` custom field's renderer id (this feature owns it). */ @@ -53,24 +53,24 @@ const TIMER_RENDERER_ID = "cache-warming-timer"; /** Parse a stat's display string (e.g. "100%", "93 %", "—") into a number or null. */ export function parsePct(value: string): number | null { - const match = value.match(/-?\d+(?:\.\d+)?/); - if (match === null) return null; - const n = Number(match[0]); - return Number.isFinite(n) ? n : null; + const match = value.match(/-?\d+(?:\.\d+)?/); + if (match === null) return null; + const n = Number(match[0]); + return Number.isFinite(n) ? n : null; } /** A finite number, else null. */ function numOrNull(v: unknown): number | null { - return typeof v === "number" && Number.isFinite(v) ? v : null; + return typeof v === "number" && Number.isFinite(v) ? v : null; } /** Pull the authoritative `nextWarmAt`/`lastWarmAt` out of the timer custom payload. */ function parseTimer(payload: unknown): { nextWarmAt: number | null; lastWarmAt: number | null } { - if (typeof payload !== "object" || payload === null) { - return { nextWarmAt: null, lastWarmAt: null }; - } - const p = payload as Record<string, unknown>; - return { nextWarmAt: numOrNull(p.nextWarmAt), lastWarmAt: numOrNull(p.lastWarmAt) }; + if (typeof payload !== "object" || payload === null) { + return { nextWarmAt: null, lastWarmAt: null }; + } + const p = payload as Record<string, unknown>; + return { nextWarmAt: numOrNull(p.nextWarmAt), lastWarmAt: numOrNull(p.lastWarmAt) }; } /** @@ -80,79 +80,79 @@ function parseTimer(payload: unknown): { nextWarmAt: number | null; lastWarmAt: * absent. */ export function parseControls(spec: SurfaceSpec | null): ParsedControls { - if (spec === null) return EMPTY_CONTROLS; - let enabled = false; - let toggleActionId: string | null = null; - let intervalSeconds = 0; - let setIntervalActionId: string | null = null; - let lastPct: number | null = null; - let retentionPct: number | null = null; - let nextWarmAt: number | null = null; - let lastWarmAt: number | null = null; - let seenToggle = false; - let seenNumber = false; - let seenRateStat = false; - for (const field of spec.fields) { - if (field.kind === "toggle" && !seenToggle) { - enabled = field.value; - toggleActionId = field.action.actionId; - seenToggle = true; - } else if (field.kind === "number" && !seenNumber) { - intervalSeconds = field.value; - setIntervalActionId = field.action.actionId; - seenNumber = true; - } else if (field.kind === "stat") { - // Retention is told apart by its label; everything else is the cache rate - // (first one wins, so a stray later stat can't clobber it). - if (/retention/i.test(field.label)) { - retentionPct = parsePct(field.value); - } else if (!seenRateStat) { - lastPct = parsePct(field.value); - seenRateStat = true; - } - } else if (field.kind === "custom" && field.rendererId === TIMER_RENDERER_ID) { - const timer = parseTimer(field.payload); - nextWarmAt = timer.nextWarmAt; - lastWarmAt = timer.lastWarmAt; - } - } - return { - enabled, - toggleActionId, - intervalSeconds, - setIntervalActionId, - lastPct, - retentionPct, - nextWarmAt, - lastWarmAt, - }; + if (spec === null) return EMPTY_CONTROLS; + let enabled = false; + let toggleActionId: string | null = null; + let intervalSeconds = 0; + let setIntervalActionId: string | null = null; + let lastPct: number | null = null; + let retentionPct: number | null = null; + let nextWarmAt: number | null = null; + let lastWarmAt: number | null = null; + let seenToggle = false; + let seenNumber = false; + let seenRateStat = false; + for (const field of spec.fields) { + if (field.kind === "toggle" && !seenToggle) { + enabled = field.value; + toggleActionId = field.action.actionId; + seenToggle = true; + } else if (field.kind === "number" && !seenNumber) { + intervalSeconds = field.value; + setIntervalActionId = field.action.actionId; + seenNumber = true; + } else if (field.kind === "stat") { + // Retention is told apart by its label; everything else is the cache rate + // (first one wins, so a stray later stat can't clobber it). + if (/retention/i.test(field.label)) { + retentionPct = parsePct(field.value); + } else if (!seenRateStat) { + lastPct = parsePct(field.value); + seenRateStat = true; + } + } else if (field.kind === "custom" && field.rendererId === TIMER_RENDERER_ID) { + const timer = parseTimer(field.payload); + nextWarmAt = timer.nextWarmAt; + lastWarmAt = timer.lastWarmAt; + } + } + return { + enabled, + toggleActionId, + intervalSeconds, + setIntervalActionId, + lastPct, + retentionPct, + nextWarmAt, + lastWarmAt, + }; } // ── Interval ↔ minutes/seconds (seconds capped at 59) ───────────────────────── export interface MinSec { - readonly minutes: number; - readonly seconds: number; + readonly minutes: number; + readonly seconds: number; } export function clampSeconds(n: number): number { - if (!Number.isFinite(n)) return 0; - return Math.min(59, Math.max(0, Math.floor(n))); + if (!Number.isFinite(n)) return 0; + return Math.min(59, Math.max(0, Math.floor(n))); } export function clampMinutes(n: number): number { - if (!Number.isFinite(n)) return 0; - return Math.max(0, Math.floor(n)); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.floor(n)); } export function toMinSec(totalSeconds: number): MinSec { - const total = Math.max(0, Math.floor(totalSeconds)); - return { minutes: Math.floor(total / 60), seconds: total % 60 }; + const total = Math.max(0, Math.floor(totalSeconds)); + return { minutes: Math.floor(total / 60), seconds: total % 60 }; } /** Combine a minutes + seconds pair (each clamped) into total seconds. */ export function fromMinSec(minutes: number, seconds: number): number { - return clampMinutes(minutes) * 60 + clampSeconds(seconds); + return clampMinutes(minutes) * 60 + clampSeconds(seconds); } // ── Status + formatting ─────────────────────────────────────────────────────── @@ -161,56 +161,56 @@ export type WarmStatus = "success" | "warning" | "error"; /** Cache-hit % → semantic status (green high, yellow mid, red low). */ export function statusForPct(pct: number): WarmStatus { - if (pct >= 80) return "success"; - if (pct >= 40) return "warning"; - return "error"; + if (pct >= 80) return "success"; + if (pct >= 40) return "warning"; + return "error"; } /** A status → its DaisyUI text-colour class (full literal so Tailwind keeps it). */ export function colorClass(status: WarmStatus): string { - switch (status) { - case "success": - return "text-success"; - case "warning": - return "text-warning"; - case "error": - return "text-error"; - } + switch (status) { + case "success": + return "text-success"; + case "warning": + return "text-warning"; + case "error": + return "text-error"; + } } /** The status line for a warm, matching the manual-warm feedback phrasing. */ export function formatWarmLabel(pct: number): string { - return `Warmed — ${Math.round(pct)}% cache hit`; + return `Warmed — ${Math.round(pct)}% cache hit`; } /** Seconds → a short countdown string (e.g. "3:05", "9s"). */ export function formatCountdown(seconds: number): string { - const s = Math.max(0, Math.floor(seconds)); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - const rem = s % 60; - return `${m}:${String(rem).padStart(2, "0")}`; + const s = Math.max(0, Math.floor(seconds)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + return `${m}:${String(rem).padStart(2, "0")}`; } // ── Warming history reducer (keyed off the authoritative `lastWarmAt`) ───────── export interface WarmEntry { - readonly pct: number; - /** Authoritative epoch-ms of this warm (the surface's `lastWarmAt`). */ - readonly at: number; + readonly pct: number; + /** Authoritative epoch-ms of this warm (the surface's `lastWarmAt`). */ + readonly at: number; } export interface WarmingViewState { - /** Warmings, MOST RECENT FIRST. */ - readonly history: readonly WarmEntry[]; - /** The last authoritative `lastWarmAt` recorded, for change-detection (de-dup key). */ - readonly lastWarmAt: number | null; + /** Warmings, MOST RECENT FIRST. */ + readonly history: readonly WarmEntry[]; + /** The last authoritative `lastWarmAt` recorded, for change-detection (de-dup key). */ + readonly lastWarmAt: number | null; } const MAX_HISTORY = 50; export function initialWarmingState(): WarmingViewState { - return { history: [], lastWarmAt: null }; + return { history: [], lastWarmAt: null }; } /** @@ -221,14 +221,14 @@ export function initialWarmingState(): WarmingViewState { * ignored; a null pct advances the de-dup key without adding an entry. */ export function observeWarm( - state: WarmingViewState, - lastWarmAt: number | null, - pct: number | null, + state: WarmingViewState, + lastWarmAt: number | null, + pct: number | null, ): WarmingViewState { - if (lastWarmAt === null || lastWarmAt === state.lastWarmAt) return state; - if (pct === null) return { ...state, lastWarmAt }; - const history = [{ pct, at: lastWarmAt }, ...state.history].slice(0, MAX_HISTORY); - return { history, lastWarmAt }; + if (lastWarmAt === null || lastWarmAt === state.lastWarmAt) return state; + if (pct === null) return { ...state, lastWarmAt }; + const history = [{ pct, at: lastWarmAt }, ...state.history].slice(0, MAX_HISTORY); + return { history, lastWarmAt }; } /** @@ -248,7 +248,7 @@ const STALE_NEXT_WARM_MS = 3000; * when `nextWarmAt` is stale (further than the grace into the past). */ export function secondsUntilNext(nextWarmAt: number | null, now: number): number | null { - if (nextWarmAt === null) return null; - if (now - nextWarmAt > STALE_NEXT_WARM_MS) return null; - return Math.max(0, Math.ceil((nextWarmAt - now) / 1000)); + if (nextWarmAt === null) return null; + if (now - nextWarmAt > STALE_NEXT_WARM_MS) return null; + return Math.max(0, Math.ceil((nextWarmAt - now) / 1000)); } diff --git a/src/features/cache-warming/ui/CacheWarmingView.svelte b/src/features/cache-warming/ui/CacheWarmingView.svelte index ced5e99..9b3d694 100644 --- a/src/features/cache-warming/ui/CacheWarmingView.svelte +++ b/src/features/cache-warming/ui/CacheWarmingView.svelte @@ -1,234 +1,244 @@ <script lang="ts"> - import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; - import { onMount, untrack } from "svelte"; - import { - clampMinutes, - clampSeconds, - colorClass, - formatCountdown, - formatWarmLabel, - fromMinSec, - initialWarmingState, - observeWarm, - parseControls, - secondsUntilNext, - statusForPct, - toMinSec, - type WarmingViewState, - type WarmNow, - } from "../logic/view-model"; - - let { - spec, - canWarm, - onInvoke, - warmNow, - }: { - /** The cache-warming surface spec for the focused conversation, or null. */ - spec: SurfaceSpec | null; - /** Whether a real conversation is focused (a draft has nothing to warm). */ - canWarm: boolean; - onInvoke: (msg: InvokeMessage) => void; - warmNow: WarmNow; - } = $props(); - - const controls = $derived(parseControls(spec)); - - // View-model state (pure reducer) + the injected clock — owned here, not ambient. - let vm = $state<WarmingViewState>(initialWarmingState()); - let now = $state(Date.now()); - let warming = $state(false); - let errorText = $state<string | null>(null); - // Transient result of the most recent manual warm (immediate feedback; history - // itself is driven authoritatively by the surface's `lastWarmAt`). - let manualResult = $state<{ cachePct: number; expectedCacheRate: number } | null>(null); - - // Local interval inputs, seeded from the surface and re-seeded only when the - // surface's interval differs from what's shown (so a stray update mid-edit - // doesn't clobber typing). - let minutes = $state(0); - let seconds = $state(0); - - onMount(() => { - const id = setInterval(() => { - now = Date.now(); - }, 1000); - return () => clearInterval(id); - }); - - // Fold each authoritative warm (new `lastWarmAt`) into history. - $effect(() => { - const at = controls.lastWarmAt; - const pct = controls.lastPct; - untrack(() => { - vm = observeWarm(vm, at, pct); - }); - }); - - // Keep the min/sec inputs in sync with the surface's interval. - $effect(() => { - const target = controls.intervalSeconds; - untrack(() => { - if (fromMinSec(minutes, seconds) !== target) { - const ms = toMinSec(target); - minutes = ms.minutes; - seconds = ms.seconds; - } - }); - }); - - const remaining = $derived(secondsUntilNext(controls.nextWarmAt, now)); - const history = $derived(vm.history); - const latest = $derived(history[0] ?? null); - const earlier = $derived(history.slice(1)); - - function commitInterval() { - const actionId = controls.setIntervalActionId; - if (actionId === null || spec === null) return; - onInvoke({ type: "invoke", surfaceId: spec.id, actionId, payload: fromMinSec(minutes, seconds) }); - } - - function onMinutes(event: Event) { - const next = (event.target as HTMLInputElement).valueAsNumber; - if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0 - minutes = clampMinutes(next); - commitInterval(); - } - - function onSeconds(event: Event) { - const next = (event.target as HTMLInputElement).valueAsNumber; - if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0 - seconds = clampSeconds(next); - commitInterval(); - } - - function onToggle() { - const actionId = controls.toggleActionId; - if (actionId === null || spec === null) return; - // The toggle action FLIPS server-side; no payload. - onInvoke({ type: "invoke", surfaceId: spec.id, actionId }); - } - - async function handleWarm() { - if (warming) return; - warming = true; - errorText = null; - const result = await warmNow(); - warming = false; - if (result === null) return; - if (result.ok) { - // Immediate feedback only — the authoritative surface `update` (new - // `lastWarmAt`) drives the history via `observeWarm`. - manualResult = { cachePct: result.cachePct, expectedCacheRate: result.expectedCacheRate }; - } else { - manualResult = null; - errorText = result.error; - } - } + import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; + import { onMount, untrack } from "svelte"; + import { + clampMinutes, + clampSeconds, + colorClass, + formatCountdown, + formatWarmLabel, + fromMinSec, + initialWarmingState, + observeWarm, + parseControls, + secondsUntilNext, + statusForPct, + toMinSec, + type WarmingViewState, + type WarmNow, + } from "../logic/view-model"; + + let { + spec, + canWarm, + onInvoke, + warmNow, + }: { + /** The cache-warming surface spec for the focused conversation, or null. */ + spec: SurfaceSpec | null; + /** Whether a real conversation is focused (a draft has nothing to warm). */ + canWarm: boolean; + onInvoke: (msg: InvokeMessage) => void; + warmNow: WarmNow; + } = $props(); + + const controls = $derived(parseControls(spec)); + + // View-model state (pure reducer) + the injected clock — owned here, not ambient. + let vm = $state<WarmingViewState>(initialWarmingState()); + let now = $state(Date.now()); + let warming = $state(false); + let errorText = $state<string | null>(null); + // Transient result of the most recent manual warm (immediate feedback; history + // itself is driven authoritatively by the surface's `lastWarmAt`). + let manualResult = $state<{ cachePct: number; expectedCacheRate: number } | null>(null); + + // Local interval inputs, seeded from the surface and re-seeded only when the + // surface's interval differs from what's shown (so a stray update mid-edit + // doesn't clobber typing). + let minutes = $state(0); + let seconds = $state(0); + + onMount(() => { + const id = setInterval(() => { + now = Date.now(); + }, 1000); + return () => clearInterval(id); + }); + + // Fold each authoritative warm (new `lastWarmAt`) into history. + $effect(() => { + const at = controls.lastWarmAt; + const pct = controls.lastPct; + untrack(() => { + vm = observeWarm(vm, at, pct); + }); + }); + + // Keep the min/sec inputs in sync with the surface's interval. + $effect(() => { + const target = controls.intervalSeconds; + untrack(() => { + if (fromMinSec(minutes, seconds) !== target) { + const ms = toMinSec(target); + minutes = ms.minutes; + seconds = ms.seconds; + } + }); + }); + + const remaining = $derived(secondsUntilNext(controls.nextWarmAt, now)); + const history = $derived(vm.history); + const latest = $derived(history[0] ?? null); + const earlier = $derived(history.slice(1)); + + function commitInterval() { + const actionId = controls.setIntervalActionId; + if (actionId === null || spec === null) return; + onInvoke({ + type: "invoke", + surfaceId: spec.id, + actionId, + payload: fromMinSec(minutes, seconds), + }); + } + + function onMinutes(event: Event) { + const next = (event.target as HTMLInputElement).valueAsNumber; + if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0 + minutes = clampMinutes(next); + commitInterval(); + } + + function onSeconds(event: Event) { + const next = (event.target as HTMLInputElement).valueAsNumber; + if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0 + seconds = clampSeconds(next); + commitInterval(); + } + + function onToggle() { + const actionId = controls.toggleActionId; + if (actionId === null || spec === null) return; + // The toggle action FLIPS server-side; no payload. + onInvoke({ type: "invoke", surfaceId: spec.id, actionId }); + } + + async function handleWarm() { + if (warming) return; + warming = true; + errorText = null; + const result = await warmNow(); + warming = false; + if (result === null) return; + if (result.ok) { + // Immediate feedback only — the authoritative surface `update` (new + // `lastWarmAt`) drives the history via `observeWarm`. + manualResult = { cachePct: result.cachePct, expectedCacheRate: result.expectedCacheRate }; + } else { + manualResult = null; + errorText = result.error; + } + } </script> <div class="flex flex-col gap-3"> - <!-- Enabled --> - <label class="flex items-center justify-between gap-2 text-sm"> - <span>Enabled</span> - <input - type="checkbox" - class="toggle toggle-sm toggle-success" - checked={controls.enabled} - disabled={spec === null} - onchange={onToggle} - /> - </label> - - <!-- Refresh interval: minutes + seconds (seconds capped at 59) --> - <div class="flex items-center justify-between gap-2 text-sm"> - <span>Refresh interval</span> - <span class="flex items-center gap-1"> - <input - type="number" - class="input input-bordered input-sm w-16" - min="0" - value={minutes} - disabled={spec === null} - onchange={onMinutes} - aria-label="Interval minutes" - /> - <span class="opacity-60">m</span> - <input - type="number" - class="input input-bordered input-sm w-16" - min="0" - max="59" - value={seconds} - disabled={spec === null} - onchange={onSeconds} - aria-label="Interval seconds" - /> - <span class="opacity-60">s</span> - </span> - </div> - - <!-- Countdown to the next automatic warm (authoritative: driven by nextWarmAt) --> - {#if !controls.enabled} - <p class="text-xs opacity-50">Warming paused.</p> - {:else if remaining !== null} - <p class="text-xs opacity-70">Next warm in {formatCountdown(remaining)}</p> - {:else} - <p class="text-xs opacity-50">Next warm: waiting…</p> - {/if} - - <!-- Cross-turn retention (the "is warming working?" health signal) --> - {#if controls.retentionPct !== null} - <p class="text-xs {colorClass(statusForPct(controls.retentionPct))}"> - Cache retention: {controls.retentionPct}% - </p> - {/if} - - <!-- Manual trigger --> - <button - type="button" - class="btn btn-sm btn-outline" - disabled={!canWarm || warming} - onclick={handleWarm} - > - {#if warming} - <span class="loading loading-spinner loading-xs"></span> - Warming… - {:else} - Warm now - {/if} - </button> - - {#if !canWarm} - <p class="text-xs opacity-60">Open or start a conversation to control its cache warming.</p> - {:else if errorText} - <p class="text-xs text-error">{errorText}</p> - {:else if manualResult} - <!-- Headline the retention (cache health) over the raw hit %. --> - <p class="text-xs {colorClass(statusForPct(manualResult.expectedCacheRate))}"> - Warmed — {manualResult.expectedCacheRate}% retained ({manualResult.cachePct}% of prompt cached) - </p> - {/if} - - <!-- Warming history: collapse whose title is the most recent warm, coloured by - hit %, with the earlier warmings inside. --> - {#if latest} - <div class="collapse collapse-arrow bg-base-200"> - <input type="checkbox" aria-label="Toggle warming history" /> - <div class="collapse-title min-h-0 py-2 font-normal text-sm {colorClass(statusForPct(latest.pct))}"> - {formatWarmLabel(latest.pct)} - </div> - <div class="collapse-content flex flex-col gap-1 text-sm"> - {#if earlier.length > 0} - {#each earlier as entry, i (i)} - <p class={colorClass(statusForPct(entry.pct))}>{formatWarmLabel(entry.pct)}</p> - {/each} - {:else} - <p class="text-xs opacity-60">No earlier warmings.</p> - {/if} - </div> - </div> - {:else} - <p class="text-xs opacity-60">No warming yet.</p> - {/if} + <!-- Enabled --> + <label class="flex items-center justify-between gap-2 text-sm"> + <span>Enabled</span> + <input + type="checkbox" + class="toggle toggle-sm toggle-success" + checked={controls.enabled} + disabled={spec === null} + onchange={onToggle} + /> + </label> + + <!-- Refresh interval: minutes + seconds (seconds capped at 59) --> + <div class="flex items-center justify-between gap-2 text-sm"> + <span>Refresh interval</span> + <span class="flex items-center gap-1"> + <input + type="number" + class="input input-bordered input-sm w-16" + min="0" + value={minutes} + disabled={spec === null} + onchange={onMinutes} + aria-label="Interval minutes" + /> + <span class="opacity-60">m</span> + <input + type="number" + class="input input-bordered input-sm w-16" + min="0" + max="59" + value={seconds} + disabled={spec === null} + onchange={onSeconds} + aria-label="Interval seconds" + /> + <span class="opacity-60">s</span> + </span> + </div> + + <!-- Countdown to the next automatic warm (authoritative: driven by nextWarmAt) --> + {#if !controls.enabled} + <p class="text-xs opacity-50">Warming paused.</p> + {:else if remaining !== null} + <p class="text-xs opacity-70">Next warm in {formatCountdown(remaining)}</p> + {:else} + <p class="text-xs opacity-50">Next warm: waiting…</p> + {/if} + + <!-- Cross-turn retention (the "is warming working?" health signal) --> + {#if controls.retentionPct !== null} + <p class="text-xs {colorClass(statusForPct(controls.retentionPct))}"> + Cache retention: {controls.retentionPct}% + </p> + {/if} + + <!-- Manual trigger --> + <button + type="button" + class="btn btn-sm btn-outline" + disabled={!canWarm || warming} + onclick={handleWarm} + > + {#if warming} + <span class="loading loading-spinner loading-xs"></span> + Warming… + {:else} + Warm now + {/if} + </button> + + {#if !canWarm} + <p class="text-xs opacity-60">Open or start a conversation to control its cache warming.</p> + {:else if errorText} + <p class="text-xs text-error">{errorText}</p> + {:else if manualResult} + <!-- Headline the retention (cache health) over the raw hit %. --> + <p class="text-xs {colorClass(statusForPct(manualResult.expectedCacheRate))}"> + Warmed — {manualResult.expectedCacheRate}% retained ({manualResult.cachePct}% of prompt + cached) + </p> + {/if} + + <!-- Warming history: collapse whose title is the most recent warm, coloured by + hit %, with the earlier warmings inside. --> + {#if latest} + <div class="collapse collapse-arrow bg-base-200"> + <input type="checkbox" aria-label="Toggle warming history" /> + <div + class="collapse-title min-h-0 py-2 font-normal text-sm {colorClass( + statusForPct(latest.pct), + )}" + > + {formatWarmLabel(latest.pct)} + </div> + <div class="collapse-content flex flex-col gap-1 text-sm"> + {#if earlier.length > 0} + {#each earlier as entry, i (i)} + <p class={colorClass(statusForPct(entry.pct))}>{formatWarmLabel(entry.pct)}</p> + {/each} + {:else} + <p class="text-xs opacity-60">No earlier warmings.</p> + {/if} + </div> + </div> + {:else} + <p class="text-xs opacity-60">No warming yet.</p> + {/if} </div> diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 1596c53..2f98a0e 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -1,30 +1,47 @@ -export type { RenderedChunk, RenderGroup, ToolBatchEntry } from "../../core/chunks"; -export { groupRenderedChunks } from "../../core/chunks"; +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, + EffortOption, + ReasoningEffortSaveResult, + SaveReasoningEffort, + SaveThinkingSelection, + SelectionOption, + SetThinkingRequest, + ThinkingResponse, + ThinkingSaveResult, + ThinkingSelection, + ThinkingSelectionSaveResult, } from "./reasoning-effort"; export { - DEFAULT_REASONING_EFFORT, - effectiveEffort, - effortOptions, - isReasoningEffort, - REASONING_EFFORT_LEVELS, + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effectiveSelection, + effortOptions, + isReasoningEffort, + isThinkingSelection, + REASONING_EFFORT_LEVELS, + selectionOptions, } from "./reasoning-effort"; export type { ChatStore, ChatStoreDependencies } from "./store.svelte"; export { createChatStore } from "./store.svelte"; 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", + name: "chat", + description: "Conversation turns, composer, model selector, and metrics", } as const; diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts index 109cae1..6d3081d 100644 --- a/src/features/chat/model-select.test.ts +++ b/src/features/chat/model-select.test.ts @@ -1,58 +1,89 @@ import { describe, expect, it } from "vitest"; -import { joinModelName, modelKeys, modelsForKey, splitModelName } from "./model-select"; +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: "" }); - }); + it("splits on the first slash", () => { + expect(splitModelName("openai/gpt-4")).toEqual({ key: "openai", model: "gpt-4" }); + }); + + it("keeps slashes in the model part (splits only the first)", () => { + expect(splitModelName("openrouter/anthropic/claude")).toEqual({ + key: "openrouter", + model: "anthropic/claude", + }); + }); + + it("treats a slashless name as all key", () => { + expect(splitModelName("local")).toEqual({ key: "local", model: "" }); + }); }); describe("joinModelName", () => { - it("recombines key + model", () => { - expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4"); - }); - - it("returns just the key when the model is empty", () => { - expect(joinModelName("local", "")).toBe("local"); - }); - - it("round-trips with splitModelName", () => { - const full = "openrouter/anthropic/claude"; - const { key, model } = splitModelName(full); - expect(joinModelName(key, model)).toBe(full); - }); + it("recombines key + model", () => { + expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4"); + }); + + it("returns just the key when the model is empty", () => { + expect(joinModelName("local", "")).toBe("local"); + }); + + it("round-trips with splitModelName", () => { + const full = "openrouter/anthropic/claude"; + const { key, model } = splitModelName(full); + expect(joinModelName(key, model)).toBe(full); + }); }); describe("modelKeys", () => { - it("returns distinct keys in first-seen order", () => { - expect( - modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]), - ).toEqual(["openai", "anthropic", "google"]); - }); - - it("is empty for no models", () => { - expect(modelKeys([])).toEqual([]); - }); + it("returns distinct keys in first-seen order", () => { + expect( + modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]), + ).toEqual(["openai", "anthropic", "google"]); + }); + + it("is empty for no models", () => { + expect(modelKeys([])).toEqual([]); + }); }); describe("modelsForKey", () => { - it("returns the model suffixes under a key, in order", () => { - const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"]; - expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]); - }); - - it("returns empty for an unknown key", () => { - expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]); - }); + it("returns the model suffixes under a key, in order", () => { + const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"]; + expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]); + }); + + it("returns empty for an unknown key", () => { + expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]); + }); +}); + +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 index b1d70b9..602e0ef 100644 --- a/src/features/chat/model-select.ts +++ b/src/features/chat/model-select.ts @@ -1,3 +1,5 @@ +import type { ModelMetadata } from "@dispatch/transport-contract"; + /** * Pure helpers for the two-step model picker. * @@ -8,42 +10,55 @@ */ export interface SplitModel { - readonly key: string; - readonly model: string; + readonly key: string; + readonly model: string; } /** Split `<key>/<model>` on the first slash. A slashless name is all-key. */ export function splitModelName(full: string): SplitModel { - const i = full.indexOf("/"); - if (i === -1) return { key: full, model: "" }; - return { key: full.slice(0, i), model: full.slice(i + 1) }; + const i = full.indexOf("/"); + if (i === -1) return { key: full, model: "" }; + return { key: full.slice(0, i), model: full.slice(i + 1) }; } /** Recombine a key + model into a `<key>/<model>` name (key-only if no model). */ export function joinModelName(key: string, model: string): string { - return model === "" ? key : `${key}/${model}`; + return model === "" ? key : `${key}/${model}`; } /** Distinct keys across all models, in first-seen order. */ export function modelKeys(models: readonly string[]): string[] { - const seen = new Set<string>(); - const out: string[] = []; - for (const full of models) { - const { key } = splitModelName(full); - if (!seen.has(key)) { - seen.add(key); - out.push(key); - } - } - return out; + const seen = new Set<string>(); + const out: string[] = []; + for (const full of models) { + const { key } = splitModelName(full); + if (!seen.has(key)) { + seen.add(key); + out.push(key); + } + } + return out; } /** The model suffixes available under a given key, in order. */ export function modelsForKey(models: readonly string[], key: string): string[] { - const out: string[] = []; - for (const full of models) { - const split = splitModelName(full); - if (split.key === key) out.push(split.model); - } - return out; + const out: string[] = []; + for (const full of models) { + const split = splitModelName(full); + if (split.key === key) out.push(split.model); + } + return out; +} + +/** + * 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 ffe2c94..53ac236 100644 --- a/src/features/chat/ports.ts +++ b/src/features/chat/ports.ts @@ -1,17 +1,20 @@ import type { - ChatQueueMessage, - ChatSendMessage, - ConversationHistoryResponse, - ConversationMetricsResponse, + ChatQueueCancelMessage, + ChatQueueMessage, + ChatSendMessage, + ConversationHistoryResponse, + ConversationMetricsResponse, } from "@dispatch/transport-contract"; /** - * Injected transport port — sends chat messages to the server. Accepts both - * `chat.send` (start a turn) and `chat.queue` (enqueue a steering message; - * auto-starts a turn if idle). + * Injected transport port — sends chat messages to the server. Accepts + * `chat.send` (start a turn), `chat.queue` (enqueue a steering message; + * auto-starts a turn if idle), and `chat.queue.cancel` (remove a single queued + * message by id so it never runs — fire-and-forget, idempotent; the + * message-queue surface confirms the removal). */ export interface ChatTransport { - send(msg: ChatSendMessage | ChatQueueMessage): void; + send(msg: ChatSendMessage | ChatQueueMessage | ChatQueueCancelMessage): void; } /** @@ -19,10 +22,10 @@ export interface ChatTransport { * Both must be POSITIVE integers when present (the server 400s otherwise). */ export interface HistoryWindow { - /** Return only the NEWEST `limit` chunks of the selection (still ascending). */ - readonly limit?: number; - /** Exclusive upper bound: only chunks with `seq < beforeSeq` (backfill paging). */ - readonly beforeSeq?: number; + /** Return only the NEWEST `limit` chunks of the selection (still ascending). */ + readonly limit?: number; + /** Exclusive upper bound: only chunks with `seq < beforeSeq` (backfill paging). */ + readonly beforeSeq?: number; } /** @@ -34,9 +37,9 @@ export interface HistoryWindow { * satisfies this naturally). */ export type HistorySync = ( - conversationId: string, - sinceSeq: number, - window?: HistoryWindow, + conversationId: string, + sinceSeq: number, + window?: HistoryWindow, ) => Promise<ConversationHistoryResponse>; /** Injected metrics-sync port — fetches persisted per-turn metrics from the server. */ diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts index 8f76dea..e870bac 100644 --- a/src/features/chat/reasoning-effort.test.ts +++ b/src/features/chat/reasoning-effort.test.ts @@ -1,45 +1,84 @@ import { describe, expect, it } from "vitest"; import { - DEFAULT_REASONING_EFFORT, - effectiveEffort, - effortOptions, - isReasoningEffort, - REASONING_EFFORT_LEVELS, + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effectiveSelection, + effortOptions, + isReasoningEffort, + isThinkingSelection, + REASONING_EFFORT_LEVELS, + selectionOptions, } 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); - } - }); + 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); + } + }); +}); + +describe("thinking selection (the separate on/off axis)", () => { + it("selectionOptions lists 'off' first, then the ladder (default marked)", () => { + const options = selectionOptions(); + expect(options).toHaveLength(1 + REASONING_EFFORT_LEVELS.length); + expect(options[0]?.value).toBe("off"); + expect(options[0]?.label).toBe("Off"); + // the rest are the ladder, unchanged from effortOptions() + expect(options.slice(1).map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]); + expect(options.find((o) => o.value === "high")?.label).toBe("high (default)"); + }); + + it("isThinkingSelection narrows 'off' + ladder strings, rejects the rest", () => { + expect(isThinkingSelection("off")).toBe(true); + for (const level of REASONING_EFFORT_LEVELS) { + expect(isThinkingSelection(level)).toBe(true); + } + expect(isThinkingSelection("banana")).toBe(false); + expect(isThinkingSelection("")).toBe(false); + expect(isThinkingSelection("OFF")).toBe(false); + expect(isThinkingSelection("none")).toBe(false); // NOT a wire value we send + }); + + it("effectiveSelection shows 'off' when thinking is explicitly disabled", () => { + // thinking off is a SEPARATE axis: the effort level is irrelevant while off. + expect(effectiveSelection("xhigh", false)).toBe("off"); + expect(effectiveSelection(null, false)).toBe("off"); + }); + + it("effectiveSelection shows the effort level when thinking is on (default)", () => { + // null thinking = never set ⇒ thinking ON (default) ⇒ show the effort level. + expect(effectiveSelection(null, null)).toBe("high"); // default effort + expect(effectiveSelection("low", null)).toBe("low"); + expect(effectiveSelection("max", true)).toBe("max"); // explicitly on + }); }); diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts index 2a55089..39e1c5a 100644 --- a/src/features/chat/reasoning-effort.ts +++ b/src/features/chat/reasoning-effort.ts @@ -13,11 +13,11 @@ import type { ReasoningEffort } from "@dispatch/transport-contract"; /** The canonical ladder, in ascending thinking-depth order (`[email protected]`). */ export const REASONING_EFFORT_LEVELS: readonly ReasoningEffort[] = [ - "low", - "medium", - "high", - "xhigh", - "max", + "low", + "medium", + "high", + "xhigh", + "max", ]; /** The server's fallback when nothing is set (the resolution chain's tail). */ @@ -25,7 +25,7 @@ export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "high"; /** Narrow an untrusted string (e.g. a `<select>` value) to the ladder. */ export function isReasoningEffort(value: string): value is ReasoningEffort { - return (REASONING_EFFORT_LEVELS as readonly string[]).includes(value); + return (REASONING_EFFORT_LEVELS as readonly string[]).includes(value); } /** @@ -33,13 +33,13 @@ export function isReasoningEffort(value: string): value is ReasoningEffort { * server default when never set (`null` = "default applies", not "off"). */ export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEffort { - return persisted ?? DEFAULT_REASONING_EFFORT; + return persisted ?? DEFAULT_REASONING_EFFORT; } -/** One `<option>` of the selector. */ +/** One `<option>` of the effort ladder. */ export interface EffortOption { - readonly value: ReasoningEffort; - readonly label: string; + readonly value: ReasoningEffort; + readonly label: string; } /** @@ -47,10 +47,10 @@ export interface EffortOption { * `(default)` so a never-set conversation reads "high (default)". */ export function effortOptions(): readonly EffortOption[] { - return REASONING_EFFORT_LEVELS.map((level) => ({ - value: level, - label: level === DEFAULT_REASONING_EFFORT ? `${level} (default)` : level, - })); + return REASONING_EFFORT_LEVELS.map((level) => ({ + value: level, + label: level === DEFAULT_REASONING_EFFORT ? `${level} (default)` : level, + })); } // ── Injected port (consumer-defines-port; the composition root adapts the @@ -58,9 +58,105 @@ export function effortOptions(): readonly EffortOption[] { /** Outcome of `PUT /conversations/:id/reasoning-effort`. */ export type ReasoningEffortSaveResult = - | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } + | { readonly ok: false; readonly error: string }; export type SaveReasoningEffort = ( - level: ReasoningEffort, + level: ReasoningEffort, ) => Promise<ReasoningEffortSaveResult | null>; + +// ── Thinking on/off (a SEPARATE axis from the effort level) ───────────────── +// +// Per the umans API (and the user's mental model), "thinking off" is NOT a +// zero-effort level — it is a distinct "disable extended thinking entirely" +// signal. The umans route expresses it as `reasoning_effort: "none"`; Dispatch +// surfaces it as a SEPARATE per-conversation boolean so the effort LEVEL is +// preserved across an off→on toggle (turning thinking back on restores the +// previously-chosen depth). The per-conversation selector CONFLATES the two +// axes into one `<select>` (UX), but the WIRE keeps them separate. +// +// ⚠️ BACKEND CONTRACT GAP — see `backend-handoff.md`. The `thinking` endpoint + +// wire types below are the PROPOSED shape; the backend has NOT shipped them yet. +// They are defined FE-local (mirroring the shipped `ReasoningEffortResponse` / +// `SetReasoningEffortRequest` shape) so the FE is built + tested against the +// target contract. Re-pin + re-mirror once the backend ships them. + +/** + * Response of `GET /conversations/:id/thinking` (PROPOSED). `thinking` is null + * when never set (the server then resolves turns with thinking ON — the + * default), `false` when explicitly disabled, `true` when explicitly enabled. + */ +export interface ThinkingResponse { + readonly conversationId: string; + readonly thinking: boolean | null; +} + +/** Body of `PUT /conversations/:id/thinking` (PROPOSED). */ +export interface SetThinkingRequest { + readonly thinking: boolean; +} + +/** + * The per-conversation selector's value: `"off"` (thinking disabled — the + * separate axis) or a reasoning-effort LEVEL. NOT a widened ladder: `"off"` is + * not a degree of effort, it is the absence of thinking. + */ +export type ThinkingSelection = "off" | ReasoningEffort; + +/** One `<option>` of the combined selector (off or a level). */ +export interface SelectionOption { + readonly value: ThinkingSelection; + readonly label: string; +} + +/** + * The selector's options: `"off"` first (the separate disable signal), then the + * effort ladder with the server default marked `(default)`. A never-set + * conversation (thinking on, effort null) reads "high (default)". + */ +export function selectionOptions(): readonly SelectionOption[] { + return [{ value: "off", label: "Off" }, ...effortOptions()]; +} + +/** Narrow an untrusted `<select>` value to a {@link ThinkingSelection}. */ +export function isThinkingSelection(value: string): value is ThinkingSelection { + return value === "off" || isReasoningEffort(value); +} + +/** + * The selection the per-conversation selector should show as selected: `"off"` + * when thinking is explicitly disabled (`persistedThinking === false`), else the + * effective effort level. `persistedThinking === null` (never set) ⇒ thinking + * ON (the default) ⇒ the effort level is shown — NOT "off". + */ +export function effectiveSelection( + persistedEffort: ReasoningEffort | null, + persistedThinking: boolean | null, +): ThinkingSelection { + if (persistedThinking === false) return "off"; + return effectiveEffort(persistedEffort); +} + +// ── Injected port for the combined selector (off OR a level) ───────────────── + +/** Outcome of persisting a {@link ThinkingSelection} (one or two PUTs). */ +export type ThinkingSelectionSaveResult = + | { readonly ok: true; readonly selection: ThinkingSelection } + | { readonly ok: false; readonly error: string }; + +/** + * Persist a thinking selection (consumer-defines-port; the composition root + * adapts the store's `PUT .../thinking` + `PUT .../reasoning-effort` to this). + * - `"off"` → disable thinking (the separate signal); the effort level is left + * untouched so an off→on toggle restores it. + * - a level → set the effort level AND ensure thinking is ON (the level is + * meaningless while thinking is off). + */ +export type SaveThinkingSelection = ( + selection: ThinkingSelection, +) => Promise<ThinkingSelectionSaveResult | null>; + +/** Outcome of `PUT /conversations/:id/thinking`. */ +export type ThinkingSaveResult = + | { readonly ok: true; readonly thinking: boolean } + | { readonly ok: false; readonly error: string }; diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index 9beabfc..24a1d06 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -1,371 +1,436 @@ import type { - ChatDeltaMessage, - ChatErrorMessage, - ChatQueueMessage, - ChatSendMessage, + ChatDeltaMessage, + ChatErrorMessage, + ChatQueueCancelMessage, + ChatQueueMessage, + ChatSendMessage, } from "@dispatch/transport-contract"; -import type { ChatMessage, StoredChunk } from "@dispatch/wire"; +import type { ChatMessage, ImageInput, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire"; import type { RenderedChunk, TranscriptState } from "../../core/chunks"; import { - appendUserMessage, - applyHistory, - clearGenerating, - foldEvent, - initialState, - initialWindowSize, - normalizeChatLimit, - restoreEarlier, - selectChunks, - selectGenerating, - selectHasEarlier, - selectMessages, - trimTranscript, - unloadCount, - windowTranscript, + appendUserMessage, + applyHistory, + clearGenerating, + foldEvent, + initialState, + initialWindowSize, + normalizeChatLimit, + restoreEarlier, + selectChunks, + selectGenerating, + selectHasEarlier, + selectMessages, + selectProviderRetry, + trimTranscript, + unloadCount, + windowTranscript, } from "../../core/chunks"; import type { MetricsState, TurnMetricsEntry } from "../../core/metrics"; import { - applyDurableMetrics, - foldMetricsEvent, - initialMetricsState, - selectCurrentContextSize, - selectOrderedTurnMetrics, + applyDurableMetrics, + foldMetricsEvent, + initialMetricsState, + selectCurrentContextSize, + selectOrderedTurnMetrics, } from "../../core/metrics"; import type { ConversationCache } from "../conversation-cache"; import type { ChatTransport, HistorySync, MetricsSync } from "./ports"; export interface ChatStoreDependencies { - readonly conversationId: string; - readonly model?: string; - readonly transport: ChatTransport; - readonly historySync: HistorySync; - readonly metricsSync: MetricsSync; - readonly cache: ConversationCache; - /** - * The 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; + readonly conversationId: string; + readonly model?: string; + readonly transport: ChatTransport; + readonly historySync: HistorySync; + readonly metricsSync: MetricsSync; + readonly cache: ConversationCache; + /** + * The workspace this conversation belongs to (its URL slug). Sent on + * `chat.send`/`chat.queue` so the backend stamps the conversation's workspace + * at creation (default "default"). Absent → omitted (legacy behavior). + */ + readonly workspaceId?: string; + /** + * The chat limit: max loaded chunks before the oldest quarter is unloaded + * (see `core/chunks/trim.ts`). Normalized via `normalizeChatLimit`; absent → + * `DEFAULT_CHAT_LIMIT`. + */ + readonly chatLimit?: number; + /** + * Whether unloading may run RIGHT NOW. The composition root wires this to the + * smart-scroll "stuck to bottom" state: while the reader is scrolled up, a + * trim would yank the content under them, so it is DEFERRED until they return + * to the bottom (the next fold retries). Absent → always allowed. + */ + readonly canUnload?: () => boolean; + /** + * Called when a swallowed error should be surfaced to the user (e.g. a + * metrics sync failure). Wired by the composition root to the app store's + * `reportError` → full-screen error modal. Absent → errors are logged only. + */ + readonly onError?: (context: string, err: unknown) => void; } export interface ChatStore { - readonly messages: readonly ChatMessage[]; - readonly chunks: readonly RenderedChunk[]; - readonly turnMetrics: readonly TurnMetricsEntry[]; - /** - * The conversation's current context size (tokens occupied) — the latest - * finalized turn's `contextSize`, or `undefined` ("unknown") when none is - * known yet. Never `0` for the unknown case. - */ - readonly currentContextSize: number | undefined; - /** - * Whether a turn is currently generating server-side — derived from the event - * stream (`turn-start`…no-`done`/`turn-sealed`-yet). True for ANY watching - * client: the sender, a second device, or a reconnected client whose in-flight - * turn was replayed. Drives the composer's "generating…" indicator. - */ - readonly generating: boolean; - readonly pendingSync: boolean; - readonly error: string | null; - readonly model: string | undefined; - /** - * Whether earlier history was unloaded by the chat limit (or never loaded by - * the fresh-load window) and can be paged back in — drives the - * "Show earlier messages" affordance. - */ - readonly hasEarlier: boolean; - /** - * Render-key base for thinking collapses: how many thinking chunks are - * unloaded below the watermark, so the UI's ordinal keys stay stable across - * a trim (see `TranscriptState.hiddenThinkingCount`). - */ - readonly thinkingKeyBase: number; - handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void; - send(text: string): void; - /** - * Enqueue a steering message onto the conversation's queue (`chat.queue` - * WS op). While a turn is generating, the message is delivered mid-turn at - * the next tool-result boundary (a `steering` `AgentEvent` fires + the - * message-queue surface updates). When no turn is active, the server - * auto-starts a turn with the message as its opening prompt (equivalent to - * `chat.send`). No optimistic transcript echo — the queue SURFACE carries the - * pending message until drain; the `steering` event places it in the - * transcript. `text` must be non-empty (the server 400/errors otherwise). - */ - queueMessage(text: string): void; - setModel(model: string): void; - /** - * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window. - * Lowering it unloads older committed chunks (deferred via the gate while the - * reader is scrolled up, catching up on the next mutation). Raising it - * REFILLS older history (cache first, then CR-5 `?beforeSeq=`) up to the - * fresh-load window (`initialWindowSize` = 75% of the limit) — the same - * window a fresh `load()` would show — so upping the limit reveals more - * history instead of leaving a partial view. New deltas + loads use the new - * limit. The refill awaits, so a caller can preserve scroll over the prepend. - */ - setChatLimit(limit: number): Promise<void>; - load(): Promise<void>; - /** - * Page one unload-unit (`ceil(limit/4)`) of earlier history back in — the - * "Show earlier messages" action. Local cache first; when the cache doesn't - * reach far enough back (a server-windowed fresh load), the missing older - * run is fetched via CR-5 `?beforeSeq=&limit=` and persisted to the cache. - */ - showEarlier(): Promise<void>; - /** - * Re-sync after a WS (re)connect. Clears any stale `generating` (a turn may - * have sealed while disconnected — the live `turn-sealed` was missed), then - * pulls newly-sealed turns from history (+ metrics). If the turn is still - * running, the server's post-subscribe replay re-asserts `generating`. The - * app store pairs this with a `chat.subscribe` for the conversation. - */ - resync(): void; - dispose(): void; + readonly messages: readonly ChatMessage[]; + readonly chunks: readonly RenderedChunk[]; + readonly turnMetrics: readonly TurnMetricsEntry[]; + /** + * The conversation's current context size (tokens occupied) — 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; + /** + * Cancel (remove) a single queued steering message by id so it never runs + * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: success is + * confirmed by the `message-queue` SURFACE updating (the cancelled message + * leaves the snapshot); a cancel of an already-drained / unknown message is a + * silent server-side no-op. The caller optimistically hides the row; the + * surface update reconciles. `messageId` is the stable `QueuedMessage.id` + * the queue surface snapshot carries. No transcript change — a cancelled + * message is never delivered as steering. + */ + cancelQueuedMessage(messageId: string): void; + setModel(model: string): void; + /** + * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window. + * Lowering it unloads older committed chunks (deferred via the gate while the + * reader is scrolled up, catching up on the next mutation). Raising it + * REFILLS older history (cache first, then CR-5 `?beforeSeq=`) up to the + * fresh-load window (`initialWindowSize` = 75% of the limit) — the same + * window a fresh `load()` would show — so upping the limit reveals more + * history instead of leaving a partial view. New deltas + loads use the new + * limit. The refill awaits, so a caller can preserve scroll over the prepend. + */ + setChatLimit(limit: number): Promise<void>; + load(): Promise<void>; + /** + * Page one unload-unit (`ceil(limit/4)`) of earlier history back in — the + * "Show earlier messages" action. Local cache first; when the cache doesn't + * reach far enough back (a server-windowed fresh load), the missing older + * run is fetched via CR-5 `?beforeSeq=&limit=` and persisted to the cache. + */ + showEarlier(): Promise<void>; + /** + * Re-sync after a WS (re)connect. Clears any stale `generating` (a turn may + * have sealed while disconnected — the live `turn-sealed` was missed), then + * pulls newly-sealed turns from history (+ metrics). If the turn is still + * running, the server's post-subscribe replay re-asserts `generating`. The + * app store pairs this with a `chat.subscribe` for the conversation. + */ + resync(): void; + dispose(): void; } export function createChatStore(deps: ChatStoreDependencies): ChatStore { - let transcript = $state<TranscriptState>(initialState()); - let metrics = $state<MetricsState>(initialMetricsState()); - let _pendingSync = $state(false); - let _error = $state<string | null>(null); - let _model = $state<string | undefined>(deps.model); - let disposed = false; + let transcript = $state<TranscriptState>(initialState()); + let metrics = $state<MetricsState>(initialMetricsState()); + let _pendingSync = $state(false); + let _error = $state<string | null>(null); + let _model = $state<string | undefined>(deps.model); + let disposed = false; - let chatLimit = normalizeChatLimit(deps.chatLimit); + let chatLimit = normalizeChatLimit(deps.chatLimit); - /** - * Enforce the chat limit after a transcript mutation — unless the injected - * gate says the reader is scrolled up (then defer; the next mutation retries - * and `trimTranscript` unloads whole quarters to catch up). - */ - function maybeTrim(): void { - if (deps.canUnload !== undefined && !deps.canUnload()) return; - transcript = trimTranscript(transcript, chatLimit); - } + /** + * Enforce the chat limit after a transcript mutation — unless the injected + * gate says the reader is scrolled up (then defer; the next mutation retries + * and `trimTranscript` unloads whole quarters to catch up). + */ + function maybeTrim(): void { + if (deps.canUnload !== undefined && !deps.canUnload()) return; + transcript = trimTranscript(transcript, chatLimit); + } - /** - * Pull `seq > cache-cursor` from the server and fold it in. `coldLimit`, when - * given AND the cache is empty (a truly fresh browser), windows the fetch to - * the newest N chunks (CR-5 `?limit=`) so a huge conversation doesn't ship - * whole. It is deliberately NOT applied to a warm-cache tail: windowing a - * tail that grew past N while we were away would leave a silent seq GAP - * between the cache and the fetched window. - */ - async function syncTail(coldLimit?: number): Promise<void> { - if (disposed || _pendingSync) return; - _pendingSync = true; - try { - const since = await deps.cache.sinceSeq(deps.conversationId); - const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined; - const res = await deps.historySync(deps.conversationId, since, window); - const merged = await deps.cache.commit(deps.conversationId, res.chunks); - transcript = applyHistory(transcript, merged); - maybeTrim(); - _error = null; - } catch (err) { - _error = err instanceof Error ? err.message : String(err); - } finally { - _pendingSync = false; - } - } + /** + * Pull `seq > cache-cursor` from the server and fold it in. `coldLimit`, when + * given AND the cache is empty (a truly fresh browser), windows the fetch to + * the newest N chunks (CR-5 `?limit=`) so a huge conversation doesn't ship + * whole. It is deliberately NOT applied to a warm-cache tail: windowing a + * tail that grew past N while we were away would leave a silent seq GAP + * between the cache and the fetched window. + */ + async function syncTail(coldLimit?: number): Promise<void> { + if (disposed || _pendingSync) return; + _pendingSync = true; + try { + const since = await deps.cache.sinceSeq(deps.conversationId); + const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined; + const res = await deps.historySync(deps.conversationId, since, window); + const merged = await deps.cache.commit(deps.conversationId, res.chunks); + transcript = applyHistory(transcript, merged); + maybeTrim(); + _error = null; + } catch (err) { + _error = err instanceof Error ? err.message : String(err); + } finally { + _pendingSync = false; + } + } - async function syncMetrics(): Promise<void> { - if (disposed) return; - try { - const res = await deps.metricsSync(deps.conversationId); - metrics = applyDurableMetrics(metrics, res.turns); - } catch { - // Metrics fetch failure must not block history sync or throw; - // live-folded metrics remain intact. - } - } + async function syncMetrics(): Promise<void> { + if (disposed) return; + try { + const res = await deps.metricsSync(deps.conversationId); + metrics = applyDurableMetrics(metrics, res.turns); + } catch (err) { + // Metrics fetch failure must not block history sync or throw; + // live-folded metrics remain intact. Surface via onError (modal). + console.error("[syncMetrics] failed:", err); + deps.onError?.("Failed to sync conversation metrics", err); + } + } - /** - * Fetch up to `want` older chunks (seq < `oldest`) — cache first, then a - * CR-5 `?beforeSeq=&limit=` server backfill when the cache is too shallow, - * persisting it so the next read is local. Returns every locally-known - * chunk older than `oldest` (the caller — `restoreEarlier` — takes the - * newest `count` of them). Shared by `showEarlier` and the raise-refill. - */ - async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> { - let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest); - const oldestKnown = earlier[0]?.seq ?? oldest; - if (earlier.length < want && oldestKnown > 1) { - const res = await deps.historySync(deps.conversationId, 0, { - beforeSeq: oldestKnown, - limit: want - earlier.length, - }); - const merged = await deps.cache.commit(deps.conversationId, res.chunks); - earlier = merged.filter((c) => c.seq < oldest); - } - return earlier; - } + /** + * Fetch up to `want` older chunks (seq < `oldest`) — cache first, then a + * CR-5 `?beforeSeq=&limit=` server backfill when the cache is too shallow, + * persisting it so the next read is local. Returns every locally-known + * chunk older than `oldest` (the caller — `restoreEarlier` — takes the + * newest `count` of them). Shared by `showEarlier` and the raise-refill. + */ + async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> { + let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest); + const oldestKnown = earlier[0]?.seq ?? oldest; + if (earlier.length < want && oldestKnown > 1) { + const res = await deps.historySync(deps.conversationId, 0, { + beforeSeq: oldestKnown, + limit: want - earlier.length, + }); + const merged = await deps.cache.commit(deps.conversationId, res.chunks); + earlier = merged.filter((c) => c.seq < oldest); + } + return earlier; + } - /** - * Refill toward the fresh-load window after a limit RAISE: pull older - * history (cache first, then server) so the loaded set grows to match what a - * fresh `load()` would show at the new limit. No-op when already at the - * origin (seq 1) or already within the window. `restoreEarlier` re-derives - * the window start at apply time, so a delta landing during the await can't - * corrupt the merge. NOT gated (refilling prepends above the viewport; the - * caller preserves scroll position). - */ - async function refill(): Promise<void> { - if (disposed) return; - const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq; - if (oldest <= 1) return; - const want = initialWindowSize(chatLimit) - transcript.committed.length; - if (want <= 0) return; - try { - const earlier = await backfillOlder(oldest, want); - if (earlier.length === 0) return; - transcript = restoreEarlier(transcript, earlier, want); - _error = null; - } catch (err) { - _error = err instanceof Error ? err.message : String(err); - } - } + /** + * Refill toward the fresh-load window after a limit RAISE: pull older + * history (cache first, then server) so the loaded set grows to match what a + * fresh `load()` would show at the new limit. No-op when already at the + * origin (seq 1) or already within the window. `restoreEarlier` re-derives + * the window start at apply time, so a delta landing during the await can't + * corrupt the merge. NOT gated (refilling prepends above the viewport; the + * caller preserves scroll position). + */ + async function refill(): Promise<void> { + if (disposed) return; + const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq; + if (oldest <= 1) return; + const want = initialWindowSize(chatLimit) - transcript.committed.length; + if (want <= 0) return; + try { + const earlier = await backfillOlder(oldest, want); + if (earlier.length === 0) return; + transcript = restoreEarlier(transcript, earlier, want); + _error = null; + } catch (err) { + _error = err instanceof Error ? err.message : String(err); + } + } - return { - get messages(): readonly ChatMessage[] { - return selectMessages(transcript); - }, - get chunks(): readonly RenderedChunk[] { - return selectChunks(transcript); - }, - get turnMetrics(): readonly TurnMetricsEntry[] { - return selectOrderedTurnMetrics(metrics); - }, - get currentContextSize(): number | undefined { - return selectCurrentContextSize(metrics); - }, - get generating(): boolean { - return selectGenerating(transcript); - }, - get pendingSync(): boolean { - return _pendingSync; - }, - get error(): string | null { - return _error; - }, - get model(): string | undefined { - return _model; - }, - get hasEarlier(): boolean { - return selectHasEarlier(transcript); - }, - get thinkingKeyBase(): number { - return transcript.hiddenThinkingCount; - }, + return { + get messages(): readonly ChatMessage[] { + return selectMessages(transcript); + }, + get chunks(): readonly RenderedChunk[] { + return selectChunks(transcript); + }, + get turnMetrics(): readonly TurnMetricsEntry[] { + return selectOrderedTurnMetrics(metrics); + }, + get currentContextSize(): number | undefined { + return selectCurrentContextSize(metrics); + }, + get generating(): boolean { + return selectGenerating(transcript); + }, + get providerRetry(): TurnProviderRetryEvent | null { + return selectProviderRetry(transcript); + }, + get pendingSync(): boolean { + return _pendingSync; + }, + get error(): string | null { + return _error; + }, + get model(): string | undefined { + return _model; + }, + get hasEarlier(): boolean { + return selectHasEarlier(transcript); + }, + get thinkingKeyBase(): number { + return transcript.hiddenThinkingCount; + }, - handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void { - if (msg.type === "chat.error") { - if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) { - return; - } - _error = msg.message; - return; - } - if (msg.event.conversationId !== deps.conversationId) { - return; - } - transcript = foldEvent(transcript, msg.event); - metrics = foldMetricsEvent(metrics, msg.event); - maybeTrim(); - if (transcript.sealedTurnId !== null) { - void syncTail(); - void syncMetrics(); - } - }, + handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void { + if (msg.type === "chat.error") { + if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) { + return; + } + _error = msg.message; + return; + } + if (msg.event.conversationId !== deps.conversationId) { + return; + } + transcript = foldEvent(transcript, msg.event); + metrics = foldMetricsEvent(metrics, msg.event); + maybeTrim(); + if (transcript.sealedTurnId !== null) { + void syncTail(); + void syncMetrics(); + } + }, - send(text: string): void { - transcript = appendUserMessage(transcript, text); - maybeTrim(); - const msg: ChatSendMessage = { - type: "chat.send", - conversationId: deps.conversationId, - message: text, - ...(_model !== undefined ? { model: _model } : {}), - }; - deps.transport.send(msg); - }, + 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.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; - }, + cancelQueuedMessage(messageId: string): void { + // Fire-and-forget + idempotent (per the contract). The caller optimistically + // hides the row; the message-queue surface update reconciles. A cancel of + // an already-drained / unknown message is a silent server no-op, so there + // is no local-state change to make and nothing to roll back on a stray + // `chat.error` (which only fires for a malformed send — a client that + // sends the id it just rendered never hits it). + const msg: ChatQueueCancelMessage = { + type: "chat.queue.cancel", + conversationId: deps.conversationId, + messageId, + }; + deps.transport.send(msg); + }, - async setChatLimit(limit: number): Promise<void> { - const prev = chatLimit; - chatLimit = normalizeChatLimit(limit); - if (chatLimit < prev) { - maybeTrim(); - } else if (chatLimit > prev) { - await refill(); - } - }, + setModel(model: string): void { + _model = model; + }, - 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 setChatLimit(limit: number): Promise<void> { + const prev = chatLimit; + chatLimit = normalizeChatLimit(limit); + if (chatLimit < prev) { + maybeTrim(); + } else if (chatLimit > prev) { + await refill(); + } + }, - async showEarlier(): Promise<void> { - if (disposed) return; - const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq; - if (oldest <= 1) return; - const want = unloadCount(chatLimit); - try { - const earlier = await backfillOlder(oldest, want); - transcript = restoreEarlier(transcript, earlier, want); - _error = null; - } catch (err) { - _error = err instanceof Error ? err.message : String(err); - } - }, + async 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(); + }, - 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(); - }, + 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); + } + }, - dispose(): void { - disposed = true; - }, - }; + 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 c1d62a6..aa5560f 100644 --- a/src/features/chat/store.test.ts +++ b/src/features/chat/store.test.ts @@ -1,1549 +1,1708 @@ -import type { AgentEvent, StepId, 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, - createFakeMetricsSync, - createFakeTransport, + createFakeCache, + createFakeHistorySync, + createFakeMetricsSync, + createFakeTransport, } from "./test-helpers"; const CONV_ID = "test-conv-1"; function makeStoredChunk(seq: number, role: "user" | "assistant" = "assistant"): StoredChunk { - return { seq, role, chunk: { type: "text", text: `chunk-${seq}` } }; + return { seq, role, chunk: { type: "text", text: `chunk-${seq}` } }; } function deltaEvent(event: AgentEvent): import("@dispatch/transport-contract").ChatDeltaMessage { - return { type: "chat.delta", event }; + return { type: "chat.delta", event }; } function errorMessage(message: string): import("@dispatch/transport-contract").ChatErrorMessage { - return { type: "chat.error", message }; + return { type: "chat.error", message }; } describe("createChatStore", () => { - it("folding a chat.delta updates messages", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hello" }), - ); - store.handleDelta( - deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: " world" }), - ); - - expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.role).toBe("assistant"); - expect(store.messages[0]?.chunks).toHaveLength(1); - expect(store.messages[0]?.chunks[0]?.type).toBe("text"); - expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe( - "Hello world", - ); - - store.dispose(); - }); - - it("turn-sealed triggers a history sync, commits to cache, and applies merged history", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // Set up what the history sync will return - historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)]; - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hi" }), - ); - store.handleDelta( - deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }), - ); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - // Wait for the async sync to complete - await vi.waitFor(() => { - expect(historySync.calls).toHaveLength(1); - }); - - expect(historySync.calls[0]?.conversationId).toBe(CONV_ID); - expect(historySync.calls[0]?.sinceSeq).toBe(0); - - // Cache should have the committed chunks - const cached = await cache.impl.load(CONV_ID); - expect(cached).toHaveLength(2); - - // Messages should include both provisional and committed - expect(store.messages.length).toBeGreaterThanOrEqual(1); - - store.dispose(); - }); - - it("send posts a chat.send with conversationId", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.send("Hello server"); - - expect(transport.sent).toHaveLength(1); - expect(transport.sent[0]?.type).toBe("chat.send"); - expect(transport.sent[0]?.conversationId).toBe(CONV_ID); - expect(transport.sent[0]?.message).toBe("Hello server"); - expect(transport.sent[0]).not.toHaveProperty("model"); - - store.dispose(); - }); - - it("send posts a chat.send with model when set", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - model: "openai/gpt-4", - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.send("Hello"); - - expect(transport.sent).toHaveLength(1); - expect(transport.sent[0]?.model).toBe("openai/gpt-4"); - - store.dispose(); - }); - - describe("queueMessage (chat.queue — steering)", () => { - it("posts a chat.queue with conversationId + text", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.queueMessage("steer left"); - - expect(transport.sent).toHaveLength(0); // chat.send stays empty - expect(transport.sentQueue).toHaveLength(1); - expect(transport.sentQueue[0]?.type).toBe("chat.queue"); - expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID); - expect(transport.sentQueue[0]?.text).toBe("steer left"); - - store.dispose(); - }); - - it("trims whitespace before sending", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.queueMessage(" padded "); - - expect(transport.sentQueue[0]?.text).toBe("padded"); - - store.dispose(); - }); - - it("does not send for empty/whitespace-only text", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.queueMessage(" "); - store.queueMessage(""); - - expect(transport.sentQueue).toHaveLength(0); - - store.dispose(); - }); - - it("does NOT optimistically echo into the transcript (the surface carries the queue)", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.queueMessage("queued steering message"); - - expect(store.chunks).toHaveLength(0); // no transcript echo - - store.dispose(); - }); - }); - - it("chat.error sets error", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - expect(store.error).toBeNull(); - - store.handleDelta(errorMessage("Something broke")); - - expect(store.error).toBe("Something broke"); - - store.dispose(); - }); - - it("load hydrates from cache then syncs the tail", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - - // Pre-populate cache - await cache.impl.commit(CONV_ID, [makeStoredChunk(1, "user"), makeStoredChunk(2, "assistant")]); - - // History sync returns new chunks - historySync.returnChunks = [makeStoredChunk(3, "assistant")]; - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - await store.load(); - - // Should have synced - expect(historySync.calls).toHaveLength(1); - expect(historySync.calls[0]?.sinceSeq).toBe(2); - - // Messages should include all chunks - expect(store.messages.length).toBeGreaterThanOrEqual(2); - - store.dispose(); - }); - - it("load with empty cache still syncs", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - - historySync.returnChunks = [makeStoredChunk(1, "assistant")]; - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - await store.load(); - - expect(historySync.calls).toHaveLength(1); - expect(historySync.calls[0]?.sinceSeq).toBe(0); - - store.dispose(); - }); - - it("error is cleared on successful sync", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // First, set an error - store.handleDelta(errorMessage("fail")); - expect(store.error).toBe("fail"); - - // Now trigger a successful sync via turn-sealed - historySync.returnChunks = [makeStoredChunk(1)]; - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }), - ); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - await vi.waitFor(() => { - expect(store.error).toBeNull(); - }); - - store.dispose(); - }); - - it("dispose prevents further syncs", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.dispose(); - - // Trigger a turn-sealed after dispose - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - // Wait a tick to let any async work settle - await new Promise((r) => setTimeout(r, 10)); - - // No sync should have happened - expect(historySync.calls).toHaveLength(0); - - store.dispose(); - }); - - it("overlapping syncs are guarded", async () => { - const transport = createFakeTransport(); - const _historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - - // Make the first sync slow - let resolveFirstSync: (() => void) | undefined; - const firstSyncPromise = new Promise<void>((resolve) => { - resolveFirstSync = resolve; - }); - - let callCount = 0; - const slowHistorySync: import("./ports").HistorySync = async (_conversationId, sinceSeq) => { - callCount++; - if (callCount === 1) { - await firstSyncPromise; - } - return { chunks: [], latestSeq: sinceSeq }; - }; - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: slowHistorySync, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // Trigger first sync - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - // Wait a tick so the first sync starts - await new Promise((r) => setTimeout(r, 0)); - - // Trigger second sync while first is pending - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t2" })); - - // Only one call should have been made (second was guarded) - expect(callCount).toBe(1); - - // Release the first sync - resolveFirstSync?.(); - await new Promise((r) => setTimeout(r, 10)); - - store.dispose(); - }); - - it("handles tool-call and tool-result chunks", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ - type: "tool-call", - conversationId: CONV_ID, - turnId: "t1", - toolCallId: "tc1", - toolName: "read_file", - input: { path: "/tmp/test.txt" }, - stepId: "t1#0" as StepId, - }), - ); - store.handleDelta( - deltaEvent({ - type: "tool-result", - conversationId: CONV_ID, - turnId: "t1", - toolCallId: "tc1", - toolName: "read_file", - content: "file contents", - isError: false, - stepId: "t1#0" as StepId, - }), - ); - - expect(store.chunks).toHaveLength(2); - expect(store.chunks[0]?.chunk.type).toBe("tool-call"); - expect(store.chunks[1]?.chunk.type).toBe("tool-result"); - - store.dispose(); - }); - - it("setModel changes the model used by the next send", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - model: "openai/gpt-4", - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.send("First"); - expect(transport.sent[0]?.model).toBe("openai/gpt-4"); - - store.setModel("anthropic/claude-3"); - store.send("Second"); - expect(transport.sent[1]?.model).toBe("anthropic/claude-3"); - - store.dispose(); - }); - - it("setModel from undefined to a model", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.send("First"); - expect(transport.sent[0]).not.toHaveProperty("model"); - - store.setModel("openai/gpt-4o"); - store.send("Second"); - expect(transport.sent[1]?.model).toBe("openai/gpt-4o"); - - store.dispose(); - }); - - it("handleDelta ignores a chat.delta for a different conversationId", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.handleDelta( - deltaEvent({ type: "turn-start", conversationId: "other-conv", turnId: "t1" }), - ); - store.handleDelta( - deltaEvent({ - type: "text-delta", - conversationId: "other-conv", - turnId: "t1", - delta: "Should be ignored", - }), - ); - - expect(store.messages).toHaveLength(0); - - store.dispose(); - }); - - it("handleDelta ignores a chat.error for a different conversationId", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.handleDelta({ type: "chat.error", conversationId: "other-conv", message: "Wrong conv" }); - - expect(store.error).toBeNull(); - - store.dispose(); - }); - - it("send optimistically shows the user message immediately", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.send("hi"); - - expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.role).toBe("user"); - expect(store.messages[0]?.chunks).toHaveLength(1); - expect(store.messages[0]?.chunks[0]?.type).toBe("text"); - expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe("hi"); - - store.dispose(); - }); - - it("the optimistic user message is replaced after turn-sealed + history sync", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - historySync.returnChunks = [ - { seq: 1, role: "user", chunk: { type: "text", text: "hi" } }, - { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } }, - ]; - - store.send("hi"); - expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.role).toBe("user"); - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hello!" }), - ); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - await vi.waitFor(() => { - expect(store.messages.length).toBe(2); - }); - - expect(store.messages[0]?.role).toBe("user"); - expect(store.messages[1]?.role).toBe("assistant"); - - store.dispose(); - }); - - it("folding usage/step-complete/done deltas exposes turnMetrics", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - expect(store.turnMetrics).toHaveLength(0); - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ - type: "usage", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - store.handleDelta( - deltaEvent({ - type: "step-complete", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - ttftMs: 200, - genTotalMs: 800, - }), - ); - store.handleDelta( - deltaEvent({ - type: "done", - conversationId: CONV_ID, - turnId: "t1", - reason: "end-turn", - durationMs: 1200, - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - - expect(store.turnMetrics).toHaveLength(1); - const entry = store.turnMetrics[0]; - expect(entry?.turnId).toBe("t1"); - expect(entry?.steps).toHaveLength(1); - expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId); - expect(entry?.steps[0]?.usage.inputTokens).toBe(100); - expect(entry?.steps[0]?.genTotalMs).toBe(800); - expect(entry?.total).not.toBeNull(); - expect(entry?.total?.usage.inputTokens).toBe(100); - expect(entry?.total?.usage.outputTokens).toBe(50); - expect(entry?.total?.durationMs).toBe(1200); - - store.dispose(); - }); - - it("turnMetrics entry has total: null before done (progressive turn)", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ - type: "usage", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - store.handleDelta( - deltaEvent({ - type: "step-complete", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - ttftMs: 200, - genTotalMs: 800, - }), - ); - - expect(store.turnMetrics).toHaveLength(1); - const entry = store.turnMetrics[0]; - expect(entry?.turnId).toBe("t1"); - expect(entry?.steps).toHaveLength(1); - expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId); - expect(entry?.total).toBeNull(); - - store.dispose(); - }); - - it("metricsSync durable result overrides live by turnId", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // Live fold gives some metrics - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ - type: "usage", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - store.handleDelta( - deltaEvent({ - type: "done", - conversationId: CONV_ID, - turnId: "t1", - reason: "end-turn", - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - - expect(store.turnMetrics).toHaveLength(1); - expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50); - - // Durable sync returns different numbers for the same turnId - metricsSync.returnTurns = [ - { - turnId: "t1", - usage: { inputTokens: 200, outputTokens: 80 }, - durationMs: 500, - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 200, outputTokens: 80 }, - genTotalMs: 400, - }, - ], - }, - ]; - - // Trigger metrics sync via turn-sealed - historySync.returnChunks = []; - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - await vi.waitFor(() => { - expect(metricsSync.calls).toHaveLength(1); - }); - - // Durable should now override live (syncMetrics is async, wait for it) - await vi.waitFor(() => { - expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(80); - }); - - expect(store.turnMetrics).toHaveLength(1); - expect(store.turnMetrics[0]?.total?.durationMs).toBe(500); - - store.dispose(); - }); - - it("rejected metricsSync leaves live metrics intact and does not throw", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // Live fold some metrics - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ - type: "usage", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - store.handleDelta( - deltaEvent({ - type: "done", - conversationId: CONV_ID, - turnId: "t1", - reason: "end-turn", - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - - expect(store.turnMetrics).toHaveLength(1); - - // Make the metrics sync reject - metricsSync.nextError = "metrics endpoint unavailable"; - - historySync.returnChunks = []; - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - await vi.waitFor(() => { - expect(metricsSync.calls).toHaveLength(1); - }); - - // Live metrics should still be intact - expect(store.turnMetrics).toHaveLength(1); - expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50); - - // No error should have been thrown to the store - expect(store.error).toBeNull(); - - store.dispose(); - }); - - it("load calls metricsSync after history sync", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - - metricsSync.returnTurns = [ - { - turnId: "t1", - usage: { inputTokens: 300, outputTokens: 100 }, - durationMs: 900, - steps: [], - }, - ]; - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - await store.load(); - - expect(historySync.calls).toHaveLength(1); - expect(metricsSync.calls).toHaveLength(1); - expect(metricsSync.calls[0]).toBe(CONV_ID); - expect(store.turnMetrics).toHaveLength(1); - expect(store.turnMetrics[0]?.total?.usage.inputTokens).toBe(300); - - store.dispose(); - }); - - it("generating reflects the turn lifecycle (idle → running → idle)", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - expect(store.generating).toBe(false); - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - expect(store.generating).toBe(true); - - store.handleDelta( - deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hi" }), - ); - expect(store.generating).toBe(true); - - store.handleDelta( - deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }), - ); - expect(store.generating).toBe(false); - - store.dispose(); - }); - - it("generating lights up for a watcher whose turn was replayed (no send first)", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // A late-joiner receives the in-flight turn replayed from turn-start. - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "partial" }), - ); - expect(store.generating).toBe(true); - expect(transport.sent).toHaveLength(0); // it never sent — it's just watching - - store.dispose(); - }); - - it("resync clears a stale generating flag and re-syncs history + metrics", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // Disconnected mid-turn: turn-start seen, but the live done/turn-sealed was - // missed, so generating is stuck true. - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - expect(store.generating).toBe(true); - - // The turn actually sealed while we were gone — history now has the chunks. - historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)]; - - store.resync(); - - // Generating is cleared synchronously (a finished turn must not spin forever). - expect(store.generating).toBe(false); - - await vi.waitFor(() => { - expect(historySync.calls).toHaveLength(1); - expect(metricsSync.calls).toHaveLength(1); - }); - - store.dispose(); - }); - - it("chat limit: crossing the limit unloads the oldest quarter in one bulk pass", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - // Commit exactly 100 chunks via a sealed turn (at the limit — no trim). - const hundred = Array.from({ length: 100 }, (_, i) => makeStoredChunk(i + 1)); - historySync.returnChunks = hundred; - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - await vi.waitFor(() => { - expect(store.chunks).toHaveLength(100); - }); - expect(store.hasEarlier).toBe(false); - - // The 101st chunk (a live tool-call) crosses the limit → 25 unload → 76 remain. - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); - store.handleDelta( - deltaEvent({ - type: "tool-call", - conversationId: CONV_ID, - turnId: "t2", - toolCallId: "tc1", - toolName: "probe", - input: {}, - stepId: "t2#0" as StepId, - }), - ); - - expect(store.chunks).toHaveLength(76); - expect(store.chunks[0]?.seq).toBe(26); - expect(store.hasEarlier).toBe(true); - - store.dispose(); - }); - - it("chat limit: unloading is deferred while the gate is closed, then catches up", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - let atBottom = false; // reader scrolled up - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 10, - canUnload: () => atBottom, - }); - - // 15 live tool-calls: over the limit, but the gate defers every trim. - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - for (let i = 0; i < 15; i++) { - store.handleDelta( - deltaEvent({ - type: "tool-call", - conversationId: CONV_ID, - turnId: "t1", - toolCallId: `tc${i}`, - toolName: "probe", - input: {}, - stepId: `t1#${i}` as StepId, - }), - ); - } - expect(store.chunks).toHaveLength(15); - - // Reader returns to the bottom — the deferred trim now catches up. - // With no committed chunks, it drops the oldest provisional chunks - // (the in-flight turn) to stay within the limit. - atBottom = true; - store.handleDelta( - deltaEvent({ - type: "tool-call", - conversationId: CONV_ID, - turnId: "t1", - toolCallId: "tc15", - toolName: "probe", - input: {}, - stepId: "t1#15" as StepId, - }), - ); - // 16 provisional, limit 10, quarter 3 → drop 6 oldest → 10 remain. - expect(store.chunks).toHaveLength(10); - - store.dispose(); - }); - - it("chat limit: a deferred trim catches up across committed history once the gate opens", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - let atBottom = false; - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - canUnload: () => atBottom, - }); - - // Seal a turn committing 130 chunks while the reader is scrolled up: no trim. - historySync.returnChunks = Array.from({ length: 130 }, (_, i) => makeStoredChunk(i + 1)); - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - await vi.waitFor(() => { - expect(store.chunks).toHaveLength(130); - }); - - // Back at the bottom: the next fold trims whole quarters down to ≤ 100. - atBottom = true; - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); - // 130 → 2 quarters of 25 → 80 committed (turn-start adds no chunk). - expect(store.chunks).toHaveLength(80); - expect(store.chunks[0]?.seq).toBe(51); - - store.dispose(); - }); - - it("chat limit: load windows a long cached conversation to 75% of the limit", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - await cache.impl.commit( - CONV_ID, - Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)), - ); - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); - - // floor(100 × 0.75) = 75 newest chunks: seqs 426..500. - expect(store.chunks).toHaveLength(75); - expect(store.chunks[0]?.seq).toBe(426); - expect(store.hasEarlier).toBe(true); - // The tail sync still used the cache's real cursor (not the window's edge). - expect(historySync.calls[0]?.sinceSeq).toBe(500); - - store.dispose(); - }); - - it("chat limit: a cold cache (fresh browser) asks the SERVER for the 75% window (CR-5 ?limit=)", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - // The server holds 500 chunks; the windowed fetch returns the newest 75. - historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)); - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); - - // The cold-cache initial sync carried the window (`?sinceSeq=0&limit=75`). - expect(historySync.calls[0]?.sinceSeq).toBe(0); - expect(historySync.calls[0]?.window).toEqual({ limit: 75 }); - - expect(store.chunks).toHaveLength(75); - expect(store.chunks[0]?.seq).toBe(426); - // hasEarlier derives from the 1-based gap-free seq contract (426 > 1) — - // no local watermark was ever set. - expect(store.hasEarlier).toBe(true); - // Only the window was shipped + cached (the point of CR-5). - const cached = await cache.impl.load(CONV_ID); - expect(cached).toHaveLength(75); - - store.dispose(); - }); - - it("chat limit: a warm cache syncs the tail UNWINDOWED (no seq gap behind the cache)", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - await cache.impl.commit(CONV_ID, [makeStoredChunk(1), makeStoredChunk(2)]); - historySync.returnChunks = [makeStoredChunk(3)]; - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); - - expect(historySync.calls[0]?.sinceSeq).toBe(2); - expect(historySync.calls[0]?.window).toBeUndefined(); - - store.dispose(); - }); - - it("chat limit: showEarlier backfills from the server when the cache is too shallow (CR-5 ?beforeSeq=)", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)); - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); // server-windowed: loaded + cached = 426..500 - expect(store.chunks[0]?.seq).toBe(426); - - await store.showEarlier(); - - // Nothing below 426 was cached → fetched the missing run from the server. - const backfill = historySync.calls[1]; - expect(backfill?.window).toEqual({ beforeSeq: 426, limit: 25 }); - expect(store.chunks).toHaveLength(100); - expect(store.chunks[0]?.seq).toBe(401); - expect(store.hasEarlier).toBe(true); - // The backfilled run is persisted: the NEXT page-in is cache-local. - const cached = await cache.impl.load(CONV_ID); - expect(cached).toHaveLength(100); - - store.dispose(); - }); - - it("chat limit: showEarlier pages a quarter back in from the cache", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - await cache.impl.commit( - CONV_ID, - Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)), - ); - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); - expect(store.chunks[0]?.seq).toBe(426); - - await store.showEarlier(); // +ceil(100/4) = 25 older chunks - expect(store.chunks).toHaveLength(100); - expect(store.chunks[0]?.seq).toBe(401); - expect(store.hasEarlier).toBe(true); - // The cache reached deep enough — no server backfill was needed. - expect(historySync.calls).toHaveLength(1); - - store.dispose(); - }); - - it("chat limit: showEarlier clears hasEarlier when the cache is exhausted", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - await cache.impl.commit( - CONV_ID, - Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)), - ); - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); // window 75: hidden 1..5 - expect(store.chunks).toHaveLength(75); - expect(store.hasEarlier).toBe(true); - - await store.showEarlier(); // restores all 5 → nothing left below - expect(store.chunks).toHaveLength(80); - expect(store.chunks[0]?.seq).toBe(1); - expect(store.hasEarlier).toBe(false); - - store.dispose(); - }); - - it("chat limit: a post-trim history sync does not resurrect unloaded chunks", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - await cache.impl.commit( - CONV_ID, - Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)), - ); - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); - expect(store.chunks[0]?.seq).toBe(426); - - // A sealed turn triggers syncTail, whose cache.commit returns the FULL - // merged cache (seqs 1..501) — the watermark must keep 1..425 out. - historySync.returnChunks = [makeStoredChunk(501)]; - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t9" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t9" })); - - await vi.waitFor(() => { - expect(store.chunks[store.chunks.length - 1]?.seq).toBe(501); - }); - expect(store.chunks[0]?.seq).toBe(426); - expect(store.chunks).toHaveLength(76); - - store.dispose(); - }); - - it("setChatLimit: lowering the limit trims older committed chunks live", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - // Load 80 committed chunks (under the limit — no trim yet). - historySync.returnChunks = Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)); - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - await vi.waitFor(() => { - expect(store.chunks).toHaveLength(80); - }); - - // Lower the limit to 10: 80 → unload ceil(10/4)=3 per quarter, needs - // ceil((80-10)/3)=24 quarters → drop min(72, 80)=72 → 8 remain. - await store.setChatLimit(10); - expect(store.chunks).toHaveLength(8); - expect(store.chunks[0]?.seq).toBe(73); - expect(store.hasEarlier).toBe(true); - - store.dispose(); - }); - - it("setChatLimit: raising the limit refills older history up to the fresh-load window", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - // Cache holds 200 chunks; load at limit 100 → window 75 → seqs 126..200. - await cache.impl.commit( - CONV_ID, - Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)), - ); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - await store.load(); - expect(store.chunks).toHaveLength(75); - expect(store.chunks[0]?.seq).toBe(126); - expect(store.hasEarlier).toBe(true); - - // Raise to 200 → window floor(0.75×200)=150 → refill 75 older chunks - // (seqs 51..125) from the cache. No server backfill (cache is deep enough). - await store.setChatLimit(200); - expect(historySync.calls).toHaveLength(1); // the load-time tail sync only - expect(store.chunks).toHaveLength(150); - expect(store.chunks[0]?.seq).toBe(51); - expect(store.hasEarlier).toBe(true); // 51 > 1 - - store.dispose(); - }); - - it("setChatLimit: raising backfills from the server when the cache is too shallow", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - // Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200. - historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - await store.load(); - expect(store.chunks[0]?.seq).toBe(126); - - // Raise to 200 → want 75 older. Cache only holds 126..200 → backfill - // seqs 51..125 from the server (CR-5 ?beforeSeq=126&limit=75). - await store.setChatLimit(200); - const backfill = historySync.calls[1]; - expect(backfill?.window).toEqual({ beforeSeq: 126, limit: 75 }); - expect(store.chunks).toHaveLength(150); - expect(store.chunks[0]?.seq).toBe(51); - - store.dispose(); - }); - - it("setChatLimit: raising refills all available older history (down to the origin)", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - // 101 chunks → one trim pass drops 25 → 76 remain (seqs 26..101). - historySync.returnChunks = Array.from({ length: 101 }, (_, i) => makeStoredChunk(i + 1)); - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - await vi.waitFor(() => { - expect(store.chunks).toHaveLength(76); - }); - expect(store.chunks[0]?.seq).toBe(26); - expect(store.hasEarlier).toBe(true); - - // Raise to 500 → window 375 → want 299 older. The cache holds only - // seqs 1..25 below the window (no more server-side) → restore all 25 → - // 101 loaded, reaching the origin. - await store.setChatLimit(500); - expect(store.chunks).toHaveLength(101); - expect(store.chunks[0]?.seq).toBe(1); - expect(store.hasEarlier).toBe(false); - - store.dispose(); - }); - - it("setChatLimit: raising is a no-op when the window already starts at the origin", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - await cache.impl.commit( - CONV_ID, - Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)), - ); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - await store.load(); // only 50 chunks → all loaded, window starts at seq 1 - expect(store.chunks).toHaveLength(50); - expect(store.hasEarlier).toBe(false); - const callsAfterLoad = historySync.calls.length; - - await store.setChatLimit(500); // raise → refill no-ops (oldest = 1) - expect(store.chunks).toHaveLength(50); - expect(store.chunks[0]?.seq).toBe(1); - expect(historySync.calls).toHaveLength(callsAfterLoad); // no backfill - - store.dispose(); - }); - - it("setChatLimit: a nonsensical value is normalized (no crash, no trim)", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - historySync.returnChunks = Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)); - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - await vi.waitFor(() => { - expect(store.chunks).toHaveLength(50); - }); - - // NaN normalizes to the default (256). prev was 100 → raise → refill, - // but the loaded window already starts at seq 1 (origin) → no-op. - await store.setChatLimit(Number.NaN); - expect(store.chunks).toHaveLength(50); - - store.dispose(); - }); - - it("resync is a no-op after dispose", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.dispose(); - store.resync(); - - await new Promise((r) => setTimeout(r, 10)); - expect(historySync.calls).toHaveLength(0); - expect(metricsSync.calls).toHaveLength(0); - }); + it("folding a chat.delta updates messages", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hello" }), + ); + store.handleDelta( + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: " world" }), + ); + + expect(store.messages).toHaveLength(1); + expect(store.messages[0]?.role).toBe("assistant"); + expect(store.messages[0]?.chunks).toHaveLength(1); + expect(store.messages[0]?.chunks[0]?.type).toBe("text"); + expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe( + "Hello world", + ); + + store.dispose(); + }); + + it("turn-sealed triggers a history sync, commits to cache, and applies merged history", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // Set up what the history sync will return + historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)]; + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hi" }), + ); + store.handleDelta( + deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }), + ); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + // Wait for the async sync to complete + await vi.waitFor(() => { + expect(historySync.calls).toHaveLength(1); + }); + + expect(historySync.calls[0]?.conversationId).toBe(CONV_ID); + expect(historySync.calls[0]?.sinceSeq).toBe(0); + + // Cache should have the committed chunks + const cached = await cache.impl.load(CONV_ID); + expect(cached).toHaveLength(2); + + // Messages should include both provisional and committed + expect(store.messages.length).toBeGreaterThanOrEqual(1); + + store.dispose(); + }); + + it("send posts a chat.send with conversationId", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.send("Hello server"); + + expect(transport.sent).toHaveLength(1); + expect(transport.sent[0]?.type).toBe("chat.send"); + expect(transport.sent[0]?.conversationId).toBe(CONV_ID); + expect(transport.sent[0]?.message).toBe("Hello server"); + expect(transport.sent[0]).not.toHaveProperty("model"); + + store.dispose(); + }); + + it("send posts a chat.send with model when set", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + model: "openai/gpt-4", + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.send("Hello"); + + expect(transport.sent).toHaveLength(1); + expect(transport.sent[0]?.model).toBe("openai/gpt-4"); + + store.dispose(); + }); + + 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(); + }); + }); + + describe("cancelQueuedMessage (chat.queue.cancel)", () => { + it("posts a chat.queue.cancel with conversationId + messageId", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.cancelQueuedMessage("msg-42"); + + expect(transport.sent).toHaveLength(0); // chat.send stays empty + expect(transport.sentQueue).toHaveLength(0); // chat.queue stays empty + expect(transport.sentCancels).toHaveLength(1); + expect(transport.sentCancels[0]?.type).toBe("chat.queue.cancel"); + expect(transport.sentCancels[0]?.conversationId).toBe(CONV_ID); + expect(transport.sentCancels[0]?.messageId).toBe("msg-42"); + + store.dispose(); + }); + + it("does NOT touch the transcript (a cancelled message never runs)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.cancelQueuedMessage("msg-42"); + + expect(store.chunks).toHaveLength(0); // no transcript echo / change + expect(store.error).toBeNull(); + + store.dispose(); + }); + + it("sends for any messageId (cancel is idempotent server-side, no FE guard)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // The server no-ops an already-drained / unknown id; the FE fires-and- + // forgetgets, so even a repeat cancel is forwarded. + store.cancelQueuedMessage("msg-42"); + store.cancelQueuedMessage("msg-42"); + + expect(transport.sentCancels).toHaveLength(2); + expect(transport.sentCancels[1]?.messageId).toBe("msg-42"); + + store.dispose(); + }); + }); + + it("chat.error sets error", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + expect(store.error).toBeNull(); + + store.handleDelta(errorMessage("Something broke")); + + expect(store.error).toBe("Something broke"); + + store.dispose(); + }); + + it("load hydrates from cache then syncs the tail", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + + // Pre-populate cache + await cache.impl.commit(CONV_ID, [makeStoredChunk(1, "user"), makeStoredChunk(2, "assistant")]); + + // History sync returns new chunks + historySync.returnChunks = [makeStoredChunk(3, "assistant")]; + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + await store.load(); + + // Should have synced + expect(historySync.calls).toHaveLength(1); + expect(historySync.calls[0]?.sinceSeq).toBe(2); + + // Messages should include all chunks + expect(store.messages.length).toBeGreaterThanOrEqual(2); + + store.dispose(); + }); + + it("load with empty cache still syncs", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + + historySync.returnChunks = [makeStoredChunk(1, "assistant")]; + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + await store.load(); + + expect(historySync.calls).toHaveLength(1); + expect(historySync.calls[0]?.sinceSeq).toBe(0); + + store.dispose(); + }); + + it("error is cleared on successful sync", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // First, set an error + store.handleDelta(errorMessage("fail")); + expect(store.error).toBe("fail"); + + // Now trigger a successful sync via turn-sealed + historySync.returnChunks = [makeStoredChunk(1)]; + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }), + ); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + await vi.waitFor(() => { + expect(store.error).toBeNull(); + }); + + store.dispose(); + }); + + it("dispose prevents further syncs", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.dispose(); + + // Trigger a turn-sealed after dispose + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + // Wait a tick to let any async work settle + await new Promise((r) => setTimeout(r, 10)); + + // No sync should have happened + expect(historySync.calls).toHaveLength(0); + + store.dispose(); + }); + + it("overlapping syncs are guarded", async () => { + const transport = createFakeTransport(); + const _historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + + // Make the first sync slow + let resolveFirstSync: (() => void) | undefined; + const firstSyncPromise = new Promise<void>((resolve) => { + resolveFirstSync = resolve; + }); + + let callCount = 0; + const slowHistorySync: import("./ports").HistorySync = async (_conversationId, sinceSeq) => { + callCount++; + if (callCount === 1) { + await firstSyncPromise; + } + return { chunks: [], latestSeq: sinceSeq }; + }; + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: slowHistorySync, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // Trigger first sync + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + // Wait a tick so the first sync starts + await new Promise((r) => setTimeout(r, 0)); + + // Trigger second sync while first is pending + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t2" })); + + // Only one call should have been made (second was guarded) + expect(callCount).toBe(1); + + // Release the first sync + resolveFirstSync?.(); + await new Promise((r) => setTimeout(r, 10)); + + store.dispose(); + }); + + it("handles tool-call and tool-result chunks", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "tool-call", + conversationId: CONV_ID, + turnId: "t1", + toolCallId: "tc1", + toolName: "read_file", + input: { path: "/tmp/test.txt" }, + stepId: "t1#0" as StepId, + }), + ); + store.handleDelta( + deltaEvent({ + type: "tool-result", + conversationId: CONV_ID, + turnId: "t1", + toolCallId: "tc1", + toolName: "read_file", + content: "file contents", + isError: false, + stepId: "t1#0" as StepId, + }), + ); + + expect(store.chunks).toHaveLength(2); + expect(store.chunks[0]?.chunk.type).toBe("tool-call"); + expect(store.chunks[1]?.chunk.type).toBe("tool-result"); + + store.dispose(); + }); + + it("setModel changes the model used by the next send", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + model: "openai/gpt-4", + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.send("First"); + expect(transport.sent[0]?.model).toBe("openai/gpt-4"); + + store.setModel("anthropic/claude-3"); + store.send("Second"); + expect(transport.sent[1]?.model).toBe("anthropic/claude-3"); + + store.dispose(); + }); + + it("setModel from undefined to a model", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.send("First"); + expect(transport.sent[0]).not.toHaveProperty("model"); + + store.setModel("openai/gpt-4o"); + store.send("Second"); + expect(transport.sent[1]?.model).toBe("openai/gpt-4o"); + + store.dispose(); + }); + + it("handleDelta ignores a chat.delta for a different conversationId", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.handleDelta( + deltaEvent({ type: "turn-start", conversationId: "other-conv", turnId: "t1" }), + ); + store.handleDelta( + deltaEvent({ + type: "text-delta", + conversationId: "other-conv", + turnId: "t1", + delta: "Should be ignored", + }), + ); + + expect(store.messages).toHaveLength(0); + + store.dispose(); + }); + + it("handleDelta ignores a chat.error for a different conversationId", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.handleDelta({ type: "chat.error", conversationId: "other-conv", message: "Wrong conv" }); + + expect(store.error).toBeNull(); + + store.dispose(); + }); + + it("send optimistically shows the user message immediately", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.send("hi"); + + expect(store.messages).toHaveLength(1); + expect(store.messages[0]?.role).toBe("user"); + expect(store.messages[0]?.chunks).toHaveLength(1); + expect(store.messages[0]?.chunks[0]?.type).toBe("text"); + expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe("hi"); + + store.dispose(); + }); + + it("the optimistic user message is replaced after turn-sealed + history sync", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + historySync.returnChunks = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" } }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } }, + ]; + + store.send("hi"); + expect(store.messages).toHaveLength(1); + expect(store.messages[0]?.role).toBe("user"); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hello!" }), + ); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + await vi.waitFor(() => { + expect(store.messages.length).toBe(2); + }); + + expect(store.messages[0]?.role).toBe("user"); + expect(store.messages[1]?.role).toBe("assistant"); + + store.dispose(); + }); + + it("folding usage/step-complete/done deltas exposes turnMetrics", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + expect(store.turnMetrics).toHaveLength(0); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "usage", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + store.handleDelta( + deltaEvent({ + type: "step-complete", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + ttftMs: 200, + genTotalMs: 800, + }), + ); + store.handleDelta( + deltaEvent({ + type: "done", + conversationId: CONV_ID, + turnId: "t1", + reason: "end-turn", + durationMs: 1200, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + + expect(store.turnMetrics).toHaveLength(1); + const entry = store.turnMetrics[0]; + expect(entry?.turnId).toBe("t1"); + expect(entry?.steps).toHaveLength(1); + expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId); + expect(entry?.steps[0]?.usage.inputTokens).toBe(100); + expect(entry?.steps[0]?.genTotalMs).toBe(800); + expect(entry?.total).not.toBeNull(); + expect(entry?.total?.usage.inputTokens).toBe(100); + expect(entry?.total?.usage.outputTokens).toBe(50); + expect(entry?.total?.durationMs).toBe(1200); + + store.dispose(); + }); + + it("turnMetrics entry has total: null before done (progressive turn)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "usage", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + store.handleDelta( + deltaEvent({ + type: "step-complete", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + ttftMs: 200, + genTotalMs: 800, + }), + ); + + expect(store.turnMetrics).toHaveLength(1); + const entry = store.turnMetrics[0]; + expect(entry?.turnId).toBe("t1"); + expect(entry?.steps).toHaveLength(1); + expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId); + expect(entry?.total).toBeNull(); + + store.dispose(); + }); + + it("metricsSync durable result overrides live by turnId", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // Live fold gives some metrics + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "usage", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + store.handleDelta( + deltaEvent({ + type: "done", + conversationId: CONV_ID, + turnId: "t1", + reason: "end-turn", + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + + expect(store.turnMetrics).toHaveLength(1); + expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50); + + // Durable sync returns different numbers for the same turnId + metricsSync.returnTurns = [ + { + turnId: "t1", + usage: { inputTokens: 200, outputTokens: 80 }, + durationMs: 500, + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 200, outputTokens: 80 }, + genTotalMs: 400, + }, + ], + }, + ]; + + // Trigger metrics sync via turn-sealed + historySync.returnChunks = []; + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + await vi.waitFor(() => { + expect(metricsSync.calls).toHaveLength(1); + }); + + // Durable should now override live (syncMetrics is async, wait for it) + await vi.waitFor(() => { + expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(80); + }); + + expect(store.turnMetrics).toHaveLength(1); + expect(store.turnMetrics[0]?.total?.durationMs).toBe(500); + + store.dispose(); + }); + + it("rejected metricsSync leaves live metrics intact and does not throw", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // Live fold some metrics + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "usage", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + store.handleDelta( + deltaEvent({ + type: "done", + conversationId: CONV_ID, + turnId: "t1", + reason: "end-turn", + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + + expect(store.turnMetrics).toHaveLength(1); + + // Make the metrics sync reject + metricsSync.nextError = "metrics endpoint unavailable"; + + historySync.returnChunks = []; + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + await vi.waitFor(() => { + expect(metricsSync.calls).toHaveLength(1); + }); + + // Live metrics should still be intact + expect(store.turnMetrics).toHaveLength(1); + expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50); + + // No error should have been thrown to the store + expect(store.error).toBeNull(); + + store.dispose(); + }); + + it("load calls metricsSync after history sync", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + + metricsSync.returnTurns = [ + { + turnId: "t1", + usage: { inputTokens: 300, outputTokens: 100 }, + durationMs: 900, + steps: [], + }, + ]; + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + await store.load(); + + expect(historySync.calls).toHaveLength(1); + expect(metricsSync.calls).toHaveLength(1); + expect(metricsSync.calls[0]).toBe(CONV_ID); + expect(store.turnMetrics).toHaveLength(1); + expect(store.turnMetrics[0]?.total?.usage.inputTokens).toBe(300); + + store.dispose(); + }); + + it("generating reflects the turn lifecycle (idle → running → idle)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + expect(store.generating).toBe(false); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + expect(store.generating).toBe(true); + + store.handleDelta( + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hi" }), + ); + expect(store.generating).toBe(true); + + store.handleDelta( + deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }), + ); + expect(store.generating).toBe(false); + + store.dispose(); + }); + + it("generating lights up for a watcher whose turn was replayed (no send first)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // A late-joiner receives the in-flight turn replayed from turn-start. + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "partial" }), + ); + expect(store.generating).toBe(true); + expect(transport.sent).toHaveLength(0); // it never sent — it's just watching + + store.dispose(); + }); + + it("resync clears a stale generating flag and re-syncs history + metrics", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // Disconnected mid-turn: turn-start seen, but the live done/turn-sealed was + // missed, so generating is stuck true. + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + expect(store.generating).toBe(true); + + // The turn actually sealed while we were gone — history now has the chunks. + historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)]; + + store.resync(); + + // Generating is cleared synchronously (a finished turn must not spin forever). + expect(store.generating).toBe(false); + + await vi.waitFor(() => { + expect(historySync.calls).toHaveLength(1); + expect(metricsSync.calls).toHaveLength(1); + }); + + store.dispose(); + }); + + it("chat limit: crossing the limit unloads the oldest quarter in one bulk pass", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + // Commit exactly 100 chunks via a sealed turn (at the limit — no trim). + const hundred = Array.from({ length: 100 }, (_, i) => makeStoredChunk(i + 1)); + historySync.returnChunks = hundred; + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + await vi.waitFor(() => { + expect(store.chunks).toHaveLength(100); + }); + expect(store.hasEarlier).toBe(false); + + // The 101st chunk (a live tool-call) crosses the limit → 25 unload → 76 remain. + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); + store.handleDelta( + deltaEvent({ + type: "tool-call", + conversationId: CONV_ID, + turnId: "t2", + toolCallId: "tc1", + toolName: "probe", + input: {}, + stepId: "t2#0" as StepId, + }), + ); + + expect(store.chunks).toHaveLength(76); + expect(store.chunks[0]?.seq).toBe(26); + expect(store.hasEarlier).toBe(true); + + store.dispose(); + }); + + it("chat limit: unloading is deferred while the gate is closed, then catches up", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + let atBottom = false; // reader scrolled up + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 10, + canUnload: () => atBottom, + }); + + // 15 live tool-calls: over the limit, but the gate defers every trim. + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + for (let i = 0; i < 15; i++) { + store.handleDelta( + deltaEvent({ + type: "tool-call", + conversationId: CONV_ID, + turnId: "t1", + toolCallId: `tc${i}`, + toolName: "probe", + input: {}, + stepId: `t1#${i}` as StepId, + }), + ); + } + expect(store.chunks).toHaveLength(15); + + // Reader returns to the bottom — the deferred trim now catches up. + // With no committed chunks, it drops the oldest provisional chunks + // (the in-flight turn) to stay within the limit. + atBottom = true; + store.handleDelta( + deltaEvent({ + type: "tool-call", + conversationId: CONV_ID, + turnId: "t1", + toolCallId: "tc15", + toolName: "probe", + input: {}, + stepId: "t1#15" as StepId, + }), + ); + // 16 provisional, limit 10, quarter 3 → drop 6 oldest → 10 remain. + expect(store.chunks).toHaveLength(10); + + store.dispose(); + }); + + it("chat limit: a deferred trim catches up across committed history once the gate opens", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + let atBottom = false; + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + canUnload: () => atBottom, + }); + + // Seal a turn committing 130 chunks while the reader is scrolled up: no trim. + historySync.returnChunks = Array.from({ length: 130 }, (_, i) => makeStoredChunk(i + 1)); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + await vi.waitFor(() => { + expect(store.chunks).toHaveLength(130); + }); + + // Back at the bottom: the next fold trims whole quarters down to ≤ 100. + atBottom = true; + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); + // 130 → 2 quarters of 25 → 80 committed (turn-start adds no chunk). + expect(store.chunks).toHaveLength(80); + expect(store.chunks[0]?.seq).toBe(51); + + store.dispose(); + }); + + it("chat limit: load windows a long cached conversation to 75% of the limit", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + await cache.impl.commit( + CONV_ID, + Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)), + ); + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); + + // floor(100 × 0.75) = 75 newest chunks: seqs 426..500. + expect(store.chunks).toHaveLength(75); + expect(store.chunks[0]?.seq).toBe(426); + expect(store.hasEarlier).toBe(true); + // The tail sync still used the cache's real cursor (not the window's edge). + expect(historySync.calls[0]?.sinceSeq).toBe(500); + + store.dispose(); + }); + + it("chat limit: a cold cache (fresh browser) asks the SERVER for the 75% window (CR-5 ?limit=)", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + // The server holds 500 chunks; the windowed fetch returns the newest 75. + historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)); + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); + + // The cold-cache initial sync carried the window (`?sinceSeq=0&limit=75`). + expect(historySync.calls[0]?.sinceSeq).toBe(0); + expect(historySync.calls[0]?.window).toEqual({ limit: 75 }); + + expect(store.chunks).toHaveLength(75); + expect(store.chunks[0]?.seq).toBe(426); + // hasEarlier derives from the 1-based gap-free seq contract (426 > 1) — + // no local watermark was ever set. + expect(store.hasEarlier).toBe(true); + // Only the window was shipped + cached (the point of CR-5). + const cached = await cache.impl.load(CONV_ID); + expect(cached).toHaveLength(75); + + store.dispose(); + }); + + it("chat limit: a warm cache syncs the tail UNWINDOWED (no seq gap behind the cache)", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + await cache.impl.commit(CONV_ID, [makeStoredChunk(1), makeStoredChunk(2)]); + historySync.returnChunks = [makeStoredChunk(3)]; + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); + + expect(historySync.calls[0]?.sinceSeq).toBe(2); + expect(historySync.calls[0]?.window).toBeUndefined(); + + store.dispose(); + }); + + it("chat limit: showEarlier backfills from the server when the cache is too shallow (CR-5 ?beforeSeq=)", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)); + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); // server-windowed: loaded + cached = 426..500 + expect(store.chunks[0]?.seq).toBe(426); + + await store.showEarlier(); + + // Nothing below 426 was cached → fetched the missing run from the server. + const backfill = historySync.calls[1]; + expect(backfill?.window).toEqual({ beforeSeq: 426, limit: 25 }); + expect(store.chunks).toHaveLength(100); + expect(store.chunks[0]?.seq).toBe(401); + expect(store.hasEarlier).toBe(true); + // The backfilled run is persisted: the NEXT page-in is cache-local. + const cached = await cache.impl.load(CONV_ID); + expect(cached).toHaveLength(100); + + store.dispose(); + }); + + it("chat limit: showEarlier pages a quarter back in from the cache", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + await cache.impl.commit( + CONV_ID, + Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)), + ); + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); + expect(store.chunks[0]?.seq).toBe(426); + + await store.showEarlier(); // +ceil(100/4) = 25 older chunks + expect(store.chunks).toHaveLength(100); + expect(store.chunks[0]?.seq).toBe(401); + expect(store.hasEarlier).toBe(true); + // The cache reached deep enough — no server backfill was needed. + expect(historySync.calls).toHaveLength(1); + + store.dispose(); + }); + + it("chat limit: showEarlier clears hasEarlier when the cache is exhausted", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + await cache.impl.commit( + CONV_ID, + Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)), + ); + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); // window 75: hidden 1..5 + expect(store.chunks).toHaveLength(75); + expect(store.hasEarlier).toBe(true); + + await store.showEarlier(); // restores all 5 → nothing left below + expect(store.chunks).toHaveLength(80); + expect(store.chunks[0]?.seq).toBe(1); + expect(store.hasEarlier).toBe(false); + + store.dispose(); + }); + + it("chat limit: a post-trim history sync does not resurrect unloaded chunks", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + await cache.impl.commit( + CONV_ID, + Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)), + ); + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); + expect(store.chunks[0]?.seq).toBe(426); + + // A sealed turn triggers syncTail, whose cache.commit returns the FULL + // merged cache (seqs 1..501) — the watermark must keep 1..425 out. + historySync.returnChunks = [makeStoredChunk(501)]; + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t9" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t9" })); + + await vi.waitFor(() => { + expect(store.chunks[store.chunks.length - 1]?.seq).toBe(501); + }); + expect(store.chunks[0]?.seq).toBe(426); + expect(store.chunks).toHaveLength(76); + + store.dispose(); + }); + + it("setChatLimit: lowering the limit trims older committed chunks live", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + // Load 80 committed chunks (under the limit — no trim yet). + historySync.returnChunks = Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + await vi.waitFor(() => { + expect(store.chunks).toHaveLength(80); + }); + + // Lower the limit to 10: 80 → unload ceil(10/4)=3 per quarter, needs + // ceil((80-10)/3)=24 quarters → drop min(72, 80)=72 → 8 remain. + await store.setChatLimit(10); + expect(store.chunks).toHaveLength(8); + expect(store.chunks[0]?.seq).toBe(73); + expect(store.hasEarlier).toBe(true); + + store.dispose(); + }); + + it("setChatLimit: raising the limit refills older history up to the fresh-load window", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + // Cache holds 200 chunks; load at limit 100 → window 75 → seqs 126..200. + await cache.impl.commit( + CONV_ID, + Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)), + ); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + await store.load(); + expect(store.chunks).toHaveLength(75); + expect(store.chunks[0]?.seq).toBe(126); + expect(store.hasEarlier).toBe(true); + + // Raise to 200 → window floor(0.75×200)=150 → refill 75 older chunks + // (seqs 51..125) from the cache. No server backfill (cache is deep enough). + await store.setChatLimit(200); + expect(historySync.calls).toHaveLength(1); // the load-time tail sync only + expect(store.chunks).toHaveLength(150); + expect(store.chunks[0]?.seq).toBe(51); + expect(store.hasEarlier).toBe(true); // 51 > 1 + + store.dispose(); + }); + + it("setChatLimit: raising backfills from the server when the cache is too shallow", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + // Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200. + historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + await store.load(); + expect(store.chunks[0]?.seq).toBe(126); + + // Raise to 200 → want 75 older. Cache only holds 126..200 → backfill + // seqs 51..125 from the server (CR-5 ?beforeSeq=126&limit=75). + await store.setChatLimit(200); + const backfill = historySync.calls[1]; + expect(backfill?.window).toEqual({ beforeSeq: 126, limit: 75 }); + expect(store.chunks).toHaveLength(150); + expect(store.chunks[0]?.seq).toBe(51); + + store.dispose(); + }); + + it("setChatLimit: raising refills all available older history (down to the origin)", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + // 101 chunks → one trim pass drops 25 → 76 remain (seqs 26..101). + historySync.returnChunks = Array.from({ length: 101 }, (_, i) => makeStoredChunk(i + 1)); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + await vi.waitFor(() => { + expect(store.chunks).toHaveLength(76); + }); + expect(store.chunks[0]?.seq).toBe(26); + expect(store.hasEarlier).toBe(true); + + // Raise to 500 → window 375 → want 299 older. The cache holds only + // seqs 1..25 below the window (no more server-side) → restore all 25 → + // 101 loaded, reaching the origin. + await store.setChatLimit(500); + expect(store.chunks).toHaveLength(101); + expect(store.chunks[0]?.seq).toBe(1); + expect(store.hasEarlier).toBe(false); + + store.dispose(); + }); + + it("setChatLimit: raising is a no-op when the window already starts at the origin", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + await cache.impl.commit( + CONV_ID, + Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)), + ); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + await store.load(); // only 50 chunks → all loaded, window starts at seq 1 + expect(store.chunks).toHaveLength(50); + expect(store.hasEarlier).toBe(false); + const callsAfterLoad = historySync.calls.length; + + await store.setChatLimit(500); // raise → refill no-ops (oldest = 1) + expect(store.chunks).toHaveLength(50); + expect(store.chunks[0]?.seq).toBe(1); + expect(historySync.calls).toHaveLength(callsAfterLoad); // no backfill + + store.dispose(); + }); + + it("setChatLimit: a nonsensical value is normalized (no crash, no trim)", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + historySync.returnChunks = Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + await vi.waitFor(() => { + expect(store.chunks).toHaveLength(50); + }); + + // NaN normalizes to the default (256). prev was 100 → raise → refill, + // but the loaded window already starts at seq 1 (origin) → no-op. + await store.setChatLimit(Number.NaN); + expect(store.chunks).toHaveLength(50); + + store.dispose(); + }); + + it("resync is a no-op after dispose", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.dispose(); + store.resync(); + + await new Promise((r) => setTimeout(r, 10)); + expect(historySync.calls).toHaveLength(0); + expect(metricsSync.calls).toHaveLength(0); + }); }); diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts index 100449f..c99d1f4 100644 --- a/src/features/chat/test-helpers.ts +++ b/src/features/chat/test-helpers.ts @@ -1,142 +1,152 @@ -import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract"; +import type { + ChatQueueCancelMessage, + ChatQueueMessage, + ChatSendMessage, +} from "@dispatch/transport-contract"; import type { StoredChunk } from "@dispatch/wire"; import type { ConversationCache } from "../conversation-cache"; import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; export interface FakeTransport { - /** All `chat.send` messages sent through the fake transport. */ - readonly sent: ChatSendMessage[]; - /** All `chat.queue` messages sent through the fake transport. */ - readonly sentQueue: ChatQueueMessage[]; - readonly impl: ChatTransport; + /** All `chat.send` messages sent through the fake transport. */ + readonly sent: ChatSendMessage[]; + /** All `chat.queue` messages sent through the fake transport. */ + readonly sentQueue: ChatQueueMessage[]; + /** All `chat.queue.cancel` messages sent through the fake transport. */ + readonly sentCancels: ChatQueueCancelMessage[]; + readonly impl: ChatTransport; } export function createFakeTransport(): FakeTransport { - const sent: ChatSendMessage[] = []; - const sentQueue: ChatQueueMessage[] = []; - return { - sent, - sentQueue, - impl: { - send(msg) { - if (msg.type === "chat.queue") { - sentQueue.push(msg); - } else { - sent.push(msg); - } - }, - }, - }; + const sent: ChatSendMessage[] = []; + const sentQueue: ChatQueueMessage[] = []; + const sentCancels: ChatQueueCancelMessage[] = []; + return { + sent, + sentQueue, + sentCancels, + impl: { + send(msg) { + if (msg.type === "chat.queue") { + sentQueue.push(msg); + } else if (msg.type === "chat.queue.cancel") { + sentCancels.push(msg); + } else { + sent.push(msg); + } + }, + }, + }; } export interface FakeHistorySync { - readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>; - /** Set the chunks to return on the next call. */ - returnChunks: readonly StoredChunk[]; - readonly impl: HistorySync; + readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>; + /** Set the chunks to return on the next call. */ + returnChunks: readonly StoredChunk[]; + readonly impl: HistorySync; } export function createFakeHistorySync(): FakeHistorySync { - const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = []; - let returnChunks: readonly StoredChunk[] = []; - return { - calls, - get returnChunks() { - return returnChunks; - }, - set returnChunks(v: readonly StoredChunk[]) { - returnChunks = v; - }, - impl: async (conversationId, sinceSeq, window) => { - calls.push({ conversationId, sinceSeq, ...(window !== undefined ? { window } : {}) }); - // Apply the CR-5 WINDOW semantics (`beforeSeq` bound, then newest-`limit`) - // so store tests exercise the real windowed flows. `sinceSeq` filtering is - // deliberately NOT applied — tests set `returnChunks` to the slice they - // mean the server to hold past the cursor. - let chunks = returnChunks; - const before = window?.beforeSeq; - if (before !== undefined) { - chunks = chunks.filter((c) => c.seq < before); - } - if (window?.limit !== undefined && chunks.length > window.limit) { - chunks = chunks.slice(-window.limit); - } - const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq; - return { chunks, latestSeq }; - }, - }; + const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = []; + let returnChunks: readonly StoredChunk[] = []; + return { + calls, + get returnChunks() { + return returnChunks; + }, + set returnChunks(v: readonly StoredChunk[]) { + returnChunks = v; + }, + impl: async (conversationId, sinceSeq, window) => { + calls.push({ conversationId, sinceSeq, ...(window !== undefined ? { window } : {}) }); + // Apply the CR-5 WINDOW semantics (`beforeSeq` bound, then newest-`limit`) + // so store tests exercise the real windowed flows. `sinceSeq` filtering is + // deliberately NOT applied — tests set `returnChunks` to the slice they + // mean the server to hold past the cursor. + let chunks = returnChunks; + const before = window?.beforeSeq; + if (before !== undefined) { + chunks = chunks.filter((c) => c.seq < before); + } + if (window?.limit !== undefined && chunks.length > window.limit) { + chunks = chunks.slice(-window.limit); + } + const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq; + return { chunks, latestSeq }; + }, + }; } export interface FakeMetricsSync { - readonly calls: string[]; - returnTurns: import("@dispatch/wire").TurnMetrics[]; - /** If set, the next call will reject with this error. */ - nextError: string | undefined; - readonly impl: MetricsSync; + readonly calls: string[]; + returnTurns: import("@dispatch/wire").TurnMetrics[]; + /** If set, the next call will reject with this error. */ + nextError: string | undefined; + readonly impl: MetricsSync; } export function createFakeMetricsSync(): FakeMetricsSync { - const calls: string[] = []; - let returnTurns: import("@dispatch/wire").TurnMetrics[] = []; - let nextError: string | undefined; - return { - calls, - get returnTurns() { - return returnTurns; - }, - set returnTurns(v: import("@dispatch/wire").TurnMetrics[]) { - returnTurns = v; - }, - get nextError() { - return nextError; - }, - set nextError(v: string | undefined) { - nextError = v; - }, - impl: async (conversationId) => { - calls.push(conversationId); - if (nextError !== undefined) { - const err = nextError; - nextError = undefined; - throw new Error(err); - } - return { turns: returnTurns }; - }, - }; + const calls: string[] = []; + let returnTurns: import("@dispatch/wire").TurnMetrics[] = []; + let nextError: string | undefined; + return { + calls, + get returnTurns() { + return returnTurns; + }, + set returnTurns(v: import("@dispatch/wire").TurnMetrics[]) { + returnTurns = v; + }, + get nextError() { + return nextError; + }, + set nextError(v: string | undefined) { + nextError = v; + }, + impl: async (conversationId) => { + calls.push(conversationId); + if (nextError !== undefined) { + const err = nextError; + nextError = undefined; + throw new Error(err); + } + return { turns: returnTurns }; + }, + }; } export interface FakeCache { - readonly store: Map<string, StoredChunk[]>; - readonly impl: ConversationCache; + readonly store: Map<string, StoredChunk[]>; + readonly impl: ConversationCache; } export function createFakeCache(): FakeCache { - const store = new Map<string, StoredChunk[]>(); - return { - store, - impl: { - async load(conversationId) { - return store.get(conversationId) ?? []; - }, - async commit(conversationId, incoming) { - const existing = store.get(conversationId) ?? []; - const seen = new Set(existing.map((c) => c.seq)); - const toAppend = incoming.filter((c) => !seen.has(c.seq)); - const merged = [...existing, ...toAppend].sort((a, b) => a.seq - b.seq); - store.set(conversationId, merged); - return merged; - }, - async sinceSeq(conversationId) { - const chunks = store.get(conversationId) ?? []; - if (chunks.length === 0) return 0; - return Math.max(...chunks.map((c) => c.seq)); - }, - async evictIfOverBudget() { - return []; - }, - async delete(conversationId) { - store.delete(conversationId); - }, - }, - }; + const store = new Map<string, StoredChunk[]>(); + return { + store, + impl: { + async load(conversationId) { + return store.get(conversationId) ?? []; + }, + async commit(conversationId, incoming) { + const existing = store.get(conversationId) ?? []; + const seen = new Set(existing.map((c) => c.seq)); + const toAppend = incoming.filter((c) => !seen.has(c.seq)); + const merged = [...existing, ...toAppend].sort((a, b) => a.seq - b.seq); + store.set(conversationId, merged); + return merged; + }, + async sinceSeq(conversationId) { + const chunks = store.get(conversationId) ?? []; + if (chunks.length === 0) return 0; + return Math.max(...chunks.map((c) => c.seq)); + }, + async evictIfOverBudget() { + return []; + }, + async delete(conversationId) { + store.delete(conversationId); + }, + }, + }; } diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index b8b3193..f4006f7 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -10,797 +10,1205 @@ import ModelSelector from "./ui/ModelSelector.svelte"; import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte"; describe("ChatView", () => { - it("renders a message's text chunk", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { type: "text", text: "Hello world" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("Hello world")).toBeInTheDocument(); - }); - - it("renders multiple chunks", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("Hi there")).toBeInTheDocument(); - expect(screen.getByText("Hello!")).toBeInTheDocument(); - }); - - it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => { - const chunks: RenderedChunk[] = [ - { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false }, - ]; - - let resolveEarlier: (() => void) | undefined; - const onShowEarlier = vi.fn( - () => - new Promise<void>((resolve) => { - resolveEarlier = resolve; - }), - ); - - render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } }); - - const button = screen.getByRole("button", { name: /show earlier messages/i }); - const user = userEvent.setup(); - await user.click(button); - - expect(onShowEarlier).toHaveBeenCalledTimes(1); - // While the page-in is awaited the button is disabled (no double-fire). - expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled(); - - resolveEarlier?.(); - await vi.waitFor(() => { - expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled(); - }); - }); - - it("hides the show-earlier button when nothing is unloaded", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false }, - ]; - - render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } }); - - expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument(); - }); - - it("renders tool-call chunks", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "read_file", - input: { path: "/tmp/test.txt" }, - }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("read_file")).toBeInTheDocument(); - const pre = screen.getByText((content, element) => { - return element?.tagName === "PRE" && content.includes("/tmp/test.txt"); - }); - expect(pre).toBeInTheDocument(); - }); - - it("renders tool-result chunks", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "tool", - chunk: { - type: "tool-result", - toolCallId: "tc1", - toolName: "read_file", - content: "file contents here", - isError: false, - }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("read_file")).toBeInTheDocument(); - expect(screen.getByText("file contents here")).toBeInTheDocument(); - }); - - it("renders error chunks with alert role", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { type: "error", message: "Something failed" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent("Something failed"); - }); - - it("renders error chunks with code", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("Rate limited")).toBeInTheDocument(); - expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument(); - }); - - it("renders system chunks", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "system", - chunk: { type: "system", text: "System context loaded" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("System context loaded")).toBeInTheDocument(); - }); - - it("renders provisional (in-flight) chunks without any dimming", () => { - const chunks: RenderedChunk[] = [ - { - seq: null, - role: "assistant", - chunk: { type: "text", text: "Streaming..." }, - provisional: true, - }, - ]; - - render(ChatView, { props: { chunks } }); - - // In-flight chunks render at full opacity (no faded "disabled" look). - const wrapper = screen.getByText("Streaming...").closest("div"); - expect(wrapper).not.toHaveClass("opacity-50"); - }); - - it("renders empty transcript", () => { - render(ChatView, { props: { chunks: [] } }); - - const log = screen.getByRole("log"); - expect(log).toBeInTheDocument(); - expect(log.children).toHaveLength(0); - }); - - it("groups batched tool calls (shared stepId) into one DaisyUI list", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "a", - toolName: "read_file", - input: { path: "/a" }, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - { - seq: 2, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "b", - toolName: "list_dir", - input: { path: "/b" }, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - { - seq: 3, - role: "tool", - chunk: { - type: "tool-result", - toolCallId: "a", - toolName: "read_file", - content: "contents-of-a", - isError: false, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - ]; - - const { container } = render(ChatView, { props: { chunks } }); - - // Batched calls render as collapsible cards (one per call), not a list. - const collapses = container.querySelectorAll(".collapse"); - expect(collapses).toHaveLength(2); - - // Both call names + the available result are shown; the result is absorbed - // (no standalone tool-result card). - expect(screen.getByText("read_file")).toBeInTheDocument(); - expect(screen.getByText("list_dir")).toBeInTheDocument(); - expect(screen.getByText("contents-of-a")).toBeInTheDocument(); - }); - - it("thinking is a checkbox collapse (no arrow) inside a visible bubble", () => { - const chunks: RenderedChunk[] = [ - { - seq: null, - role: "assistant", - chunk: { type: "thinking", text: "Let me think..." }, - provisional: true, - streaming: true, - }, - ]; - - const { container } = render(ChatView, { props: { chunks } }); - - const collapse = container.querySelector(".collapse"); - expect(collapse).not.toBeNull(); - expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon - expect(collapse).not.toHaveClass("collapse-plus"); - // Visible bubble, like tool cards. - expect(collapse).toHaveClass("bg-base-200"); - expect(collapse).toHaveClass("rounded-box"); - expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument(); - }); - - it("title is 'Thinking' + dots while streaming, then 'Thoughts' with no dots once complete; open state persists", async () => { - const streaming: RenderedChunk[] = [ - { - seq: null, - role: "assistant", - chunk: { type: "thinking", text: "hmm" }, - provisional: true, - streaming: true, - }, - ]; - - const { container, rerender } = render(ChatView, { props: { chunks: streaming } }); - - // Streaming: "Thinking" + loading dots. - expect(screen.getByText("Thinking")).toBeInTheDocument(); - expect(screen.queryByText("Thoughts")).toBeNull(); - expect(container.querySelector(".loading")).not.toBeNull(); - - // Open it. - const checkbox = screen.getByRole("checkbox", { name: "Toggle thoughts" }); - await userEvent.click(checkbox); - expect(checkbox).toBeChecked(); - - // Transition generating → completed/committed (seq assigned, no longer streaming). - await rerender({ - chunks: [ - { - seq: 1, - role: "assistant", - chunk: { type: "thinking", text: "hmm, all done" }, - provisional: false, - }, - ], - }); - - // Completed: "Thoughts", no dots — and the open state survived the transition. - expect(screen.getByText("Thoughts")).toBeInTheDocument(); - expect(screen.queryByText("Thinking")).toBeNull(); - expect(container.querySelector(".loading")).toBeNull(); - expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked(); - expect(container).toHaveTextContent("hmm, all done"); - }); - - it("renders step and turn metrics as separate rows", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - { - seq: 3, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "test", - input: {}, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - ]; - - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 800, - }, - ], - total: { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - durationMs: 1200, - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 800, - }, - ], - }, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics } }); - - expect(screen.getByText("Hi")).toBeInTheDocument(); - expect(screen.getByText("Hello!")).toBeInTheDocument(); - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/150 tok/)).toHaveLength(2); - expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument(); - expect(screen.getByText(/1\.2s/)).toBeInTheDocument(); - }); - - it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - ]; - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [], - total: { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 }, - steps: [], - }, - }, - ]; - - const { container } = render(ChatView, { props: { chunks, turnMetrics } }); - - expect(screen.getByText("Last turn:")).toBeInTheDocument(); - expect(screen.getByText("Chat Total:")).toBeInTheDocument(); - // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge - const badges = container.querySelectorAll(".badge"); - expect(badges).toHaveLength(2); - for (const b of badges) { - expect(b.textContent).toBe("93%"); - expect(b.classList.contains("badge-success")).toBe(true); - } - }); - - it("renders step-metrics inline after tool group", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "bash", - input: { command: "ls" }, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - { - seq: 3, - role: "tool", - chunk: { - type: "tool-result", - toolCallId: "tc1", - toolName: "bash", - content: "file.txt", - isError: false, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - { - seq: 4, - role: "assistant", - chunk: { type: "text", text: "Done!" }, - provisional: false, - }, - ]; - - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 80, outputTokens: 20 }, - genTotalMs: 300, - }, - ], - total: { - turnId: "t1", - usage: { inputTokens: 80, outputTokens: 20 }, - durationMs: 500, - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 80, outputTokens: 20 }, - genTotalMs: 300, - }, - ], - }, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics } }); - - // Both step-metrics and turn-metrics render - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument(); - - // They are in separate elements (different rows) - const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div"); - const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div"); - expect(stepEl).not.toBe(turnEl); - }); - - it("renders no metrics bubble when turnMetrics is empty", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics: [] } }); - - expect(screen.getByText("Hi")).toBeInTheDocument(); - expect(screen.getByText("Hello!")).toBeInTheDocument(); - expect(screen.queryByText(/step 1/)).toBeNull(); - expect(screen.queryByText(/^turn/)).toBeNull(); - }); - - it("omits null view values from metrics bubbles", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Response" }, - provisional: false, - }, - { - seq: 3, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "test", - input: {}, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - ]; - - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - }, - ], - total: { - turnId: "t1", - usage: { inputTokens: 10, outputTokens: 5 }, - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - }, - ], - }, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics } }); - - // Step metrics rendered - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/15 tok/)).toHaveLength(2); - // Turn metrics rendered - expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument(); - // No "null" or "undefined" in the DOM - expect(screen.queryByText("null")).toBeNull(); - expect(screen.queryByText("undefined")).toBeNull(); - }); - - it("renders step text but no turn total for a progressive turn (total: null)", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - { - seq: 3, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "test", - input: {}, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - ]; - - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 800, - }, - ], - total: null, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics } }); - - // Step metrics should render - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/150 tok/)).toBeInTheDocument(); - - // Turn total should NOT render (total is null — turn still in progress) - expect(screen.queryByText(/^turn/)).toBeNull(); - }); + it("renders a message's text chunk", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "text", text: "Hello world" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("Hello world")).toBeInTheDocument(); + }); + + it("renders multiple chunks", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("Hi there")).toBeInTheDocument(); + expect(screen.getByText("Hello!")).toBeInTheDocument(); + }); + + it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => { + const chunks: RenderedChunk[] = [ + { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false }, + ]; + + let resolveEarlier: (() => void) | undefined; + const onShowEarlier = vi.fn( + () => + new Promise<void>((resolve) => { + resolveEarlier = resolve; + }), + ); + + render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } }); + + const button = screen.getByRole("button", { name: /show earlier messages/i }); + const user = userEvent.setup(); + await user.click(button); + + expect(onShowEarlier).toHaveBeenCalledTimes(1); + // While the page-in is awaited the button is disabled (no double-fire). + expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled(); + + resolveEarlier?.(); + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled(); + }); + }); + + it("hides the show-earlier button when nothing is unloaded", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false }, + ]; + + render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } }); + + expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument(); + }); + + it("renders tool-call chunks", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "read_file", + input: { path: "/tmp/test.txt" }, + }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("read_file")).toBeInTheDocument(); + const pre = screen.getByText((content, element) => { + return element?.tagName === "PRE" && content.includes("/tmp/test.txt"); + }); + expect(pre).toBeInTheDocument(); + }); + + it("renders tool-result chunks", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "tc1", + toolName: "read_file", + content: "file contents here", + isError: false, + }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("file contents here")).toBeInTheDocument(); + }); + + it("renders error chunks with alert role", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "error", message: "Something failed" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Something failed"); + }); + + it("renders error chunks with code", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("Rate limited")).toBeInTheDocument(); + expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument(); + }); + + it("renders system chunks", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "system", + chunk: { type: "system", text: "System context loaded" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("System context loaded")).toBeInTheDocument(); + }); + + it("renders provisional (in-flight) chunks without any dimming", () => { + const chunks: RenderedChunk[] = [ + { + seq: null, + role: "assistant", + chunk: { type: "text", text: "Streaming..." }, + provisional: true, + }, + ]; + + render(ChatView, { props: { chunks } }); + + // In-flight chunks render at full opacity (no faded "disabled" look). + const wrapper = screen.getByText("Streaming...").closest("div"); + expect(wrapper).not.toHaveClass("opacity-50"); + }); + + it("renders empty transcript", () => { + render(ChatView, { props: { chunks: [] } }); + + const log = screen.getByRole("log"); + expect(log).toBeInTheDocument(); + expect(log.children).toHaveLength(0); + }); + + it("groups batched tool calls (shared stepId) into one DaisyUI list", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "read_file", + input: { path: "/a" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 2, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "b", + toolName: "list_dir", + input: { path: "/b" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 3, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "read_file", + content: "contents-of-a", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const { container } = render(ChatView, { props: { chunks } }); + + // Batched calls render as collapsible cards (one per call), not a list. + const collapses = container.querySelectorAll(".collapse"); + expect(collapses).toHaveLength(2); + + // Both call names + the available result are shown; the result is absorbed + // (no standalone tool-result card). + expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("list_dir")).toBeInTheDocument(); + expect(screen.getByText("contents-of-a")).toBeInTheDocument(); + }); + + it("thinking is a checkbox collapse (no arrow) inside a visible bubble", () => { + const chunks: RenderedChunk[] = [ + { + seq: null, + role: "assistant", + chunk: { type: "thinking", text: "Let me think..." }, + provisional: true, + streaming: true, + }, + ]; + + const { container } = render(ChatView, { props: { chunks } }); + + const collapse = container.querySelector(".collapse"); + expect(collapse).not.toBeNull(); + expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon + expect(collapse).not.toHaveClass("collapse-plus"); + // Visible bubble, like tool cards. + expect(collapse).toHaveClass("bg-base-200"); + expect(collapse).toHaveClass("rounded-box"); + expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument(); + }); + + it("title is 'Thinking' + dots while streaming, then 'Thoughts' with no dots once complete; open state persists", async () => { + const streaming: RenderedChunk[] = [ + { + seq: null, + role: "assistant", + chunk: { type: "thinking", text: "hmm" }, + provisional: true, + streaming: true, + }, + ]; + + const { container, rerender } = render(ChatView, { props: { chunks: streaming } }); + + // Streaming: "Thinking" + loading dots. + expect(screen.getByText("Thinking")).toBeInTheDocument(); + expect(screen.queryByText("Thoughts")).toBeNull(); + expect(container.querySelector(".loading")).not.toBeNull(); + + // Open it. + const checkbox = screen.getByRole("checkbox", { name: "Toggle thoughts" }); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + + // Transition generating → completed/committed (seq assigned, no longer streaming). + await rerender({ + chunks: [ + { + seq: 1, + role: "assistant", + chunk: { type: "thinking", text: "hmm, all done" }, + provisional: false, + }, + ], + }); + + // Completed: "Thoughts", no dots — and the open state survived the transition. + expect(screen.getByText("Thoughts")).toBeInTheDocument(); + expect(screen.queryByText("Thinking")).toBeNull(); + expect(container.querySelector(".loading")).toBeNull(); + expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked(); + expect(container).toHaveTextContent("hmm, all done"); + }); + + it("renders step and turn metrics as separate rows", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "test", + input: {}, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 800, + }, + ], + total: { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + durationMs: 1200, + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 800, + }, + ], + }, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics } }); + + expect(screen.getByText("Hi")).toBeInTheDocument(); + expect(screen.getByText("Hello!")).toBeInTheDocument(); + expect(screen.getByText(/step 1/)).toBeInTheDocument(); + expect(screen.getAllByText(/150 tok/)).toHaveLength(2); + expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument(); + expect(screen.getByText(/1\.2s/)).toBeInTheDocument(); + }); + + it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + ]; + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [], + total: { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 }, + steps: [], + }, + }, + ]; + + const { container } = render(ChatView, { props: { chunks, turnMetrics } }); + + expect(screen.getByText("Last turn:")).toBeInTheDocument(); + expect(screen.getByText("Chat Total:")).toBeInTheDocument(); + // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge + const badges = container.querySelectorAll(".badge"); + expect(badges).toHaveLength(2); + for (const b of badges) { + expect(b.textContent).toBe("93%"); + expect(b.classList.contains("badge-success")).toBe(true); + } + }); + + it("renders step-metrics inline after tool group", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "bash", + input: { command: "ls" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 3, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "tc1", + toolName: "bash", + content: "file.txt", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 4, + role: "assistant", + chunk: { type: "text", text: "Done!" }, + provisional: false, + }, + ]; + + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 80, outputTokens: 20 }, + genTotalMs: 300, + }, + ], + total: { + turnId: "t1", + usage: { inputTokens: 80, outputTokens: 20 }, + durationMs: 500, + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 80, outputTokens: 20 }, + genTotalMs: 300, + }, + ], + }, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics } }); + + // Both step-metrics and turn-metrics render + expect(screen.getByText(/step 1/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument(); + + // They are in separate elements (different rows) + const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div"); + const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div"); + expect(stepEl).not.toBe(turnEl); + }); + + it("renders no metrics bubble when turnMetrics is empty", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics: [] } }); + + expect(screen.getByText("Hi")).toBeInTheDocument(); + expect(screen.getByText("Hello!")).toBeInTheDocument(); + expect(screen.queryByText(/step 1/)).toBeNull(); + expect(screen.queryByText(/^turn/)).toBeNull(); + }); + + it("omits null view values from metrics bubbles", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Response" }, + provisional: false, + }, + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "test", + input: {}, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + }, + ], + total: { + turnId: "t1", + usage: { inputTokens: 10, outputTokens: 5 }, + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + }, + ], + }, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics } }); + + // Step metrics rendered + expect(screen.getByText(/step 1/)).toBeInTheDocument(); + expect(screen.getAllByText(/15 tok/)).toHaveLength(2); + // Turn metrics rendered + expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument(); + // No "null" or "undefined" in the DOM + expect(screen.queryByText("null")).toBeNull(); + expect(screen.queryByText("undefined")).toBeNull(); + }); + + it("renders step text but no turn total for a progressive turn (total: null)", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "test", + input: {}, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 800, + }, + ], + total: null, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics } }); + + // Step metrics should render + expect(screen.getByText(/step 1/)).toBeInTheDocument(); + expect(screen.getByText(/150 tok/)).toBeInTheDocument(); + + // Turn total should NOT render (total is null — turn still in progress) + expect(screen.queryByText(/^turn/)).toBeNull(); + }); + + 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 } }); + render(Composer, { props: { onSend } }); - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, "Hello world"); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "Hello world"); - const sendButton = screen.getByRole("button", { name: "Send" }); - await user.click(sendButton); + const sendButton = screen.getByRole("button", { name: "Send" }); + await user.click(sendButton); - expect(onSend).toHaveBeenCalledTimes(1); - expect(onSend).toHaveBeenCalledWith("Hello world"); - expect(textarea).toHaveValue(""); - }); + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith("Hello world", undefined); + expect(textarea).toHaveValue(""); + }); - it("does not call onSend with empty text", async () => { - const onSend = vi.fn(); - const _user = userEvent.setup(); + it("does not call onSend with empty text", async () => { + const onSend = vi.fn(); + const _user = userEvent.setup(); - render(Composer, { props: { onSend } }); + render(Composer, { props: { onSend } }); - const sendButton = screen.getByRole("button", { name: "Send" }); - expect(sendButton).toBeDisabled(); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).toBeDisabled(); - expect(onSend).not.toHaveBeenCalled(); - }); + expect(onSend).not.toHaveBeenCalled(); + }); - it("trims whitespace before sending", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); + it("trims whitespace before sending", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); - render(Composer, { props: { onSend } }); + render(Composer, { props: { onSend } }); - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, " hello "); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, " hello "); - const sendButton = screen.getByRole("button", { name: "Send" }); - await user.click(sendButton); + const sendButton = screen.getByRole("button", { name: "Send" }); + await user.click(sendButton); - expect(onSend).toHaveBeenCalledWith("hello"); - }); + expect(onSend).toHaveBeenCalledWith("hello", undefined); + }); - it("sends on Enter key (without Shift)", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); + it("sends on Enter key (without Shift)", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); - render(Composer, { props: { onSend } }); + render(Composer, { props: { onSend } }); - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, "Test message{Enter}"); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "Test message{Enter}"); - expect(onSend).toHaveBeenCalledWith("Test message"); - }); + expect(onSend).toHaveBeenCalledWith("Test message", undefined); + }); - it("does not send on Shift+Enter", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); + it("does not send on Shift+Enter", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + + render(Composer, { props: { onSend } }); - render(Composer, { props: { onSend } }); - - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2"); - - expect(onSend).not.toHaveBeenCalled(); - }); + 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 } }); + + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "look at this"); + + // 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); + + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: "Send" })); + + expect(onSend).toHaveBeenCalledTimes(1); + const [, images] = onSend.mock.calls[0] ?? []; + expect(images).toHaveLength(1); + expect(images[0]?.url).toMatch(/^data:image\/png;base64,/); + expect(images[0]?.mimeType).toBe("image/png"); + }); + + 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 textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "hello"); + + // 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); + + await new Promise((r) => setTimeout(r, 0)); + expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument(); + }); + + 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 file = new File(["JPG"], "photo.jpg", { type: "image/jpeg" }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { value: [file], writable: false }); + input.dispatchEvent(new Event("change", { bubbles: true })); + + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument(); + }); + + // 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); + + 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"); + }); + + 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 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: "Remove image" })); + + 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(); + }); + + it("ignores a non-image file chosen via the picker", async () => { + const onSend = vi.fn(); + const { container } = render(Composer, { props: { onSend } }); + + 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 })); + + // 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", () => { - const optionValues = (el: HTMLElement): string[] => - within(el) - .getAllByRole("option") - .map((o) => (o as HTMLOptionElement).value); - - it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => { - const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]; - render(ModelSelector, { - props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() }, - }); - - const keySelect = screen.getByRole("combobox", { name: "Key selector" }); - const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); - expect(keySelect).toHaveValue("anthropic"); - expect(modelSelect).toHaveValue("claude-3"); - - expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]); - // only the models under the selected key - expect(optionValues(modelSelect)).toEqual(["claude-3"]); - }); - - it("selecting a key switches to the first model under it", async () => { - const onSelect = vi.fn(); - const user = userEvent.setup(); - const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"]; - - render(ModelSelector, { - props: { models, selected: "openai/gpt-4o", onSelect }, - }); - - await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic"); - - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3"); - }); - - it("selecting a model keeps the current key", async () => { - const onSelect = vi.fn(); - const user = userEvent.setup(); - const models = ["openai/gpt-4", "openai/gpt-4o"]; - - render(ModelSelector, { - props: { models, selected: "openai/gpt-4", onSelect }, - }); - - await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o"); - - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o"); - }); + const optionValues = (el: HTMLElement): string[] => + within(el) + .getAllByRole("option") + .map((o) => (o as HTMLOptionElement).value); + + it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => { + const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]; + render(ModelSelector, { + props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() }, + }); + + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); + expect(keySelect).toHaveValue("anthropic"); + expect(modelSelect).toHaveValue("claude-3"); + + expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]); + // only the models under the selected key + expect(optionValues(modelSelect)).toEqual(["claude-3"]); + }); + + it("selecting a key switches to the first model under it", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"]; + + render(ModelSelector, { + props: { models, selected: "openai/gpt-4o", onSelect }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic"); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3"); + }); + + it("selecting a model keeps the current key", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const models = ["openai/gpt-4", "openai/gpt-4o"]; + + render(ModelSelector, { + props: { models, selected: "openai/gpt-4", onSelect }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o"); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o"); + }); + + 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(); - }); - }); + it("renders null effort + null thinking (never set) as the default level, marked '(default)'", () => { + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save: vi.fn() }, + }); + + const select = screen.getByRole("combobox", { name: "Reasoning effort" }); + expect(select).toHaveValue("high"); + expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument(); + // "Off" first, then the five ladder levels. + const options = within(select).getAllByRole("option"); + expect(options).toHaveLength(6); + expect(options[0]).toHaveValue("off"); + expect(within(select).getByRole("option", { name: "Off" })).toBeInTheDocument(); + }); + + it("renders a persisted level as selected when thinking is on", () => { + render(ReasoningEffortSelector, { + props: { persistedEffort: "xhigh", persistedThinking: null, save: vi.fn() }, + }); + + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh"); + }); + + it("renders 'off' as selected when thinking is disabled (effort level is preserved but hidden)", () => { + // thinking off is a SEPARATE axis: even with a persisted effort level, the + // selector shows "off" while thinking is disabled. + render(ReasoningEffortSelector, { + props: { persistedEffort: "xhigh", persistedThinking: false, save: vi.fn() }, + }); + + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off"); + // the level option is still present (restored on an off→on toggle) + expect( + within(screen.getByRole("combobox")).getByRole("option", { name: "xhigh" }), + ).toBeInTheDocument(); + }); + + it("selecting a level saves it via the injected port and confirms", async () => { + const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({ + ok: true as const, + selection, + })); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: 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("selecting 'off' saves the separate disable signal (not a level)", async () => { + const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({ + ok: true as const, + selection, + })); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { + props: { persistedEffort: "high", persistedThinking: null, save }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "off"); + + expect(save).toHaveBeenCalledTimes(1); + // "off" is the separate thinking-disable signal — NOT a zero-effort level. + expect(save).toHaveBeenCalledWith("off"); + await vi.waitFor(() => { + expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument(); + }); + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off"); + }); + + it("a failed save shows the error and reverts to the persisted selection", async () => { + const save = vi.fn(async () => ({ ok: false as const, error: "nope" })); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { + props: { persistedEffort: "low", persistedThinking: null, 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; selection: "max" }) => void) | undefined; + const save = vi.fn( + () => + new Promise<{ ok: true; selection: "max" }>((resolve) => { + resolveSave = resolve; + }), + ); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); + + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled(); + + resolveSave?.({ ok: true, selection: "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 2b55eb3..e67ca5b 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -1,264 +1,368 @@ <script lang="ts"> - import { groupRenderedChunks, type RenderedChunk } from "../index"; - import { - interleaveTurnMetrics, - viewCacheRate, - viewExpectedCache, - viewStepMetrics, - viewTurnMetrics, - type TurnMetricsEntry, - } from "../../../core/metrics"; - import { Markdown } from "../../markdown"; + import type { TurnProviderRetryEvent } from "@dispatch/wire"; + import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index"; + import { + interleaveTurnMetrics, + viewCacheRate, + viewExpectedCache, + viewStepMetrics, + viewTurnMetrics, + type TurnMetricsEntry, + } from "../../../core/metrics"; + import { Markdown } from "../../markdown"; - const badgeClass = { - success: "badge-success", - warning: "badge-warning", - error: "badge-error", - } as const; + const badgeClass = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + } as const; - let { - chunks, - turnMetrics = [], - hasEarlier = false, - onShowEarlier, - thinkingKeyBase = 0, - }: { - 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; - } = $props(); + 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); + // True while a show-earlier page-in is awaited (disables the button). + let loadingEarlier = $state(false); - async function showEarlier() { - if (!onShowEarlier || loadingEarlier) return; - loadingEarlier = true; - try { - await onShowEarlier(); - } finally { - loadingEarlier = false; - } - } + async function showEarlier() { + if (!onShowEarlier || loadingEarlier) return; + loadingEarlier = true; + try { + await onShowEarlier(); + } finally { + loadingEarlier = false; + } + } - const groups = $derived(groupRenderedChunks(chunks)); + const groups = $derived(groupRenderedChunks(chunks)); - const rows = $derived(interleaveTurnMetrics(groups, turnMetrics)); + const rows = $derived(interleaveTurnMetrics(groups, turnMetrics)); - // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that - // survives the provisional→committed (seq null → seq N) transition, so the - // collapse's open/close state is NOT lost when a turn seals. The ordinal - // starts at `thinkingKeyBase` so keys also survive a chat-limit trim removing - // older thinking blocks. (App isolates these keys per conversation via {#key}.) - const keyedRows = $derived.by(() => { - let thinking = thinkingKeyBase; - return rows.map((row, i) => { - if (row.kind === "step-metrics") { - return { row, key: `s${row.step.stepId}` }; - } - if (row.kind === "turn-metrics") { - return { row, key: `m${row.turn.turnId}` }; - } - const group = row.group; - let key: string; - if (group.kind === "tool-batch") { - key = `b${group.stepId}`; - } else if (group.chunk.chunk.type === "thinking") { - key = `think${thinking++}`; - } else if (group.chunk.seq != null) { - key = `c${group.chunk.seq}`; - } else { - key = `p${i}`; - } - return { row, key }; - }); - }); + // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that + // survives the provisional→committed (seq null → seq N) transition, so the + // collapse's open/close state is NOT lost when a turn seals. The ordinal + // starts at `thinkingKeyBase` so keys also survive a chat-limit trim removing + // older thinking blocks. (App isolates these keys per conversation via {#key}.) + const keyedRows = $derived.by(() => { + let thinking = thinkingKeyBase; + return rows.map((row, i) => { + if (row.kind === "step-metrics") { + return { row, key: `s${row.step.stepId}` }; + } + if (row.kind === "turn-metrics") { + return { row, key: `m${row.turn.turnId}` }; + } + const group = row.group; + let key: string; + if (group.kind === "tool-batch") { + key = `b${group.stepId}`; + } else if (group.chunk.chunk.type === "thinking") { + key = `think${thinking++}`; + } else if (group.chunk.seq != null) { + key = `c${group.chunk.seq}`; + } else { + key = `p${i}`; + } + return { row, key }; + }); + }); </script> {#snippet chunkRow(rendered: RenderedChunk)} - {#if rendered.role === "user"} - <!-- User: a speech bubble, left-aligned --> - <div class="chat chat-start"> - <div class="chat-bubble chat-bubble-primary"> - {#if rendered.chunk.type === "text"} - <p>{rendered.chunk.text}</p> - {/if} - </div> - </div> - {:else if rendered.chunk.type === "thinking"} - <!-- Thinking: a visible bubble (like tool cards), holding a checkbox collapse - (no arrow icon, smooth open/close). Title reads "Thinking" + loading dots - while generating, then "Thoughts" with no dots once complete. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> - <div class="chat-bubble w-full bg-transparent"> - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle thoughts" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> - <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span> - {#if rendered.streaming} - <span class="loading loading-dots loading-sm" aria-label="Generating"></span> - {/if} - </div> - <div class="collapse-content"> - <p class="whitespace-pre-wrap">{rendered.chunk.text}</p> - </div> - </div> - </div> - </div> - {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} - <!-- Single tool call/result: a collapsible card (collapsed by default, - like thinking). Title shows the tool name; content shows the - input/output. Same chat-start grid shim as the thinking block. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> - <div class="chat-bubble w-full bg-transparent"> - {#if rendered.chunk.type === "tool-call"} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool call" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> - <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> - <span>{rendered.chunk.toolName}</span> - </div> - <div class="collapse-content"> - <pre class="overflow-x-auto text-xs">{JSON.stringify(rendered.chunk.input, null, 2)}</pre> - </div> - </div> - {:else} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool result" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium" class:text-error={rendered.chunk.isError}> - <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> - <span>{rendered.chunk.toolName}</span> - {#if rendered.chunk.isError} - <span class="badge badge-error badge-xs">error</span> - {/if} - </div> - <div class="collapse-content"> - <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre> - </div> - </div> - {/if} - </div> - </div> - {:else} - <!-- Assistant text / system / error: an INVISIBLE speech bubble — same - chat-start grid as the user bubble, so it inherits identical left spacing. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> - <div class="chat-bubble w-full bg-transparent"> - {#if rendered.chunk.type === "text"} - <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} /> - {:else if rendered.chunk.type === "error"} - <div class="text-error" role="alert"> - {rendered.chunk.message} - {#if rendered.chunk.code} - <span class="text-xs opacity-70">[{rendered.chunk.code}]</span> - {/if} - </div> - {:else if rendered.chunk.type === "system"} - <div class="text-sm opacity-70">{rendered.chunk.text}</div> - {/if} - </div> - </div> - {/if} + {#if rendered.role === "user"} + <!-- User: a speech bubble, left-aligned. 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 p-4 pl-6" role="log" aria-live="polite"> - {#if hasEarlier && onShowEarlier} - <!-- Chat limit: older chunks are unloaded; offer to page them back in. --> - <div class="flex justify-center"> - <button class="btn btn-ghost btn-xs" disabled={loadingEarlier} onclick={showEarlier}> - {#if loadingEarlier} - <span class="loading loading-spinner loading-xs" aria-hidden="true"></span> - Loading earlier messages… - {:else} - Show earlier messages - {/if} - </button> - </div> - {/if} - {#each keyedRows as { row, key } (key)} - {#if row.kind === "step-metrics"} - {@const sv = viewStepMetrics(row.step, row.index)} - <div class="chat chat-start"> - <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> - <div class="text-xs opacity-70"> - {sv.label} · {sv.tokensLabel} - {#if sv.tps} · {sv.tps}{/if} - {#if sv.genTotal} · {sv.genTotal}{/if} - </div> - </div> - </div> - {:else if row.kind === "turn-metrics"} - {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)} - {@const lastCache = viewCacheRate(row.turn.usage)} - {@const chatCache = viewCacheRate(row.cumulativeUsage)} - {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} - <div class="chat chat-start"> - <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> - <div class="flex flex-col gap-1 text-xs"> - <div class="opacity-70"> - {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown}) - {#if turnView.tps} · {turnView.tps}{/if} - {#if turnView.duration} · {turnView.duration}{/if} - </div> - <div class="flex flex-wrap items-center gap-x-3 gap-y-1"> - <span class="flex items-center gap-1"> - <span class="opacity-70">Last turn:</span> - <span class="badge badge-sm {badgeClass[lastCache.level]}">{lastCache.pct}%</span> - </span> - <span class="flex items-center gap-1"> - <span class="opacity-70">Chat Total:</span> - <span class="badge badge-sm {badgeClass[chatCache.level]}">{chatCache.pct}%</span> - </span> - {#if retention} - <span class="flex items-center gap-1"> - <span class="opacity-70">Retention:</span> - <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span> - </span> - {/if} - </div> - </div> - </div> - </div> - {:else if row.group.kind === "single"} - {@render chunkRow(row.group.chunk)} - {:else} - <!-- Batched tool calls (one step): each entry is a collapsible card. - Click to expand and see the input/output. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> - <div class="chat-bubble w-full bg-transparent"> - <div class="flex flex-col gap-1"> - {#each row.group.entries as entry (entry.call.toolCallId)} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool call" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> - <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> - <span>{entry.call.toolName}</span> - {#if entry.result?.isError} - <span class="badge badge-error badge-xs">error</span> - {:else if entry.result === null} - <span class="loading loading-spinner loading-xs" aria-label="Running"></span> - {/if} - </div> - <div class="collapse-content"> - <pre class="overflow-x-auto text-xs">{JSON.stringify(entry.call.input, null, 2)}</pre> - {#if entry.result} - <pre class="mt-1 max-h-96 overflow-auto text-xs" class:text-error={entry.result.isError}>{entry.result.content}</pre> - {/if} - </div> - </div> - {/each} - </div> - </div> - </div> - {/if} - {/each} +<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 index 7bec984..5014e5c 100644 --- a/src/features/chat/ui/CompactionView.svelte +++ b/src/features/chat/ui/CompactionView.svelte @@ -1,154 +1,154 @@ <script lang="ts"> - export type CompactNowResult = - | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number } - | { readonly ok: false; readonly error: string }; + export type CompactNowResult = + | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number } + | { readonly ok: false; readonly error: string }; - export type SaveCompactPercentResult = - | { readonly ok: true; readonly percent: number } - | { readonly ok: false; readonly error: string }; + export type SaveCompactPercentResult = + | { readonly ok: true; readonly percent: number } + | { readonly ok: false; readonly error: string }; - let { - percent, - canCompact, - compactNow, - savePercent, - }: { - /** The conversation's auto-compact percent (0-100), or null when not yet fetched. 0 = disabled. */ - percent: number | null; - /** Whether a real conversation is focused (a draft has nothing to compact). */ - canCompact: boolean; - compactNow: () => Promise<CompactNowResult | null>; - savePercent: (percent: number) => Promise<SaveCompactPercentResult | null>; - } = $props(); + let { + percent, + canCompact, + compactNow, + savePercent, + }: { + /** The conversation's auto-compact percent (0-100), or null when not yet fetched. 0 = disabled. */ + percent: number | null; + /** Whether a real conversation is focused (a draft has nothing to compact). */ + canCompact: boolean; + compactNow: () => Promise<CompactNowResult | null>; + savePercent: (percent: number) => Promise<SaveCompactPercentResult | null>; + } = $props(); - const DEFAULT_PERCENT = 85; + const DEFAULT_PERCENT = 85; - let compacting = $state(false); - let compactError = $state<string | null>(null); - let compactResult = $state<{ summarized: number; kept: number } | null>(null); + let compacting = $state(false); + let compactError = $state<string | null>(null); + let compactResult = $state<{ summarized: number; kept: number } | null>(null); - let percentInput = $state(""); - let savingPercent = $state(false); - let percentError = $state<string | null>(null); - let percentSaved = $state(false); + let percentInput = $state(""); + let savingPercent = $state(false); + let percentError = $state<string | null>(null); + let percentSaved = $state(false); - // Sync the input from the prop when it changes (focus switch / initial load). - let lastPercent = $state<number | null>(null); - $effect(() => { - if (percent !== lastPercent) { - lastPercent = percent; - percentInput = percent !== null ? String(percent) : ""; - percentError = null; - percentSaved = false; - } - }); + // Sync the input from the prop when it changes (focus switch / initial load). + let lastPercent = $state<number | null>(null); + $effect(() => { + if (percent !== lastPercent) { + lastPercent = percent; + percentInput = percent !== null ? String(percent) : ""; + percentError = null; + percentSaved = false; + } + }); - const percentLabel = $derived( - percent == null - ? "Loading…" - : percent === 0 - ? "Disabled (manual only)" - : percent === DEFAULT_PERCENT - ? `${percent}% (default)` - : `${percent}%`, - ); + const percentLabel = $derived( + percent == null + ? "Loading…" + : percent === 0 + ? "Disabled (manual only)" + : percent === DEFAULT_PERCENT + ? `${percent}% (default)` + : `${percent}%`, + ); - async function handleCompact() { - if (compacting || !canCompact) return; - compacting = true; - compactError = null; - compactResult = null; - const result = await compactNow(); - compacting = false; - if (result === null) return; - if (result.ok) { - compactResult = { summarized: result.messagesSummarized, kept: result.messagesKept }; - } else { - compactError = result.error; - } - } + async function handleCompact() { + if (compacting || !canCompact) return; + compacting = true; + compactError = null; + compactResult = null; + const result = await compactNow(); + compacting = false; + if (result === null) return; + if (result.ok) { + compactResult = { summarized: result.messagesSummarized, kept: result.messagesKept }; + } else { + compactError = result.error; + } + } - async function handleSavePercent() { - const value = Number.parseInt(percentInput, 10); - if (Number.isNaN(value) || value < 0 || value > 100) { - percentError = "Must be 0-100"; - return; - } - savingPercent = true; - percentError = null; - percentSaved = false; - const result = await savePercent(value); - savingPercent = false; - if (result === null) return; - if (result.ok) { - percentSaved = true; - } else { - percentError = result.error; - } - } + async function handleSavePercent() { + const value = Number.parseInt(percentInput, 10); + if (Number.isNaN(value) || value < 0 || value > 100) { + percentError = "Must be 0-100"; + return; + } + savingPercent = true; + percentError = null; + percentSaved = false; + const result = await savePercent(value); + savingPercent = false; + if (result === null) return; + if (result.ok) { + percentSaved = true; + } else { + percentError = result.error; + } + } </script> <div class="flex flex-col gap-3"> - <!-- Manual compaction --> - <section class="flex flex-col gap-1"> - <span class="text-xs font-semibold uppercase opacity-60">Manual compaction</span> - <button - type="button" - class="btn btn-sm btn-outline" - disabled={!canCompact || compacting} - onclick={handleCompact} - > - {#if compacting} - <span class="loading loading-spinner loading-xs"></span> - Compacting… - {:else} - Compact now - {/if} - </button> - {#if !canCompact} - <p class="text-xs opacity-60">Open or start a conversation to compact its history.</p> - {:else if compactError} - <p class="text-xs text-error">{compactError}</p> - {:else if compactResult} - <p class="text-xs text-success"> - Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept. - </p> - {:else} - <p class="text-xs opacity-50"> - Summarizes old messages into a system summary + retains the most recent messages. - </p> - {/if} - </section> + <!-- Manual compaction --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Manual compaction</span> + <button + type="button" + class="btn btn-sm btn-outline" + disabled={!canCompact || compacting} + onclick={handleCompact} + > + {#if compacting} + <span class="loading loading-spinner loading-xs"></span> + Compacting… + {:else} + Compact now + {/if} + </button> + {#if !canCompact} + <p class="text-xs opacity-60">Open or start a conversation to compact its history.</p> + {:else if compactError} + <p class="text-xs text-error">{compactError}</p> + {:else if compactResult} + <p class="text-xs text-success"> + Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept. + </p> + {:else} + <p class="text-xs opacity-50"> + Summarizes old messages into a system summary + retains the most recent messages. + </p> + {/if} + </section> - <!-- Auto-compact percent --> - <section class="flex flex-col gap-1"> - <span class="text-xs font-semibold uppercase opacity-60">Auto-compact percent</span> - <div class="flex items-center gap-2"> - <input - type="number" - class="input input-bordered input-sm w-24" - min="0" - max="100" - placeholder={String(DEFAULT_PERCENT)} - value={percentInput} - disabled={savingPercent} - onchange={handleSavePercent} - aria-label="Compact percent (0-100)" - /> - <span class="text-xs opacity-60">%</span> - {#if savingPercent} - <span class="loading loading-spinner loading-xs"></span> - {/if} - </div> - <p class="text-xs opacity-50"> - Current: {percentLabel} - <br /> - 0 disables auto-compact. Default is {DEFAULT_PERCENT}%. - </p> - {#if percentError} - <p class="text-xs text-error">{percentError}</p> - {:else if percentSaved} - <p class="text-xs text-success">Saved.</p> - {/if} - </section> + <!-- Auto-compact percent --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Auto-compact percent</span> + <div class="flex items-center gap-2"> + <input + type="number" + class="input input-bordered input-sm w-24" + min="0" + max="100" + placeholder={String(DEFAULT_PERCENT)} + value={percentInput} + disabled={savingPercent} + onchange={handleSavePercent} + aria-label="Compact percent (0-100)" + /> + <span class="text-xs opacity-60">%</span> + {#if savingPercent} + <span class="loading loading-spinner loading-xs"></span> + {/if} + </div> + <p class="text-xs opacity-50"> + Current: {percentLabel} + <br /> + 0 disables auto-compact. Default is {DEFAULT_PERCENT}%. + </p> + {#if percentError} + <p class="text-xs text-error">{percentError}</p> + {:else if percentSaved} + <p class="text-xs text-success">Saved.</p> + {/if} + </section> </div> diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index fe9ea94..04c28cd 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -1,198 +1,413 @@ <script lang="ts"> - import { computeContextUsage, formatCompactTokens } from "../../../core/metrics"; - - const FALLBACK_CONTEXT_WINDOW = 1_000_000; - const MAX_LINES = 7; - - let { - onSend, - onQueue, - onStop, - contextSize = undefined, - contextWindow = undefined, - status = "idle", - }: { - onSend: (text: string) => void; - /** - * Enqueue a steering message (`chat.queue`). When provided AND the status - * is `running`, the send button becomes a "Queue" button that steers the - * in-flight turn instead of starting a new one. When absent, `onSend` is - * used regardless (tests / non-steering contexts). - */ - onQueue?: (text: string) => void; - /** Stop the in-flight generation (`POST /conversations/:id/stop`). */ - onStop?: () => void; - // Current context occupancy (latest turn's contextSize), or `undefined` - // when unknown — the status bar then shows "— tokens", never 0%. - contextSize?: number | undefined; - /** Per-model context window (max tokens) from `GET /models` modelInfo. */ - contextWindow?: number | undefined; - // Coarse agent status for the status-bar icon. - status?: "idle" | "running" | "error"; - } = $props(); - - let text = $state(""); - let inputEl: HTMLTextAreaElement | undefined; - - const hasText = $derived(text.trim().length > 0); - const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW); - const usage = $derived(computeContextUsage(contextSize, effectiveMax)); - const hasUsage = $derived(contextSize !== undefined); - - // One button, three modes: - // - idle → "Send" (starts a turn via chat.send) - // - running + text → "Queue" (steers via chat.queue) - // - running + empty → "Stop" (aborts via POST /stop) - const buttonMode = $derived.by<"send" | "queue" | "stop">(() => { - if (status === "running" && !hasText && onStop !== undefined) return "stop"; - if (status === "running" && hasText && onQueue !== undefined) return "queue"; - return "send"; - }); - const placeholder = $derived(status === "running" ? "Steer the conversation..." : "Type a message..."); - - // As the window fills, escalate color: calm → warning → danger. - function fillClass(pct: number): string { - if (pct >= 90) return "progress-error"; - if (pct >= 70) return "progress-warning"; - return "progress-success"; - } - - 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(); - }); - - function handleSubmit(): void { - const trimmed = text.trim(); - if (trimmed.length === 0) return; - if (buttonMode === "queue") { - onQueue?.(trimmed); - } else { - onSend(trimmed); - } - text = ""; - } - - function handleKeydown(e: KeyboardEvent): void { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - } + 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 flex-col" - onsubmit={(e) => { - e.preventDefault(); - handleSubmit(); - }} + 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 + single context-aware button --> - <div class="flex items-end gap-2 px-4 pt-3 pb-2"> - <textarea - bind:this={inputEl} - class="textarea textarea-bordered flex-1 resize-none leading-normal !min-h-0 h-auto" - bind:value={text} - onkeydown={handleKeydown} - placeholder={placeholder} - rows="1" - aria-label="Message input" - ></textarea> - {#if buttonMode === "stop"} - <button - class="btn btn-error w-20 shrink-0" - type="button" - aria-label="Stop generation" - onclick={() => onStop?.()} - > - Stop - </button> - {:else} - <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!hasText}> - {buttonMode === "queue" ? "Queue" : "Send"} - </button> - {/if} - </div> - - <!-- Bottom status bar: status icon · context-window fill · token count --> - <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50"> - <span class="shrink-0"> - {#if status === "running"} - <span class="loading loading-spinner loading-xs text-primary"></span> - {:else if status === "error"} - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - class="h-4 w-4 text-error" - aria-label="Error" - > - <circle cx="12" cy="12" r="10"></circle> - <line x1="12" y1="8" x2="12" y2="12"></line> - <line x1="12" y1="16" x2="12.01" y2="16"></line> - </svg> - {:else} - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2.5" - stroke-linecap="round" - stroke-linejoin="round" - class="h-4 w-4 text-success" - aria-label="Idle" - > - <polyline points="20 6 9 17 4 12"></polyline> - </svg> - {/if} - </span> - - {#if usage.percent !== null} - <progress - class="progress h-2 flex-1 {fillClass(usage.percent)}" - value={usage.percent} - max="100" - ></progress> - {:else} - <progress class="progress h-2 flex-1 opacity-40" value="0" max="100"></progress> - {/if} - - <span class="shrink-0 whitespace-nowrap font-mono"> - {#if hasUsage} - {formatCompactTokens(usage.current)}{#if usage.max !== null}<span - class="text-base-content/40" - > - / {formatCompactTokens(usage.max)}</span - >{/if} - {#if usage.percent !== null} - <span class="ml-1">· {usage.percent.toFixed(1)}%</span> - {/if} - {:else} - <span class="text-base-content/40">— tokens</span> - {/if} - </span> - </div> + <!-- 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 a288cb8..11b9feb 100644 --- a/src/features/chat/ui/ModelSelector.svelte +++ b/src/features/chat/ui/ModelSelector.svelte @@ -1,50 +1,92 @@ <script lang="ts"> - import { joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select"; + import type { ModelMetadata } from "@dispatch/transport-contract"; + import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select"; - let { - models, - selected, - onSelect, - }: { - models: readonly string[]; - selected: string; - onSelect: (model: string) => void; - } = $props(); + let { + models, + selected, + onSelect, + 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)); + const keys = $derived(modelKeys(models)); + const current = $derived(splitModelName(selected)); + const keyModels = $derived(modelsForKey(models, current.key)); - // Switching key jumps to the first model available under it. - function selectKey(key: string): void { - const first = modelsForKey(models, key)[0] ?? ""; - onSelect(joinModelName(key, first)); - } + // Whether the currently-selected full model name is vision-capable. + const selectedVision = $derived(isVisionModel(modelInfo, selected)); - function selectModel(model: string): void { - onSelect(joinModelName(current.key, model)); - } + // 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> <div class="flex flex-col gap-2"> - <select - class="select w-full" - value={current.key} - onchange={(e) => selectKey(e.currentTarget.value)} - aria-label="Key selector" - > - {#each keys as key (key)} - <option value={key}>{key}</option> - {/each} - </select> - <select - class="select w-full" - value={current.model} - onchange={(e) => selectModel(e.currentTarget.value)} - aria-label="Model selector" - > - {#each keyModels as model (model)} - <option value={model}>{model}</option> - {/each} - </select> + <select + class="select w-full" + value={current.key} + onchange={(e) => selectKey(e.currentTarget.value)} + aria-label="Key selector" + > + {#each keys as key (key)} + <option value={key}>{key}</option> + {/each} + </select> + <select + class="select w-full" + value={current.model} + onchange={(e) => selectModel(e.currentTarget.value)} + aria-label="Model selector" + > + {#each keyModels as model (model)} + <option value={model}> + {model}{#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 index 8c7b193..6858779 100644 --- a/src/features/chat/ui/ReasoningEffortSelector.svelte +++ b/src/features/chat/ui/ReasoningEffortSelector.svelte @@ -1,75 +1,86 @@ <script lang="ts"> - import type { ReasoningEffort } from "@dispatch/transport-contract"; - import { - effectiveEffort, - effortOptions, - isReasoningEffort, - type SaveReasoningEffort, - } from "../reasoning-effort"; + import type { ReasoningEffort } from "@dispatch/transport-contract"; + import { + effectiveSelection, + isThinkingSelection, + selectionOptions, + type SaveThinkingSelection, + type ThinkingSelection, + } from "../reasoning-effort"; - let { - persisted, - save, - }: { - /** The conversation's persisted level, or null when never set (default applies). */ - persisted: ReasoningEffort | null; - save: SaveReasoningEffort; - } = $props(); + let { + persistedEffort, + persistedThinking, + save, + }: { + /** The conversation's persisted effort level, or null when never set (default applies). */ + persistedEffort: ReasoningEffort | null; + /** + * The conversation's persisted thinking flag, or null when never set + * (thinking ON — the default). `false` ⇒ thinking disabled (the separate + * "off" axis); the effort level is preserved across an off→on toggle. + */ + persistedThinking: boolean | null; + /** Persist a thinking selection (off or a level). */ + save: SaveThinkingSelection; + } = $props(); - const options = effortOptions(); + const options = selectionOptions(); - // The user's in-flight choice; null = mirror the (async-loaded) persisted prop. - // Re-mounted per conversation, so there is no cross-tab bleed. - let chosen = $state<ReasoningEffort | null>(null); - let saving = $state(false); - let error = $state<string | null>(null); - let justSaved = $state(false); + // The user's in-flight choice; null = mirror the (async-loaded) persisted + // selection. Re-mounted per conversation, so there is no cross-tab bleed. + let chosen = $state<ThinkingSelection | null>(null); + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state(false); - const selected = $derived(chosen ?? effectiveEffort(persisted)); + const selected = $derived( + chosen ?? effectiveSelection(persistedEffort, persistedThinking), + ); - async function handleChange(value: string) { - if (!isReasoningEffort(value) || saving) return; - chosen = value; - saving = true; - error = null; - justSaved = false; - const result = await save(value); - saving = false; - if (result === null) return; - if (result.ok) { - justSaved = true; - } else { - error = result.error; - chosen = null; // revert to the persisted value - } - } + async function handleChange(value: string) { + if (!isThinkingSelection(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 selection + } + } </script> <div class="flex flex-col gap-1"> - <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span> - <div class="flex items-center gap-2"> - <select - class="select select-sm w-full" - value={selected} - disabled={saving} - onchange={(e) => handleChange(e.currentTarget.value)} - aria-label="Reasoning effort" - > - {#each options as option (option.value)} - <option value={option.value}>{option.label}</option> - {/each} - </select> - {#if saving} - <span class="loading loading-spinner loading-xs" aria-label="Saving reasoning effort"></span> - {/if} - </div> - {#if error} - <p class="text-xs text-error">{error}</p> - {:else if justSaved} - <p class="text-xs text-success">Saved — applies from the next turn.</p> - {:else} - <p class="text-xs opacity-50"> - How long the model thinks before answering. Changing it can re-prefill the prompt cache once. - </p> - {/if} + <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span> + <div class="flex items-center gap-2"> + <select + class="select select-sm w-full" + value={selected} + disabled={saving} + onchange={(e) => handleChange(e.currentTarget.value)} + aria-label="Reasoning effort" + > + {#each options as option (option.value)} + <option value={option.value}>{option.label}</option> + {/each} + </select> + {#if saving} + <span class="loading loading-spinner loading-xs" aria-label="Saving reasoning effort"></span> + {/if} + </div> + {#if error} + <p class="text-xs text-error">{error}</p> + {:else if justSaved} + <p class="text-xs text-success">Saved — applies from the next turn.</p> + {:else} + <p class="text-xs opacity-50"> + How long the model thinks before answering. “Off” disables thinking entirely. Changing it can re-prefill the prompt cache once. + </p> + {/if} </div> diff --git a/src/features/computer/index.ts b/src/features/computer/index.ts new file mode 100644 index 0000000..05e56cc --- /dev/null +++ b/src/features/computer/index.ts @@ -0,0 +1,31 @@ +export type { + Badge, + ComputerListResult, + ComputerSaveResult, + ComputerStatusResult, + ComputerStatusView, + ComputerView, + LoadComputerStatus, + LoadComputers, + SaveComputer, + TestComputer, + TestComputerResult, + TestResultView, +} from "./logic/view-model"; +export { + formatHost, + knownHostLabel, + summarizeComputers, + viewComputer, + viewComputerStatus, + viewComputers, + viewTestResult, +} from "./logic/view-model"; +export { default as ComputerField } from "./ui/ComputerField.svelte"; +export { default as ComputerSelect } from "./ui/ComputerSelect.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "computer", + description: "Per-conversation / per-workspace SSH computer selection + status", +} as const; diff --git a/src/features/computer/logic/view-model.test.ts b/src/features/computer/logic/view-model.test.ts new file mode 100644 index 0000000..45d459e --- /dev/null +++ b/src/features/computer/logic/view-model.test.ts @@ -0,0 +1,167 @@ +import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/transport-contract"; +import type { ComputerEntry } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { + formatHost, + knownHostLabel, + summarizeComputers, + viewComputer, + viewComputerStatus, + viewComputers, + viewTestResult, +} from "./view-model"; + +function computer(overrides: Partial<ComputerEntry> = {}): ComputerEntry { + return { + alias: "buildbox", + hostName: "10.0.0.5", + port: 22, + user: "deploy", + identityFile: "/home/deploy/.ssh/id_ed25519", + knownHost: true, + usageCount: 0, + ...overrides, + }; +} + +describe("formatHost", () => { + it("is user@host with no port when port is the SSH default (22)", () => { + expect(formatHost(computer({ port: 22 }))).toBe("[email protected]"); + }); + + it("appends a non-default port", () => { + expect(formatHost(computer({ port: 2222 }))).toBe("[email protected]:2222"); + }); + + it("falls back to the alias when hostName is empty", () => { + expect(formatHost(computer({ hostName: "" }))).toBe("deploy@buildbox"); + }); +}); + +describe("knownHostLabel", () => { + it("is 'known host' when known", () => { + expect(knownHostLabel(true)).toBe("known host"); + }); + + it("is 'new host' when not known", () => { + expect(knownHostLabel(false)).toBe("new host"); + }); +}); + +describe("viewComputer", () => { + it("projects alias + hostSummary + identity + known-host label", () => { + const v = viewComputer(computer()); + expect(v.alias).toBe("buildbox"); + expect(v.hostSummary).toBe("[email protected]"); + expect(v.identityFile).toBe("/home/deploy/.ssh/id_ed25519"); + expect(v.knownHostLabel).toBe("known host"); + expect(v.knownHost).toBe(true); + }); + + it("preserves a null identityFile (default key)", () => { + const v = viewComputer(computer({ identityFile: null })); + expect(v.identityFile).toBeNull(); + }); +}); + +describe("viewComputers", () => { + it("maps each entry (and drops usageCount from the view)", () => { + const views = viewComputers([ + computer({ alias: "a", usageCount: 3 }), + computer({ alias: "b", hostName: "b.local", usageCount: 0 }), + ]); + expect(views).toHaveLength(2); + expect(views.at(0)?.alias).toBe("a"); + expect(views.at(1)?.hostSummary).toBe("[email protected]"); + }); +}); + +describe("summarizeComputers", () => { + it("is 'No computers discovered' for an empty list", () => { + expect(summarizeComputers([])).toBe("No computers discovered"); + }); + + it("is singular for one computer", () => { + expect(summarizeComputers([computer()])).toBe("1 computer"); + }); + + it("is plural for many", () => { + expect(summarizeComputers([computer(), computer(), computer()])).toBe("3 computers"); + }); +}); + +describe("viewComputerStatus", () => { + function status( + state: ComputerStatusResponse["state"], + overrides: Partial<ComputerStatusResponse> = {}, + ): ComputerStatusResponse { + return { alias: "buildbox", state, knownHost: true, ...overrides }; + } + + it("connected → success badge, not busy, no error", () => { + const v = viewComputerStatus(status("connected")); + expect(v.statusLabel).toBe("Connected"); + expect(v.badge).toBe("success"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + }); + + it("connecting → warning badge + busy (spinner), no error", () => { + const v = viewComputerStatus(status("connecting")); + expect(v.statusLabel).toBe("Connecting…"); + expect(v.badge).toBe("warning"); + expect(v.busy).toBe(true); + expect(v.error).toBeNull(); + }); + + it("disconnected → neutral badge, NOT busy (a stable idle state)", () => { + const v = viewComputerStatus(status("disconnected")); + expect(v.statusLabel).toBe("Disconnected"); + expect(v.badge).toBe("neutral"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + }); + + it("error → error badge, not busy, surfaces the reason", () => { + const v = viewComputerStatus(status("error", { error: "auth refused" })); + expect(v.statusLabel).toBe("Error"); + expect(v.badge).toBe("error"); + expect(v.busy).toBe(false); + expect(v.error).toBe("auth refused"); + }); + + it("error falls back to a default reason when the backend omits one", () => { + const v = viewComputerStatus(status("error")); + expect(v.error).toBe("Connection failed"); + }); + + it("carries the knownHost flag through", () => { + const v = viewComputerStatus(status("connected", { knownHost: false })); + expect(v.knownHost).toBe(false); + }); +}); + +describe("viewTestResult", () => { + it("ok=true → no error, 'Connection OK' label", () => { + const r = viewTestResult({ alias: "buildbox", ok: true } as TestComputerResponse); + expect(r.ok).toBe(true); + expect(r.error).toBeNull(); + expect(r.label).toBe("Connection OK"); + }); + + it("ok=false → surfaces the failure reason", () => { + const r = viewTestResult({ + alias: "buildbox", + ok: false, + error: "host unreachable", + } as TestComputerResponse); + expect(r.ok).toBe(false); + expect(r.error).toBe("host unreachable"); + expect(r.label).toBe("Failed"); + }); + + it("ok=false without a reason falls back to a default", () => { + const r = viewTestResult({ alias: "buildbox", ok: false } as TestComputerResponse); + expect(r.error).toBe("Connection failed"); + }); +}); diff --git a/src/features/computer/logic/view-model.ts b/src/features/computer/logic/view-model.ts new file mode 100644 index 0000000..d489fb5 --- /dev/null +++ b/src/features/computer/logic/view-model.ts @@ -0,0 +1,184 @@ +import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/transport-contract"; +import type { Computer, ComputerEntry } from "@dispatch/wire"; + +/** + * Pure core for the computer feature — zero DOM, zero effects, zero Svelte. + * + * A **computer** is a remote SSH target discovered from the user's `~/.ssh/config` + * (read-only — there is no CRUD store; the user edits their config to add one). + * The computer feature surfaces, per-conversation and per-workspace, WHICH + * computer a turn's tools execute on (the computer analog of `cwd`). It is a + * USER-facing control only: the computer is a tool-execution target forwarded to + * tools and NEVER part of the model prompt (so it does not affect prompt + * caching) — the agent never sees it. + * + * This module holds the pure logic: formatting a `Computer` for display, mapping a + * live `ComputerStatusResponse.state` to a display badge, and the one-line + * discovered-list summary. The effects (the HTTP list/get/status/test + + * per-conversation get/set) are INJECTED via the ports below; the composition root + * implements them. + */ + +// ── Injected ports (consumer-defines-port; the composition root adapts the +// store's HTTP calls to these shapes). Mirrors cwd-lsp's ports. ────────────── + +/** Outcome of `PUT /conversations/:id/computer`; `null` when no real conversation is focused. */ +export type ComputerSaveResult = + | { readonly ok: true; readonly computerId: string | null } + | { readonly ok: false; readonly error: string }; + +export type SaveComputer = (computerId: string | null) => Promise<ComputerSaveResult | null>; + +/** Outcome of `GET /computers`; `null` when the list couldn't be loaded. */ +export type ComputerListResult = + | { readonly ok: true; readonly computers: readonly ComputerEntry[] } + | { readonly ok: false; readonly error: string }; + +export type LoadComputers = () => Promise<ComputerListResult | null>; + +/** Outcome of `GET /computers/:alias/status`; `null` when no alias / not loaded. */ +export type ComputerStatusResult = + | { readonly ok: true; readonly status: ComputerStatusResponse } + | { readonly ok: false; readonly error: string }; + +export type LoadComputerStatus = (alias: string) => Promise<ComputerStatusResult | null>; + +/** Outcome of `POST /computers/:alias/test`. */ +export type TestComputerResult = + | { readonly ok: true; readonly response: TestComputerResponse } + | { readonly ok: false; readonly error: string }; + +export type TestComputer = (alias: string) => Promise<TestComputerResult | null>; + +// ── Computer → display view ────────────────────────────────────────────────── + +export type Badge = "success" | "warning" | "error" | "neutral"; + +export interface ComputerView { + /** The SSH config `Host` alias — also the `computerId` users select. */ + readonly alias: string; + /** A compact `user@host:port` connection string (the resolved config values). */ + readonly hostSummary: string; + /** The resolved `IdentityFile`, or `null` = default `~/.ssh/id_*`. */ + readonly identityFile: string | null; + /** Short label for the known-host indicator, e.g. "known host" / "new host". */ + readonly knownHostLabel: string; + /** Whether the host's key is already in `~/.ssh/known_hosts`. */ + readonly knownHost: boolean; +} + +/** + * Format a computer's connection target as `user@host:port`, omitting the port + * when it is the SSH default (22) so the summary stays compact (mirrors how a user + * reads an ssh config `Host` block). `hostName` falls back to the alias itself + * (the backend resolves this, but this is defensive). + */ +export function formatHost(computer: Computer): string { + const host = computer.hostName || computer.alias; + const port = computer.port === 22 ? "" : `:${computer.port}`; + return `${computer.user}@${host}${port}`; +} + +/** The display label for the known-host indicator. */ +export function knownHostLabel(knownHost: boolean): string { + return knownHost ? "known host" : "new host"; +} + +export function viewComputer(computer: Computer): ComputerView { + return { + alias: computer.alias, + hostSummary: formatHost(computer), + identityFile: computer.identityFile, + knownHostLabel: knownHostLabel(computer.knownHost), + knownHost: computer.knownHost, + }; +} + +export function viewComputers(computers: readonly ComputerEntry[]): readonly ComputerView[] { + return computers.map(viewComputer); +} + +/** + * A one-line summary of the discovered list, e.g. "3 computers" / "No computers + * discovered" (the latter is the expected state until the `ssh` extension lands). + */ +export function summarizeComputers(computers: readonly ComputerEntry[]): string { + if (computers.length === 0) return "No computers discovered"; + return `${computers.length} computer${computers.length === 1 ? "" : "s"}`; +} + +// ── Connection status → display view ────────────────────────────────────────── + +export interface ComputerStatusView { + readonly alias: string; + readonly state: ComputerStatusResponse["state"]; + readonly statusLabel: string; + readonly badge: Badge; + /** True while the state is transient (show a spinner). */ + readonly busy: boolean; + /** The error reason when `state === "error"`, else null. */ + readonly error: string | null; + /** Whether the host's key is already in `~/.ssh/known_hosts`. */ + readonly knownHost: boolean; +} + +/** + * Map a computer's live connection state to a display label + badge + busy flag. + * Mirrors the LSP/MCP status→badge pattern (each feature owns its own mapping — + * the enums differ, so they are not shared). Exhaustive vs the contract: + * `connected`→success, `connecting`→warning+busy, `disconnected`→neutral (a stable + * idle state, NOT busy), `error`→error. + */ +export function viewComputerStatus(status: ComputerStatusResponse): ComputerStatusView { + let statusLabel: string; + let badge: Badge; + let busy = false; + switch (status.state) { + case "connected": + statusLabel = "Connected"; + badge = "success"; + break; + case "connecting": + statusLabel = "Connecting…"; + badge = "warning"; + busy = true; + break; + case "disconnected": + statusLabel = "Disconnected"; + badge = "neutral"; + break; + case "error": + statusLabel = "Error"; + badge = "error"; + break; + } + return { + alias: status.alias, + state: status.state, + statusLabel, + badge, + busy, + error: status.state === "error" ? (status.error ?? "Connection failed") : null, + knownHost: status.knownHost, + }; +} + +// ── Test-connection result → display view ─────────────────────────────────── + +export interface TestResultView { + readonly alias: string; + readonly ok: boolean; + /** The failure reason when `ok` is false, else null. */ + readonly error: string | null; + /** A short result label, e.g. "Connection OK" / "Failed". */ + readonly label: string; +} + +export function viewTestResult(response: TestComputerResponse): TestResultView { + return { + alias: response.alias, + ok: response.ok, + error: response.ok ? null : (response.error ?? "Connection failed"), + label: response.ok ? "Connection OK" : "Failed", + }; +} diff --git a/src/features/computer/ui/ComputerField.svelte b/src/features/computer/ui/ComputerField.svelte new file mode 100644 index 0000000..6b54c88 --- /dev/null +++ b/src/features/computer/ui/ComputerField.svelte @@ -0,0 +1,211 @@ +<script lang="ts"> + import type { ComputerStatusResponse } from "@dispatch/transport-contract"; + import ComputerSelect from "./ComputerSelect.svelte"; + import { + viewComputerStatus, + type ComputerStatusView, + type LoadComputerStatus, + type SaveComputer, + type TestComputer, + } from "../logic/view-model"; + import type { ComputerEntry } from "@dispatch/wire"; + import { untrack } from "svelte"; + + let { + computerId, + canEdit, + computers, + save, + loadStatus, + test, + }: { + /** The active conversation's persisted computer alias, or null (local/inherit). */ + computerId: string | null; + /** Whether a real conversation is focused (a draft can't persist yet). */ + canEdit: boolean; + /** Discovered computers from `GET /computers` (read-only). */ + computers: readonly ComputerEntry[]; + save: SaveComputer; + loadStatus: LoadComputerStatus; + test: TestComputer; + } = $props(); + + // ── Save: selecting a dropdown option persists immediately (PUT /computer). ── + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state(false); + + async function select(computerId: string | null) { + if (saving || !canEdit) return; + saving = true; + error = null; + justSaved = false; + const result = await save(computerId); + saving = false; + if (result === null) return; + if (result.ok) { + justSaved = true; + } else { + error = result.error; + } + } + + // ── Connection status: poll the selected computer's live state. Owned + ── + // disposed here (never leaks across conversations — the field re-mounts per + // conversation via the {#key} in App.svelte, like CwdField). No poll while + // local (no alias) or while a draft can't persist. + let statusView = $state<ComputerStatusView | null>(null); + let statusError = $state<string | null>(null); + + async function refreshStatus() { + const alias = untrack(() => computerId); + if (alias === null) { + statusView = null; + statusError = null; + return; + } + const result = await loadStatus(alias); + if (result === null) return; + if (result.ok) { + statusView = viewComputerStatus(result.status); + statusError = null; + } else { + statusView = null; + statusError = result.error; + } + } + + // Re-fetch on mount + whenever the selected alias changes (incl. after a save). + // Clear the test result so a stale ✓ from alias A doesn't persist for alias B. + $effect(() => { + void computerId; + testResult = null; + void refreshStatus(); + }); + + // Poll the status while a computer is selected. `connecting` is transient, so + // a faster cadence helps it flip to `connected`; a connected host is stable. + const POLL_MS = 4000; + $effect(() => { + const alias = computerId; + if (alias === null) return; + const handle = setInterval(() => void refreshStatus(), POLL_MS); + return () => clearInterval(handle); + }); + + // ── Test connection: one-shot probe (POST /computers/:alias/test). ────────── + let testing = $state(false); + let testResult = $state<{ ok: boolean; error: string | null } | null>(null); + + async function runTest() { + const alias = untrack(() => computerId); + if (alias === null || testing) return; + testing = true; + testResult = null; + const result = await test(alias); + testing = false; + if (result === null) return; + testResult = result.ok ? { ok: true, error: null } : { ok: false, error: result.error }; + // Refresh the connection-status badge so it reflects the post-test state + // (clears a stale "connecting" spinner caught by the poll mid-test). + void refreshStatus(); + } + + const badgeClass = $derived.by(() => { + const b = statusView?.badge ?? "neutral"; + switch (b) { + case "success": + return "badge-success"; + case "warning": + return "badge-warning"; + case "error": + return "badge-error"; + default: + return "badge-ghost"; + } + }); +</script> + +<div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Computer (SSH)</span> + <div class="flex items-center gap-2"> + <ComputerSelect + value={computerId} + {computers} + disabled={!canEdit || saving} + onSelect={select} + /> + {#if saving} + <span class="loading loading-spinner loading-xs shrink-0"></span> + {/if} + </div> + + {#if !canEdit} + <p class="text-xs opacity-60">Start or open a conversation to set its computer.</p> + {:else if computerId !== null} + <!-- Connection status badge + Test affordance for the selected computer. --> + <div class="flex flex-wrap items-center gap-2"> + {#if statusView} + <span + class="badge badge-sm {badgeClass}" + title={statusView.error ?? statusView.statusLabel} + > + {#if statusView.busy} + <span class="loading loading-spinner loading-[10px]"></span> + {/if} + {statusView.statusLabel} + </span> + {:else if statusError} + <span class="badge badge-sm badge-error" title={statusError}>Status error</span> + {:else} + <span class="badge badge-sm badge-ghost">—</span> + {/if} + + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={testing} + onclick={runTest} + title={testResult + ? testResult.ok + ? "Connection OK" + : (testResult.error ?? "Failed") + : "Test connection"} + > + {#if testing} + <span class="loading loading-spinner loading-[10px]"></span> + {:else if testResult?.ok} + <span class="text-success">✓</span> + {:else if testResult} + <span class="text-error">✗</span> + {:else} + Test + {/if} + </button> + + {#if testResult} + <span class="text-xs {testResult.ok ? 'text-success' : 'text-error'}"> + {testResult.ok ? "OK" : (testResult.error ?? "Failed")} + </span> + {/if} + </div> + {#if statusView?.error} + <p class="text-xs text-error">{statusView.error}</p> + {/if} + {:else if computers.length === 0} + <p class="text-xs opacity-50"> + No computers discovered — add a `Host` block to your `~/.ssh/config` to use a remote computer. + </p> + {:else if justSaved && !error} + <p class="text-xs text-success">Saved.</p> + {/if} + + {#if error} + <p class="text-xs text-error">{error}</p> + {/if} + + <p class="text-xs opacity-50"> + Where this conversation's tools run. `null` (Local) runs on the server; an alias runs over SSH. + Not seen by the agent. + </p> +</div> diff --git a/src/features/computer/ui/ComputerSelect.svelte b/src/features/computer/ui/ComputerSelect.svelte new file mode 100644 index 0000000..c580b60 --- /dev/null +++ b/src/features/computer/ui/ComputerSelect.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + import type { ComputerEntry } from "@dispatch/wire"; + + let { + value, + computers, + disabled = false, + onSelect, + }: { + /** The currently selected computer alias, or null for "Local (none)". */ + value: string | null; + /** Discovered computers from `GET /computers` (read-only). */ + computers: readonly ComputerEntry[]; + disabled?: boolean; + onSelect: (computerId: string | null) => void; + } = $props(); + + // A `<select>` value is a string; map "" ↔ null (Local). The chosen option's + // value is the alias, with "" meaning "clear / local". + const selectValue = $derived(value ?? ""); + + function onChange(e: Event) { + const v = (e.currentTarget as HTMLSelectElement).value; + onSelect(v === "" ? null : v); + } +</script> + +<select + class="select select-bordered select-sm w-full font-mono text-xs" + value={selectValue} + {disabled} + onchange={onChange} + aria-label="Computer" +> + <option value="">Local (none)</option> + {#each computers as c (c.alias)} + <option value={c.alias}>{c.alias}{c.knownHost ? "" : " · new host"}</option> + {/each} +</select> diff --git a/src/features/concurrency/index.ts b/src/features/concurrency/index.ts new file mode 100644 index 0000000..c0870e3 --- /dev/null +++ b/src/features/concurrency/index.ts @@ -0,0 +1,63 @@ +export type { + // Contract shapes re-exported for a single import surface. + ConcurrencyCooldownResponse, + ConcurrencyCooldownResult, + ConcurrencyDeleteResult, + ConcurrencyLimitEntry, + ConcurrencyLimitResponse, + ConcurrencyLimitResult, + ConcurrencyLimitsResponse, + ConcurrencyLimitsResult, + ConcurrencyStatusEntry, + ConcurrencyStatusResponse, + ConcurrencyStatusResult, + DeleteConcurrencyLimit, + GetConcurrencyCooldown, + GetConcurrencyLimit, + LoadConcurrencyLimits, + LoadConcurrencyStatus, + RestoreOutcome, + SaveConcurrencyCooldown, + SaveConcurrencyLimit, + SetConcurrencyCooldownRequest, + SetConcurrencyLimitRequest, +} from "./logic/types"; +export type { + AutoReduceNotice, + Badge, + ConcurrencyLimitView, + ConcurrencyStatusView, +} from "./logic/view-model"; +export { + autoReduceNotices, + cooldownLabel, + DEFAULT_COOLDOWN_MS, + formatPauseDuration, + normalizeConcurrencyCooldown, + normalizeConcurrencyLimit, + normalizeConcurrencyLimits, + normalizeConcurrencyStatus, + normalizeLimit, + parseCooldownInput, + parseLimitInput, + pauseLabel, + providerFromModel, + providerOptions, + statusLabel, + summarizeLimits, + summarizeStatus, + viewAutoReduce, + viewConcurrencyLimit, + viewConcurrencyLimits, + viewConcurrencyStatus, + viewConcurrencyStatuses, +} from "./logic/view-model"; +export { default as AutoReduceBanner } from "./ui/AutoReduceBanner.svelte"; +export { default as ConcurrencyLimitRow } from "./ui/ConcurrencyLimitRow.svelte"; +export { default as ConcurrencyView } from "./ui/ConcurrencyView.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "concurrency", + description: "Per-provider concurrency limits + live in-flight/queue status", +} as const; diff --git a/src/features/concurrency/logic/types.ts b/src/features/concurrency/logic/types.ts new file mode 100644 index 0000000..a0c5f6b --- /dev/null +++ b/src/features/concurrency/logic/types.ts @@ -0,0 +1,119 @@ +import type { + ConcurrencyCooldownResponse, + ConcurrencyLimitResponse, + ConcurrencyLimitsResponse, + ConcurrencyStatusEntry, + ConcurrencyStatusResponse, + SetConcurrencyCooldownRequest, + SetConcurrencyLimitRequest, +} from "@dispatch/transport-contract"; + +/** + * Pure core types for the concurrency feature — zero DOM, zero effects, zero + * Svelte. + * + * The backend tracks + limits how many concurrent token-generating API requests + * are in flight PER PROVIDER. When the cap is reached, additional requests queue + * and are granted slots oldest-agent-first; a 429 backoff PAUSES a provider's + * queue until `pausedUntil`. The cap is in-memory + per-provider (no persistence), + * managed via a plain REST surface under `/concurrency/...` provided by the + * `concurrency` extension. When the extension isn't loaded, the list + status + * endpoints return empty arrays (`{ limits: [] }` / `{ providers: [] }`); the + * single / PUT / DELETE endpoints return `503`. + * + * The data shapes ARE part of `@dispatch/transport-contract` (0.23.0), so they + * are imported directly (mirrors `mcp` / `computer`). The result types + injected + * ports below are FE-owned (the composition root adapts the store's HTTP calls to + * them). The endpoints are GLOBAL (not workspace- or conversation-scoped). + * + * Concurrency-fixes (additive, no version bump): each `ConcurrencyStatusEntry` + * now also carries `cooldownMs` (per-slot release cooldown, configurable + + * persisted), `autoReduced` (a 429 auto-reduced the limit by 1, one-way), and + * when auto-reduced, `autoReducedFrom` + a `notice` banner string. A manual + * `PUT /concurrency/limits/:providerId` clears `autoReduced`. Two new endpoints + * `GET`/`PUT /concurrency/cooldown/:providerId` view/change the cooldown. + */ + +/** Re-export the contract shapes so consumers import a single surface. */ +export type { + ConcurrencyCooldownResponse, + ConcurrencyLimitResponse, + ConcurrencyLimitsResponse, + ConcurrencyStatusEntry, + ConcurrencyStatusResponse, + SetConcurrencyCooldownRequest, + SetConcurrencyLimitRequest, +}; + +/** + * A configured concurrency limit — one provider's cap on in-flight requests. + * Same shape as the contract's `ConcurrencyLimitResponse`. + */ +export interface ConcurrencyLimitEntry { + readonly providerId: string; + readonly limit: number; +} + +// ── Result types (port outcomes; the store returns these directly) ────────────── + +/** Outcome of `GET /concurrency/limits` (all configured limits). */ +export type ConcurrencyLimitsResult = + | { readonly ok: true; readonly limits: readonly ConcurrencyLimitEntry[] } + | { readonly ok: false; readonly error: string }; + +/** + * Outcome of `GET`/`PUT /concurrency/limits/:providerId` — the configured limit + * for one provider. `GET` returns `404` when the provider has no limit (surfaced + * as `ok: false`); `PUT` returns `400` for a non-positive-integer body. + */ +export type ConcurrencyLimitResult = + | { readonly ok: true; readonly providerId: string; readonly limit: number } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `DELETE /concurrency/limits/:providerId` (remove → unlimited). */ +export type ConcurrencyDeleteResult = + | { readonly ok: true; readonly providerId: string } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `GET /concurrency/status` (live status for every limited provider). */ +export type ConcurrencyStatusResult = + | { readonly ok: true; readonly providers: readonly ConcurrencyStatusEntry[] } + | { readonly ok: false; readonly error: string }; + +/** + * Outcome of `GET`/`PUT /concurrency/cooldown/:providerId` — the per-slot + * release cooldown (ms) for one provider. `GET` returns `404` when the provider + * has no concurrency config at all (no limit, no cooldown); `PUT` returns `400` + * for a non-negative-integer body. Both return `503` when the extension isn't + * loaded. + */ +export type ConcurrencyCooldownResult = + | { readonly ok: true; readonly providerId: string; readonly cooldownMs: number } + | { readonly ok: false; readonly error: string }; + +/** + * Outcome of an auto-reduce banner's "Restore to N" action (PUT the limit back + * to `autoReducedFrom` via `PUT /concurrency/limits/:providerId`). Carried back to + * the banner so a FAILED restore surfaces an inline error next to the button + * (instead of silently re-enabling the button / showing the error far away). + */ +export type RestoreOutcome = { readonly ok: true } | { readonly ok: false; readonly error: string }; + +// ── Injected ports (consumer-defines-port; the composition root adapts the +// store's HTTP calls to these shapes). ────────────────────────────────────── + +export type LoadConcurrencyLimits = () => Promise<ConcurrencyLimitsResult>; +export type GetConcurrencyLimit = (providerId: string) => Promise<ConcurrencyLimitResult>; +export type SaveConcurrencyLimit = ( + providerId: string, + limit: number, +) => Promise<ConcurrencyLimitResult>; +export type DeleteConcurrencyLimit = (providerId: string) => Promise<ConcurrencyDeleteResult>; +export type LoadConcurrencyStatus = () => Promise<ConcurrencyStatusResult>; +/** `GET /concurrency/cooldown/:providerId` — read the per-slot release cooldown. */ +export type GetConcurrencyCooldown = (providerId: string) => Promise<ConcurrencyCooldownResult>; +/** `PUT /concurrency/cooldown/:providerId` — set the per-slot release cooldown (non-negative int). */ +export type SaveConcurrencyCooldown = ( + providerId: string, + cooldownMs: number, +) => Promise<ConcurrencyCooldownResult>; diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts new file mode 100644 index 0000000..6e6b770 --- /dev/null +++ b/src/features/concurrency/logic/view-model.test.ts @@ -0,0 +1,670 @@ +import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + autoReduceNotices, + cooldownLabel, + DEFAULT_COOLDOWN_MS, + formatPauseDuration, + normalizeConcurrencyCooldown, + normalizeConcurrencyLimit, + normalizeConcurrencyLimits, + normalizeConcurrencyStatus, + parseCooldownInput, + parseLimitInput, + pauseLabel, + providerFromModel, + providerOptions, + statusLabel, + summarizeLimits, + summarizeStatus, + viewAutoReduce, + viewConcurrencyLimit, + viewConcurrencyLimits, + viewConcurrencyStatus, + viewConcurrencyStatuses, +} from "./view-model"; + +const status = (over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEntry => ({ + providerId: "umans", + limit: 4, + inFlight: 2, + queued: 0, + paused: false, + cooldownMs: 350, + autoReduced: false, + ...over, +}); + +// ── parseLimitInput ─────────────────────────────────────────────────────────── + +describe("parseLimitInput", () => { + it("accepts positive integers", () => { + expect(parseLimitInput("4")).toBe(4); + expect(parseLimitInput(" 12 ")).toBe(12); + expect(parseLimitInput("1")).toBe(1); + }); + + it("rejects zero, negatives, non-integers, and garbage", () => { + expect(parseLimitInput("0")).toBeNull(); + expect(parseLimitInput("-1")).toBeNull(); + expect(parseLimitInput("4.5")).toBeNull(); + expect(parseLimitInput("")).toBeNull(); + expect(parseLimitInput(" ")).toBeNull(); + expect(parseLimitInput("abc")).toBeNull(); + expect(parseLimitInput("4abc")).toBeNull(); + }); +}); + +// ── parseCooldownInput (non-negative integer — 0 is valid, unlike the limit) ── + +describe("parseCooldownInput", () => { + it("accepts zero + positive integers", () => { + expect(parseCooldownInput("0")).toBe(0); + expect(parseCooldownInput("350")).toBe(350); + expect(parseCooldownInput(" 100 ")).toBe(100); + }); + + it("rejects negatives, non-integers, and garbage", () => { + expect(parseCooldownInput("-1")).toBeNull(); + expect(parseCooldownInput("4.5")).toBeNull(); + expect(parseCooldownInput("")).toBeNull(); + expect(parseCooldownInput("abc")).toBeNull(); + expect(parseCooldownInput("100ms")).toBeNull(); + }); +}); + +// ── cooldownLabel ───────────────────────────────────────────────────────────── + +describe("cooldownLabel", () => { + it("0 → off label", () => { + expect(cooldownLabel(0)).toBe("0ms (off)"); + }); + it("sub-second → ms", () => { + expect(cooldownLabel(350)).toBe("350ms"); + expect(cooldownLabel(999)).toBe("999ms"); + }); + it("≥1s → seconds (trims trailing .0)", () => { + expect(cooldownLabel(1000)).toBe("1s"); + expect(cooldownLabel(1500)).toBe("1.5s"); + expect(cooldownLabel(60_000)).toBe("60s"); + }); +}); + +// ── providerFromModel / providerOptions ─────────────────────────────────────── + +describe("providerFromModel", () => { + it("takes the part before the first slash", () => { + expect(providerFromModel("openai/gpt-4o")).toBe("openai"); + expect(providerFromModel("openai-compat/gpt-4o-mini")).toBe("openai-compat"); + }); + it("returns the whole string when there is no slash", () => { + expect(providerFromModel("umans")).toBe("umans"); + }); +}); + +describe("providerOptions", () => { + it("derives distinct provider ids from models, first-seen order", () => { + expect( + providerOptions(["openai/gpt-4o", "umans/umans-glm-5.2", "openai/gpt-4o-mini"], []), + ).toEqual(["openai", "umans"]); + }); + it("unions with providers already carrying a configured limit", () => { + expect(providerOptions(["openai/gpt-4o"], [{ providerId: "anthropic", limit: 4 }])).toEqual([ + "openai", + "anthropic", + ]); + }); + it("does not duplicate a provider present in both models and limits", () => { + expect(providerOptions(["openai/gpt-4o"], [{ providerId: "openai", limit: 4 }])).toEqual([ + "openai", + ]); + }); + it("ignores models whose provider prefix is empty", () => { + expect(providerOptions(["/model-only", "umans/x"], [])).toEqual(["umans"]); + }); + it("returns [] when there are no models and no limits", () => { + expect(providerOptions([], [])).toEqual([]); + }); +}); + +// ── pauseLabel + formatPauseDuration ─────────────────────────────────────────── + +describe("formatPauseDuration", () => { + it("formats seconds / minutes+seconds / hours+minutes", () => { + expect(formatPauseDuration(30_000)).toBe("30s"); + expect(formatPauseDuration(65_000)).toBe("1m 05s"); + expect(formatPauseDuration(3_660_000)).toBe("1h 01m"); + }); + + it("non-positive → resuming", () => { + expect(formatPauseDuration(0)).toBe("resuming"); + expect(formatPauseDuration(-5_000)).toBe("resuming"); + }); +}); + +describe("pauseLabel", () => { + it("null when not paused", () => { + expect(pauseLabel(false, undefined, 0)).toBeNull(); + expect(pauseLabel(false, 10_000, 0)).toBeNull(); + }); + + it("'paused' (bare) when paused without a usable timestamp", () => { + expect(pauseLabel(true, undefined, 0)).toBe("paused"); + expect(pauseLabel(true, null, 0)).toBe("paused"); + expect(pauseLabel(true, Number.NaN, 0)).toBe("paused"); + }); + + it("countdown when paused with a future timestamp", () => { + const now = 1_000_000; + expect(pauseLabel(true, now + 30_000, now)).toBe("paused — resumes in 30s"); + expect(pauseLabel(true, now + 65_000, now)).toBe("paused — resumes in 1m 05s"); + }); + + it("'paused' (bare) when the timestamp is missing, non-finite, or expired", () => { + expect(pauseLabel(true, 0, 1_000)).toBe("paused"); + expect(pauseLabel(true, 1_000, 2_000)).toBe("paused"); + expect(pauseLabel(true, Number.NaN, 0)).toBe("paused"); + // The countdown prefix only appears with a FUTURE timestamp: + expect(pauseLabel(true, 2_000, 1_000)).toBe("paused — resumes in 1s"); + }); +}); + +// ── viewConcurrencyStatus ────────────────────────────────────────────────────── + +describe("viewConcurrencyStatus", () => { + it("serving under capacity → success badge, in-flight label, no queue", () => { + const v = viewConcurrencyStatus(status({ inFlight: 2, limit: 4, queued: 0 }), 0); + expect(v.inFlightLabel).toBe("2/4"); + expect(v.queuedLabel).toBe("no queue"); + expect(v.pausedLabel).toBeNull(); + expect(v.badge).toBe("success"); + expect(v.busy).toBe(false); + }); + + it("at capacity with a queue → warning badge + busy (spinner)", () => { + const v = viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 3 }), 0); + expect(v.inFlightLabel).toBe("4/4"); + expect(v.queuedLabel).toBe("3 queued"); + expect(v.badge).toBe("warning"); + expect(v.busy).toBe(true); + }); + + it("idle (no in-flight) → neutral badge, not busy", () => { + const v = viewConcurrencyStatus(status({ inFlight: 0, limit: 4, queued: 0 }), 0); + expect(v.badge).toBe("neutral"); + expect(v.busy).toBe(false); + expect(v.inFlightLabel).toBe("0/4"); + }); + + it("paused → warning badge + pause countdown label", () => { + const now = 1_000_000; + const v = viewConcurrencyStatus( + status({ paused: true, pausedUntil: now + 30_000, inFlight: 4, limit: 4, queued: 3 }), + now, + ); + expect(v.paused).toBe(true); + expect(v.pausedLabel).toBe("paused — resumes in 30s"); + expect(v.badge).toBe("warning"); + expect(v.busy).toBe(true); + }); + + it("at capacity but no queue → success (busy only when queuing)", () => { + const v = viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 0 }), 0); + expect(v.badge).toBe("success"); + expect(v.busy).toBe(false); + }); + + it("normalizes garbage counts to 0 and a malformed limit to 1", () => { + const v = viewConcurrencyStatus( + { + providerId: "x", + limit: -3, + inFlight: Number.NaN, + queued: "oops" as unknown as number, + paused: false, + cooldownMs: Number.NaN, + autoReduced: false, + }, + 0, + ); + expect(v.limit).toBe(1); + expect(v.inFlight).toBe(0); + expect(v.queued).toBe(0); + expect(v.inFlightLabel).toBe("0/1"); + expect(v.cooldownMs).toBe(DEFAULT_COOLDOWN_MS); + }); + + it("viewConcurrencyStatuses maps a list preserving order", () => { + const views = viewConcurrencyStatuses( + [status({ providerId: "a" }), status({ providerId: "b" })], + 0, + ); + expect(views.map((v) => v.providerId)).toEqual(["a", "b"]); + }); + + it("carries cooldownMs + label + autoReduced fields onto the view", () => { + const v = viewConcurrencyStatus(status({ cooldownMs: 1500 }), 0); + expect(v.cooldownMs).toBe(1500); + expect(v.cooldownLabel).toBe("1.5s"); + expect(v.autoReduced).toBe(false); + expect(v.autoReducedFrom).toBeNull(); + }); + + it("auto-reduced → warning badge (not busy) + autoReducedFrom carried", () => { + const v = viewConcurrencyStatus( + status({ limit: 3, autoReduced: true, autoReducedFrom: 4, inFlight: 0 }), + 0, + ); + expect(v.autoReduced).toBe(true); + expect(v.autoReducedFrom).toBe(4); + expect(v.badge).toBe("warning"); + // autoReduced alone does NOT flip busy (a reduced limit still admits agents). + expect(v.busy).toBe(false); + }); +}); + +// ── statusLabel (the row's status badge word) ────────────────────────────────── + +describe("statusLabel", () => { + it("idle (no in-flight) → Idle", () => { + expect( + statusLabel(viewConcurrencyStatus(status({ inFlight: 0, limit: 4, queued: 0 }), 0)), + ).toBe("Idle"); + }); + + it("serving under capacity → Active", () => { + expect( + statusLabel(viewConcurrencyStatus(status({ inFlight: 2, limit: 4, queued: 0 }), 0)), + ).toBe("Active"); + }); + + it("at capacity with a queue → At capacity", () => { + expect( + statusLabel(viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 3 }), 0)), + ).toBe("At capacity"); + }); + + it("at capacity but no queue → Active (not At capacity)", () => { + expect( + statusLabel(viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 0 }), 0)), + ).toBe("Active"); + }); + + it("paused → Paused (regardless of in-flight/queue)", () => { + expect( + statusLabel( + viewConcurrencyStatus(status({ paused: true, inFlight: 4, limit: 4, queued: 3 }), 0), + ), + ).toBe("Paused"); + }); +}); + +// ── viewAutoReduce / autoReduceNotices (the auto-reduce banner view) ─────────── + +describe("viewAutoReduce", () => { + it("returns null when not auto-reduced", () => { + expect(viewAutoReduce(status({ autoReduced: false }))).toBeNull(); + }); + + it("uses the backend notice verbatim + carries from/current limits", () => { + const notice = viewAutoReduce( + status({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ); + expect(notice).toEqual({ + providerId: "umans", + message: "Concurrency limit auto-reduced to 3 after a 429.", + fromLimit: 4, + currentLimit: 3, + }); + }); + + it("synthesizes a fallback notice when the backend notice is absent/empty", () => { + expect(viewAutoReduce(status({ limit: 3, autoReduced: true, autoReducedFrom: 4 }))).toEqual({ + providerId: "umans", + message: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.", + fromLimit: 4, + currentLimit: 3, + }); + expect( + viewAutoReduce(status({ limit: 3, autoReduced: true, autoReducedFrom: 4, notice: "" })), + ).not.toBeNull(); + }); + + it("falls back to currentLimit+1 when autoReducedFrom is missing/garbage", () => { + const notice = viewAutoReduce(status({ limit: 3, autoReduced: true })); + expect(notice?.fromLimit).toBe(4); // 3 + 1 + }); +}); + +describe("autoReduceNotices", () => { + it("collects one banner per auto-reduced provider (input order), empty when none", () => { + expect(autoReduceNotices([status({ providerId: "a" })])).toEqual([]); + const out = autoReduceNotices([ + status({ providerId: "a", autoReduced: true, autoReducedFrom: 4, limit: 3 }), + status({ providerId: "b" }), + status({ providerId: "c", autoReduced: true, autoReducedFrom: 2, limit: 1 }), + ]); + expect(out.map((n) => n.providerId)).toEqual(["a", "c"]); + expect(out[1]?.fromLimit).toBe(2); + }); +}); + +// ── viewConcurrencyLimit ─────────────────────────────────────────────────────── + +describe("viewConcurrencyLimit", () => { + it("passes through id + normalizes the limit", () => { + const v = viewConcurrencyLimit({ providerId: "umans", limit: 4 }); + expect(v.providerId).toBe("umans"); + expect(v.limit).toBe(4); + }); + + it("clamps a malformed limit to 1", () => { + expect(viewConcurrencyLimit({ providerId: "x", limit: 0 }).limit).toBe(1); + expect(viewConcurrencyLimit({ providerId: "x", limit: -2 }).limit).toBe(1); + expect(viewConcurrencyLimit({ providerId: "x", limit: 2.9 }).limit).toBe(2); + }); + + it("viewConcurrencyLimits maps a list preserving order", () => { + const views = viewConcurrencyLimits([ + { providerId: "a", limit: 1 }, + { providerId: "b", limit: 2 }, + ]); + expect(views.map((v) => v.providerId)).toEqual(["a", "b"]); + }); +}); + +// ── summarizeLimits / summarizeStatus ────────────────────────────────────────── + +describe("summarizeLimits", () => { + it("empty → No limits configured", () => { + expect(summarizeLimits([])).toBe("No limits configured"); + }); + it("counts limits (singular/plural)", () => { + expect(summarizeLimits([{ providerId: "a", limit: 1 }])).toBe("1 limit configured"); + expect( + summarizeLimits([ + { providerId: "a", limit: 1 }, + { providerId: "b", limit: 2 }, + ]), + ).toBe("2 limits configured"); + }); +}); + +describe("summarizeStatus", () => { + it("empty → No limits configured", () => { + expect(summarizeStatus([], 0)).toBe("No limits configured"); + }); + it("aggregates providers + in-flight totals", () => { + const s = summarizeStatus( + [ + status({ providerId: "a", limit: 4, inFlight: 2 }), + status({ providerId: "b", limit: 6, inFlight: 3 }), + ], + 0, + ); + expect(s).toBe("2 providers · 5/10 in flight"); + }); + it("includes queued + paused fragments only when non-zero", () => { + const s = summarizeStatus( + [ + status({ providerId: "a", limit: 4, inFlight: 4, queued: 2 }), + status({ + providerId: "b", + limit: 4, + inFlight: 1, + queued: 0, + paused: true, + pausedUntil: 1000, + }), + ], + 0, + ); + expect(s).toBe("2 providers · 5/8 in flight · 2 queued · 1 paused"); + }); + it("singular provider", () => { + expect(summarizeStatus([status({ providerId: "a", limit: 4, inFlight: 1 })], 0)).toBe( + "1 provider · 1/4 in flight", + ); + }); + it("includes an auto-reduced fragment only when non-zero", () => { + const s = summarizeStatus( + [ + status({ providerId: "a", limit: 3, inFlight: 1, autoReduced: true, autoReducedFrom: 4 }), + status({ providerId: "b", limit: 4, inFlight: 1 }), + ], + 0, + ); + expect(s).toBe("2 providers · 2/7 in flight · 1 auto-reduced"); + }); +}); + +// ── Network-seam normalizers ─────────────────────────────────────────────────── + +describe("normalizeConcurrencyLimits", () => { + it("coerces a well-formed body", () => { + const limits = normalizeConcurrencyLimits({ + limits: [ + { providerId: "umans", limit: 4 }, + { providerId: "openai-compat", limit: 5 }, + ], + }); + expect(limits).toEqual([ + { providerId: "umans", limit: 4 }, + { providerId: "openai-compat", limit: 5 }, + ]); + }); + + it("non-array / missing limits → []", () => { + expect(normalizeConcurrencyLimits({})).toEqual([]); + expect(normalizeConcurrencyLimits({ limits: "nope" })).toEqual([]); + expect(normalizeConcurrencyLimits(null)).toEqual([]); + expect(normalizeConcurrencyLimits(undefined)).toEqual([]); + }); + + it("drops entries without a provider id + clamps limits", () => { + const limits = normalizeConcurrencyLimits({ + limits: [ + { providerId: "umans", limit: 4 }, + { providerId: "", limit: 9 }, + { providerId: 123, limit: 1 }, + { limit: 2 }, + { providerId: "anthropic", limit: -5 }, + ], + }); + expect(limits).toEqual([ + { providerId: "umans", limit: 4 }, + { providerId: "anthropic", limit: 1 }, + ]); + }); +}); + +describe("normalizeConcurrencyLimit", () => { + it("coerces a well-formed single response", () => { + expect(normalizeConcurrencyLimit({ providerId: "umans", limit: 4 })).toEqual({ + providerId: "umans", + limit: 4, + }); + }); + it("null when the provider id is missing/non-string", () => { + expect(normalizeConcurrencyLimit({ limit: 4 })).toBeNull(); + expect(normalizeConcurrencyLimit({ providerId: "", limit: 4 })).toBeNull(); + expect(normalizeConcurrencyLimit(null)).toBeNull(); + }); + it("clamps a malformed limit to 1", () => { + expect(normalizeConcurrencyLimit({ providerId: "x", limit: 0 })).toEqual({ + providerId: "x", + limit: 1, + }); + }); +}); + +describe("normalizeConcurrencyStatus", () => { + it("coerces a well-formed body, preserving pausedUntil only when present", () => { + const now = Date.now(); + const providers = normalizeConcurrencyStatus({ + providers: [ + { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, + { + providerId: "openai-compat", + limit: 5, + inFlight: 5, + queued: 3, + paused: true, + pausedUntil: now, + }, + ], + }); + expect(providers).toHaveLength(2); + const [first, second] = providers; + expect(first).toEqual({ + providerId: "umans", + limit: 4, + inFlight: 2, + queued: 1, + paused: false, + cooldownMs: 350, + autoReduced: false, + }); + expect(first !== undefined && !("pausedUntil" in first)).toBe(true); + expect(second).toEqual({ + providerId: "openai-compat", + limit: 5, + inFlight: 5, + queued: 3, + paused: true, + pausedUntil: now, + cooldownMs: 350, + autoReduced: false, + }); + }); + + it("non-array / missing providers → []", () => { + expect(normalizeConcurrencyStatus({})).toEqual([]); + expect(normalizeConcurrencyStatus({ providers: 42 })).toEqual([]); + expect(normalizeConcurrencyStatus(null)).toEqual([]); + }); + + it("drops entries without a provider id + clamps counts", () => { + const providers = normalizeConcurrencyStatus({ + providers: [ + { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, + { providerId: "", inFlight: 1 }, + { limit: 2 }, + { providerId: 9, inFlight: 0 }, + { providerId: "x", limit: -1, inFlight: "bad", queued: null, paused: "yes" }, + ], + }); + expect(providers).toEqual([ + { + providerId: "umans", + limit: 4, + inFlight: 2, + queued: 1, + paused: false, + cooldownMs: 350, + autoReduced: false, + }, + { + providerId: "x", + limit: 1, + inFlight: 0, + queued: 0, + paused: false, + cooldownMs: 350, + autoReduced: false, + }, + ]); + }); + + it("omits pausedUntil when it is not a finite number", () => { + const providers = normalizeConcurrencyStatus({ + providers: [ + { providerId: "a", limit: 1, inFlight: 0, queued: 0, paused: true, pausedUntil: "x" }, + { providerId: "b", limit: 1, inFlight: 0, queued: 0, paused: true, pausedUntil: null }, + ], + }); + for (const p of providers) expect("pausedUntil" in p).toBe(false); + }); + + it("coerces cooldownMs (default 350) + carries auto-reduce fields only when true", () => { + const [reduced, healthy] = normalizeConcurrencyStatus({ + providers: [ + { + providerId: "umans", + limit: 3, + inFlight: 1, + queued: 0, + paused: false, + cooldownMs: 500, + autoReduced: true, + autoReducedFrom: 4, + notice: "auto-reduced to 3 after a 429.", + }, + { providerId: "openai", limit: 4, inFlight: 0, queued: 0, paused: false }, + ], + }); + expect(reduced?.cooldownMs).toBe(500); + expect(reduced?.autoReduced).toBe(true); + expect(reduced?.autoReducedFrom).toBe(4); + expect(reduced?.notice).toBe("auto-reduced to 3 after a 429."); + // Healthy entry: cooldownMs defaults to 350 when absent; auto-reduce fields + // are NOT present (they are only included when autoReduced===true). + expect(healthy?.cooldownMs).toBe(DEFAULT_COOLDOWN_MS); + expect(healthy?.autoReduced).toBe(false); + expect(healthy && "autoReducedFrom" in healthy).toBe(false); + expect(healthy && "notice" in healthy).toBe(false); + }); + + it("drops autoReducedFrom/notice when autoReduced is false (even if present in JSON)", () => { + const [p] = normalizeConcurrencyStatus({ + providers: [ + { + providerId: "x", + limit: 4, + inFlight: 0, + queued: 0, + paused: false, + autoReduced: false, + autoReducedFrom: 9, + notice: "stale", + }, + ], + }); + expect(p?.autoReduced).toBe(false); + expect(p && "autoReducedFrom" in p).toBe(false); + expect(p && "notice" in p).toBe(false); + }); +}); + +// ── normalizeConcurrencyCooldown ─────────────────────────────────────────────── + +describe("normalizeConcurrencyCooldown", () => { + it("coerces a well-formed body", () => { + expect(normalizeConcurrencyCooldown({ providerId: "umans", cooldownMs: 500 })).toEqual({ + providerId: "umans", + cooldownMs: 500, + }); + }); + + it("defaults a malformed/absent cooldownMs to 350", () => { + expect(normalizeConcurrencyCooldown({ providerId: "x", cooldownMs: -1 })?.cooldownMs).toBe( + DEFAULT_COOLDOWN_MS, + ); + expect(normalizeConcurrencyCooldown({ providerId: "x" })?.cooldownMs).toBe(DEFAULT_COOLDOWN_MS); + expect(normalizeConcurrencyCooldown({ providerId: "x", cooldownMs: "fast" })?.cooldownMs).toBe( + DEFAULT_COOLDOWN_MS, + ); + }); + + it("returns null for a missing/malformed providerId", () => { + expect(normalizeConcurrencyCooldown({ cooldownMs: 350 })).toBeNull(); + expect(normalizeConcurrencyCooldown({ providerId: "", cooldownMs: 350 })).toBeNull(); + expect(normalizeConcurrencyCooldown(null)).toBeNull(); + expect(normalizeConcurrencyCooldown({})).toBeNull(); + }); +}); diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts new file mode 100644 index 0000000..8a37198 --- /dev/null +++ b/src/features/concurrency/logic/view-model.ts @@ -0,0 +1,487 @@ +import type { + ConcurrencyCooldownResponse, + ConcurrencyStatusEntry, +} from "@dispatch/transport-contract"; +import type { ConcurrencyLimitEntry } from "./types"; + +/** + * Pure view-models for the concurrency feature — zero DOM, zero effects, zero + * Svelte. Maps backend `ConcurrencyLimitEntry` / `ConcurrencyStatusEntry` to + * display shapes (badges, "2/4" in-flight labels, pause countdowns, cooldown + * labels, auto-reduce banners, summaries), holds the limit/cooldown-input + * parsing, and the network-seam normalizers the composition root coerces the + * untyped JSON with. + */ + +export type Badge = "success" | "warning" | "error" | "neutral"; + +/** A configured limit row shaped for display. */ +export interface ConcurrencyLimitView { + readonly providerId: string; + readonly limit: number; +} + +/** + * A live status row shaped for display. Carries the raw counts plus pre-computed + * labels so the template stays thin. + */ +export interface ConcurrencyStatusView { + readonly providerId: string; + readonly limit: number; + readonly inFlight: number; + readonly queued: number; + readonly paused: boolean; + /** "2/4" — in-flight slots held vs the cap. */ + readonly inFlightLabel: string; + /** "1 queued" / "no queue". */ + readonly queuedLabel: string; + /** A pause label when paused, e.g. "paused — resumes in 30s"; null otherwise. */ + readonly pausedLabel: string | null; + readonly badge: Badge; + /** True when paused or at capacity (show a spinner). */ + readonly busy: boolean; + /** Per-slot release cooldown in ms (defensive default 350 on garbage). */ + readonly cooldownMs: number; + /** "350ms" / "1.2s" / "0ms (off)" — display label for the cooldown. */ + readonly cooldownLabel: string; + /** Whether the limit was auto-reduced by a 429 (one-way; user restores manually). */ + readonly autoReduced: boolean; + /** The original limit before auto-reduction; null when not auto-reduced. */ + readonly autoReducedFrom: number | null; +} + +/** + * A view-model for the auto-reduce banner — derived from a status entry whose + * `autoReduced` is `true`. `message` is the backend's `notice` when present, else + * a synthesized fallback. `viewAutoReduce` returns this (or null) so the banner + * section renders without reaching into the raw entry. + */ +export interface AutoReduceNotice { + readonly providerId: string; + readonly message: string; + /** The original limit before reduction — the value "Restore to N" PUTs. */ + readonly fromLimit: number; + /** The current (reduced) limit. */ + readonly currentLimit: number; +} + +// ── Limit input parsing ─────────────────────────────────────────────────────── + +/** + * Parse a raw limit input into a positive integer, or `null` when it is not a + * valid positive integer. Accepts "4" → 4; rejects "0", "-1", "4.5", "", "abc". + * Drives the Add/Save button's disabled state so an invalid value never reaches + * the backend (the backend is still the authority — it 400s a non-positive body). + */ +export function parseLimitInput(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === "" || !/^[0-9]+$/.test(trimmed)) return null; + const n = Number.parseInt(trimmed, 10); + return Number.isFinite(n) && n >= 1 ? n : null; +} + +/** + * Coerce an untrusted limit value into a positive integer (default 1). Used when + * normalizing backend responses so a malformed `limit` can never be 0/negative. + */ +export function normalizeLimit(value: unknown): number { + const n = typeof value === "number" && Number.isFinite(value) ? value : 1; + const int = Math.floor(n); + return int >= 1 ? int : 1; +} + +// ── Cooldown input parsing ──────────────────────────────────────────────────── +// +// The per-slot release cooldown (ms) is a NON-NEGATIVE integer (0 = no cooldown, +// instant re-admission) — unlike the limit, 0 is a VALID value. The default is +// 350ms (the backend's server default when a limit is set but no explicit +// cooldown was configured). It is configurable + persisted per provider via +// `PUT /concurrency/cooldown/:providerId`. + +/** The server's default cooldown (ms) — used when none is explicitly set. */ +export const DEFAULT_COOLDOWN_MS = 350; + +/** + * Parse a raw cooldown input into a non-negative integer, or `null` when it is + * not valid. Accepts "0" → 0, "350" → 350; rejects "-1", "4.5", "", "abc". + * Drives the cooldown Save button's disabled state so an invalid value never + * reaches the backend (the backend 400s a non-negative-integer body). + */ +export function parseCooldownInput(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === "" || !/^[0-9]+$/.test(trimmed)) return null; + const n = Number.parseInt(trimmed, 10); + return Number.isFinite(n) && n >= 0 ? n : null; +} + +/** + * Coerce an untrusted cooldown value into a non-negative integer (default + * {@link DEFAULT_COOLDOWN_MS}). Used when normalizing backend responses so a + * malformed `cooldownMs` can never be negative/non-finite. + */ +export function normalizeCooldown(value: unknown): number { + const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_COOLDOWN_MS; + const int = Math.floor(n); + return int >= 0 ? int : DEFAULT_COOLDOWN_MS; +} + +/** + * Format a cooldown (ms) as a short display label: + * 0 → "0ms (off)" · <1000 → "350ms" · ≥1000 → "1.2s" (trailing ".0" trimmed). + */ +export function cooldownLabel(ms: number): string { + if (ms <= 0) return "0ms (off)"; + if (ms < 1000) return `${ms}ms`; + const secs = ms / 1000; + const fixed = secs.toFixed(1); + return `${fixed.endsWith(".0") ? fixed.slice(0, -2) : fixed}s`; +} + +// ── Provider options (the Add-form dropdown) ─────────────────────────────────── +// +// A concurrency `providerId` is the credential name that prefixes a model name +// (`<provider>/<model>` — the same key the model picker groups by). The dropdown +// is the UNION of providers discoverable from the available models AND providers +// already carrying a configured limit (so a limit set out-of-band but whose +// model list is empty still appears), in first-seen order. Models are the +// authority; a provider with models but no limit is still selectable (Add sets it). + +/** The provider id prefix of a `<provider>/<model>` name (the part before the first `/`, or the whole string). */ +export function providerFromModel(full: string): string { + const i = full.indexOf("/"); + return i === -1 ? full : full.slice(0, i); +} + +/** Distinct provider ids to offer in the Add dropdown, first-seen order. */ +export function providerOptions( + models: readonly string[], + limits: readonly ConcurrencyLimitEntry[], +): string[] { + const seen = new Set<string>(); + const out: string[] = []; + const add = (p: string): void => { + if (p !== "" && !seen.has(p)) { + seen.add(p); + out.push(p); + } + }; + for (const m of models) add(providerFromModel(m)); + for (const l of limits) add(l.providerId); + return out; +} + +// ── Status → display view ────────────────────────────────────────────────────── + +const NO_LIMITS = "No limits configured"; + +/** + * Format a remaining-ms delta as a short pause countdown: "30s", "1m 05s", + * "resuming" (≤ 0). Pure via the injected `remainingMs`. The component recomputes + * this on each status poll (every ~2s) — a 1s ticking timer is optional. + */ +export function formatPauseDuration(remainingMs: number): string { + if (remainingMs <= 0) return "resuming"; + const totalSec = Math.floor(remainingMs / 1000); + const hours = Math.floor(totalSec / 3600); + const mins = Math.floor((totalSec % 3600) / 60); + const secs = totalSec % 60; + if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m`; + if (mins > 0) return `${mins}m ${String(secs).padStart(2, "0")}s`; + return `${secs}s`; +} + +/** + * The pause label for a status entry, or `null` when not paused. When paused with + * a future `pausedUntil`, shows "paused — resumes in 30s"; when paused without a + * usable timestamp, shows "paused". Pure via the injectable `now`. + */ +export function pauseLabel( + paused: boolean, + pausedUntil: number | null | undefined, + now: number = Date.now(), +): string | null { + if (!paused) return null; + if (typeof pausedUntil === "number" && Number.isFinite(pausedUntil)) { + const remaining = pausedUntil - now; + if (remaining > 0) return `paused — resumes in ${formatPauseDuration(remaining)}`; + } + // Paused without a usable future timestamp (missing, non-finite, or already + // expired): the next status poll will clear `paused`. Show "paused" meanwhile. + return "paused"; +} + +/** + * Build a display view for a status entry. `now` is injectable for tests + * (defaults to `Date.now()`); the composition-root component passes nothing in + * production (it recomputes on each poll). + * + * `autoReduced` does NOT flip `busy` (a reduced limit still admits agents; it is + * a degraded-but-active state surfaced via the banner, not a spinner) — it only + * nudges the badge to `warning` so the row signals attention. + */ +export function viewConcurrencyStatus( + entry: ConcurrencyStatusEntry, + now: number = Date.now(), +): ConcurrencyStatusView { + const limit = normalizeLimit(entry.limit); + const inFlight = clampCount(entry.inFlight); + const queued = clampCount(entry.queued); + const paused = entry.paused === true; + const autoReduced = entry.autoReduced === true; + const atCapacity = inFlight >= limit; + let badge: Badge; + if (paused) badge = "warning"; + else if (autoReduced) badge = "warning"; + else if (atCapacity && queued > 0) badge = "warning"; + else if (inFlight > 0) badge = "success"; + else badge = "neutral"; + const cooldownMs = normalizeCooldown(entry.cooldownMs); + const autoReducedFrom = + autoReduced && + typeof entry.autoReducedFrom === "number" && + Number.isFinite(entry.autoReducedFrom) + ? normalizeLimit(entry.autoReducedFrom) + : null; + return { + providerId: entry.providerId, + limit, + inFlight, + queued, + paused, + inFlightLabel: `${inFlight}/${limit}`, + queuedLabel: queued === 0 ? "no queue" : `${queued} queued`, + pausedLabel: pauseLabel(paused, entry.pausedUntil, now), + badge, + busy: paused || (atCapacity && queued > 0), + cooldownMs, + cooldownLabel: cooldownLabel(cooldownMs), + autoReduced, + autoReducedFrom, + }; +} + +/** + * A short status word for a status view, for the row's status badge: + * "Paused" · "At capacity" (in-flight at the cap with a queue) · "Active" + * (in-flight > 0) · "Idle". Mirrors the badge-text branching so the template + * holds no branching logic. + */ +export function statusLabel(view: ConcurrencyStatusView): string { + if (view.paused) return "Paused"; + if (view.inFlight >= view.limit && view.queued > 0) return "At capacity"; + if (view.inFlight > 0) return "Active"; + return "Idle"; +} + +/** + * The auto-reduce banner view for a status entry, or `null` when it is not + * auto-reduced. `message` prefers the backend's `notice` (verbatim, when present + * + non-empty); otherwise a synthesized fallback is built from + * `autoReducedFrom` → `limit`. `fromLimit` is the value "Restore to N" PUTs back. + */ +export function viewAutoReduce(entry: ConcurrencyStatusEntry): AutoReduceNotice | null { + if (entry.autoReduced !== true) return null; + const currentLimit = normalizeLimit(entry.limit); + const fromLimit = + typeof entry.autoReducedFrom === "number" && Number.isFinite(entry.autoReducedFrom) + ? normalizeLimit(entry.autoReducedFrom) + : currentLimit + 1; + const notice = + typeof entry.notice === "string" && entry.notice.length > 0 + ? entry.notice + : `Concurrency limit auto-reduced to ${currentLimit} after a 429 — restore manually when ready.`; + return { + providerId: entry.providerId, + message: notice, + fromLimit, + currentLimit, + }; +} + +/** + * All auto-reduce banners across a status list (one per auto-reduced provider), + * in input order. Empty when none are auto-reduced. + */ +export function autoReduceNotices( + entries: readonly ConcurrencyStatusEntry[], +): readonly AutoReduceNotice[] { + const out: AutoReduceNotice[] = []; + for (const e of entries) { + const n = viewAutoReduce(e); + if (n !== null) out.push(n); + } + return out; +} + +export function viewConcurrencyStatuses( + entries: readonly ConcurrencyStatusEntry[], + now: number = Date.now(), +): readonly ConcurrencyStatusView[] { + return entries.map((e) => viewConcurrencyStatus(e, now)); +} + +/** A display view for a configured limit entry. */ +export function viewConcurrencyLimit(entry: ConcurrencyLimitEntry): ConcurrencyLimitView { + return { providerId: entry.providerId, limit: normalizeLimit(entry.limit) }; +} + +export function viewConcurrencyLimits( + entries: readonly ConcurrencyLimitEntry[], +): readonly ConcurrencyLimitView[] { + return entries.map(viewConcurrencyLimit); +} + +// ── Summaries ────────────────────────────────────────────────────────────────── + +/** A one-line summary of the configured limits list, e.g. "2 limits configured". */ +export function summarizeLimits(limits: readonly ConcurrencyLimitEntry[]): string { + if (limits.length === 0) return NO_LIMITS; + return `${limits.length} limit${limits.length === 1 ? "" : "s"} configured`; +} + +/** + * A one-line summary of the live status, e.g. + * "2 providers · 6/10 in flight · 1 queued · 1 paused · 1 auto-reduced". Only the + * queued / paused / auto-reduced fragments appear when non-zero. + */ +export function summarizeStatus( + providers: readonly ConcurrencyStatusEntry[], + now: number = Date.now(), +): string { + if (providers.length === 0) return NO_LIMITS; + let inFlight = 0; + let limitTotal = 0; + let queued = 0; + let paused = 0; + let autoReduced = 0; + for (const p of providers) { + const limit = normalizeLimit(p.limit); + inFlight += clampCount(p.inFlight); + limitTotal += limit; + queued += clampCount(p.queued); + if (p.paused === true) paused += 1; + if (p.autoReduced === true) autoReduced += 1; + } + const parts: string[] = []; + parts.push( + `${providers.length} provider${providers.length === 1 ? "" : "s"}`, + `${inFlight}/${limitTotal} in flight`, + ); + if (queued > 0) parts.push(`${queued} queued`); + if (paused > 0) parts.push(`${paused} paused`); + if (autoReduced > 0) parts.push(`${autoReduced} auto-reduced`); + // Touch `now` so the summary recomputes alongside the per-row pause countdown. + void now; + return parts.join(" · "); +} + +// ── Network-seam normalization (pure; called by the composition root) ─────────── +// +// The concurrency responses are untyped JSON at runtime. The store coerces each +// defensively HERE (pure + tested) — a malformed/partial backend value (e.g. the +// extension returning `{}`) can never crash the renderer. Mirrors the +// `normalizeHeartbeatConfig` / inline `Array.isArray(data.servers)` guards. + +function isRecord(value: unknown): value is Record<string, unknown> { + return value !== null && typeof value === "object"; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +/** Coerce a non-negative count field to a non-negative integer (0 on garbage). */ +function clampCount(value: unknown): number { + const n = typeof value === "number" && Number.isFinite(value) ? value : 0; + const int = Math.floor(n); + return int >= 0 ? int : 0; +} + +/** Coerce an untrusted `GET /concurrency/limits` body into a typed limit list. */ +export function normalizeConcurrencyLimits(data: unknown): readonly ConcurrencyLimitEntry[] { + if (!isRecord(data) || !Array.isArray(data.limits)) return []; + const limits = data.limits as readonly unknown[]; + return limits + .filter((r): r is Record<string, unknown> => isRecord(r)) + .map((r) => ({ + providerId: asString(r.providerId) ?? "", + limit: normalizeLimit(r.limit), + })) + .filter((r) => r.providerId !== ""); +} + +/** Coerce an untrusted `GET`/`PUT /concurrency/limits/:id` body into a limit, or null. */ +export function normalizeConcurrencyLimit(data: unknown): ConcurrencyLimitEntry | null { + if (!isRecord(data)) return null; + const providerId = asString(data.providerId); + if (providerId === null) return null; + return { providerId, limit: normalizeLimit(data.limit) }; +} + +/** + * Coerce an untrusted `GET /concurrency/status` body into a typed status list. + * `pausedUntil` is included only when it is a finite number (it is absent when + * not paused). `cooldownMs` (default 350) + `autoReduced` are always coerced; + * `autoReducedFrom` + `notice` are included only when `autoReduced` is true (and + * well-formed), mirroring the backend's "present only when auto-reduced" contract. + */ +export function normalizeConcurrencyStatus(data: unknown): readonly ConcurrencyStatusEntry[] { + if (!isRecord(data) || !Array.isArray(data.providers)) return []; + const providers = data.providers as readonly unknown[]; + return providers + .filter((r): r is Record<string, unknown> => isRecord(r)) + .map((r): ConcurrencyStatusEntry => { + const providerId = asString(r.providerId) ?? ""; + const limit = normalizeLimit(r.limit); + const inFlight = clampCount(r.inFlight); + const queued = clampCount(r.queued); + const paused = r.paused === true; + const cooldownMs = normalizeCooldown(r.cooldownMs); + const autoReduced = r.autoReduced === true; + // Build immutably (the contract fields are readonly): start with the always- + // present fields, then layer the optional `pausedUntil` (finite number only) + // + the auto-reduce-only `autoReducedFrom`/`notice` (present only when true). + let entry: ConcurrencyStatusEntry = { + providerId, + limit, + inFlight, + queued, + paused, + cooldownMs, + autoReduced, + }; + if (typeof r.pausedUntil === "number" && Number.isFinite(r.pausedUntil)) { + entry = { ...entry, pausedUntil: r.pausedUntil }; + } + if (autoReduced) { + // Accumulate the auto-reduce-only optionals into a plain record (the + // contract fields are readonly, so we can't mutate a typed partial — + // collect then spread into a fresh entry). + const patch: { autoReducedFrom?: number; notice?: string } = {}; + if (typeof r.autoReducedFrom === "number" && Number.isFinite(r.autoReducedFrom)) { + patch.autoReducedFrom = normalizeLimit(r.autoReducedFrom); + } + if (typeof r.notice === "string" && r.notice.length > 0) { + patch.notice = r.notice; + } + if (patch.autoReducedFrom !== undefined || patch.notice !== undefined) { + entry = { ...entry, ...patch }; + } + } + return entry; + }) + .filter((r) => r.providerId !== ""); +} + +/** + * Coerce an untrusted `GET`/`PUT /concurrency/cooldown/:providerId` body into a + * typed cooldown response, or `null` when it is malformed (missing/malformed + * `providerId` or `cooldownMs`). The composition root surfaces a 404/400/503 as + * `ok: false` separately; this only defends the success body. + */ +export function normalizeConcurrencyCooldown(data: unknown): ConcurrencyCooldownResponse | null { + if (!isRecord(data)) return null; + const providerId = asString(data.providerId); + if (providerId === null) return null; + return { providerId, cooldownMs: normalizeCooldown(data.cooldownMs) }; +} diff --git a/src/features/concurrency/ui/AutoReduceBanner.svelte b/src/features/concurrency/ui/AutoReduceBanner.svelte new file mode 100644 index 0000000..132ebc7 --- /dev/null +++ b/src/features/concurrency/ui/AutoReduceBanner.svelte @@ -0,0 +1,81 @@ +<script lang="ts"> + import type { AutoReduceNotice } from "../logic/view-model"; + import type { RestoreOutcome } from "../logic/types"; + + let { + notice, + onRestore, + onDismiss, + }: { + /** The auto-reduce banner view (providerId + message + from/current limit). */ + notice: AutoReduceNotice; + /** + * "Restore to N" — PUT the limit back to `fromLimit`. Returns the outcome so + * a FAILED restore surfaces an inline error here (the banner owns its error + * display; the parent only refreshes on success). + */ + onRestore: (providerId: string, limit: number) => Promise<RestoreOutcome>; + /** Hide this banner locally (persists hidden while autoReduced stays true). */ + onDismiss: (providerId: string) => void; + } = $props(); + + let restoring = $state(false); + /** Inline restore error (e.g. "Concurrency service not available"); cleared on retry. */ + let error = $state<string | null>(null); + + async function handleRestore(): Promise<void> { + restoring = true; + error = null; + // The parent PUTs the limit + refreshes status on success; the banner clears + // once the next poll shows autoReduced===false. On failure the outcome is + // bubbled back here so the error shows inline next to the button. + const result = await onRestore(notice.providerId, notice.fromLimit); + restoring = false; + if (!result.ok) { + error = result.error; + } + } +</script> + +<div + class="alert alert-warning flex flex-col gap-2 py-2 text-xs" + role="status" + data-testid={`auto-reduce-banner-${notice.providerId}`} +> + <div class="flex items-start gap-2"> + <span class="shrink-0">⚠</span> + <div class="flex-1"> + <p>{notice.message}</p> + <p class="opacity-70"> + Was {notice.fromLimit}, now {notice.currentLimit}. + </p> + </div> + <div class="flex shrink-0 items-center gap-1"> + <!-- The "Restore to N" text stays visible while loading (only the spinner is + prepended) so the button keeps its accessible name during the PUT — a + spinner-only button loses its name for screen-reader users. --> + <button + type="button" + class="btn btn-warning btn-xs gap-1" + disabled={restoring} + onclick={handleRestore} + > + {#if restoring} + <span class="loading loading-spinner loading-xs"></span> + {/if} + Restore to {notice.fromLimit} + </button> + <button + type="button" + class="btn btn-ghost btn-xs" + aria-label={`Dismiss auto-reduce notice for ${notice.providerId}`} + onclick={() => onDismiss(notice.providerId)} + > + ✕ + </button> + </div> + </div> + {#if error} + <p class="font-mono text-error" data-testid={`restore-error-${notice.providerId}`}>{error}</p> + {/if} +</div> diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte new file mode 100644 index 0000000..bf06ac0 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte @@ -0,0 +1,204 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { + DEFAULT_COOLDOWN_MS, + parseCooldownInput, + parseLimitInput, + statusLabel, + type Badge, + type ConcurrencyLimitView, + type ConcurrencyStatusView, + } from "../logic/view-model"; + import type { + DeleteConcurrencyLimit, + SaveConcurrencyCooldown, + SaveConcurrencyLimit, + } from "../logic/types"; + + let { + limit, + status, + save, + saveCooldown, + remove, + }: { + /** The configured limit row (providerId + current limit). */ + limit: ConcurrencyLimitView; + /** The provider's live status view (in-flight/queue/badge), or null when no + * status entry exists yet. Drives the status line + seeds the cooldown input. */ + status: ConcurrencyStatusView | null; + save: SaveConcurrencyLimit; + saveCooldown: SaveConcurrencyCooldown; + remove: DeleteConcurrencyLimit; + } = $props(); + + // The badge→color map (presentational). Mirrors the old status-card mapping. + const badgeClass: Record<Badge, string> = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + neutral: "badge-ghost", + }; + + // The cooldown input seed: the live cooldown when a status entry exists, else + // the server default (350). + const cooldownMs = $derived(status?.cooldownMs ?? DEFAULT_COOLDOWN_MS); + + // Inline-edit state for the limit + cooldown inputs. Each is seeded from its + // canonical value, but only while untouched — so a save echo / status-poll + // refresh re-syncs without clobbering an in-flight edit. Mirrors the + // ChatLimitField seed pattern (avoids reading the prop in the $state init). + let limitDraft = $state(""); + let lastLimitSeed = $state(""); + let cooldownDraft = $state(""); + let lastCooldownSeed = $state(""); + let saving = $state(false); + let removing = $state(false); + let error = $state<string | null>(null); + /** Brief "Saved" confirmation after a successful save; cleared on edit. */ + let justSaved = $state(false); + + $effect(() => { + const incomingLimit = String(limit.limit); + const incomingCooldown = String(cooldownMs); + untrack(() => { + if (limitDraft === lastLimitSeed) limitDraft = incomingLimit; + lastLimitSeed = incomingLimit; + if (cooldownDraft === lastCooldownSeed) cooldownDraft = incomingCooldown; + lastCooldownSeed = incomingCooldown; + }); + }); + + const parsedLimit = $derived(parseLimitInput(limitDraft)); + const parsedCooldown = $derived(parseCooldownInput(cooldownDraft)); + const dirtyLimit = $derived(parsedLimit !== null && parsedLimit !== limit.limit); + const dirtyCooldown = $derived(parsedCooldown !== null && parsedCooldown !== cooldownMs); + const dirty = $derived(dirtyLimit || dirtyCooldown); + + // Clear the "Saved" hint + any error as soon as the user edits either field. + function onInput(): void { + justSaved = false; + error = null; + } + + // "Set" saves whichever field is dirty: the limit first (PUT + // /concurrency/limits/:id), then the cooldown (PUT /concurrency/cooldown/:id). + // Stops + surfaces an inline error on the first failure. + async function handleSet(): Promise<void> { + if (!dirty || saving || removing) return; + saving = true; + error = null; + try { + if (dirtyLimit && parsedLimit !== null) { + const r = await save(limit.providerId, parsedLimit); + if (!r.ok) { + error = r.error; + return; + } + // Reflect the echoed limit back immediately (the prop re-asserts it via + // the seed effect once the parent reloads). + limitDraft = String(r.limit); + lastLimitSeed = limitDraft; + } + if (dirtyCooldown && parsedCooldown !== null) { + const r = await saveCooldown(limit.providerId, parsedCooldown); + if (!r.ok) { + error = r.error; + return; + } + cooldownDraft = String(r.cooldownMs); + lastCooldownSeed = cooldownDraft; + } + justSaved = true; + } finally { + saving = false; + } + } + + async function handleRemove(): Promise<void> { + removing = true; + error = null; + const result = await remove(limit.providerId); + removing = false; + if (!result.ok) { + error = result.error; + } + // On success the parent drops this row (re-loaded limits list). + } +</script> + +<div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <!-- Line 1: provider + limit + cooldown + Set + ✕ (all on one line — nowrap so + the buttons never wrap; tight gap + narrow inputs keep the provider name + visible; the provider shrinks via flex-1 + min-w-0). --> + <div class="flex flex-nowrap items-center gap-1"> + <span class="min-w-0 flex-1 truncate font-medium font-mono" title={limit.providerId} + >{limit.providerId}</span + > + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-12 min-w-0 font-mono" + aria-label={`Concurrency limit for ${limit.providerId}`} + bind:value={limitDraft} + oninput={onInput} + disabled={saving || removing} + /> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-14 min-w-0 font-mono" + aria-label={`Release cooldown (ms) for ${limit.providerId}`} + bind:value={cooldownDraft} + oninput={onInput} + disabled={saving || removing} + /> + <span class="shrink-0 text-[10px] opacity-50">ms</span> + <button + type="button" + class="btn btn-primary btn-xs shrink-0" + aria-label={`Set concurrency for ${limit.providerId}`} + disabled={!dirty || saving || removing} + onclick={handleSet} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + <button + type="button" + class="btn btn-ghost btn-xs shrink-0 text-error" + aria-label={`Remove concurrency limit for ${limit.providerId}`} + disabled={saving || removing} + onclick={handleRemove} + > + {#if removing} + <span class="loading loading-spinner loading-xs"></span> + {:else} + ✕ + {/if} + </button> + </div> + + <!-- Line 2: in-flight count (left) + status badge (right). Hidden until the + first status poll for this provider lands. --> + {#if status !== null} + <div class="flex items-center justify-between gap-2 text-xs opacity-70"> + <span title="In-flight slots held vs cap">{status.inFlightLabel} in flight</span> + <span class="badge badge-sm {badgeClass[status.badge]} gap-1"> + {#if status.busy} + <span class="loading loading-spinner loading-xs"></span> + {/if} + {statusLabel(status)} + </span> + </div> + {/if} + + {#if error} + <span class="font-mono text-xs text-error">{error}</span> + {:else if justSaved && !dirty} + <span class="text-xs text-success">Saved.</span> + {/if} +</div> diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte new file mode 100644 index 0000000..aadb8d1 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -0,0 +1,433 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; + import { + autoReduceNotices, + DEFAULT_COOLDOWN_MS, + parseCooldownInput, + parseLimitInput, + providerOptions, + summarizeLimits, + viewConcurrencyLimits, + viewConcurrencyStatus, + type ConcurrencyStatusView, + } from "../logic/view-model"; + import type { + ConcurrencyLimitEntry, + DeleteConcurrencyLimit, + LoadConcurrencyLimits, + LoadConcurrencyStatus, + RestoreOutcome, + SaveConcurrencyCooldown, + SaveConcurrencyLimit, + } from "../logic/types"; + import AutoReduceBanner from "./AutoReduceBanner.svelte"; + import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte"; + + let { + models, + loadLimits, + saveLimit, + deleteLimit, + loadStatus, + saveCooldown, + }: { + /** Available models (`<provider>/<model>`) — the source of provider ids for the Add dropdown. */ + models: readonly string[]; + loadLimits: LoadConcurrencyLimits; + saveLimit: SaveConcurrencyLimit; + deleteLimit: DeleteConcurrencyLimit; + loadStatus: LoadConcurrencyStatus; + saveCooldown: SaveConcurrencyCooldown; + } = $props(); + + // ── Limits (config: list / add / update / remove) ──────────────────────────── + let limits = $state<readonly ConcurrencyLimitEntry[]>([]); + let limitsError = $state<string | null>(null); + /** True after the first load settles (gates the empty state). */ + let hasLoadedLimits = $state(false); + /** Re-entrancy guard for background/silent refreshes (no UI — prevents + * overlapping fetches). The refresh is near-instant, so a visible loading + * indicator would flicker every poll/reload; it stays INVISIBLE (mirrors the + * heartbeat runs list). */ + let limitsInFlight = false; + + // Add-row state. The provider id is chosen from a dropdown of known providers + // (derived from the available models + any already-configured limit providers). + // The row is revealed by the "Add" button; "Set" saves it, ✕ cancels. + let addOpen = $state(false); + let newProviderId = $state(""); + let newLimitInput = $state(""); + let newCooldownInput = $state(""); + let adding = $state(false); + let addError = $state<string | null>(null); + + const providerOpts = $derived(providerOptions(models, limits)); + const limitViews = $derived(viewConcurrencyLimits(limits)); + const limitsSummary = $derived(summarizeLimits(limits)); + const parsedNewLimit = $derived(parseLimitInput(newLimitInput)); + const parsedNewCooldown = $derived(parseCooldownInput(newCooldownInput)); + /** Only send a cooldown PUT when the user moved it off the server default. */ + const newCooldownChanged = $derived( + parsedNewCooldown !== null && parsedNewCooldown !== DEFAULT_COOLDOWN_MS, + ); + const canSet = $derived( + newProviderId !== "" && + parsedNewLimit !== null && + parsedNewCooldown !== null && + !limits.some((l) => l.providerId === newProviderId) && + !adding, + ); + + // Keep the dropdown selection valid: default to the first option, and if the + // selected provider is removed from the options (e.g. its limit was deleted and + // it has no models), fall back to the first remaining option. Runs untracked so + // it doesn't loop on its own assignment. + $effect(() => { + const opts = providerOpts; + untrack(() => { + if (opts.length === 0) { + if (newProviderId !== "") newProviderId = ""; + return; + } + if (!opts.includes(newProviderId)) newProviderId = opts[0] ?? ""; + }); + }); + + async function refreshLimits(): Promise<void> { + if (limitsInFlight) return; + limitsInFlight = true; + const result = await loadLimits(); + limitsInFlight = false; + hasLoadedLimits = true; + if (result.ok) { + limits = result.limits; + // Clear the error only on success so it stays visible (stable, no flicker) + // during an in-flight retry rather than vanishing mid-refresh. + limitsError = null; + } else { + limitsError = result.error; + } + } + + function startAdd(): void { + addOpen = true; + addError = null; + newLimitInput = ""; + newCooldownInput = String(DEFAULT_COOLDOWN_MS); + // newProviderId is kept valid (defaults to the first option) by the effect above. + } + + function cancelAdd(): void { + addOpen = false; + addError = null; + newLimitInput = ""; + newCooldownInput = ""; + } + + // "Set" on the add row: save the limit, then the cooldown (only when the user + // moved it off the server default of 350ms — the backend defaults to 350 when a + // limit is set, so an unchanged value needs no extra PUT). On full success the + // add row closes + the limits/status reload (the new limit appears as a row). + async function handleAdd(): Promise<void> { + if (parsedNewLimit === null || parsedNewCooldown === null || newProviderId === "") return; + adding = true; + addError = null; + const limitResult = await saveLimit(newProviderId, parsedNewLimit); + if (!limitResult.ok) { + adding = false; + addError = limitResult.error; + return; + } + if (newCooldownChanged) { + const cooldownResult = await saveCooldown(newProviderId, parsedNewCooldown); + adding = false; + if (!cooldownResult.ok) { + // The limit was saved (→ a row will appear after reload); the cooldown PUT + // failed. Surface the error but keep the add row open so it's visible. The + // user can edit the cooldown on the now-saved row. + addError = cooldownResult.error; + void refreshLimits(); + void refreshStatus(); + return; + } + } else { + adding = false; + } + addOpen = false; + newLimitInput = ""; + newCooldownInput = ""; + void refreshLimits(); + void refreshStatus(); + } + + // Wrap the ports so a row's save/remove reloads the authoritative list + status + // on success (the row still gets the result to drive its own UI). + async function rowSave(providerId: string, limit: number) { + const result = await saveLimit(providerId, limit); + if (result.ok) { + void refreshLimits(); + void refreshStatus(); + } + return result; + } + + async function rowRemove(providerId: string) { + const result = await deleteLimit(providerId); + if (result.ok) { + void refreshLimits(); + void refreshStatus(); + } + return result; + } + + // Wrap the cooldown save so a successful PUT refreshes the live status (which + // re-carries the new `cooldownMs`). The row still gets the result to drive its + // own UI. + async function cooldownSave(providerId: string, cooldownMs: number) { + const result = await saveCooldown(providerId, cooldownMs); + if (result.ok) { + void refreshStatus(); + } + return result; + } + + // ── Live status (polls while mounted — seeds cooldown inputs + drives the + // auto-reduce banners; the poll is silent, no status cards) ──────────────── + let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]); + let statusError = $state<string | null>(null); + /** True after the first load settles (gates the empty state). */ + let hasLoadedStatus = $state(false); + /** Re-entrancy guard for the 2s background poll (no UI — a visible loading + * indicator flickered every poll because the refresh is near-instant; it stays + * INVISIBLE, mirroring the heartbeat runs list). */ + let statusInFlight = false; + + // Per-provider status view (from the live status poll) so each saved limit row + // renders its in-flight count + status badge + seeds its cooldown input. Null + // when a provider has no status entry yet (the row falls back to Idle + the + // server-default cooldown of 350). + const statusByProvider = $derived.by(() => { + const map = new Map<string, ConcurrencyStatusView>(); + for (const e of statusEntries) map.set(e.providerId, viewConcurrencyStatus(e)); + return map; + }); + + // ── Auto-reduce banners (persist while autoReduced===true; dismissible) ─────── + // + // When a provider's limit is auto-reduced by a 429, `GET /concurrency/status` + // carries `autoReduced: true` (+ `autoReducedFrom` + `notice`). We render a + // banner per such provider. The banner is DISMISSIBLE: a dismissed provider + // stays hidden while it remains auto-reduced (persist-while-true), and is + // UN-dismissed the moment a poll shows it no longer auto-reduced — so a future + // auto-reduce re-shows the banner. Restoring the limit (PUT) clears + // `autoReduced` server-side → the next poll drops the banner automatically. + // + // The dismissed set is intentionally COMPONENT-LOCAL (NOT persisted to + // localStorage / a module-global): it resets on remount (sidebar view switch / + // reload). This is correct — `autoReduced` is a REAL persisted degraded state, + // so re-showing the banner on a fresh mount reminds the user. Persisting a + // dismissal across reloads would risk HIDING an ongoing degradation (a + // footgun), and AGENTS.md forbids module-global ambient state. Mirrors the + // component-local `limitsError`/`statusError` pattern. + let dismissedAutoReduce = $state<ReadonlySet<string>>(new Set()); + + const allNotices = $derived(autoReduceNotices(statusEntries)); + const visibleNotices = $derived( + allNotices.filter((n) => !dismissedAutoReduce.has(n.providerId)), + ); + + // Reconcile the dismissed set against the live auto-reduced providers: keep a + // dismissed entry ONLY while its provider is still auto-reduced. A provider + // that has been restored (no longer in `allNotices`) is dropped from the + // dismissed set so a future auto-reduce re-shows its banner. + $effect(() => { + const autoReducedIds = new Set(allNotices.map((n) => n.providerId)); + untrack(() => { + let changed = false; + const next = new Set<string>(); + for (const id of dismissedAutoReduce) { + if (autoReducedIds.has(id)) next.add(id); + else changed = true; + } + if (changed) dismissedAutoReduce = next; + }); + }); + + function dismissAutoReduce(providerId: string): void { + if (dismissedAutoReduce.has(providerId)) return; + dismissedAutoReduce = new Set([...dismissedAutoReduce, providerId]); + } + + // "Restore to N" — PUT the limit back to `autoReducedFrom` via the limits + // endpoint (a manual PUT clears `autoReduced` server-side). Refreshes limits + + // status on success; the next status poll shows `autoReduced===false` and the + // banner drops (the dismissed-set effect above un-dismisses it too). The banner + // component owns its own restoring-spinner + inline error; on FAILURE the + // outcome is bubbled back so the banner shows the error inline (instead of + // silently re-enabling the button / surfacing it only in the limits section). + async function restoreLimit(providerId: string, limit: number): Promise<RestoreOutcome> { + const result = await saveLimit(providerId, limit); + if (result.ok) { + void refreshLimits(); + void refreshStatus(); + return { ok: true }; + } + return { ok: false, error: result.error }; + } + + async function refreshStatus(): Promise<void> { + if (statusInFlight) return; + statusInFlight = true; + const result = await loadStatus(); + statusInFlight = false; + hasLoadedStatus = true; + if (result.ok) { + statusEntries = result.providers; + // Clear the error only on success so it stays visible (stable, no flicker) + // during an in-flight retry rather than vanishing mid-poll. + statusError = null; + } else { + statusError = result.error; + } + } + + const STATUS_POLL_MS = 2000; + + // Load limits + status on mount, and poll the live status while the view is + // alive (so a saved limit's cooldown input re-seeds + auto-reduce banners stay + // fresh without a manual refresh). Runs once — no reactive deps read inside. + $effect(() => { + untrack(() => { + void refreshLimits(); + void refreshStatus(); + }); + const h = setInterval(() => { + void refreshStatus(); + }, STATUS_POLL_MS); + return () => clearInterval(h); + }); +</script> + +<div class="flex flex-col gap-4"> + <!-- Auto-reduce banners (appear when a provider's limit was auto-reduced by a 429) --> + {#if visibleNotices.length > 0} + <section class="flex flex-col gap-2" aria-label="Concurrency auto-reduce notices"> + {#each visibleNotices as notice (notice.providerId)} + <AutoReduceBanner {notice} onRestore={restoreLimit} onDismiss={dismissAutoReduce} /> + {/each} + </section> + {/if} + + <!-- Limits (config) — a single list of editable rows. --> + <section class="flex flex-col gap-2"> + <div class="flex items-center justify-between gap-2"> + <h3 class="text-xs font-semibold uppercase opacity-60">Concurrency limits</h3> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => { + void refreshLimits(); + void refreshStatus(); + }} + aria-label="Refresh concurrency limits" + > + Refresh + </button> + </div> + + <span class="text-xs opacity-70">{limitsSummary}</span> + + {#if limitsError} + <p class="text-xs text-error">{limitsError}</p> + {:else if hasLoadedLimits && limitViews.length === 0 && !addOpen} + <p class="text-xs opacity-60">No limits configured — providers run unlimited.</p> + {/if} + + <ul class="flex flex-col gap-2"> + {#each limitViews as limit (limit.providerId)} + <li> + <ConcurrencyLimitRow + {limit} + status={statusByProvider.get(limit.providerId) ?? null} + save={rowSave} + saveCooldown={cooldownSave} + remove={rowRemove} + /> + </li> + {/each} + </ul> + + <!-- Add row: an "Add" button reveals a new item (dropdown + limit + cooldown + + Set + ✕). Set saves the limit (+ cooldown when moved off the default); + ✕ cancels the draft. --> + {#if addOpen} + <div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <!-- All on one line — nowrap so the buttons never wrap (tight gap + narrow + inputs keep the provider dropdown visible; it shrinks via flex-1 + min-w-0). --> + <div class="flex flex-nowrap items-center gap-1"> + <select + class="select select-bordered select-xs min-w-0 flex-1 font-mono" + aria-label="Provider" + bind:value={newProviderId} + disabled={adding || providerOpts.length === 0} + > + {#if providerOpts.length === 0} + <option value="" disabled>No providers available</option> + {:else} + {#each providerOpts as provider (provider)} + <option value={provider}>{provider}</option> + {/each} + {/if} + </select> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-12 min-w-0 font-mono" + placeholder="4" + aria-label="New concurrency limit" + bind:value={newLimitInput} + disabled={adding} + /> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-14 min-w-0 font-mono" + aria-label="New release cooldown (ms)" + bind:value={newCooldownInput} + disabled={adding} + /> + <span class="shrink-0 text-[10px] opacity-50">ms</span> + <button + type="button" + class="btn btn-primary btn-xs shrink-0" + disabled={!canSet} + onclick={handleAdd} + > + {#if adding} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + <button + type="button" + class="btn btn-ghost btn-xs shrink-0 text-error" + aria-label="Cancel add" + disabled={adding} + onclick={cancelAdd} + > + ✕ + </button> + </div> + {#if addError} + <p class="font-mono text-xs text-error">{addError}</p> + {/if} + </div> + {:else} + <button type="button" class="btn btn-ghost btn-xs w-fit" onclick={startAdd}> + + Add + </button> + {/if} + </section> +</div> diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts new file mode 100644 index 0000000..a8163c2 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -0,0 +1,559 @@ +import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { + ConcurrencyCooldownResult, + ConcurrencyDeleteResult, + ConcurrencyLimitResult, + ConcurrencyLimitsResult, + ConcurrencyStatusResult, +} from "../logic/types"; +import ConcurrencyView from "./ConcurrencyView.svelte"; + +// Available models → provider ids are "umans", "anthropic", "openai-compat". +const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "openai-compat/gpt-4o"] as const; + +// A status entry factory (defaults to a healthy limited provider). The new +// concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present. +function statusEntry(over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEntry { + return { + providerId: "umans", + limit: 4, + inFlight: 2, + queued: 1, + paused: false, + cooldownMs: 350, + autoReduced: false, + ...over, + }; +} + +// Fakes for the injected ports. Each resolves immediately so the mount effect's +// initial load settles in a microtask (assertions await via findBy*). The status +// list is mutable so a test can flip `autoReduced` between polls to simulate a +// restore clearing the banner. +function makeFakes(opts?: { + limits?: readonly { providerId: string; limit: number }[]; + status?: ConcurrencyStatusEntry[]; + /** + * When set, `saveLimit` rejects with this error (returns `ok: false`) — used + * to test the auto-reduce banner's inline restore-error feedback. + */ + saveLimitError?: string; + /** + * Optional hook invoked inside `saveLimit` AFTER recording the call. Lets a + * test simulate a backend side-effect of the PUT (e.g. clearing `autoReduced` + * on the next status poll). Receives the providerId + limit + the fakes bag so + * it can mutate the status list. (A plain method reassignment would NOT reach + * the already-rendered component — the prop captured the original closure.) + */ + onSaveLimit?: ( + providerId: string, + limit: number, + self: { calls: MakeFakesCalls; setStatus: (next: ConcurrencyStatusEntry[]) => void }, + ) => void; +}) { + let limits = opts?.limits ?? [{ providerId: "umans", limit: 4 }]; + let status = opts?.status ?? [statusEntry()]; + const onSaveLimit = opts?.onSaveLimit; + const saveLimitError = opts?.saveLimitError; + + const calls: MakeFakesCalls = { + loadLimits: 0, + loadStatus: 0, + saves: [] as { providerId: string; limit: number }[], + deletes: [] as string[], + cooldownSaves: [] as { providerId: string; cooldownMs: number }[], + }; + + function setStatus(next: ConcurrencyStatusEntry[]): void { + status = next; + } + + return { + calls, + // Allow a test to mutate the status list between polls (e.g. clear + // autoReduced after a restore to simulate the next poll). + setStatus, + loadLimits: async (): Promise<ConcurrencyLimitsResult> => { + calls.loadLimits++; + return { ok: true, limits }; + }, + saveLimit: async (providerId: string, limit: number): Promise<ConcurrencyLimitResult> => { + calls.saves.push({ providerId, limit }); + if (saveLimitError !== undefined) { + return { ok: false, error: saveLimitError }; + } + // Reflect the new limit into the list the next load returns. + limits = [...limits.filter((l) => l.providerId !== providerId), { providerId, limit }]; + if (onSaveLimit !== undefined) onSaveLimit(providerId, limit, { calls, setStatus }); + return { ok: true, providerId, limit }; + }, + deleteLimit: async (providerId: string): Promise<ConcurrencyDeleteResult> => { + calls.deletes.push(providerId); + limits = limits.filter((l) => l.providerId !== providerId); + return { ok: true, providerId }; + }, + loadStatus: async (): Promise<ConcurrencyStatusResult> => { + calls.loadStatus++; + return { ok: true, providers: status }; + }, + saveCooldown: async ( + providerId: string, + cooldownMs: number, + ): Promise<ConcurrencyCooldownResult> => { + calls.cooldownSaves.push({ providerId, cooldownMs }); + // Reflect the new cooldown into the status list the next load returns. + status = status.map((s) => (s.providerId === providerId ? { ...s, cooldownMs } : s)); + return { ok: true, providerId, cooldownMs }; + }, + }; +} + +type MakeFakesCalls = { + loadLimits: number; + loadStatus: number; + saves: { providerId: string; limit: number }[]; + deletes: string[]; + cooldownSaves: { providerId: string; cooldownMs: number }[]; +}; + +function props(fakes: ReturnType<typeof makeFakes>) { + return { + models: MODELS as unknown as readonly string[], + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + saveCooldown: fakes.saveCooldown, + }; +} + +describe("ConcurrencyView", () => { + it("loads + renders the configured limits list on mount", async () => { + const fakes = makeFakes(); + render(ConcurrencyView, { props: props(fakes) }); + + // The limits summary + the row's remove control (unique to the limits list). + expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument(); + expect(await screen.findByLabelText("Remove concurrency limit for umans")).toBeVisible(); + expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(1); + expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1); + }); + + it("renders the per-provider cooldown input seeded from the live status", async () => { + const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] }); + render(ConcurrencyView, { props: props(fakes) }); + + // The saved row's cooldown input (in the same row as the limit) is seeded 350. + const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans"); + expect((cooldownInput as HTMLInputElement).value).toBe("350"); + }); + + it("renders a status line (in-flight count left + badge right) below the edit line", async () => { + // Default status: limit 4, inFlight 2, queued 1, not paused → Active, "2/4". + const fakes = makeFakes(); + render(ConcurrencyView, { props: props(fakes) }); + + expect(await screen.findByText("2/4 in flight")).toBeVisible(); + expect(await screen.findByText("Active")).toBeVisible(); + }); + + it("shows the At-capacity badge when in-flight is at the cap with a queue", async () => { + const fakes = makeFakes({ + status: [statusEntry({ inFlight: 4, limit: 4, queued: 3 })], + }); + render(ConcurrencyView, { props: props(fakes) }); + + expect(await screen.findByText("4/4 in flight")).toBeVisible(); + expect(await screen.findByText("At capacity")).toBeVisible(); + }); + + it("shows the Idle badge when no slots are in flight", async () => { + const fakes = makeFakes({ + status: [statusEntry({ inFlight: 0, limit: 4, queued: 0 })], + }); + render(ConcurrencyView, { props: props(fakes) }); + + expect(await screen.findByText("0/4 in flight")).toBeVisible(); + expect(await screen.findByText("Idle")).toBeVisible(); + }); + + it("surfaces NO loading indicator during refresh (background poll is silent — no flicker)", async () => { + // The 2s status poll + post-mutation reloads are SILENT: they never toggle a + // visible loading state, so the Refresh button is plain-text (no spinner). + const fakes = makeFakes(); + render(ConcurrencyView, { props: props(fakes) }); + + await screen.findByText(/1 limit configured/); + + const limitsRefresh = screen.getByLabelText("Refresh concurrency limits"); + expect(limitsRefresh).toHaveTextContent("Refresh"); + expect(limitsRefresh.querySelector(".loading-spinner")).toBeNull(); + expect(limitsRefresh).not.toBeDisabled(); + + // A manual refresh stays silent too (no spinner appears). + await fakes.loadStatus(); + expect(limitsRefresh.querySelector(".loading-spinner")).toBeNull(); + }); + + it("shows an empty state + Add button when no limits are configured", async () => { + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { props: props(fakes) }); + + expect(await screen.findByText(/No limits configured/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "+ Add" })).toBeVisible(); + // No provider dropdown until Add is clicked. + expect(screen.queryByLabelText("Provider")).toBeNull(); + }); + + it("reveals a new item row (dropdown + limit + cooldown + Set + ✕) when Add is clicked", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { props: props(fakes) }); + + await screen.findByText(/No limits configured/); + await user.click(screen.getByRole("button", { name: "+ Add" })); + + // The new-item row appears with a provider dropdown (auto-selected first), + // a limit input, a cooldown input (seeded with the default 350), Set + ✕. + const providerSelect = screen.getByLabelText("Provider"); + expect((providerSelect as HTMLSelectElement).value).not.toBe(""); + expect(screen.getByPlaceholderText("4")).toBeVisible(); + const cooldownInput = screen.getByLabelText("New release cooldown (ms)"); + expect((cooldownInput as HTMLInputElement).value).toBe("350"); + expect(screen.getByRole("button", { name: "Set" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Cancel add" })).toBeVisible(); + }); + + it("adds a provider limit via the new-item row Set (calls saveLimit + reloads)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { props: props(fakes) }); + + await screen.findByText(/No limits configured/); + await user.click(screen.getByRole("button", { name: "+ Add" })); + + const providerSelect = screen.getByLabelText("Provider"); + // Choose "anthropic" from the dropdown (the list is auto-selected first). + await user.selectOptions(providerSelect, "anthropic"); + await user.type(screen.getByPlaceholderText("4"), "8"); + await user.click(screen.getByRole("button", { name: "Set" })); + + expect(fakes.calls.saves).toEqual([{ providerId: "anthropic", limit: 8 }]); + // The cooldown was left at the default (350) → no extra cooldown PUT fired. + expect(fakes.calls.cooldownSaves).toHaveLength(0); + // After save the component reloads the limits list (now showing the row). + expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2); + expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument(); + expect(await screen.findByLabelText("Remove concurrency limit for anthropic")).toBeVisible(); + // The add row closed back to the Add button. + expect(screen.getByRole("button", { name: "+ Add" })).toBeVisible(); + }); + + it("sends a cooldown PUT when the new item's cooldown is moved off the default", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { props: props(fakes) }); + + await screen.findByText(/No limits configured/); + await user.click(screen.getByRole("button", { name: "+ Add" })); + + await user.selectOptions(screen.getByLabelText("Provider"), "anthropic"); + await user.type(screen.getByPlaceholderText("4"), "8"); + const cooldownInput = screen.getByLabelText("New release cooldown (ms)"); + await user.clear(cooldownInput); + await user.type(cooldownInput, "500"); + await user.click(screen.getByRole("button", { name: "Set" })); + + expect(fakes.calls.saves).toEqual([{ providerId: "anthropic", limit: 8 }]); + expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "anthropic", cooldownMs: 500 }]); + }); + + it("disables Set when the limit is empty/invalid (provider is auto-selected)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { props: props(fakes) }); + + await screen.findByText(/No limits configured/); + await user.click(screen.getByRole("button", { name: "+ Add" })); + + const providerSelect = screen.getByLabelText("Provider"); + // A provider is auto-selected from the dropdown. + expect((providerSelect as HTMLSelectElement).value).not.toBe(""); + const setBtn = screen.getByRole("button", { name: "Set" }); + expect(setBtn).toBeDisabled(); // no limit entered yet + + // An invalid (non-numeric) limit keeps Set disabled. + await user.type(screen.getByPlaceholderText("4"), "abc"); + expect(setBtn).toBeDisabled(); + + // A valid positive-integer limit enables Set. + const limitInput = screen.getByPlaceholderText("4"); + await user.clear(limitInput); + await user.type(limitInput, "5"); + expect(setBtn).toBeEnabled(); + }); + + it("shows no-providers + disables the dropdown when there are no models", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { + props: { ...props(fakes), models: [] as unknown as readonly string[] }, + }); + + await screen.findByText(/No limits configured/); + await user.click(screen.getByRole("button", { name: "+ Add" })); + + const providerSelect = screen.getByLabelText("Provider"); + expect(providerSelect).toBeDisabled(); + expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); + }); + + it("cancels the new-item row (✕) without saving", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { props: props(fakes) }); + + await screen.findByText(/No limits configured/); + await user.click(screen.getByRole("button", { name: "+ Add" })); + await user.type(screen.getByPlaceholderText("4"), "8"); + await user.click(screen.getByRole("button", { name: "Cancel add" })); + + // The row collapses back to the Add button; nothing was saved. + expect(screen.queryByLabelText("Provider")).toBeNull(); + expect(screen.getByRole("button", { name: "+ Add" })).toBeVisible(); + expect(fakes.calls.saves).toHaveLength(0); + }); + + it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes(); + render(ConcurrencyView, { props: props(fakes) }); + + // Wait for the limits to load (unique summary) before interacting. + await screen.findByText(/1 limit configured/); + await user.click(screen.getByLabelText("Remove concurrency limit for umans")); + + expect(fakes.calls.deletes).toEqual(["umans"]); + expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2); + }); + + it("surfaces a load error from the limits endpoint", async () => { + const failing = { + models: MODELS as unknown as readonly string[], + loadLimits: async (): Promise<ConcurrencyLimitsResult> => ({ + ok: false, + error: "Concurrency service not available", + }), + saveLimit: async (): Promise<ConcurrencyLimitResult> => ({ ok: false, error: "noop" }), + deleteLimit: async (): Promise<ConcurrencyDeleteResult> => ({ ok: false, error: "noop" }), + loadStatus: async (): Promise<ConcurrencyStatusResult> => ({ ok: true, providers: [] }), + saveCooldown: async (): Promise<ConcurrencyCooldownResult> => ({ ok: false, error: "noop" }), + }; + render(ConcurrencyView, { props: failing }); + expect(await screen.findByText("Concurrency service not available")).toBeVisible(); + }); + + // ── Concurrency-fixes: auto-reduce banner + cooldown editing ──────────────── + + it("renders an auto-reduce banner (with the backend notice + Restore) when a provider is auto-reduced", async () => { + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.", + }), + ], + }); + render(ConcurrencyView, { props: props(fakes) }); + + // The banner shows the backend notice verbatim + a "Restore to 4" action. + expect(await screen.findByText(/auto-reduced to 3 after a 429/)).toBeVisible(); + expect(await screen.findByRole("button", { name: /Restore to 4/ })).toBeVisible(); + // The "Was 4, now 3." provenance line is shown. + expect(await screen.findByText(/Was 4, now 3\./)).toBeVisible(); + }); + + it("clears the banner after Restore (next status poll shows autoReduced===false)", async () => { + const user = userEvent.setup(); + // Start auto-reduced (limit 3, was 4). The restore PUT clears `autoReduced` + // server-side; the next status poll returns limit 4 + autoReduced===false → + // the banner drops. + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + onSaveLimit: (_providerId, limit, self) => { + // Simulate the backend clearing `autoReduced` on the manual PUT: the next + // status load returns the restored limit with autoReduced===false. + self.setStatus([statusEntry({ limit, autoReduced: false })]); + }, + }); + render(ConcurrencyView, { props: props(fakes) }); + + const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ }); + await user.click(restoreBtn); + + // The restore PUT the limit back to the original (autoReducedFrom = 4). + expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]); + // The banner is gone (no Restore button, no notice text); the limits list + // now reflects the restored limit (the row's limit input re-seeds to 4). + const limitInput = await screen.findByLabelText("Concurrency limit for umans"); + expect((limitInput as HTMLInputElement).value).toBe("4"); + expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull(); + expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull(); + }); + + it("dismisses the auto-reduce banner locally while it stays auto-reduced", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + }); + render(ConcurrencyView, { props: props(fakes) }); + + await screen.findByRole("button", { name: /Restore to 4/ }); + // Dismiss the banner (hide locally — the provider is still auto-reduced). + await user.click(screen.getByLabelText("Dismiss auto-reduce notice for umans")); + expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull(); + expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull(); + }); + + it("shows an inline error in the banner when the Restore PUT fails (no silent re-enable)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + saveLimitError: "Concurrency service not available", + }); + render(ConcurrencyView, { props: props(fakes) }); + + const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ }); + await user.click(restoreBtn); + + // The error surfaces INLINE in the banner (near the restore action), not + // only in the far-away limits section. The banner is still present (restore + // did not succeed) and the button re-enabled for a retry. + expect(await screen.findByTestId("restore-error-umans")).toHaveTextContent( + "Concurrency service not available", + ); + expect(screen.getByRole("button", { name: /Restore to 4/ })).toBeVisible(); + expect(screen.getByRole("button", { name: /Restore to 4/ })).not.toBeDisabled(); + // The restore PUT was attempted. + expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]); + }); + + it("clears the inline restore error on a retry that succeeds", async () => { + const user = userEvent.setup(); + // First restore fails; the second succeeds (clears autoReduced). Reassigning + // `fakes.saveLimit` BEFORE `props(fakes)` is captured would NOT reach the + // rendered component, so swap it BEFORE render here. + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + onSaveLimit: (_providerId, limit, self) => { + self.setStatus([statusEntry({ limit, autoReduced: false })]); + }, + }); + let attempts = 0; + const succeeding = fakes.saveLimit; + fakes.saveLimit = async (providerId, limit) => { + attempts++; + if (attempts === 1) return { ok: false, error: "Concurrency service not available" }; + return succeeding(providerId, limit); + }; + render(ConcurrencyView, { props: props(fakes) }); + + const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ }); + await user.click(restoreBtn); + // First attempt: inline error appears. + expect(await screen.findByTestId("restore-error-umans")).toBeInTheDocument(); + + // Retry: the error clears, the banner drops (restore succeeded). + await user.click(screen.getByRole("button", { name: /Restore to 4/ })); + const limitInput = await screen.findByLabelText("Concurrency limit for umans"); + expect((limitInput as HTMLInputElement).value).toBe("4"); + expect(screen.queryByTestId("restore-error-umans")).toBeNull(); + expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull(); + }); + + it("edits the per-provider cooldown in the limit row (PUT /concurrency/cooldown + reloads)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] }); + render(ConcurrencyView, { props: props(fakes) }); + + // Wait for the saved row + its cooldown input (seeded with 350). + const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans"); + expect((cooldownInput as HTMLInputElement).value).toBe("350"); + + await user.clear(cooldownInput); + await user.type(cooldownInput, "500"); + await user.click(screen.getByRole("button", { name: "Set concurrency for umans" })); + + // The cooldown PUT fired with the new value. + expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "umans", cooldownMs: 500 }]); + // The limit was NOT re-saved (unchanged) — only the cooldown PUT fired. + expect(fakes.calls.saves).toHaveLength(0); + }); + + it("edits the per-provider limit in the row (PUT /concurrency/limits + reloads)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes(); + render(ConcurrencyView, { props: props(fakes) }); + + const limitInput = await screen.findByLabelText("Concurrency limit for umans"); + expect((limitInput as HTMLInputElement).value).toBe("4"); + + await user.clear(limitInput); + await user.type(limitInput, "8"); + await user.click(screen.getByRole("button", { name: "Set concurrency for umans" })); + + expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 8 }]); + // Cooldown unchanged → no cooldown PUT. + expect(fakes.calls.cooldownSaves).toHaveLength(0); + }); + + it("rejects a negative cooldown input (Set disabled — non-negative integer only)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] }); + render(ConcurrencyView, { props: props(fakes) }); + + const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans"); + // 0 is valid (no cooldown); a negative is not. + await user.clear(cooldownInput); + await user.type(cooldownInput, "0"); + expect(screen.getByRole("button", { name: "Set concurrency for umans" })).toBeEnabled(); + + await user.clear(cooldownInput); + await user.type(cooldownInput, "-5"); + expect(screen.getByRole("button", { name: "Set concurrency for umans" })).toBeDisabled(); + expect(fakes.calls.cooldownSaves).toHaveLength(0); + }); +}); diff --git a/src/features/conversation-cache/cache.test.ts b/src/features/conversation-cache/cache.test.ts index 89e81b8..ce1c60c 100644 --- a/src/features/conversation-cache/cache.test.ts +++ b/src/features/conversation-cache/cache.test.ts @@ -4,9 +4,9 @@ import { createConversationCache } from "./cache"; import type { ConversationCacheIndexEntry, ConversationChunkStore } from "./types"; const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => ({ - seq, - role, - chunk: { type: "text", text: `chunk-${seq}` }, + seq, + role, + chunk: { type: "text", text: `chunk-${seq}` }, }); /** @@ -14,184 +14,184 @@ const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => * An outermost edge: simulates the storage port without any real I/O. */ function createFakeStore(): ConversationChunkStore { - const store = new Map<string, StoredChunk[]>(); - - return { - async load(conversationId) { - return store.get(conversationId) ?? []; - }, - - async append(conversationId, chunks) { - const existing = store.get(conversationId) ?? []; - const existingSeqs = new Set(existing.map((c) => c.seq)); - const toAdd = chunks.filter((c) => !existingSeqs.has(c.seq)); - store.set( - conversationId, - [...existing, ...toAdd].sort((a, b) => a.seq - b.seq), - ); - }, - - async delete(conversationId) { - store.delete(conversationId); - }, - - async index() { - const entries: ConversationCacheIndexEntry[] = []; - for (const [id, chunks] of store) { - if (chunks.length === 0) continue; - let maxSeq = 0; - for (const c of chunks) { - if (c.seq > maxSeq) maxSeq = c.seq; - } - entries.push({ - conversationId: id, - chunkCount: chunks.length, - maxSeq, - }); - } - return entries; - }, - }; + const store = new Map<string, StoredChunk[]>(); + + return { + async load(conversationId) { + return store.get(conversationId) ?? []; + }, + + async append(conversationId, chunks) { + const existing = store.get(conversationId) ?? []; + const existingSeqs = new Set(existing.map((c) => c.seq)); + const toAdd = chunks.filter((c) => !existingSeqs.has(c.seq)); + store.set( + conversationId, + [...existing, ...toAdd].sort((a, b) => a.seq - b.seq), + ); + }, + + async delete(conversationId) { + store.delete(conversationId); + }, + + async index() { + const entries: ConversationCacheIndexEntry[] = []; + for (const [id, chunks] of store) { + if (chunks.length === 0) continue; + let maxSeq = 0; + for (const c of chunks) { + if (c.seq > maxSeq) maxSeq = c.seq; + } + entries.push({ + conversationId: id, + chunkCount: chunks.length, + maxSeq, + }); + } + return entries; + }, + }; } describe("cache.load", () => { - it("returns stored chunks", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - await store.append("conv-1", [chunk(1), chunk(2)]); - const result = await cache.load("conv-1"); - expect(result).toEqual([chunk(1), chunk(2)]); - }); - - it("returns empty array for absent conversation", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - const result = await cache.load("nonexistent"); - expect(result).toEqual([]); - }); + it("returns stored chunks", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("conv-1", [chunk(1), chunk(2)]); + const result = await cache.load("conv-1"); + expect(result).toEqual([chunk(1), chunk(2)]); + }); + + it("returns empty array for absent conversation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + const result = await cache.load("nonexistent"); + expect(result).toEqual([]); + }); }); describe("cache.commit", () => { - it("appends only new chunks", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - await store.append("conv-1", [chunk(1), chunk(2)]); - - const merged = await cache.commit("conv-1", [chunk(2), chunk(3)]); - expect(merged).toEqual([chunk(1), chunk(2), chunk(3)]); - - // Verify store has all chunks - const stored = await store.load("conv-1"); - expect(stored).toEqual([chunk(1), chunk(2), chunk(3)]); - }); - - it("returns full merged result", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - - const merged = await cache.commit("conv-1", [chunk(3), chunk(1)]); - expect(merged).toEqual([chunk(1), chunk(3)]); - }); - - it("is idempotent — re-committing same chunks is a no-op", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - - await cache.commit("conv-1", [chunk(1), chunk(2)]); - const merged = await cache.commit("conv-1", [chunk(1), chunk(2)]); - expect(merged).toEqual([chunk(1), chunk(2)]); - - const stored = await store.load("conv-1"); - expect(stored).toEqual([chunk(1), chunk(2)]); - }); + it("appends only new chunks", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("conv-1", [chunk(1), chunk(2)]); + + const merged = await cache.commit("conv-1", [chunk(2), chunk(3)]); + expect(merged).toEqual([chunk(1), chunk(2), chunk(3)]); + + // Verify store has all chunks + const stored = await store.load("conv-1"); + expect(stored).toEqual([chunk(1), chunk(2), chunk(3)]); + }); + + it("returns full merged result", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + + const merged = await cache.commit("conv-1", [chunk(3), chunk(1)]); + expect(merged).toEqual([chunk(1), chunk(3)]); + }); + + it("is idempotent — re-committing same chunks is a no-op", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + + await cache.commit("conv-1", [chunk(1), chunk(2)]); + const merged = await cache.commit("conv-1", [chunk(1), chunk(2)]); + expect(merged).toEqual([chunk(1), chunk(2)]); + + const stored = await store.load("conv-1"); + expect(stored).toEqual([chunk(1), chunk(2)]); + }); }); describe("cache.sinceSeq", () => { - it("returns max seq from cache", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - await store.append("conv-1", [chunk(1), chunk(5), chunk(3)]); - expect(await cache.sinceSeq("conv-1")).toBe(5); - }); - - it("returns 0 for empty conversation", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - expect(await cache.sinceSeq("conv-1")).toBe(0); - }); + it("returns max seq from cache", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("conv-1", [chunk(1), chunk(5), chunk(3)]); + expect(await cache.sinceSeq("conv-1")).toBe(5); + }); + + it("returns 0 for empty conversation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + expect(await cache.sinceSeq("conv-1")).toBe(0); + }); }); describe("cache.evictIfOverBudget", () => { - it("deletes selected conversations", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store, { maxChunks: 5 }); - - await store.append("a", [chunk(1), chunk(2)]); - await store.append("b", [chunk(1), chunk(2)]); - await store.append("c", [chunk(1)]); - - // Total = 5, max = 5, under budget - const evicted = await cache.evictIfOverBudget(null); - expect(evicted).toEqual([]); - - // Add more to go over budget - await store.append("d", [chunk(1), chunk(2), chunk(3)]); - // Total = 8, max = 5, need to evict 3+ chunks - - const evicted2 = await cache.evictIfOverBudget(null); - expect(evicted2.length).toBeGreaterThan(0); - - // Verify evicted conversations are deleted - for (const id of evicted2) { - expect(await store.load(id)).toEqual([]); - } - }); - - it("never evicts the active conversation", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store, { maxChunks: 3 }); - - await store.append("active", [chunk(1), chunk(2), chunk(3)]); - await store.append("other", [chunk(1), chunk(2)]); - - // Total = 5, max = 3, need to evict 2+ chunks - const evicted = await cache.evictIfOverBudget("active"); - expect(evicted).not.toContain("active"); - expect(evicted).toContain("other"); - }); - - it("returns empty when under budget", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store, { maxChunks: 100 }); - - await store.append("a", [chunk(1)]); - await store.append("b", [chunk(1)]); - - const evicted = await cache.evictIfOverBudget(null); - expect(evicted).toEqual([]); - }); + it("deletes selected conversations", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store, { maxChunks: 5 }); + + await store.append("a", [chunk(1), chunk(2)]); + await store.append("b", [chunk(1), chunk(2)]); + await store.append("c", [chunk(1)]); + + // Total = 5, max = 5, under budget + const evicted = await cache.evictIfOverBudget(null); + expect(evicted).toEqual([]); + + // Add more to go over budget + await store.append("d", [chunk(1), chunk(2), chunk(3)]); + // Total = 8, max = 5, need to evict 3+ chunks + + const evicted2 = await cache.evictIfOverBudget(null); + expect(evicted2.length).toBeGreaterThan(0); + + // Verify evicted conversations are deleted + for (const id of evicted2) { + expect(await store.load(id)).toEqual([]); + } + }); + + it("never evicts the active conversation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store, { maxChunks: 3 }); + + await store.append("active", [chunk(1), chunk(2), chunk(3)]); + await store.append("other", [chunk(1), chunk(2)]); + + // Total = 5, max = 3, need to evict 2+ chunks + const evicted = await cache.evictIfOverBudget("active"); + expect(evicted).not.toContain("active"); + expect(evicted).toContain("other"); + }); + + it("returns empty when under budget", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store, { maxChunks: 100 }); + + await store.append("a", [chunk(1)]); + await store.append("b", [chunk(1)]); + + const evicted = await cache.evictIfOverBudget(null); + expect(evicted).toEqual([]); + }); }); describe("cache.delete", () => { - it("removes the conversation from the store", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); + it("removes the conversation from the store", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); - await store.append("conv-1", [chunk(1), chunk(2)]); - await cache.delete("conv-1"); + await store.append("conv-1", [chunk(1), chunk(2)]); + await cache.delete("conv-1"); - const stored = await store.load("conv-1"); - expect(stored).toEqual([]); - }); + const stored = await store.load("conv-1"); + expect(stored).toEqual([]); + }); - it("then load returns []", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); + it("then load returns []", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); - await cache.commit("conv-1", [chunk(1), chunk(2), chunk(3)]); - await cache.delete("conv-1"); + await cache.commit("conv-1", [chunk(1), chunk(2), chunk(3)]); + await cache.delete("conv-1"); - const result = await cache.load("conv-1"); - expect(result).toEqual([]); - }); + const result = await cache.load("conv-1"); + expect(result).toEqual([]); + }); }); diff --git a/src/features/conversation-cache/cache.ts b/src/features/conversation-cache/cache.ts index 3d5743a..4953944 100644 --- a/src/features/conversation-cache/cache.ts +++ b/src/features/conversation-cache/cache.ts @@ -3,31 +3,31 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic"; import type { ConversationChunkStore } from "./types"; export interface ConversationCache { - /** Load all cached chunks for a conversation. */ - load(conversationId: string): Promise<readonly StoredChunk[]>; + /** Load all cached chunks for a conversation. */ + load(conversationId: string): Promise<readonly StoredChunk[]>; - /** - * Load + reconcile + append new chunks. - * Returns the merged cache (the new authoritative cache for this conversation). - */ - commit(conversationId: string, incoming: readonly StoredChunk[]): Promise<readonly StoredChunk[]>; + /** + * Load + reconcile + append new chunks. + * Returns the merged cache (the new authoritative cache for this conversation). + */ + commit(conversationId: string, incoming: readonly StoredChunk[]): Promise<readonly StoredChunk[]>; - /** Return the `?sinceSeq=` cursor for the next incremental sync. */ - sinceSeq(conversationId: string): Promise<number>; + /** Return the `?sinceSeq=` cursor for the next incremental sync. */ + sinceSeq(conversationId: string): Promise<number>; - /** - * Evict conversations over budget. - * Returns the evicted conversationIds. - */ - evictIfOverBudget(activeConversationId: string | null): Promise<readonly string[]>; + /** + * Evict conversations over budget. + * Returns the evicted conversationIds. + */ + evictIfOverBudget(activeConversationId: string | null): Promise<readonly string[]>; - /** Delete all cached data for a single conversation (local forget). */ - delete(conversationId: string): Promise<void>; + /** Delete all cached data for a single conversation (local forget). */ + delete(conversationId: string): Promise<void>; } export interface ConversationCacheOptions { - /** Maximum total chunks across all conversations before eviction triggers. */ - readonly maxChunks?: number; + /** Maximum total chunks across all conversations before eviction triggers. */ + readonly maxChunks?: number; } const DEFAULT_MAX_CHUNKS = 10_000; @@ -38,41 +38,41 @@ const DEFAULT_MAX_CHUNKS = 10_000; * The ONLY impurity is the injected `store`; all logic delegates to pure functions. */ export function createConversationCache( - store: ConversationChunkStore, - opts?: ConversationCacheOptions, + store: ConversationChunkStore, + opts?: ConversationCacheOptions, ): ConversationCache { - const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS; + const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS; - return { - async load(conversationId) { - return store.load(conversationId); - }, + return { + async load(conversationId) { + return store.load(conversationId); + }, - async commit(conversationId, incoming) { - const cached = await store.load(conversationId); - const { merged, toAppend } = reconcileCache(cached, incoming); - if (toAppend.length > 0) { - await store.append(conversationId, toAppend); - } - return merged; - }, + async commit(conversationId, incoming) { + const cached = await store.load(conversationId); + const { merged, toAppend } = reconcileCache(cached, incoming); + if (toAppend.length > 0) { + await store.append(conversationId, toAppend); + } + return merged; + }, - async sinceSeq(conversationId) { - const cached = await store.load(conversationId); - return nextSinceSeq(cached); - }, + async sinceSeq(conversationId) { + const cached = await store.load(conversationId); + return nextSinceSeq(cached); + }, - async evictIfOverBudget(activeConversationId) { - const idx = await store.index(); - const toEvict = selectEvictions(idx, { maxChunks, activeConversationId }); - for (const id of toEvict) { - await store.delete(id); - } - return toEvict; - }, + async evictIfOverBudget(activeConversationId) { + const idx = await store.index(); + const toEvict = selectEvictions(idx, { maxChunks, activeConversationId }); + for (const id of toEvict) { + await store.delete(id); + } + return toEvict; + }, - async delete(conversationId) { - await store.delete(conversationId); - }, - }; + async delete(conversationId) { + await store.delete(conversationId); + }, + }; } diff --git a/src/features/conversation-cache/index.ts b/src/features/conversation-cache/index.ts index 32e32d9..25e2af7 100644 --- a/src/features/conversation-cache/index.ts +++ b/src/features/conversation-cache/index.ts @@ -2,13 +2,13 @@ export type { ConversationCache, ConversationCacheOptions } from "./cache"; export { createConversationCache } from "./cache"; export { nextSinceSeq, reconcileCache, selectEvictions } from "./logic"; export type { - ConversationCacheIndexEntry, - ConversationChunkStore, - ReconcileResult, + ConversationCacheIndexEntry, + ConversationChunkStore, + ReconcileResult, } from "./types"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "conversation-cache", - description: "IndexedDB-backed chunk cache with reconciliation", + name: "conversation-cache", + description: "IndexedDB-backed chunk cache with reconciliation", } as const; diff --git a/src/features/conversation-cache/logic.test.ts b/src/features/conversation-cache/logic.test.ts index 858460a..880c32e 100644 --- a/src/features/conversation-cache/logic.test.ts +++ b/src/features/conversation-cache/logic.test.ts @@ -4,137 +4,137 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic"; import type { ConversationCacheIndexEntry } from "./types"; const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => ({ - seq, - role, - chunk: { type: "text", text: `chunk-${seq}` }, + seq, + role, + chunk: { type: "text", text: `chunk-${seq}` }, }); describe("reconcileCache", () => { - it("merges and dedupes by seq", () => { - const cached = [chunk(1), chunk(2)]; - const incoming = [chunk(2), chunk(3)]; - const result = reconcileCache(cached, incoming); - expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3)]); - }); - - it("toAppend excludes already-cached seqs", () => { - const cached = [chunk(1), chunk(2)]; - const incoming = [chunk(2), chunk(3)]; - const result = reconcileCache(cached, incoming); - expect(result.toAppend).toEqual([chunk(3)]); - }); - - it("tolerates out-of-order incoming", () => { - const cached = [chunk(1)]; - const incoming = [chunk(5), chunk(3), chunk(2)]; - const result = reconcileCache(cached, incoming); - expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3), chunk(5)]); - expect(result.toAppend).toEqual([chunk(5), chunk(3), chunk(2)]); - }); - - it("returns empty merged and toAppend when both inputs are empty", () => { - const result = reconcileCache([], []); - expect(result.merged).toEqual([]); - expect(result.toAppend).toEqual([]); - }); - - it("handles empty cached with incoming", () => { - const incoming = [chunk(3), chunk(1)]; - const result = reconcileCache([], incoming); - expect(result.merged).toEqual([chunk(1), chunk(3)]); - expect(result.toAppend).toEqual([chunk(3), chunk(1)]); - }); - - it("handles cached with empty incoming", () => { - const cached = [chunk(1), chunk(2)]; - const result = reconcileCache(cached, []); - expect(result.merged).toEqual([chunk(1), chunk(2)]); - expect(result.toAppend).toEqual([]); - }); - - it("is idempotent — re-reconciling same incoming produces same result", () => { - const cached = [chunk(1)]; - const incoming = [chunk(2), chunk(3)]; - const first = reconcileCache(cached, incoming); - const second = reconcileCache(first.merged, incoming); - expect(second.merged).toEqual(first.merged); - expect(second.toAppend).toEqual([]); - }); + it("merges and dedupes by seq", () => { + const cached = [chunk(1), chunk(2)]; + const incoming = [chunk(2), chunk(3)]; + const result = reconcileCache(cached, incoming); + expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3)]); + }); + + it("toAppend excludes already-cached seqs", () => { + const cached = [chunk(1), chunk(2)]; + const incoming = [chunk(2), chunk(3)]; + const result = reconcileCache(cached, incoming); + expect(result.toAppend).toEqual([chunk(3)]); + }); + + it("tolerates out-of-order incoming", () => { + const cached = [chunk(1)]; + const incoming = [chunk(5), chunk(3), chunk(2)]; + const result = reconcileCache(cached, incoming); + expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3), chunk(5)]); + expect(result.toAppend).toEqual([chunk(5), chunk(3), chunk(2)]); + }); + + it("returns empty merged and toAppend when both inputs are empty", () => { + const result = reconcileCache([], []); + expect(result.merged).toEqual([]); + expect(result.toAppend).toEqual([]); + }); + + it("handles empty cached with incoming", () => { + const incoming = [chunk(3), chunk(1)]; + const result = reconcileCache([], incoming); + expect(result.merged).toEqual([chunk(1), chunk(3)]); + expect(result.toAppend).toEqual([chunk(3), chunk(1)]); + }); + + it("handles cached with empty incoming", () => { + const cached = [chunk(1), chunk(2)]; + const result = reconcileCache(cached, []); + expect(result.merged).toEqual([chunk(1), chunk(2)]); + expect(result.toAppend).toEqual([]); + }); + + it("is idempotent — re-reconciling same incoming produces same result", () => { + const cached = [chunk(1)]; + const incoming = [chunk(2), chunk(3)]; + const first = reconcileCache(cached, incoming); + const second = reconcileCache(first.merged, incoming); + expect(second.merged).toEqual(first.merged); + expect(second.toAppend).toEqual([]); + }); }); describe("nextSinceSeq", () => { - it("returns max seq", () => { - const cached = [chunk(1), chunk(5), chunk(3)]; - expect(nextSinceSeq(cached)).toBe(5); - }); - - it("returns 0 when empty", () => { - expect(nextSinceSeq([])).toBe(0); - }); - - it("returns single seq for single chunk", () => { - expect(nextSinceSeq([chunk(42)])).toBe(42); - }); + it("returns max seq", () => { + const cached = [chunk(1), chunk(5), chunk(3)]; + expect(nextSinceSeq(cached)).toBe(5); + }); + + it("returns 0 when empty", () => { + expect(nextSinceSeq([])).toBe(0); + }); + + it("returns single seq for single chunk", () => { + expect(nextSinceSeq([chunk(42)])).toBe(42); + }); }); describe("selectEvictions", () => { - it("never evicts the active conversation", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "active", chunkCount: 100, maxSeq: 100, lastAccess: 1000 }, - { conversationId: "other", chunkCount: 50, maxSeq: 50, lastAccess: 1 }, - ]; - const result = selectEvictions(index, { maxChunks: 50, activeConversationId: "active" }); - expect(result).not.toContain("active"); - expect(result).toContain("other"); - }); - - it("evicts LRU until under budget", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, - { conversationId: "b", chunkCount: 30, maxSeq: 30, lastAccess: 50 }, - { conversationId: "c", chunkCount: 30, maxSeq: 30, lastAccess: 200 }, - { conversationId: "d", chunkCount: 30, maxSeq: 30, lastAccess: 10 }, - ]; - // Total = 120, max = 60, need to evict 60+ chunks - // LRU order: d(10), b(50), a(100), c(200) - const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); - expect(result).toEqual(["d", "b"]); - }); - - it("is a no-op under budget", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "a", chunkCount: 10, maxSeq: 10, lastAccess: 100 }, - { conversationId: "b", chunkCount: 10, maxSeq: 10, lastAccess: 50 }, - ]; - const result = selectEvictions(index, { maxChunks: 100, activeConversationId: null }); - expect(result).toEqual([]); - }); - - it("returns empty for empty index", () => { - const result = selectEvictions([], { maxChunks: 100, activeConversationId: null }); - expect(result).toEqual([]); - }); - - it("tie-breaks by smaller maxSeq when lastAccess is equal", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "a", chunkCount: 30, maxSeq: 100, lastAccess: 50 }, - { conversationId: "b", chunkCount: 30, maxSeq: 50, lastAccess: 50 }, - { conversationId: "c", chunkCount: 30, maxSeq: 200, lastAccess: 50 }, - ]; - // Total = 90, max = 60, need to evict 30+ chunks - // All have same lastAccess, tie-break by maxSeq: b(50), a(100), c(200) - const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); - expect(result).toEqual(["b"]); - }); - - it("handles missing lastAccess (treated as 0)", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, - { conversationId: "b", chunkCount: 30, maxSeq: 30 }, - ]; - // Total = 60, max = 30, need to evict 30+ chunks - // b has no lastAccess (0), a has 100 - const result = selectEvictions(index, { maxChunks: 30, activeConversationId: null }); - expect(result).toEqual(["b"]); - }); + it("never evicts the active conversation", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "active", chunkCount: 100, maxSeq: 100, lastAccess: 1000 }, + { conversationId: "other", chunkCount: 50, maxSeq: 50, lastAccess: 1 }, + ]; + const result = selectEvictions(index, { maxChunks: 50, activeConversationId: "active" }); + expect(result).not.toContain("active"); + expect(result).toContain("other"); + }); + + it("evicts LRU until under budget", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, + { conversationId: "b", chunkCount: 30, maxSeq: 30, lastAccess: 50 }, + { conversationId: "c", chunkCount: 30, maxSeq: 30, lastAccess: 200 }, + { conversationId: "d", chunkCount: 30, maxSeq: 30, lastAccess: 10 }, + ]; + // Total = 120, max = 60, need to evict 60+ chunks + // LRU order: d(10), b(50), a(100), c(200) + const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); + expect(result).toEqual(["d", "b"]); + }); + + it("is a no-op under budget", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 10, maxSeq: 10, lastAccess: 100 }, + { conversationId: "b", chunkCount: 10, maxSeq: 10, lastAccess: 50 }, + ]; + const result = selectEvictions(index, { maxChunks: 100, activeConversationId: null }); + expect(result).toEqual([]); + }); + + it("returns empty for empty index", () => { + const result = selectEvictions([], { maxChunks: 100, activeConversationId: null }); + expect(result).toEqual([]); + }); + + it("tie-breaks by smaller maxSeq when lastAccess is equal", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 30, maxSeq: 100, lastAccess: 50 }, + { conversationId: "b", chunkCount: 30, maxSeq: 50, lastAccess: 50 }, + { conversationId: "c", chunkCount: 30, maxSeq: 200, lastAccess: 50 }, + ]; + // Total = 90, max = 60, need to evict 30+ chunks + // All have same lastAccess, tie-break by maxSeq: b(50), a(100), c(200) + const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); + expect(result).toEqual(["b"]); + }); + + it("handles missing lastAccess (treated as 0)", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, + { conversationId: "b", chunkCount: 30, maxSeq: 30 }, + ]; + // Total = 60, max = 30, need to evict 30+ chunks + // b has no lastAccess (0), a has 100 + const result = selectEvictions(index, { maxChunks: 30, activeConversationId: null }); + expect(result).toEqual(["b"]); + }); }); diff --git a/src/features/conversation-cache/logic.ts b/src/features/conversation-cache/logic.ts index 4a4479e..3cb23e2 100644 --- a/src/features/conversation-cache/logic.ts +++ b/src/features/conversation-cache/logic.ts @@ -9,24 +9,24 @@ import type { ConversationCacheIndexEntry, ReconcileResult } from "./types"; * (exactly what to persist). Idempotent; tolerant of out-of-order/overlapping `incoming`. */ export function reconcileCache( - cached: readonly StoredChunk[], - incoming: readonly StoredChunk[], + cached: readonly StoredChunk[], + incoming: readonly StoredChunk[], ): ReconcileResult { - const seen = new Set<number>(); - for (const chunk of cached) { - seen.add(chunk.seq); - } + const seen = new Set<number>(); + for (const chunk of cached) { + seen.add(chunk.seq); + } - const toAppend: StoredChunk[] = []; - for (const chunk of incoming) { - if (!seen.has(chunk.seq)) { - toAppend.push(chunk); - seen.add(chunk.seq); - } - } + const toAppend: StoredChunk[] = []; + for (const chunk of incoming) { + if (!seen.has(chunk.seq)) { + toAppend.push(chunk); + seen.add(chunk.seq); + } + } - const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq); - return { merged, toAppend }; + const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq); + return { merged, toAppend }; } /** @@ -34,12 +34,12 @@ export function reconcileCache( * This is the `?sinceSeq=` cursor for the next incremental sync. */ export function nextSinceSeq(cached: readonly StoredChunk[]): number { - if (cached.length === 0) return 0; - let max = 0; - for (const chunk of cached) { - if (chunk.seq > max) max = chunk.seq; - } - return max; + if (cached.length === 0) return 0; + let max = 0; + for (const chunk of cached) { + if (chunk.seq > max) max = chunk.seq; + } + return max; } /** @@ -50,28 +50,28 @@ export function nextSinceSeq(cached: readonly StoredChunk[]): number { * Returns [] when under budget. */ export function selectEvictions( - index: readonly ConversationCacheIndexEntry[], - opts: { maxChunks: number; activeConversationId: string | null }, + index: readonly ConversationCacheIndexEntry[], + opts: { maxChunks: number; activeConversationId: string | null }, ): readonly string[] { - const totalChunks = index.reduce((sum, entry) => sum + entry.chunkCount, 0); - if (totalChunks <= opts.maxChunks) return []; + const totalChunks = index.reduce((sum, entry) => sum + entry.chunkCount, 0); + if (totalChunks <= opts.maxChunks) return []; - const candidates = index - .filter((entry) => entry.conversationId !== opts.activeConversationId) - .sort((a, b) => { - const aAccess = a.lastAccess ?? 0; - const bAccess = b.lastAccess ?? 0; - if (aAccess !== bAccess) return aAccess - bAccess; - return a.maxSeq - b.maxSeq; - }); + const candidates = index + .filter((entry) => entry.conversationId !== opts.activeConversationId) + .sort((a, b) => { + const aAccess = a.lastAccess ?? 0; + const bAccess = b.lastAccess ?? 0; + if (aAccess !== bAccess) return aAccess - bAccess; + return a.maxSeq - b.maxSeq; + }); - let remaining = totalChunks; - const evictions: string[] = []; - for (const entry of candidates) { - if (remaining <= opts.maxChunks) break; - evictions.push(entry.conversationId); - remaining -= entry.chunkCount; - } + let remaining = totalChunks; + const evictions: string[] = []; + for (const entry of candidates) { + if (remaining <= opts.maxChunks) break; + evictions.push(entry.conversationId); + remaining -= entry.chunkCount; + } - return evictions; + return evictions; } diff --git a/src/features/conversation-cache/types.ts b/src/features/conversation-cache/types.ts index 2a349cc..345ab71 100644 --- a/src/features/conversation-cache/types.ts +++ b/src/features/conversation-cache/types.ts @@ -2,10 +2,10 @@ import type { StoredChunk } from "@dispatch/wire"; /** Metadata entry for a cached conversation, used by eviction logic. */ export interface ConversationCacheIndexEntry { - readonly conversationId: string; - readonly chunkCount: number; - readonly maxSeq: number; - readonly lastAccess?: number; + readonly conversationId: string; + readonly chunkCount: number; + readonly maxSeq: number; + readonly lastAccess?: number; } /** @@ -17,26 +17,26 @@ export interface ConversationCacheIndexEntry { * All methods MUST be idempotent on `seq`: re-appending an existing seq is a no-op. */ export interface ConversationChunkStore { - /** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */ - load(conversationId: string): Promise<readonly StoredChunk[]>; + /** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */ + load(conversationId: string): Promise<readonly StoredChunk[]>; - /** - * Append committed chunks to a conversation's cache. - * MUST be idempotent on `seq`: re-appending an existing seq is a no-op. - */ - append(conversationId: string, chunks: readonly StoredChunk[]): Promise<void>; + /** + * Append committed chunks to a conversation's cache. + * MUST be idempotent on `seq`: re-appending an existing seq is a no-op. + */ + append(conversationId: string, chunks: readonly StoredChunk[]): Promise<void>; - /** Delete all cached data for a conversation. */ - delete(conversationId: string): Promise<void>; + /** Delete all cached data for a conversation. */ + delete(conversationId: string): Promise<void>; - /** Return metadata for all cached conversations (for eviction). */ - index(): Promise<readonly ConversationCacheIndexEntry[]>; + /** Return metadata for all cached conversations (for eviction). */ + index(): Promise<readonly ConversationCacheIndexEntry[]>; } /** Result of reconciling cached chunks with incoming authoritative chunks. */ export interface ReconcileResult { - /** The merged, deduplicated, seq-ordered chunk list. */ - readonly merged: readonly StoredChunk[]; - /** The subset of incoming chunks that need to be appended (not already cached). */ - readonly toAppend: readonly StoredChunk[]; + /** The merged, deduplicated, seq-ordered chunk list. */ + readonly merged: readonly StoredChunk[]; + /** The subset of incoming chunks that need to be appended (not already cached). */ + readonly toAppend: readonly StoredChunk[]; } diff --git a/src/features/workspace/index.ts b/src/features/cwd-lsp/index.ts index 9acf994..36ea1b2 100644 --- a/src/features/workspace/index.ts +++ b/src/features/cwd-lsp/index.ts @@ -1,14 +1,14 @@ export type { - CwdSaveResult, - LoadLspStatus, - LspStatusResult, - SaveCwd, + CwdSaveResult, + LoadLspStatus, + LspStatusResult, + SaveCwd, } from "./logic/view-model"; export { default as CwdField } from "./ui/CwdField.svelte"; export { default as LspStatusView } from "./ui/LspStatusView.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "workspace", - description: "Per-conversation working directory + language-server status", + name: "cwd-lsp", + description: "Per-conversation working directory + language-server status", } as const; diff --git a/src/features/cwd-lsp/logic/view-model.test.ts b/src/features/cwd-lsp/logic/view-model.test.ts new file mode 100644 index 0000000..7bc0aad --- /dev/null +++ b/src/features/cwd-lsp/logic/view-model.test.ts @@ -0,0 +1,101 @@ +import type { LspServerInfo } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + cwdChanged, + isSubmittableCwd, + normalizeCwd, + summarizeServers, + viewLspServer, + viewLspServers, +} from "./view-model"; + +const server = (over: Partial<LspServerInfo> = {}): LspServerInfo => ({ + id: "typescript", + name: "TypeScript", + root: "/home/me/project", + extensions: [".ts", ".tsx"], + state: "connected", + ...over, +}); + +describe("cwd helpers", () => { + it("normalizeCwd trims surrounding whitespace", () => { + expect(normalizeCwd(" /a/b ")).toBe("/a/b"); + expect(normalizeCwd("\t/x\n")).toBe("/x"); + }); + + it("isSubmittableCwd is false for empty / whitespace-only", () => { + expect(isSubmittableCwd("")).toBe(false); + expect(isSubmittableCwd(" ")).toBe(false); + expect(isSubmittableCwd("/a")).toBe(true); + }); + + it("cwdChanged: true only when a non-empty trimmed value differs from current", () => { + expect(cwdChanged("/a/b", null)).toBe(true); + expect(cwdChanged("/a/b", "/a/b")).toBe(false); + expect(cwdChanged(" /a/b ", "/a/b")).toBe(false); // trim-equal → no change + expect(cwdChanged("/a/c", "/a/b")).toBe(true); + expect(cwdChanged("", "/a/b")).toBe(false); // empty is not a change (can't clear) + expect(cwdChanged(" ", null)).toBe(false); + }); +}); + +describe("viewLspServer", () => { + it("connected → success badge, not busy, no error", () => { + const v = viewLspServer(server({ state: "connected" })); + expect(v.badge).toBe("success"); + expect(v.statusLabel).toBe("Connected"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + expect(v.extensionsLabel).toBe(".ts .tsx"); + }); + + it("starting / not-started → busy (spinner) with warning / neutral badge", () => { + const starting = viewLspServer(server({ state: "starting" })); + expect(starting.badge).toBe("warning"); + expect(starting.busy).toBe(true); + + const notStarted = viewLspServer(server({ state: "not-started" })); + expect(notStarted.badge).toBe("neutral"); + expect(notStarted.busy).toBe(true); + }); + + it("error → error badge + surfaces the reason (with a fallback)", () => { + const withReason = viewLspServer(server({ state: "error", error: "ENOENT" })); + expect(withReason.badge).toBe("error"); + expect(withReason.busy).toBe(false); + expect(withReason.error).toBe("ENOENT"); + + const noReason = viewLspServer(server({ state: "error" })); + expect(noReason.error).toBe("Failed to start"); + }); + + it("viewLspServers maps a list preserving order", () => { + const views = viewLspServers([server({ id: "a" }), server({ id: "b" })]); + expect(views.map((v) => v.id)).toEqual(["a", "b"]); + }); +}); + +describe("summarizeServers", () => { + it("empty list", () => { + expect(summarizeServers([])).toBe("No language servers"); + }); + + it("counts connected / starting / errors", () => { + expect(summarizeServers([server({ state: "connected" })])).toBe("1 connected"); + expect( + summarizeServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "error" }), + ]), + ).toBe("1 connected, 1 error"); + expect( + summarizeServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "starting" }), + server({ id: "c", state: "error" }), + server({ id: "d", state: "error" }), + ]), + ).toBe("1 connected, 1 starting, 2 errors"); + }); +}); diff --git a/src/features/workspace/logic/view-model.ts b/src/features/cwd-lsp/logic/view-model.ts index bc9b30b..7642418 100644 --- a/src/features/workspace/logic/view-model.ts +++ b/src/features/cwd-lsp/logic/view-model.ts @@ -1,9 +1,9 @@ import type { LspServerInfo, LspServerState } from "@dispatch/transport-contract"; /** - * Pure core for the workspace feature — zero DOM, zero effects, zero Svelte. + * Pure core for the cwd-lsp feature — zero DOM, zero effects, zero Svelte. * - * The workspace feature exposes a conversation's per-tab working directory (cwd) + * The cwd-lsp feature exposes a conversation's per-tab working directory (cwd) * and the live status of the language servers configured for that cwd. This * module holds the pure logic: cwd normalization/validation, the mapping of a * backend `LspServerState` to a display badge, and a one-line server summary. @@ -16,15 +16,15 @@ import type { LspServerInfo, LspServerState } from "@dispatch/transport-contract /** Outcome of `PUT /conversations/:id/cwd`; `null` when no real conversation is focused. */ export type CwdSaveResult = - | { readonly ok: true; readonly cwd: string | null } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly cwd: string | null } + | { readonly ok: false; readonly error: string }; export type SaveCwd = (cwd: string) => Promise<CwdSaveResult | null>; /** Outcome of `GET /conversations/:id/lsp`; `null` when no real conversation is focused. */ export type LspStatusResult = - | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly LspServerInfo[] } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly LspServerInfo[] } + | { readonly ok: false; readonly error: string }; export type LoadLspStatus = () => Promise<LspStatusResult | null>; @@ -32,12 +32,12 @@ export type LoadLspStatus = () => Promise<LspStatusResult | null>; /** Trim surrounding whitespace; the backend rejects an empty cwd. */ export function normalizeCwd(raw: string): string { - return raw.trim(); + return raw.trim(); } /** Whether a typed cwd is submittable (non-empty after trim). */ export function isSubmittableCwd(raw: string): boolean { - return normalizeCwd(raw).length > 0; + return normalizeCwd(raw).length > 0; } /** @@ -45,9 +45,9 @@ export function isSubmittableCwd(raw: string): boolean { * (unchanged, or empty) should be disabled. */ export function cwdChanged(typed: string, current: string | null): boolean { - const next = normalizeCwd(typed); - if (next.length === 0) return false; - return next !== (current ?? ""); + const next = normalizeCwd(typed); + if (next.length === 0) return false; + return next !== (current ?? ""); } // ── LSP server status → display view ────────────────────────────────────────── @@ -55,76 +55,76 @@ export function cwdChanged(typed: string, current: string | null): boolean { export type Badge = "success" | "warning" | "error" | "neutral"; export interface LspServerView { - readonly id: string; - readonly name: string; - readonly root: string; - /** Space-joined extension list, e.g. ".ts .tsx". */ - readonly extensionsLabel: string; - readonly state: LspServerState; - readonly statusLabel: string; - readonly badge: Badge; - /** True while the state is transient (show a spinner). */ - readonly busy: boolean; - /** The error reason when `state === "error"`, else null. */ - readonly error: string | null; + readonly id: string; + readonly name: string; + readonly root: string; + /** Space-joined extension list, e.g. ".ts .tsx". */ + readonly extensionsLabel: string; + readonly state: LspServerState; + readonly statusLabel: string; + readonly badge: Badge; + /** True while the state is transient (show a spinner). */ + readonly busy: boolean; + /** The error reason when `state === "error"`, else null. */ + readonly error: string | null; } /** Map a server's state to a display label + badge severity + busy flag. */ export function viewLspServer(server: LspServerInfo): LspServerView { - let statusLabel: string; - let badge: Badge; - let busy = false; - switch (server.state) { - case "connected": - statusLabel = "Connected"; - badge = "success"; - break; - case "starting": - statusLabel = "Starting…"; - badge = "warning"; - busy = true; - break; - case "not-started": - statusLabel = "Not started"; - badge = "neutral"; - busy = true; - break; - case "error": - statusLabel = "Error"; - badge = "error"; - break; - } - return { - id: server.id, - name: server.name, - root: server.root, - extensionsLabel: server.extensions.join(" "), - state: server.state, - statusLabel, - badge, - busy, - error: server.state === "error" ? (server.error ?? "Failed to start") : null, - }; + let statusLabel: string; + let badge: Badge; + let busy = false; + switch (server.state) { + case "connected": + statusLabel = "Connected"; + badge = "success"; + break; + case "starting": + statusLabel = "Starting…"; + badge = "warning"; + busy = true; + break; + case "not-started": + statusLabel = "Not started"; + badge = "neutral"; + busy = true; + break; + case "error": + statusLabel = "Error"; + badge = "error"; + break; + } + return { + id: server.id, + name: server.name, + root: server.root, + extensionsLabel: server.extensions.join(" "), + state: server.state, + statusLabel, + badge, + busy, + error: server.state === "error" ? (server.error ?? "Failed to start") : null, + }; } export function viewLspServers(servers: readonly LspServerInfo[]): readonly LspServerView[] { - return servers.map(viewLspServer); + return servers.map(viewLspServer); } /** A short one-line summary, e.g. "2 connected" / "1 connected, 1 error". */ export function summarizeServers(servers: readonly LspServerInfo[]): string { - if (servers.length === 0) return "No language servers"; - let connected = 0; - let errored = 0; - let pending = 0; - for (const s of servers) { - if (s.state === "connected") connected++; - else if (s.state === "error") errored++; - else pending++; - } - const parts: string[] = []; - if (connected > 0) parts.push(`${connected} connected`); - if (pending > 0) parts.push(`${pending} starting`); - if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`); - return parts.join(", "); + if (servers.length === 0) return "No language servers"; + let connected = 0; + let errored = 0; + let pending = 0; + for (const s of servers) { + if (s.state === "connected") connected++; + else if (s.state === "error") errored++; + else pending++; + } + const parts: string[] = []; + if (connected > 0) parts.push(`${connected} connected`); + if (pending > 0) parts.push(`${pending} starting`); + if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`); + return parts.join(", "); } diff --git a/src/features/cwd-lsp/ui/CwdField.svelte b/src/features/cwd-lsp/ui/CwdField.svelte new file mode 100644 index 0000000..c83f5ce --- /dev/null +++ b/src/features/cwd-lsp/ui/CwdField.svelte @@ -0,0 +1,96 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { cwdChanged, normalizeCwd, type SaveCwd } from "../logic/view-model"; + + let { + cwd, + canEdit, + save, + }: { + /** The active conversation's persisted cwd, or null when unset. */ + cwd: string | null; + /** Whether a real conversation is focused (a draft can't persist a cwd yet). */ + canEdit: boolean; + save: SaveCwd; + } = $props(); + + // Start empty; the $effect below seeds from the (async-loaded) cwd prop. (Reading + // the prop directly into initial $state would only capture its first value.) + let value = $state(""); + let lastSeed = $state(""); + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state(false); + + // Seed the input from the persisted cwd (it loads async). Only reseed while the + // field is untouched, so an in-flight load can't clobber what the user typed. + // Re-mounted per conversation, so there is no cross-tab bleed. + $effect(() => { + const incoming = cwd ?? ""; + untrack(() => { + if (value === lastSeed) value = incoming; + lastSeed = incoming; + }); + }); + + const dirty = $derived(cwdChanged(value, cwd)); + + async function handleSave() { + if (saving || !canEdit || !dirty) return; + saving = true; + error = null; + justSaved = false; + const result = await save(normalizeCwd(value)); + saving = false; + if (result === null) return; + if (result.ok) { + justSaved = true; + } else { + error = result.error; + } + } + + function onInput() { + justSaved = false; + error = null; + } +</script> + +<div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Working directory</span> + <div class="flex items-center gap-2"> + <input + type="text" + class="input input-bordered input-sm w-full font-mono text-xs" + placeholder={canEdit ? "/abs/path/to/project" : "Open a conversation first"} + bind:value + disabled={!canEdit || saving} + oninput={onInput} + onkeydown={(e) => { + if (e.key === "Enter") handleSave(); + }} + aria-label="Working directory" + /> + <button + type="button" + class="btn btn-primary btn-sm" + disabled={!canEdit || saving || !dirty} + onclick={handleSave} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + </div> + {#if !canEdit} + <p class="text-xs opacity-60">Start or open a conversation to set its working directory.</p> + {:else if error} + <p class="text-xs text-error">{error}</p> + {:else if justSaved && !dirty} + <p class="text-xs text-success">Saved.</p> + {:else} + <p class="text-xs opacity-50">Defaults each turn's cwd; drives the language servers below.</p> + {/if} +</div> diff --git a/src/features/cwd-lsp/ui/LspStatusView.svelte b/src/features/cwd-lsp/ui/LspStatusView.svelte new file mode 100644 index 0000000..c8c9ea8 --- /dev/null +++ b/src/features/cwd-lsp/ui/LspStatusView.svelte @@ -0,0 +1,129 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { + type Badge, + type LoadLspStatus, + type LspServerView, + summarizeServers, + viewLspServers, + } from "../logic/view-model"; + + let { + cwd, + canView, + load, + }: { + /** The active conversation's cwd — the trigger to (re)load when it changes. */ + cwd: string | null; + /** Whether a real conversation is focused. */ + canView: boolean; + load: LoadLspStatus; + } = $props(); + + const badgeClass: Record<Badge, string> = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + neutral: "badge-ghost", + }; + + let servers = $state<readonly LspServerView[]>([]); + let loading = $state(false); + let error = $state<string | null>(null); + let loadedCwd = $state<string | null>(null); + let hasLoaded = $state(false); + let summary = $state(""); + + async function refresh() { + if (!canView) return; + loading = true; + error = null; + const result = await load(); + loading = false; + if (result === null) return; + hasLoaded = true; + if (result.ok) { + servers = viewLspServers(result.servers); + summary = summarizeServers(result.servers); + loadedCwd = result.cwd; + } else { + error = result.error; + } + } + + // (Re)load on mount and whenever the conversation's cwd changes. The LSP GET + // lazily spawns servers, so we avoid a redundant fetch when `cwd` resolves to + // the value we already loaded for. + $effect(() => { + const target = cwd; + const can = canView; + untrack(() => { + if (!can) return; + if (!hasLoaded || target !== loadedCwd) void refresh(); + }); + }); +</script> + +<div class="flex flex-col gap-2"> + <div class="flex items-center justify-between gap-2"> + <span class="text-xs opacity-70"> + {#if loading} + Resolving… + {:else if hasLoaded && loadedCwd !== null} + {summary} + {:else} + Language servers + {/if} + </span> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={!canView || loading} + onclick={() => refresh()} + aria-label="Refresh language server status" + > + {#if loading} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Refresh + {/if} + </button> + </div> + + {#if !canView} + <p class="text-xs opacity-60">Open or start a conversation to see its language servers.</p> + {:else if error} + <p class="text-xs text-error">{error}</p> + {:else if hasLoaded && loadedCwd === null} + <p class="text-xs opacity-60"> + Set a working directory in the Model panel to enable language servers. + </p> + {:else if hasLoaded && servers.length === 0 && !loading} + <p class="text-xs opacity-60">No language servers configured for this directory.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each servers as server (server.id)} + <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <div class="flex items-center justify-between gap-2"> + <span class="font-medium">{server.name}</span> + <span class="badge badge-sm {badgeClass[server.badge]} gap-1"> + {#if server.busy} + <span class="loading loading-spinner loading-xs"></span> + {/if} + {server.statusLabel} + </span> + </div> + {#if server.extensionsLabel} + <span class="font-mono text-xs opacity-60">{server.extensionsLabel}</span> + {/if} + <span class="truncate font-mono text-xs opacity-50" title={server.root} + >{server.root}</span + > + {#if server.error} + <span class="font-mono text-xs text-error">{server.error}</span> + {/if} + </li> + {/each} + </ul> + {/if} +</div> diff --git a/src/features/heartbeat/index.ts b/src/features/heartbeat/index.ts new file mode 100644 index 0000000..cc438f1 --- /dev/null +++ b/src/features/heartbeat/index.ts @@ -0,0 +1,50 @@ +export type { + HeartbeatConfig, + HeartbeatConfigPatch, + HeartbeatConfigResult, + HeartbeatNextRunResult, + HeartbeatRun, + HeartbeatRunStatus, + HeartbeatRunsResult, + HeartbeatStopResult, + LoadHeartbeatConfig, + LoadHeartbeatNextRun, + LoadHeartbeatRuns, + SaveHeartbeatConfig, + StopHeartbeatRun, +} from "./logic/types"; +export type { Badge, HeartbeatFormState, HeartbeatRunView } from "./logic/view-model"; +export { + approximateNextRunEpoch, + badgeForStatus, + DEFAULT_INTERVAL_MINUTES, + effectiveSystemPrompt, + effortOptions, + emptyForm, + formatCountdown, + formatRunTime, + formDiffers, + formFromConfig, + isInheritingSystemPrompt, + joinInterval, + nextRunEpoch, + normalizeHeartbeatConfig, + normalizeHeartbeatRuns, + normalizeInterval, + patchFromForm, + persistedSystemPrompt, + relativeLabel, + splitInterval, + statusLabelFor, + viewRun, + viewRuns, +} from "./logic/view-model"; +export { default as HeartbeatView } from "./ui/HeartbeatView.svelte"; +export { default as PromptEditor } from "./ui/PromptEditor.svelte"; +export { default as RunModal } from "./ui/RunModal.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "heartbeat", + description: "Workspace autonomous-agent heartbeat: config, run history, live run chat", +} as const; diff --git a/src/features/heartbeat/logic/types.ts b/src/features/heartbeat/logic/types.ts new file mode 100644 index 0000000..83cec74 --- /dev/null +++ b/src/features/heartbeat/logic/types.ts @@ -0,0 +1,113 @@ +import type { ReasoningEffort } from "@dispatch/transport-contract"; + +/** + * Pure core types for the heartbeat feature — zero DOM, zero effects, zero Svelte. + * + * Heartbeat is a workspace-scoped autonomous agent loop: the backend periodically + * runs a turn in a dedicated conversation using a configured system prompt, task + * prompt, model, reasoning effort, and interval. The FE exposes the config + * (`GET`/`PUT /workspaces/:id/heartbeat`), the run history + * (`GET /workspaces/:id/heartbeat/runs`), and a per-run stop + * (`POST /workspaces/:id/heartbeat/runs/:runId/stop`). + * + * The backend's heartbeat API is a plain REST surface — it is NOT part of the + * shared `@dispatch/transport-contract` / `@dispatch/wire` packages (verified: + * no `heartbeat` symbol in either `dist/`). So, following the consumer-defines- + * port pattern (mirrors `features/mcp` / `features/computer` result types), the + * FE owns these shapes here and adapts the untyped JSON at the network seam in + * the composition root. If the backend later promotes these to a shared contract + * package, swap the local types for the imports (see `backend-handoff.md`). + */ + +/** The canonical run lifecycle status (backend-owned enum, verbatim). */ +export type HeartbeatRunStatus = "running" | "completed" | "stopped"; + +/** The workspace's heartbeat configuration (`GET /workspaces/:id/heartbeat`). */ +export interface HeartbeatConfig { + /** Whether the autonomous loop is enabled (running on the interval). */ + readonly enabled: boolean; + /** + * When true (the default), the heartbeat SKIPS a fire whenever the configured + * workspace has any active agents (a conversation whose persisted status is + * `"active"` or `"queued"`) — it stays quiet while the user is actively + * working and only fires when the workspace is idle. When false, the heartbeat + * fires unconditionally on every interval. The heartbeat-spawned conversation + * lives in a dedicated workspace, so an in-flight run never self-blocks. + */ + readonly inactiveOnly: boolean; + readonly systemPrompt: string; + readonly taskPrompt: string; + /** Minutes between runs. */ + readonly intervalMinutes: number; + /** The model name (`<credential>/<model>`) the heartbeat runs with. */ + readonly model: string; + /** + * The heartbeat's reasoning effort, or null when never set (the server + * default `"high"` then applies) — mirrors the per-conversation knob's + * resolution chain. + */ + readonly reasoningEffort: ReasoningEffort | null; +} + +/** + * A partial config patch for `PUT /workspaces/:id/heartbeat`. Every field is + * optional — the backend merges the patch onto the stored config. + */ +export interface HeartbeatConfigPatch { + readonly enabled?: boolean; + readonly inactiveOnly?: boolean; + readonly systemPrompt?: string; + readonly taskPrompt?: string; + readonly intervalMinutes?: number; + readonly model?: string; + readonly reasoningEffort?: ReasoningEffort | null; +} + +/** One heartbeat run (`GET /workspaces/:id/heartbeat/runs`). */ +export interface HeartbeatRun { + readonly id: string; + /** The conversation this run wrote to (watch it live for the chat). */ + readonly conversationId: string; + /** ISO timestamp of when the run was triggered. */ + readonly triggeredAt: string; + readonly status: HeartbeatRunStatus; +} + +// ── Injected ports (consumer-defines-port; the composition root adapts the +// store's HTTP calls to these shapes). ────────────────────────────────────── + +/** Outcome of `GET /workspaces/:id/heartbeat` (or the PUT response). */ +export type HeartbeatConfigResult = + | { readonly ok: true; readonly config: HeartbeatConfig } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `GET /workspaces/:id/heartbeat/runs`. */ +export type HeartbeatRunsResult = + | { readonly ok: true; readonly runs: readonly HeartbeatRun[] } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */ +export type HeartbeatStopResult = + | { readonly ok: true } + | { readonly ok: false; readonly error: string }; + +export type LoadHeartbeatConfig = () => Promise<HeartbeatConfigResult | null>; +export type SaveHeartbeatConfig = ( + patch: HeartbeatConfigPatch, +) => Promise<HeartbeatConfigResult | null>; +export type LoadHeartbeatRuns = () => Promise<HeartbeatRunsResult | null>; +export type StopHeartbeatRun = (runId: string) => Promise<HeartbeatStopResult | null>; + +/** + * Outcome of `GET /workspaces/:id/heartbeat/next-run` — the server-authoritative + * timestamp of the next scheduled heartbeat run (ISO 8601 string), or `null` + * when the heartbeat is disabled or no run is scheduled. The FE computes a live + * countdown from this + a 1s clock (see `formatCountdown`). When the endpoint is + * unavailable (404 — backend hasn't shipped it yet), the FE falls back to an + * approximation from the runs + config (see `approximateNextRunEpoch`). + */ +export type HeartbeatNextRunResult = + | { readonly ok: true; readonly nextRunAt: string | null } + | { readonly ok: false; readonly error: string }; + +export type LoadHeartbeatNextRun = () => Promise<HeartbeatNextRunResult | null>; diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts new file mode 100644 index 0000000..c9ef118 --- /dev/null +++ b/src/features/heartbeat/logic/view-model.test.ts @@ -0,0 +1,527 @@ +import type { ReasoningEffort } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import type { HeartbeatConfig, HeartbeatRun } from "./types"; +import { + approximateNextRunEpoch, + badgeForStatus, + DEFAULT_INTERVAL_MINUTES, + effectiveSystemPrompt, + effortOptions, + emptyForm, + formatCountdown, + formatRunTime, + formDiffers, + formFromConfig, + isInheritingSystemPrompt, + joinInterval, + nextRunEpoch, + normalizeHeartbeatConfig, + normalizeHeartbeatRuns, + normalizeInterval, + patchFromForm, + persistedSystemPrompt, + relativeLabel, + splitInterval, + statusLabelFor, + viewRun, + viewRuns, +} from "./view-model"; + +const NOW = Date.UTC(2026, 5, 25, 14, 30, 5); // 2026-06-25T14:30:05Z +const ISO_AT = "2026-06-25T14:30:05Z"; // exactly NOW +const run = (over: Partial<HeartbeatRun> = {}): HeartbeatRun => ({ + id: "run-1", + conversationId: "conv-1", + triggeredAt: ISO_AT, + status: "completed", + ...over, +}); + +const config = (over: Partial<HeartbeatConfig> = {}): HeartbeatConfig => ({ + enabled: false, + inactiveOnly: true, + systemPrompt: "be helpful", + taskPrompt: "check status", + intervalMinutes: 15, + model: "openai/gpt-4o", + reasoningEffort: null, + ...over, +}); + +describe("badgeForStatus", () => { + it("running → warning + busy (spinner)", () => { + expect(badgeForStatus("running")).toEqual({ badge: "warning", busy: true }); + }); + it("completed → success, not busy", () => { + expect(badgeForStatus("completed")).toEqual({ badge: "success", busy: false }); + }); + it("stopped → neutral, not busy", () => { + expect(badgeForStatus("stopped")).toEqual({ badge: "neutral", busy: false }); + }); +}); + +describe("statusLabelFor", () => { + it("maps each status to a display label", () => { + expect(statusLabelFor("running")).toBe("Running"); + expect(statusLabelFor("completed")).toBe("Completed"); + expect(statusLabelFor("stopped")).toBe("Stopped"); + }); +}); + +describe("formatRunTime", () => { + it("formats an ISO timestamp as HH:MM:SS (UTC components)", () => { + // Uses local getHours/Minutes/Seconds; under UTC env (TZ=UTC) reads 14:30:05. + // We assert the SHAPE (3 colon-separated 2-digit groups) so it's TZ-stable. + expect(formatRunTime(ISO_AT)).toMatch(/^\d{2}:\d{2}:\d{2}$/); + expect(formatRunTime(ISO_AT).split(":")).toHaveLength(3); + }); + it("returns — for an unparseable timestamp", () => { + expect(formatRunTime("not-a-date")).toBe("—"); + expect(formatRunTime("")).toBe("—"); + }); +}); + +describe("relativeLabel", () => { + it("just now when within a minute", () => { + expect(relativeLabel(ISO_AT, NOW)).toBe("just now"); + expect(relativeLabel(ISO_AT, NOW + 30_000)).toBe("just now"); + }); + it("Nm ago under an hour", () => { + expect(relativeLabel(ISO_AT, NOW + 5 * 60_000)).toBe("5m ago"); + expect(relativeLabel(ISO_AT, NOW + 59 * 60_000)).toBe("59m ago"); + }); + it("Nh ago under a day", () => { + expect(relativeLabel(ISO_AT, NOW + 2 * 3_600_000)).toBe("2h ago"); + }); + it("absolute date+time past a day", () => { + const label = relativeLabel(ISO_AT, NOW + 26 * 3_600_000); + expect(label).toMatch(/^[A-Z][a-z]{2} \d+, \d{2}:\d{2}$/); + }); + it("future timestamp → just now (clock skew tolerance)", () => { + expect(relativeLabel(ISO_AT, NOW - 10_000)).toBe("just now"); + }); + it("returns — for an unparseable timestamp", () => { + expect(relativeLabel("nope", NOW)).toBe("—"); + }); +}); + +describe("viewRun / viewRuns", () => { + it("running run: warning badge + busy + labels", () => { + const v = viewRun(run({ status: "running" }), NOW); + expect(v.badge).toBe("warning"); + expect(v.busy).toBe(true); + expect(v.statusLabel).toBe("Running"); + expect(v.id).toBe("run-1"); + expect(v.conversationId).toBe("conv-1"); + expect(v.timeLabel).toMatch(/^\d{2}:\d{2}:\d{2}$/); + expect(v.relativeLabel).toBe("just now"); + }); + it("completed run: success badge, not busy", () => { + expect(viewRun(run({ status: "completed" }), NOW).badge).toBe("success"); + }); + it("stopped run: neutral badge, not busy", () => { + expect(viewRun(run({ status: "stopped" }), NOW).badge).toBe("neutral"); + }); + it("viewRuns preserves order", () => { + const views = viewRuns([run({ id: "a" }), run({ id: "b" })], NOW); + expect(views.map((v) => v.id)).toEqual(["a", "b"]); + }); +}); + +describe("config form", () => { + it("emptyForm has defaults (disabled, default interval split, default effort)", () => { + const f = emptyForm(); + expect(f.enabled).toBe(false); + // 30 min → 0h 30m + expect(f.intervalHours).toBe(0); + expect(f.intervalMinutes).toBe(30); + expect(f.reasoningEffort).toBe("high"); // DEFAULT_REASONING_EFFORT + expect(f.systemPrompt).toBe(""); + expect(f.model).toBe(""); + }); + + it("formFromConfig resolves null reasoningEffort to the default", () => { + const f = formFromConfig(config({ reasoningEffort: null })); + expect(f.reasoningEffort).toBe("high"); + }); + + it("formFromConfig passes through a set reasoningEffort", () => { + const f = formFromConfig(config({ reasoningEffort: "max" })); + expect(f.reasoningEffort).toBe("max"); + }); + + it("formFromConfig splits intervalMinutes into hours + minutes (0–59)", () => { + expect(formFromConfig(config({ intervalMinutes: 90 }))).toMatchObject({ + intervalHours: 1, + intervalMinutes: 30, + }); + expect(formFromConfig(config({ intervalMinutes: 60 }))).toMatchObject({ + intervalHours: 1, + intervalMinutes: 0, + }); + expect(formFromConfig(config({ intervalMinutes: 59 }))).toMatchObject({ + intervalHours: 0, + intervalMinutes: 59, + }); + expect(formFromConfig(config({ intervalMinutes: 1440 }))).toMatchObject({ + intervalHours: 24, + intervalMinutes: 0, + }); + }); + + it("formFromConfig coerces malformed fields safely", () => { + const f = formFromConfig( + config({ + enabled: "yes" as unknown as boolean, + intervalMinutes: -5, + model: 42 as unknown as string, + systemPrompt: undefined as unknown as string, + }), + ); + expect(f.enabled).toBe(false); // non-true → false + // -5 clamps to 1 → 0h 1m + expect(f.intervalHours).toBe(0); + expect(f.intervalMinutes).toBe(1); + expect(f.model).toBe(""); // non-string → "" + expect(f.systemPrompt).toBe(""); // undefined → "" + }); + + it("normalizeInterval clamps to 1–1440 and rounds", () => { + expect(normalizeInterval(0)).toBe(1); + expect(normalizeInterval(-10)).toBe(1); + expect(normalizeInterval(1.4)).toBe(1); + expect(normalizeInterval(15.6)).toBe(16); + expect(normalizeInterval(2000)).toBe(1440); + expect(normalizeInterval("30" as unknown as number)).toBe(30); // default on non-number + expect(normalizeInterval(undefined)).toBe(DEFAULT_INTERVAL_MINUTES); + }); + + it("splitInterval / joinInterval round-trip (and clamp)", () => { + expect(splitInterval(90)).toEqual({ hours: 1, minutes: 30 }); + expect(splitInterval(0)).toEqual({ hours: 0, minutes: 1 }); // 0 → clamps to 1 + expect(splitInterval(1440)).toEqual({ hours: 24, minutes: 0 }); + expect(splitInterval(2000)).toEqual({ hours: 24, minutes: 0 }); // clamped + // join recomputes + clamps + expect(joinInterval(1, 30)).toBe(90); + expect(joinInterval(0, 0)).toBe(1); // 0 → clamps to 1 + expect(joinInterval(25, 0)).toBe(1440); // 1500 → clamps to 1440 + expect(joinInterval(-1, 30)).toBe(30); // negatives floored to 0 + expect(joinInterval("x" as unknown as number, 15)).toBe(15); // non-finite → 0h + }); + + it("patchFromForm recombines hours+minutes into intervalMinutes + carries every field", () => { + const f = formFromConfig(config({ intervalMinutes: 2000 })); + // 2000 clamps to 1440 → 24h 0m in the form + expect(f.intervalHours).toBe(24); + expect(f.intervalMinutes).toBe(0); + const patch = patchFromForm(f); + expect(patch.intervalMinutes).toBe(1440); + expect(patch.enabled).toBe(false); + expect(patch.model).toBe("openai/gpt-4o"); + expect(patch.reasoningEffort).toBe("high"); + expect(patch.systemPrompt).toBe("be helpful"); + expect(patch.taskPrompt).toBe("check status"); + }); + + it("patchFromForm recombines an arbitrary hours/minutes edit", () => { + const f = formFromConfig(config({ intervalMinutes: 15 })); + f.intervalHours = 2; + f.intervalMinutes = 45; + expect(patchFromForm(f).intervalMinutes).toBe(165); + }); + + it("formDiffers is false for a form seeded from the config (no edits)", () => { + const c = config({ reasoningEffort: "medium" }); + const f = formFromConfig(c); + expect(formDiffers(f, c)).toBe(false); + }); + + it("formDiffers is true after an edit", () => { + const c = config(); + const f = formFromConfig(c); + f.systemPrompt = "changed"; + expect(formDiffers(f, c)).toBe(true); + }); + + it("formDiffers is true after an interval edit (hours or minutes)", () => { + const c = config({ intervalMinutes: 90 }); + const f = formFromConfig(c); + f.intervalMinutes = 45; // 1h45m vs 1h30m + expect(formDiffers(f, c)).toBe(true); + }); + + it("formDiffers treats null config effort as the default (matches the resolved form)", () => { + const c = config({ reasoningEffort: null }); + const f = formFromConfig(c); + expect(formDiffers(f, c)).toBe(false); // null resolves to "high" == form + }); + + it("emptyForm defaults inactiveOnly to true (on by default)", () => { + expect(emptyForm().inactiveOnly).toBe(true); + }); + + it("formFromConfig carries inactiveOnly through verbatim", () => { + expect(formFromConfig(config({ inactiveOnly: true })).inactiveOnly).toBe(true); + expect(formFromConfig(config({ inactiveOnly: false })).inactiveOnly).toBe(false); + }); + + it("formFromConfig coerces a missing/malformed inactiveOnly to the default (true)", () => { + // A legacy config (undefined) or a non-boolean is read as ON (true) — matches + // normalizeHeartbeatConfig's default and the backend's "on by default". + const f = formFromConfig(config({ inactiveOnly: undefined as unknown as boolean })); + expect(f.inactiveOnly).toBe(true); + }); + + it("patchFromForm carries inactiveOnly", () => { + expect(patchFromForm(formFromConfig(config({ inactiveOnly: false }))).inactiveOnly).toBe(false); + expect(patchFromForm(formFromConfig(config({ inactiveOnly: true }))).inactiveOnly).toBe(true); + }); + + it("formDiffers is true after toggling inactiveOnly", () => { + const c = config({ inactiveOnly: true }); + const f = formFromConfig(c); + f.inactiveOnly = false; + expect(formDiffers(f, c)).toBe(true); + }); + + it("formDiffers is false for a form seeded from the config (inactiveOnly unchanged)", () => { + const c = config({ inactiveOnly: false }); + const f = formFromConfig(c); + expect(formDiffers(f, c)).toBe(false); + }); +}); + +describe("system-prompt inheritance (override ⇄ global default)", () => { + const DEFAULT = "You are a helpful assistant."; + + it("effectiveSystemPrompt: override wins when non-empty, else the default", () => { + expect(effectiveSystemPrompt("custom", DEFAULT)).toBe("custom"); + expect(effectiveSystemPrompt("", DEFAULT)).toBe(DEFAULT); + }); + + it("isInheritingSystemPrompt: true iff the override is empty", () => { + expect(isInheritingSystemPrompt("")).toBe(true); + expect(isInheritingSystemPrompt("custom")).toBe(false); + }); + + it('persistedSystemPrompt: empty or matching-the-default → inherit ("")', () => { + // matching the default → inherit (never duplicate the default into the config) + expect(persistedSystemPrompt(DEFAULT, DEFAULT)).toBe(""); + // empty edit → inherit + expect(persistedSystemPrompt("", DEFAULT)).toBe(""); + }); + + it("persistedSystemPrompt: a distinct edit → the override verbatim", () => { + expect(persistedSystemPrompt("custom", DEFAULT)).toBe("custom"); + expect(persistedSystemPrompt(`${DEFAULT}\nmore`, DEFAULT)).toBe(`${DEFAULT}\nmore`); + }); + + it("round-trip: inherit → display default → reset (no edit) → persist inherit", () => { + // A heartbeat inheriting (override "") displays the default; with no edit, + // persisting yields inherit ("") — so the global default stays the source. + const override = ""; + const displayed = effectiveSystemPrompt(override, DEFAULT); + expect(displayed).toBe(DEFAULT); + expect(persistedSystemPrompt(displayed, DEFAULT)).toBe(""); + }); + + it("round-trip: override → reset to default → persist inherit (clears override)", () => { + // User had an override, clicks Reset (textarea ← default): persisting clears + // the override ("" → inherit) because the text now matches the default. + const afterReset = DEFAULT; + expect(persistedSystemPrompt(afterReset, DEFAULT)).toBe(""); + }); +}); + +describe("effortOptions re-export", () => { + it("exposes the canonical ladder with the default marked", () => { + const opts = effortOptions(); + const values = opts.map((o) => o.value) as readonly string[]; + expect(values).toEqual(["low", "medium", "high", "xhigh", "max"]); + const def = opts.find((o) => o.value === "high"); + expect(def?.label).toBe("high (default)"); + }); +}); + +describe("reasoningEffort type narrowing (sanity)", () => { + // Ensures the imported ladder stays the wire's canonical set — if the wire + // ladder changes, this test flags the drift alongside the chat feature. + it("the five canonical levels", () => { + const levels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; + expect(levels).toHaveLength(5); + }); +}); + +describe("normalizeHeartbeatConfig", () => { + it("passes through a well-formed config", () => { + const c = normalizeHeartbeatConfig({ + enabled: true, + inactiveOnly: false, + systemPrompt: "sys", + taskPrompt: "task", + intervalMinutes: 20, + model: "openai/gpt-4o", + reasoningEffort: "max", + }); + expect(c).toEqual({ + enabled: true, + inactiveOnly: false, + systemPrompt: "sys", + taskPrompt: "task", + intervalMinutes: 20, + model: "openai/gpt-4o", + reasoningEffort: "max", + }); + }); + it("coerces a malformed body safely (never throws, never undefined)", () => { + const c = normalizeHeartbeatConfig({ + enabled: "yes", + inactiveOnly: "yes", + intervalMinutes: -3, + reasoningEffort: "bogus", + }); + expect(c.enabled).toBe(false); + expect(c.inactiveOnly).toBe(true); // non-boolean → default ON + expect(c.intervalMinutes).toBe(1); + expect(c.reasoningEffort).toBeNull(); + expect(c.systemPrompt).toBe(""); + expect(c.taskPrompt).toBe(""); + expect(c.model).toBe(""); + }); + it("accepts a null reasoningEffort", () => { + expect(normalizeHeartbeatConfig({ reasoningEffort: null }).reasoningEffort).toBeNull(); + }); + it("handles null / non-object input", () => { + const c = normalizeHeartbeatConfig(null); + expect(c.enabled).toBe(false); + expect(c.inactiveOnly).toBe(true); // default ON for an absent config + expect(c.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES); + expect(c.model).toBe(""); + }); + it("clamps a huge interval", () => { + expect(normalizeHeartbeatConfig({ intervalMinutes: 99999 }).intervalMinutes).toBe(1440); + }); + it("inactiveOnly defaults to true when absent (legacy config → on by default)", () => { + // A config persisted by an older backend (no inactiveOnly field) reads back + // as true — the feature is ON by default for everyone. + expect(normalizeHeartbeatConfig({}).inactiveOnly).toBe(true); + expect(normalizeHeartbeatConfig({ inactiveOnly: undefined }).inactiveOnly).toBe(true); + }); + it("inactiveOnly passes through an explicit false (opt-out)", () => { + expect(normalizeHeartbeatConfig({ inactiveOnly: false }).inactiveOnly).toBe(false); + expect(normalizeHeartbeatConfig({ inactiveOnly: true }).inactiveOnly).toBe(true); + }); + it("inactiveOnly treats only an explicit boolean false as false (not 0, not null)", () => { + // The wire contract requires a JSON boolean; a non-boolean (0, null, "no") + // is treated as the default (true) rather than silently misbehaving. + expect(normalizeHeartbeatConfig({ inactiveOnly: 0 }).inactiveOnly).toBe(true); + expect(normalizeHeartbeatConfig({ inactiveOnly: null }).inactiveOnly).toBe(true); + expect(normalizeHeartbeatConfig({ inactiveOnly: "false" }).inactiveOnly).toBe(true); + }); +}); + +describe("normalizeHeartbeatRuns", () => { + it("maps a well-formed runs list", () => { + const runs = normalizeHeartbeatRuns({ + runs: [ + { id: "r1", conversationId: "c1", triggeredAt: "2026-06-25T10:00:00Z", status: "running" }, + { + id: "r2", + conversationId: "c2", + triggeredAt: "2026-06-25T09:00:00Z", + status: "completed", + }, + ], + }); + expect(runs).toHaveLength(2); + expect(runs[0]).toMatchObject({ id: "r1", status: "running" }); + expect(runs[1]).toMatchObject({ id: "r2", status: "completed" }); + }); + it("returns [] for malformed body", () => { + expect(normalizeHeartbeatRuns(null)).toEqual([]); + expect(normalizeHeartbeatRuns({})).toEqual([]); + expect(normalizeHeartbeatRuns({ runs: "nope" })).toEqual([]); + }); + it("drops runs missing id/conversationId and defaults unknown status", () => { + const runs = normalizeHeartbeatRuns({ + runs: [ + { id: "r1", conversationId: "c1", triggeredAt: "x", status: "garbage" }, + { id: "", conversationId: "c2", triggeredAt: "x", status: "completed" }, + { id: "r3", conversationId: "", triggeredAt: "x", status: "running" }, + { id: "r4", conversationId: "c4", triggeredAt: "x", status: "stopped" }, + ], + }); + expect(runs).toHaveLength(2); + expect(runs[0]?.status).toBe("completed"); // "garbage" → default + expect(runs[0]?.id).toBe("r1"); + expect(runs[1]?.id).toBe("r4"); + }); +}); + +describe("next-run countdown", () => { + const ISO_AT = "2026-06-25T14:05:00Z"; // 5 min past the hour + + describe("nextRunEpoch", () => { + it("parses an ISO timestamp to epoch-ms", () => { + expect(nextRunEpoch(ISO_AT)).toBe(Date.parse(ISO_AT)); + }); + it("returns null for unparseable / empty / non-string", () => { + expect(nextRunEpoch("not-a-date")).toBeNull(); + expect(nextRunEpoch("")).toBeNull(); + expect(nextRunEpoch(null)).toBeNull(); + expect(nextRunEpoch(undefined)).toBeNull(); + }); + }); + + describe("formatCountdown", () => { + it("null → —", () => { + expect(formatCountdown(null)).toBe("—"); + }); + it("≤ 0 → due", () => { + expect(formatCountdown(0)).toBe("due"); + expect(formatCountdown(-5000)).toBe("due"); + }); + it("seconds only (< 1m)", () => { + expect(formatCountdown(32_000)).toBe("32s"); + expect(formatCountdown(1_000)).toBe("1s"); + }); + it("minutes + seconds (1m–1h)", () => { + expect(formatCountdown(4 * 60_000 + 32_000)).toBe("4m 32s"); + expect(formatCountdown(59 * 60_000 + 5_000)).toBe("59m 05s"); + }); + it("hours + minutes (≥ 1h)", () => { + expect(formatCountdown(3_600_000 + 5 * 60_000)).toBe("1h 05m"); + expect(formatCountdown(2 * 3_600_000 + 30 * 60_000)).toBe("2h 30m"); + }); + }); + + describe("approximateNextRunEpoch", () => { + const runs = (times: string[]): HeartbeatRun[] => + times.map((t, i) => ({ + id: `r${i}`, + conversationId: "c", + triggeredAt: t, + status: "completed", + })); + + it("disabled → null", () => { + expect(approximateNextRunEpoch(runs([ISO_AT]), 15, false)).toBeNull(); + }); + it("no runs → null (no fabricated countdown)", () => { + expect(approximateNextRunEpoch([], 15, true)).toBeNull(); + }); + it("latest run + interval (minutes)", () => { + // latest is the max triggeredAt (runs need not be ordered) + const unordered = runs(["2026-06-25T13:00:00Z", "2026-06-25T13:50:00Z"]); + // 13:50 + 15 min = 14:05 + expect(approximateNextRunEpoch(unordered, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z")); + }); + it("ignores unparseable triggeredAt values", () => { + const mixed = runs(["not-a-date", "2026-06-25T13:50:00Z"]); + expect(approximateNextRunEpoch(mixed, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z")); + }); + it("all-unparseable → null", () => { + expect(approximateNextRunEpoch(runs(["nope", "also-nope"]), 15, true)).toBeNull(); + }); + }); +}); diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts new file mode 100644 index 0000000..4a5ba7f --- /dev/null +++ b/src/features/heartbeat/logic/view-model.ts @@ -0,0 +1,419 @@ +import type { ReasoningEffort } from "@dispatch/transport-contract"; +import { + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effortOptions, +} from "../../chat/reasoning-effort"; +import type { + HeartbeatConfig, + HeartbeatConfigPatch, + HeartbeatRun, + HeartbeatRunStatus, +} from "./types"; + +/** + * Pure view-models for the heartbeat feature — zero DOM, zero effects, zero + * Svelte. Maps backend `HeartbeatConfig`/`HeartbeatRun` to display shapes + * (badges, labels, formatted times) and holds the config-form helpers. + * + * The reasoning-effort ladder + resolution are SERVER-owned and shared with the + * per-conversation knob, so they are REUSED from `features/chat/reasoning-effort` + * (a sanctioned cross-feature import through its public exports) rather than + * redefined — no drift. + */ + +export type Badge = "success" | "warning" | "error" | "neutral"; + +/** A run shaped for display in the scrolling runs list. */ +export interface HeartbeatRunView { + readonly id: string; + readonly conversationId: string; + readonly status: HeartbeatRunStatus; + readonly statusLabel: string; + readonly badge: Badge; + /** True while the run is in flight (show a spinner). */ + readonly busy: boolean; + /** A short absolute clock label, e.g. "14:30:05". */ + readonly timeLabel: string; + /** A relative label, e.g. "5m ago" / "just now". */ + readonly relativeLabel: string; +} + +const RUNNING_LABEL = "Running"; +const COMPLETED_LABEL = "Completed"; +const STOPPED_LABEL = "Stopped"; + +/** + * Map a run's status to a display badge + busy flag. `running` → warning + + * spinner, `completed` → success, `stopped` → neutral. Mirrors the LSP/MCP + * status visual treatment. + */ +export function badgeForStatus(status: HeartbeatRunStatus): { badge: Badge; busy: boolean } { + switch (status) { + case "running": + return { badge: "warning", busy: true }; + case "completed": + return { badge: "success", busy: false }; + case "stopped": + return { badge: "neutral", busy: false }; + } +} + +export function statusLabelFor(status: HeartbeatRunStatus): string { + switch (status) { + case "running": + return RUNNING_LABEL; + case "completed": + return COMPLETED_LABEL; + case "stopped": + return STOPPED_LABEL; + } +} + +/** + * Format an ISO timestamp as a short absolute clock label (HH:MM:SS) in the + * viewer's locale. Returns "—" for an unparseable timestamp so the UI never + * crashes on a malformed backend value. Pure (no `now` needed — an absolute + * clock label doesn't depend on the current time). + */ +export function formatRunTime(triggeredAt: string): string { + const t = parseTime(triggeredAt); + if (t === null) return "—"; + return clockLabel(t); +} + +/** + * A coarse relative label — "just now" (<1m), "Nm ago", "Nh ago", else the + * absolute date+time (so an old run reads "Jun 24, 14:30"). Pure via `now`. + */ +export function relativeLabel(triggeredAt: string, now: number = Date.now()): string { + const t = parseTime(triggeredAt); + if (t === null) return "—"; + const deltaMs = now - t; + if (deltaMs < 0) return "just now"; + const mins = Math.floor(deltaMs / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return dateLabel(t); +} + +/** + * Build a display view for a run. `now` is injectable for tests (defaults to + * `Date.now()`); the composition-root component passes nothing in production. + */ +export function viewRun(run: HeartbeatRun, now: number = Date.now()): HeartbeatRunView { + const { badge, busy } = badgeForStatus(run.status); + return { + id: run.id, + conversationId: run.conversationId, + status: run.status, + statusLabel: statusLabelFor(run.status), + badge, + busy, + timeLabel: formatRunTime(run.triggeredAt), + relativeLabel: relativeLabel(run.triggeredAt, now), + }; +} + +export function viewRuns( + runs: readonly HeartbeatRun[], + now: number = Date.now(), +): readonly HeartbeatRunView[] { + return runs.map((r) => viewRun(r, now)); +} + +// ── Time formatting (pure: no `Date` mutation; injectable `now` for tests) ───── + +/** Parse an ISO timestamp to epoch ms, or null if unparseable. */ +function parseTime(iso: string): number | null { + if (typeof iso !== "string" || iso.length === 0) return null; + const t = Date.parse(iso); + return Number.isNaN(t) ? null : t; +} + +/** `HH:MM:SS` in the viewer's locale (24h where the locale uses it). */ +function clockLabel(epochMs: number): string { + const d = new Date(epochMs); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + const ss = String(d.getSeconds()).padStart(2, "0"); + return `${hh}:${mm}:${ss}`; +} + +/** A short absolute date+time label for an old run, e.g. "Jun 24, 14:30". */ +function dateLabel(epochMs: number): string { + const d = new Date(epochMs); + const month = d.toLocaleString(undefined, { month: "short" }); + const day = d.getDate(); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${month} ${day}, ${hh}:${mm}`; +} + +// ── Next-run countdown (timer of when the next heartbeat fires) ─────────────── +// +// The authoritative next-run time comes from the backend +// (`GET /workspaces/:id/heartbeat/next-run` → `nextRunAt` ISO string); the FE +// computes a live countdown from it + a 1s clock. When that endpoint is absent, +// the FE falls back to an approximation (`approximateNextRunEpoch`) from the +// latest run + the configured interval. + +/** Parse an ISO timestamp to epoch-ms, or null if unparseable. */ +export function nextRunEpoch(iso: string | null | undefined): number | null { + if (typeof iso !== "string" || iso.length === 0) return null; + const t = Date.parse(iso); + return Number.isNaN(t) ? null : t; +} + +/** + * Format a remaining-ms delta as a short countdown: "4m 32s", "32s", "1h 05m", + * "due" (≤ 0), or "—" (unknown/null). Pure via the injected `remainingMs`. + */ +export function formatCountdown(remainingMs: number | null): string { + if (remainingMs === null) return "—"; + if (remainingMs <= 0) return "due"; + const totalSec = Math.floor(remainingMs / 1000); + const hours = Math.floor(totalSec / 3600); + const mins = Math.floor((totalSec % 3600) / 60); + const secs = totalSec % 60; + if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m`; + if (mins > 0) return `${mins}m ${String(secs).padStart(2, "0")}s`; + return `${secs}s`; +} + +/** + * Approximate the next-run epoch-ms when the backend's `next-run` endpoint is + * unavailable: the LATEST run's `triggeredAt` + `intervalMinutes` (only when the + * heartbeat is enabled AND at least one run exists). Returns null otherwise (the + * FE then shows no countdown — never a fabricated one). The latest run is the + * max `triggeredAt` (runs need not be ordered). Pure (no `now` needed — the next + * run is latest + interval, independent of the current time). + */ +export function approximateNextRunEpoch( + runs: readonly HeartbeatRun[], + intervalMinutes: number, + enabled: boolean, +): number | null { + if (!enabled) return null; + let latest: number | null = null; + for (const r of runs) { + const t = Date.parse(r.triggeredAt); + if (!Number.isNaN(t) && (latest === null || t > latest)) latest = t; + } + if (latest === null) return null; + return latest + intervalMinutes * 60_000; +} + +// ── Config form ─────────────────────────────────────────────────────────────── + +/** + * The editable form state for the config panel — a mutable mirror of a loaded + * `HeartbeatConfig` that the inputs bind to. `reasoningEffort` is resolved to + * an effective level for the `<select>` (null ⇒ default `high`), exactly like + * the per-conversation selector. + * + * The interval is split into `intervalHours` + `intervalMinutes` (0–59) for the + * UI (two inputs), and recombined to a total-minutes value at the patch seam + * (`patchFromForm`); the backend stores a single `intervalMinutes`. + */ +export interface HeartbeatFormState { + enabled: boolean; + inactiveOnly: boolean; + systemPrompt: string; + taskPrompt: string; + intervalHours: number; + intervalMinutes: number; + model: string; + reasoningEffort: ReasoningEffort; +} + +/** The default interval (minutes) shown for an empty/unset config. */ +export const DEFAULT_INTERVAL_MINUTES = 30; + +/** Split a total-minutes value into { hours, minutes (0–59) }. Pure. */ +export function splitInterval(totalMinutes: number): { hours: number; minutes: number } { + const total = normalizeInterval(totalMinutes); + const hours = Math.floor(total / 60); + const minutes = total - hours * 60; + return { hours, minutes }; +} + +/** Recombine hours + minutes into a clamped total-minutes value. Pure. */ +export function joinInterval(hours: number, minutes: number): number { + const h = Number.isFinite(hours) ? Math.max(0, Math.floor(hours)) : 0; + const m = Number.isFinite(minutes) ? Math.max(0, Math.floor(minutes)) : 0; + return normalizeInterval(h * 60 + m); +} + +/** + * Seed the editable form state from a loaded config, applying safe defaults for + * any malformed/absent backend field so the inputs are never `undefined`. + */ +export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState { + const { hours, minutes } = splitInterval(config.intervalMinutes); + return { + enabled: config.enabled === true, + inactiveOnly: config.inactiveOnly !== false, + systemPrompt: config.systemPrompt ?? "", + taskPrompt: config.taskPrompt ?? "", + intervalHours: hours, + intervalMinutes: minutes, + model: typeof config.model === "string" ? config.model : "", + reasoningEffort: effectiveEffort(config.reasoningEffort ?? null), + }; +} + +/** An empty form (before the config loads). */ +export function emptyForm(): HeartbeatFormState { + const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES); + return { + enabled: false, + inactiveOnly: true, + systemPrompt: "", + taskPrompt: "", + intervalHours: hours, + intervalMinutes: minutes, + model: "", + reasoningEffort: DEFAULT_REASONING_EFFORT, + }; +} + +/** Clamp a raw interval to a sane positive-minute range (1–1440 = 1 min–24 h). */ +export function normalizeInterval(value: unknown): number { + const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_INTERVAL_MINUTES; + const int = Math.round(n); + if (int < 1) return 1; + if (int > 1440) return 1440; + return int; +} + +/** + * The patch to PUT when persisting the form. The split hours+minutes are + * recombined into a single `intervalMinutes` (clamped); text fields are sent + * verbatim. `reasoningEffort` is always present (a resolved level) since the + * heartbeat has no per-run override — it persists the level. + */ +export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch { + return { + enabled: form.enabled, + inactiveOnly: form.inactiveOnly, + systemPrompt: form.systemPrompt, + taskPrompt: form.taskPrompt, + intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes), + model: form.model, + reasoningEffort: form.reasoningEffort, + }; +} + +/** Whether the form differs from the loaded config (drives the Save button). */ +export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): boolean { + const { hours, minutes } = splitInterval(config.intervalMinutes); + return ( + form.enabled !== config.enabled || + form.inactiveOnly !== config.inactiveOnly || + form.systemPrompt !== (config.systemPrompt ?? "") || + form.taskPrompt !== (config.taskPrompt ?? "") || + form.intervalHours !== hours || + form.intervalMinutes !== minutes || + form.model !== (typeof config.model === "string" ? config.model : "") || + form.reasoningEffort !== effectiveEffort(config.reasoningEffort ?? null) + ); +} + +// ── System-prompt inheritance (heartbeat override ⇄ global default) ──────────── +// +// The heartbeat's `systemPrompt` is an OVERRIDE of the global system prompt (the +// one every workspace conversation uses — there is no per-workspace system +// prompt; `GET /system-prompt` is global). An EMPTY override means "inherit the +// global default" (server-owned resolution: the backend resolves empty → global +// at run time; see CR-HB-2). These pure helpers keep the override/inherit +// semantics in ONE place so the editor + form agree. + +/** + * The prompt to DISPLAY: the heartbeat's override if it set one, else the global + * default. The editor pre-fills the textarea with this so the user can see (and + * tweak) what will run — but a pre-filled default is NOT an explicit edit. + */ +export function effectiveSystemPrompt(override: string, defaultPrompt: string): string { + return override !== "" ? override : defaultPrompt; +} + +/** Whether the heartbeat is inheriting the global default (empty override). */ +export function isInheritingSystemPrompt(override: string): boolean { + return override === ""; +} + +/** + * The `systemPrompt` value to PERSIST for the given editable text: if the user's + * text matches the global default (or is empty), persist `""` to INHERIT (so a + * later change to the global default still flows through); otherwise persist the + * text verbatim as an override. This keeps "matching the default = inheriting it" + * — never duplicating the default into the heartbeat config. + */ +export function persistedSystemPrompt(editable: string, defaultPrompt: string): string { + if (editable === "" || editable === defaultPrompt) return ""; + return editable; +} + +// The reasoning-effort `<option>`s are reused verbatim from the per-conversation +// selector (re-exported so the config panel imports a single source). +export { effortOptions }; + +// ── Network-seam normalization (pure; called by the composition root) ──────── +// +// The heartbeat API is untyped JSON (not a transport-contract type), so the +// store coerces each response defensively HERE (pure + tested) — a malformed/ +// partial backend value can never crash the renderer. Mirrors the inline +// `Array.isArray(data.servers) ? … : []` guard the store does for LSP/MCP. + +/** Narrow an untrusted string to the run-status enum, defaulting to "completed". */ +function asRunStatus(value: unknown): HeartbeatRunStatus { + if (value === "running" || value === "completed" || value === "stopped") return value; + return "completed"; +} + +/** Coerce an untrusted `GET .../heartbeat/runs` body into a typed run list. */ +export function normalizeHeartbeatRuns(data: unknown): readonly HeartbeatRun[] { + if (!isRecord(data) || !Array.isArray(data.runs)) return []; + const runs = data.runs as readonly unknown[]; + return runs + .filter((r): r is Record<string, unknown> => r !== null && typeof r === "object") + .map((r) => ({ + id: typeof r.id === "string" ? r.id : "", + conversationId: typeof r.conversationId === "string" ? r.conversationId : "", + triggeredAt: typeof r.triggeredAt === "string" ? r.triggeredAt : "", + status: asRunStatus(r.status), + })) + .filter((r) => r.id !== "" && r.conversationId !== ""); +} + +/** Coerce an untrusted `GET`/`PUT .../heartbeat` body into a typed config. */ +export function normalizeHeartbeatConfig(data: unknown): HeartbeatConfig { + const d = isRecord(data) ? data : {}; + const effort = d.reasoningEffort; + return { + enabled: d.enabled === true, + // Default ON (true): a missing/falsey-but-not-false field (a legacy config + // persisted before the field shipped) reads back as inactiveOnly: true — + // the feature is on by default for everyone. Only an explicit `false` opts out. + inactiveOnly: d.inactiveOnly !== false, + systemPrompt: typeof d.systemPrompt === "string" ? d.systemPrompt : "", + taskPrompt: typeof d.taskPrompt === "string" ? d.taskPrompt : "", + intervalMinutes: normalizeInterval(d.intervalMinutes), + model: typeof d.model === "string" ? d.model : "", + reasoningEffort: + effort === "low" || + effort === "medium" || + effort === "high" || + effort === "xhigh" || + effort === "max" + ? effort + : null, + }; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return value !== null && typeof value === "object"; +} diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte new file mode 100644 index 0000000..7f95c40 --- /dev/null +++ b/src/features/heartbeat/ui/HeartbeatView.svelte @@ -0,0 +1,582 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { ReasoningEffort } from "@dispatch/transport-contract"; + import { isReasoningEffort } from "../../chat/reasoning-effort"; + import { + approximateNextRunEpoch, + badgeForStatus, + type Badge, + emptyForm, + effortOptions, + formatCountdown, + formDiffers, + formFromConfig, + joinInterval, + nextRunEpoch, + patchFromForm, + viewRuns, + type HeartbeatFormState, + type HeartbeatRunView, + } from "../logic/view-model"; + import type { + HeartbeatRun, + LoadHeartbeatConfig, + LoadHeartbeatNextRun, + LoadHeartbeatRuns, + SaveHeartbeatConfig, + StopHeartbeatRun, + } from "../logic/types"; + import type { + LoadSystemPrompt, + LoadSystemPromptVariables, + } from "../../system-prompt"; + import PromptEditor from "./PromptEditor.svelte"; + + let { + models, + loadConfig, + saveConfig, + loadRuns, + stopRun, + loadVariables, + loadDefaultPrompt, + loadNextRun, + onOpenRun, + }: { + /** The available model names (for the config's model dropdown). */ + models: readonly string[]; + loadConfig: LoadHeartbeatConfig; + saveConfig: SaveHeartbeatConfig; + loadRuns: LoadHeartbeatRuns; + stopRun: StopHeartbeatRun; + /** Load the available system-prompt variables (palette in the prompt editor). */ + loadVariables: LoadSystemPromptVariables; + /** Load the global system prompt — the default the heartbeat inherits when + * its `systemPrompt` is empty (the workspace's regular prompt). */ + loadDefaultPrompt: LoadSystemPrompt; + /** Load the server-authoritative next-run timestamp (the countdown source). */ + loadNextRun: LoadHeartbeatNextRun; + /** Open a run's chat in the fullscreen modal (composition-root wires the live watch). */ + onOpenRun: (run: HeartbeatRunView) => void; + } = $props(); + + const badgeClass: Record<Badge, string> = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + neutral: "badge-ghost", + }; + + const effortOpts = effortOptions(); + + // ── Config form ────────────────────────────────────────────────────────── + let form = $state<HeartbeatFormState>(emptyForm()); + /** The last successfully loaded/saved config, to diff the form against. */ + let loadedConfig = $state<HeartbeatFormState>(emptyForm()); + let configLoading = $state(false); + let configError = $state<string | null>(null); + let saving = $state(false); + let saveError = $state<string | null>(null); + let justSaved = $state(false); + let hasConfig = $state(false); + let promptEditorOpen = $state(false); + + const hasChanges = $derived(formDiffers(form, loadedConfig) && hasConfig); + + async function refreshConfig(): Promise<void> { + configLoading = true; + configError = null; + const result = await loadConfig(); + configLoading = false; + if (result === null) return; + if (result.ok) { + hasConfig = true; + form = formFromConfig(result.config); + loadedConfig = formFromConfig(result.config); + saveError = null; + } else { + configError = result.error; + } + } + + async function handleSave(): Promise<void> { + if (saving || !hasChanges) return; + saving = true; + saveError = null; + justSaved = false; + const result = await saveConfig(patchFromForm(form)); + saving = false; + if (result === null) return; + if (result.ok) { + // Re-seed from the authoritative response so the form tracks the server. + form = formFromConfig(result.config); + loadedConfig = formFromConfig(result.config); + justSaved = true; + } else { + saveError = result.error; + } + } + + // The enable toggle is the primary action — persist it immediately (don't + // require a separate Save). Mirrors the codebase's save-on-change controls. + async function handleToggleEnabled(): Promise<void> { + if (saving) return; + const next = !form.enabled; + form = { ...form, enabled: next }; + saving = true; + saveError = null; + justSaved = false; + const result = await saveConfig({ enabled: next }); + saving = false; + if (result === null) return; + if (result.ok) { + form = formFromConfig(result.config); + loadedConfig = formFromConfig(result.config); + justSaved = true; + } else { + saveError = result.error; + // Revert the toggle to the last-known state. + form = { ...form, enabled: loadedConfig.enabled }; + } + } + + // The inactive-only checkbox is a save-on-change control (like the enable + // toggle): a partial PUT { inactiveOnly } — no need to round-trip the rest + // of the form. The heartbeat then skips a fire whenever the workspace has + // active agents (a conversation whose status is "active" or "queued"). + async function handleToggleInactiveOnly(): Promise<void> { + if (saving) return; + const next = !form.inactiveOnly; + form = { ...form, inactiveOnly: next }; + saving = true; + saveError = null; + justSaved = false; + const result = await saveConfig({ inactiveOnly: next }); + saving = false; + if (result === null) return; + if (result.ok) { + form = formFromConfig(result.config); + loadedConfig = formFromConfig(result.config); + justSaved = true; + } else { + saveError = result.error; + // Revert the checkbox to the last-known state. + form = { ...form, inactiveOnly: loadedConfig.inactiveOnly }; + } + } + + // ── Runs list (polls while mounted) ─────────────────────────────────────── + let runs = $state<readonly HeartbeatRunView[]>([]); + /** The raw backend runs (carry `triggeredAt`), kept for the next-run + * approximation fallback (the view drops `triggeredAt` for display labels). */ + let rawRuns = $state<readonly HeartbeatRun[]>([]); + /** True after the first successful load (gates the "No runs yet" empty state + * WITHOUT flashing it before the initial fetch resolves). The per-poll + * loading is intentionally INVISIBLE — it's near-instant and a visible + * loading indicator caused the sidebar to flicker every poll (height shift). */ + let hasLoadedRuns = $state(false); + let runsError = $state<string | null>(null); + let stoppingId = $state<string | null>(null); + let stopError = $state<string | null>(null); + let pollHandle: ReturnType<typeof setInterval> | null = null; + /** Re-entrancy guard for background polling (no UI — prevents overlapping fetches). */ + let refreshInFlight = false; + + // ── Next-run countdown ─────────────────────────────────────────────────── + /** Epoch-ms of the next scheduled run, or null (no countdown shown). Sourced + * from the backend's `next-run` endpoint; falls back to an approximation + * (latest run + interval) when the endpoint is unavailable (404 — pre-CR-HB-3). */ + let nextRunAt = $state<number | null>(null); + /** Once the next-run endpoint fails (404), stop polling it (avoid 404 spam) and + * rely on the approximation. Reset only on remount. */ + let nextRunEndpointFailed = $state(false); + + async function refreshNextRun(): Promise<void> { + if (nextRunEndpointFailed) return; + const result = await loadNextRun(); + if (result === null) return; + if (result.ok) { + nextRunAt = nextRunEpoch(result.nextRunAt); + } else { + // Endpoint absent / errored → stop polling it + use the approximation. + nextRunEndpointFailed = true; + } + } + + /** The fallback countdown source: latest run + interval (only when enabled + + * ≥1 run). Recomputed reactively from the loaded config + raw runs. */ + const approxNextRun = $derived( + approximateNextRunEpoch( + rawRuns, + joinInterval(loadedConfig.intervalHours, loadedConfig.intervalMinutes), + loadedConfig.enabled, + ), + ); + /** The effective next-run epoch: the server value if available, else the + * approximation. Drives the countdown. */ + const effectiveNextRun = $derived(nextRunEndpointFailed ? approxNextRun : nextRunAt); + + const RUN_POLL_MS = 4000; + + async function refreshRuns(): Promise<void> { + if (refreshInFlight) return; + refreshInFlight = true; + const result = await loadRuns(); + refreshInFlight = false; + if (result === null) return; + if (result.ok) { + rawRuns = result.runs; + runs = viewRuns(result.runs); + // Clear the error only on success so it stays visible (stable, no + // flicker) during an in-flight retry rather than vanishing mid-poll. + runsError = null; + hasLoadedRuns = true; + } else { + runsError = result.error; + } + } + + async function handleStop(runId: string): Promise<void> { + if (stoppingId !== null) return; + stoppingId = runId; + stopError = null; + const result = await stopRun(runId); + stoppingId = null; + if (result === null) return; + if (result.ok) { + await refreshRuns(); + } else { + stopError = result.error; + } + } + + // Load config + runs + next-run on mount, and poll them while the view is + // alive so a running run's completion/stopped transition + the next-run timer + // stay fresh without a manual refresh. + $effect(() => { + untrack(() => { + void refreshConfig(); + void refreshRuns(); + void refreshNextRun(); + }); + pollHandle = setInterval(() => { + void refreshRuns(); + void refreshNextRun(); + }, RUN_POLL_MS); + return () => { + if (pollHandle !== null) clearInterval(pollHandle); + pollHandle = null; + }; + }); + + // A relative label ("5m ago") drifts as time passes; re-derive runs every + // minute so the list stays fresh without a full re-fetch. + let tick = $state(0); + $effect(() => { + const h = setInterval(() => { + tick++; + }, 60000); + return () => clearInterval(h); + }); + const runsView = $derived.by(() => { + void tick; // depend on the ticker + return runs; + }); + + // The countdown clock: ticks every second so the "next run in Xm Ys" stays + // live. Pure countdown math is in `formatCountdown` (view-model); this only + // advances `now`. + let now = $state(Date.now()); + $effect(() => { + const h = setInterval(() => { + now = Date.now(); + }, 1000); + return () => clearInterval(h); + }); + const countdownMs = $derived( + effectiveNextRun !== null ? effectiveNextRun - now : null, + ); + const countdownLabel = $derived(formatCountdown(countdownMs)); +</script> + +<div class="flex flex-col gap-3"> + <!-- Enable / status header --> + <section class="flex flex-col gap-1"> + <div class="flex items-center justify-between gap-2"> + <div class="flex items-center gap-2"> + <button + type="button" + role="switch" + aria-checked={form.enabled} + aria-label="Toggle heartbeat" + class="toggle toggle-sm" + class:toggle-primary={form.enabled} + disabled={saving || configLoading} + onclick={handleToggleEnabled} + ></button> + <span class="text-xs font-semibold uppercase opacity-60"> + {#if configLoading} + Loading… + {:else if form.enabled} + Enabled + {:else} + Disabled + {/if} + </span> + </div> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={configLoading} + onclick={() => refreshConfig()} + aria-label="Refresh heartbeat config" + > + {#if configLoading} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Refresh + {/if} + </button> + </div> + {#if form.enabled && effectiveNextRun !== null} + <p class="text-xs opacity-60" title="When the next heartbeat run fires"> + Next run in {countdownLabel} + </p> + {/if} + </section> + + {#if configError} + <p class="text-xs text-error">{configError}</p> + {:else} + <!-- Inactive-only (skip fires while the workspace has active agents) --> + <section class="flex flex-col gap-1"> + <label class="flex items-start gap-2 text-sm"> + <input + type="checkbox" + class="checkbox checkbox-sm checkbox-primary mt-0.5" + checked={form.inactiveOnly} + disabled={saving || configLoading} + onchange={handleToggleInactiveOnly} + aria-label="Only run the heartbeat when the workspace is idle" + /> + <span class="flex flex-col gap-0.5"> + <span>Only run when idle</span> + <span class="text-xs opacity-50"> + Skip heartbeat fires while agents are active in this workspace. When off, the + heartbeat runs on every interval regardless of activity. + </span> + </span> + </label> + </section> + + <!-- Prompts (open the full-page editor) --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Prompts</span> + <button + type="button" + class="btn btn-sm btn-outline" + disabled={saving || configLoading} + onclick={() => (promptEditorOpen = true)} + > + Edit prompts + </button> + <p class="text-xs opacity-50"> + Open the editor for the system + task prompts (with a variable palette). + </p> + </section> + + <!-- Model + reasoning effort --> + <section class="flex flex-col gap-2"> + <div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Model</span> + <select + class="select select-sm w-full" + value={form.model} + disabled={saving || configLoading} + onchange={(e) => (form = { ...form, model: e.currentTarget.value })} + aria-label="Heartbeat model" + > + {#if models.length === 0} + <option value="">No models available</option> + {:else} + <option value="" disabled>Select a model</option> + {#each models as model (model)} + <option value={model}>{model}</option> + {/each} + {/if} + </select> + </div> + + <div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span> + <select + class="select select-sm w-full" + value={form.reasoningEffort} + disabled={saving || configLoading} + onchange={(e) => { + const v = e.currentTarget.value; + if (isReasoningEffort(v)) form = { ...form, reasoningEffort: v as ReasoningEffort }; + }} + aria-label="Heartbeat reasoning effort" + > + {#each effortOpts as option (option.value)} + <option value={option.value}>{option.label}</option> + {/each} + </select> + </div> + </section> + + <!-- Interval (hours + minutes) --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Interval</span> + <div class="flex items-center gap-2"> + <input + type="number" + class="input input-bordered input-sm w-20" + min="0" + max="24" + value={form.intervalHours} + disabled={saving || configLoading} + oninput={(e) => { + const n = Number.parseInt(e.currentTarget.value, 10); + form = { ...form, intervalHours: Number.isNaN(n) ? 0 : n }; + }} + onchange={(e) => { + const clamped = Math.max(0, Math.min(24, form.intervalHours)); + form = { ...form, intervalHours: clamped }; + e.currentTarget.value = String(clamped); + }} + aria-label="Heartbeat interval hours" + /> + <span class="text-xs opacity-60">h</span> + <input + type="number" + class="input input-bordered input-sm w-20" + min="0" + max="59" + value={form.intervalMinutes} + disabled={saving || configLoading} + oninput={(e) => { + const n = Number.parseInt(e.currentTarget.value, 10); + form = { ...form, intervalMinutes: Number.isNaN(n) ? 0 : n }; + }} + onchange={(e) => { + const clamped = Math.max(0, Math.min(59, form.intervalMinutes)); + form = { ...form, intervalMinutes: clamped }; + e.currentTarget.value = String(clamped); + }} + aria-label="Heartbeat interval minutes" + /> + <span class="text-xs opacity-60">m between runs</span> + </div> + </section> + + <!-- Save --> + <section class="flex flex-col gap-1"> + <button + type="button" + class="btn btn-sm btn-primary" + disabled={!hasChanges || saving || configLoading} + onclick={handleSave} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + Saving… + {:else} + Save config + {/if} + </button> + {#if saveError} + <p class="text-xs text-error">{saveError}</p> + {:else if justSaved} + <p class="text-xs text-success">Saved.</p> + {/if} + </section> + {/if} + + <!-- Runs list --> + <section class="flex flex-col gap-1"> + <div class="flex items-center justify-between gap-2"> + <span class="text-xs font-semibold uppercase opacity-60">Runs</span> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => refreshRuns()} + aria-label="Refresh heartbeat runs" + > + Refresh + </button> + </div> + + {#if runsError} + <p class="text-xs text-error">{runsError}</p> + {:else if runs.length > 0} + <ul class="flex max-h-72 flex-col gap-1 overflow-y-auto"> + {#each runsView as run (run.id)} + <li> + <button + type="button" + class="flex w-full items-center justify-between gap-2 rounded-box bg-base-200 p-2 text-left hover:bg-base-300" + onclick={() => onOpenRun(run)} + aria-label="Open heartbeat run {run.id} chat" + > + <span class="flex min-w-0 flex-col gap-0.5"> + <span class="truncate font-mono text-xs opacity-70">{run.id}</span> + <span class="text-xs opacity-60"> + {run.relativeLabel} · {run.timeLabel} + </span> + </span> + <span class="flex items-center gap-1"> + {#if run.busy} + <span class="loading loading-spinner loading-xs"></span> + {/if} + <span class="badge badge-sm {badgeClass[run.badge]}">{run.statusLabel}</span> + </span> + </button> + {#if run.busy} + <button + type="button" + class="btn btn-ghost btn-xs mt-0.5 text-xs" + disabled={stoppingId === run.id} + onclick={() => handleStop(run.id)} + > + {#if stoppingId === run.id} + <span class="loading loading-spinner loading-xs"></span> + Stopping… + {:else} + Stop + {/if} + </button> + {/if} + </li> + {/each} + </ul> + {#if stopError} + <p class="text-xs text-error">{stopError}</p> + {/if} + {:else if hasLoadedRuns} + <!-- Loaded with zero runs (not the pre-first-load gap). No loading + indicator — polling is near-instant and a visible one flickered. --> + <p class="text-xs opacity-60">No runs yet. Enable the heartbeat to start the loop.</p> + {/if} + </section> +</div> + +{#if promptEditorOpen} + <PromptEditor + systemPrompt={form.systemPrompt} + taskPrompt={form.taskPrompt} + {loadVariables} + {loadDefaultPrompt} + {saveConfig} + onSaved={(systemPrompt, taskPrompt) => { + // Sync the form + the diff baseline so the main Save button + formDiffers + // stay accurate (the editor persisted the prompts already). `systemPrompt` + // may be "" (inherit) — the form stores the raw override. + form = { ...form, systemPrompt, taskPrompt }; + loadedConfig = { ...loadedConfig, systemPrompt, taskPrompt }; + justSaved = true; + }} + onClose={() => (promptEditorOpen = false)} + /> +{/if} diff --git a/src/features/heartbeat/ui/HeartbeatView.test.ts b/src/features/heartbeat/ui/HeartbeatView.test.ts new file mode 100644 index 0000000..89eeee3 --- /dev/null +++ b/src/features/heartbeat/ui/HeartbeatView.test.ts @@ -0,0 +1,203 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { LoadSystemPrompt, LoadSystemPromptVariables } from "../../system-prompt"; +import type { + HeartbeatConfig, + HeartbeatConfigPatch, + HeartbeatConfigResult, + HeartbeatNextRunResult, + HeartbeatStopResult, + LoadHeartbeatConfig, + LoadHeartbeatNextRun, + LoadHeartbeatRuns, + SaveHeartbeatConfig, + StopHeartbeatRun, +} from "../logic/types"; +import HeartbeatView from "./HeartbeatView.svelte"; + +// ── Fakes for the injected ports ───────────────────────────────────────────── +// Only the OUTERMOST edges are faked (the save/load ports); no sibling module is +// mocked. Mirrors the PromptEditor test's fake-port pattern. + +function makeConfig(over: Partial<HeartbeatConfig> = {}): HeartbeatConfig { + return { + enabled: false, + inactiveOnly: true, + systemPrompt: "", + taskPrompt: "", + intervalMinutes: 30, + model: "", + reasoningEffort: null, + ...over, + }; +} + +/** A capturing saveConfig that echoes a merged config (so the form re-seeds). */ +function fakeSaveConfig(initial: HeartbeatConfig): { + calls: HeartbeatConfigPatch[]; + impl: SaveHeartbeatConfig; +} { + const calls: HeartbeatConfigPatch[] = []; + let current = initial; + const impl: SaveHeartbeatConfig = async (patch) => { + calls.push(patch); + // Echo the merged config so the component re-seeds from the server response. + current = { ...current, ...patch }; + return { ok: true, config: current } satisfies HeartbeatConfigResult; + }; + return { calls, impl }; +} + +function fakeLoadConfig(config: HeartbeatConfig): LoadHeartbeatConfig { + return vi.fn(async () => ({ ok: true, config }) as const); +} + +function fakeLoadRuns(): LoadHeartbeatRuns { + return vi.fn(async () => ({ ok: true, runs: [] }) as const); +} + +function fakeStopRun(): StopHeartbeatRun { + return vi.fn(async () => ({ ok: true }) as const satisfies HeartbeatStopResult); +} + +function fakeLoadNextRun(): LoadHeartbeatNextRun { + // No scheduled run (heartbeat disabled in the default config) → no countdown. + return vi.fn( + async () => ({ ok: true, nextRunAt: null }) as const satisfies HeartbeatNextRunResult, + ); +} + +function fakeLoadVariables(): LoadSystemPromptVariables { + return vi.fn(async () => ({ ok: true, variables: [] }) as const); +} + +function fakeLoadDefaultPrompt(): LoadSystemPrompt { + return vi.fn(async () => ({ ok: true, template: "" }) as const); +} + +const baseProps = (overrides: Record<string, unknown> = {}) => ({ + models: [] as readonly string[], + loadConfig: fakeLoadConfig(makeConfig()), + saveConfig: fakeSaveConfig(makeConfig()).impl, + loadVariables: fakeLoadVariables(), + loadDefaultPrompt: fakeLoadDefaultPrompt(), + loadRuns: fakeLoadRuns(), + stopRun: fakeStopRun(), + loadNextRun: fakeLoadNextRun(), + onOpenRun: vi.fn(), + ...overrides, +}); + +// HeartbeatView sets up polling intervals (runs + next-run + clock) on mount. +// Clear any stray timers between tests so a later test never hangs on a leaked +// interval (the $effect cleanup clears them on unmount; this is belt+suspenders). +afterEach(() => { + vi.clearAllTimers(); +}); + +describe("HeartbeatView — inactive-only checkbox", () => { + it("renders checked when the loaded config has inactiveOnly: true (the default)", async () => { + const loadConfig = fakeLoadConfig(makeConfig({ inactiveOnly: true })); + render(HeartbeatView, { + props: baseProps({ loadConfig }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).toBeChecked(); + }); + + it("renders unchecked when the loaded config has inactiveOnly: false", async () => { + const loadConfig = fakeLoadConfig(makeConfig({ inactiveOnly: false })); + render(HeartbeatView, { + props: baseProps({ loadConfig }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).not.toBeChecked(); + }); + + it("toggling the checkbox persists a PARTIAL patch { inactiveOnly } and re-seeds", async () => { + const user = userEvent.setup(); + const initial = makeConfig({ inactiveOnly: true }); + const save = fakeSaveConfig(initial); + const loadConfig = fakeLoadConfig(initial); + render(HeartbeatView, { + props: baseProps({ loadConfig, saveConfig: save.impl }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).toBeChecked(); + + await user.click(checkbox); + + // The save port was called with ONLY { inactiveOnly: false } — a partial + // update, not the whole config (mirrors the enable toggle's partial PUT). + await vi.waitFor(() => { + expect(save.calls).toHaveLength(1); + }); + expect(save.calls[0]).toEqual({ inactiveOnly: false }); + + // After the save resolves, the checkbox reflects the server response (unchecked). + await vi.waitFor(() => { + expect(checkbox).not.toBeChecked(); + }); + }); + + it("toggling back on sends { inactiveOnly: true }", async () => { + const user = userEvent.setup(); + const initial = makeConfig({ inactiveOnly: false }); + const save = fakeSaveConfig(initial); + const loadConfig = fakeLoadConfig(initial); + render(HeartbeatView, { + props: baseProps({ loadConfig, saveConfig: save.impl }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).not.toBeChecked(); + + await user.click(checkbox); + + await vi.waitFor(() => { + expect(save.calls).toHaveLength(1); + }); + expect(save.calls[0]).toEqual({ inactiveOnly: true }); + await vi.waitFor(() => { + expect(checkbox).toBeChecked(); + }); + }); + + it("a failed save reverts the checkbox to the last-known state", async () => { + const user = userEvent.setup(); + const initial = makeConfig({ inactiveOnly: true }); + const failingSave: SaveHeartbeatConfig = async () => ({ + ok: false, + error: "boom", + }); + const loadConfig = fakeLoadConfig(initial); + render(HeartbeatView, { + props: baseProps({ loadConfig, saveConfig: failingSave }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).toBeChecked(); + + await user.click(checkbox); + + // The failed save surfaces the error AND reverts the checkbox (stays checked). + await vi.waitFor(() => { + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + expect(checkbox).toBeChecked(); + }); +}); diff --git a/src/features/heartbeat/ui/PromptEditor.svelte b/src/features/heartbeat/ui/PromptEditor.svelte new file mode 100644 index 0000000..2320827 --- /dev/null +++ b/src/features/heartbeat/ui/PromptEditor.svelte @@ -0,0 +1,412 @@ +<script lang="ts"> + import type { SystemPromptVariable } from "@dispatch/transport-contract"; + import { tick, untrack } from "svelte"; + import { + buildTag, + groupVariables, + insertTag, + isDynamicVariable, + type LoadSystemPrompt, + type LoadSystemPromptVariables, + } from "../../system-prompt"; + import type { SaveHeartbeatConfig } from "../logic/types"; + import { + effectiveSystemPrompt, + isInheritingSystemPrompt, + persistedSystemPrompt, + } from "../logic/view-model"; + import { portal } from "../../../adapters/portal"; + + let { + systemPrompt, + taskPrompt, + loadVariables, + loadDefaultPrompt, + saveConfig, + onSaved, + onClose, + }: { + /** + * The heartbeat's persisted system prompt (raw override). Empty = inherit + * the global system prompt (the workspace's regular prompt). + */ + systemPrompt: string; + /** The current task prompt (seeded from the loaded config). */ + taskPrompt: string; + /** Load the available variables (`GET /system-prompt/variables`). */ + loadVariables: LoadSystemPromptVariables; + /** Load the GLOBAL system prompt (`GET /system-prompt`) — the default the + * heartbeat inherits when its `systemPrompt` is empty. */ + loadDefaultPrompt: LoadSystemPrompt; + /** Persist both prompts via a partial heartbeat config PUT. */ + saveConfig: SaveHeartbeatConfig; + /** Called after a successful save with the RAW persisted prompts (system + * may be "" = inherit), so the parent can sync its form. */ + onSaved: (systemPrompt: string, taskPrompt: string) => void; + onClose: () => void; + } = $props(); + + // The global default system prompt (loaded async on open). Empty until loaded + // (or when no global prompt is configured) — the editor degrades gracefully. + let defaultPrompt = $state(""); + + // The editable system text. Pre-filled with the EFFECTIVE prompt — the + // heartbeat's override, or the global default when inheriting (so the user + // can see + tweak what will run). A pre-filled default is NOT an explicit + // edit (see `hasChanges`). + let system = $state(untrack(() => systemPrompt)); + let task = $state(untrack(() => taskPrompt)); + + // The raw persisted override at open + after each save (the diff baseline for + // the system field). Empty = the heartbeat is inheriting the global default. + // REACTIVE so a successful save can update it to the newly-persisted value — + // otherwise `systemBaseline` stays pinned to the open-time value and + // `hasChanges` never clears (the "Save flickers and reverts" bug). + let loadedSystemRaw = $state(untrack(() => systemPrompt)); + let loadedTask = $state(untrack(() => taskPrompt)); + + let variables = $state<readonly SystemPromptVariable[]>([]); + let varsLoading = $state(false); + let varsError = $state<string | null>(null); + let defaultLoading = $state(false); + + let saving = $state(false); + let saveError = $state<string | null>(null); + let justSaved = $state(false); + + // The textarea currently focused — variable insertion targets THIS one. + type Field = "system" | "task"; + let activeField = $state<Field>("system"); + let systemEl = $state<HTMLTextAreaElement | null>(null); + let taskEl = $state<HTMLTextAreaElement | null>(null); + + const groups = $derived(groupVariables(variables)); + /** The baseline system text to diff against: the effective prompt at open + + * after the last save (override, or the default when inheriting) — so a + * pre-filled default does NOT register as an unsaved change, and a saved + * edit clears `hasChanges` (the baseline tracks the persisted value). */ + const systemBaseline = $derived(effectiveSystemPrompt(loadedSystemRaw, defaultPrompt)); + const hasChanges = $derived(system !== systemBaseline || task !== loadedTask); + /** Whether the current text matches the default (i.e. saving would inherit). */ + const inheriting = $derived(system === defaultPrompt && defaultPrompt !== ""); + + async function loadVars(): Promise<void> { + untrack(() => { + varsLoading = true; + varsError = null; + }); + const result = await loadVariables(); + varsLoading = false; + if (result.ok) { + variables = result.variables; + } else { + varsError = result.error; + } + } + + async function loadDefault(): Promise<void> { + untrack(() => { + defaultLoading = true; + }); + const result = await loadDefaultPrompt(); + defaultLoading = false; + if (result.ok) { + defaultPrompt = result.template; + // Pre-fill an inheriting (empty) override with the global default so the + // user can see + tweak what will run — but ONLY if they haven't edited + // the system field yet (system still equals the open-time raw override). + // Done here (not in a reactive $effect) so a late-loading default can't + // clobber an in-flight edit. + if (isInheritingSystemPrompt(loadedSystemRaw) && system === loadedSystemRaw) { + system = defaultPrompt; + } + } + // A failed default load is non-fatal: the editor still works with the + // raw override; only the "inherit" affordance is unavailable. + } + + async function save(): Promise<void> { + if (saving || !hasChanges) return; + saving = true; + saveError = null; + justSaved = false; + // Persist the system prompt via the inheritance helper: matching the + // default (or empty) → "" (inherit); otherwise the override verbatim. + const systemToPersist = persistedSystemPrompt(system, defaultPrompt); + const result = await saveConfig({ systemPrompt: systemToPersist, taskPrompt: task }); + saving = false; + if (result === null) return; + if (result.ok) { + // Advance the diff baseline to the persisted value so `hasChanges` + // clears (systemBaseline recomputes off loadedSystemRaw). Without this + // the baseline stays pinned to the open-time value and the Save button + // never settles ("flickers and reverts to unsaved"). + loadedSystemRaw = systemToPersist; + loadedTask = task; + justSaved = true; + onSaved(systemToPersist, task); + } else { + saveError = result.error; + } + } + + /** Revert ALL edits to the open-time state (system effective prompt + task). */ + function reset(): void { + system = systemBaseline; + task = loadedTask; + saveError = null; + justSaved = false; + } + + /** Reset ONLY the system prompt to the global default (clears any override → + * inherit on save). No-op until the default has loaded. */ + function resetSystemToDefault(): void { + if (defaultPrompt === "") return; + system = defaultPrompt; + saveError = null; + justSaved = false; + } + + /** + * Insert a variable tag into the ACTIVE textarea at its cursor. The active + * field is tracked via focus handlers; insertion uses that field's element + + * its own text (so a tag never lands in the wrong box). + */ + async function insertAtActive(tag: string): Promise<void> { + const el = activeField === "system" ? systemEl : taskEl; + if (el === null) return; + const start = el.selectionStart; + const end = el.selectionEnd; + if (activeField === "system") { + const ins = insertTag(system, tag, start, end); + system = ins.template; + await tick(); + el.focus(); + el.setSelectionRange(ins.cursor, ins.cursor); + } else { + const ins = insertTag(task, tag, start, end); + task = ins.template; + await tick(); + el.focus(); + el.setSelectionRange(ins.cursor, ins.cursor); + } + } + + /** Dynamic (file:<path>) variable: build the tag from the input + insert. */ + async function insertDynamic(type: string, path: string): Promise<void> { + const trimmed = path.trim(); + if (trimmed.length === 0) return; + await insertAtActive(buildTag(type, trimmed)); + } + + function onKeydown(e: KeyboardEvent): void { + if (e.key === "Escape") onClose(); + } + + // Load the variable palette + the global default once on open. + $effect(() => { + void loadVars(); + void loadDefault(); + }); +</script> + +<svelte:window onkeydown={onKeydown} /> + +<!-- Teleported to <body> (use:portal) so `position: fixed` resolves against the + VIEWPORT, not the sidebar's `transform: translateX(...)` container — an + ancestor transform establishes a containing block for `fixed`, which would + otherwise clip this overlay to the sidebar area. (RunModal/SystemPromptBuilder + avoid this by rendering at the composition root; this modal lives inside + HeartbeatView, so it must escape its ancestor.) --> +<!-- svelte-ignore a11y_no_static_element_interactions --> +<div + use:portal + class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" + role="dialog" + aria-modal="true" + aria-label="Heartbeat prompt editor" + tabindex="-1" + onclick={onClose} + onkeydown={onKeydown} +> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div + class="flex h-[85vh] w-full max-w-6xl flex-col overflow-hidden rounded-box bg-base-100 shadow-2xl" + onclick={(e) => e.stopPropagation()} + > + <!-- Header --> + <div class="flex shrink-0 items-center justify-between border-b border-base-300 px-4 py-3"> + <div class="flex items-center gap-2"> + <h2 class="text-sm font-semibold">Heartbeat Prompts</h2> + {#if varsLoading} + <span class="loading loading-spinner loading-xs"></span> + {/if} + </div> + <button + type="button" + class="btn btn-ghost btn-sm btn-square" + onclick={onClose} + aria-label="Close prompt editor" + > + ✕ + </button> + </div> + + <!-- Body: half editor (two boxes) / half variables --> + <div class="flex min-h-0 flex-1"> + <!-- Left: two text editors (system top, task bottom) --> + <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4"> + <div class="flex min-h-0 flex-1 flex-col gap-1"> + <div class="flex shrink-0 items-center justify-between gap-2"> + <div class="flex items-center gap-2"> + <span class="text-xs font-semibold uppercase opacity-60">System prompt</span> + {#if defaultLoading} + <span class="loading loading-spinner loading-xs"></span> + {:else if inheriting} + <span class="badge badge-ghost badge-sm font-normal">Inheriting workspace default</span> + {/if} + </div> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={defaultPrompt === "" || saving} + onclick={resetSystemToDefault} + title="Reset the system prompt to the workspace default (inherit)" + > + Reset to default + </button> + </div> + <textarea + bind:this={systemEl} + bind:value={system} + onfocus={() => (activeField = "system")} + class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs" + placeholder={defaultPrompt || "You are an autonomous agent…"} + disabled={saving} + aria-label="Heartbeat system prompt" + ></textarea> + <p class="shrink-0 text-xs opacity-50"> + {#if inheriting} + Matches the workspace default — saving will inherit it (no override). + {:else if defaultPrompt !== ""} + Editing overrides the workspace default. + {:else} + Empty — no system prompt set. + {/if} + </p> + </div> + + <div class="flex min-h-0 flex-1 flex-col gap-1"> + <span class="shrink-0 text-xs font-semibold uppercase opacity-60">Task prompt</span> + <textarea + bind:this={taskEl} + bind:value={task} + onfocus={() => (activeField = "task")} + class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs" + placeholder="Check the system status and report…" + disabled={saving} + aria-label="Heartbeat task prompt" + ></textarea> + </div> + + <div class="flex shrink-0 flex-wrap items-center gap-2"> + <button + type="button" + class="btn btn-primary btn-sm" + disabled={saving || !hasChanges} + onclick={save} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Save + {/if} + </button> + <button + type="button" + class="btn btn-ghost btn-sm" + disabled={!hasChanges} + onclick={reset} + > + Reset + </button> + {#if justSaved && !hasChanges} + <span class="text-xs text-success">Saved.</span> + {:else if hasChanges} + <span class="text-xs opacity-60">Unsaved changes</span> + {/if} + </div> + + {#if saveError} + <p class="shrink-0 text-xs text-error">{saveError}</p> + {/if} + </div> + + <!-- Right: variable palette --> + <div class="flex w-1/2 min-w-0 flex-col overflow-y-auto p-4"> + <h3 class="mb-2 shrink-0 text-xs font-semibold uppercase opacity-60">Variables</h3> + <p class="mb-3 shrink-0 text-xs opacity-50"> + Click a variable to insert it into the focused prompt box. + </p> + {#if varsError} + <p class="text-xs text-error">{varsError}</p> + {:else if groups.length === 0 && !varsLoading} + <p class="text-xs opacity-60">No variables available.</p> + {:else} + <div class="flex flex-col gap-3"> + {#each groups as group (group.type)} + <div class="rounded-box bg-base-200 p-3"> + <span class="text-xs font-semibold uppercase opacity-70">{group.type}</span> + <div class="mt-2 flex flex-wrap gap-1"> + {#each group.variables as variable (variable.type + variable.name)} + {#if isDynamicVariable(variable)} + <!-- Dynamic (file:<path>) variable: a path input + Insert button. --> + <div class="flex items-center gap-1"> + <input + type="text" + class="input input-bordered input-xs w-32 font-mono" + placeholder={variable.name} + onkeydown={(e) => { + if (e.key === "Enter") { + const v = e.currentTarget.value; + void insertDynamic(variable.type, v); + e.currentTarget.value = ""; + } + }} + /> + <button + type="button" + class="btn btn-xs" + onclick={(e) => { + const input = (e.currentTarget as HTMLButtonElement) + .previousElementSibling as HTMLInputElement | null; + if (input !== null) { + void insertDynamic(variable.type, input.value); + input.value = ""; + } + }} + > + Insert + </button> + </div> + {:else} + <button + type="button" + class="btn btn-xs" + title={variable.description} + onclick={() => + void insertAtActive(buildTag(variable.type, variable.name))} + > + {variable.name} + </button> + {/if} + {/each} + </div> + </div> + {/each} + </div> + {/if} + </div> + </div> + </div> +</div> diff --git a/src/features/heartbeat/ui/PromptEditor.test.ts b/src/features/heartbeat/ui/PromptEditor.test.ts new file mode 100644 index 0000000..c4bd3b8 --- /dev/null +++ b/src/features/heartbeat/ui/PromptEditor.test.ts @@ -0,0 +1,168 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { + HeartbeatConfigPatch, + HeartbeatConfigResult, + SaveHeartbeatConfig, +} from "../logic/types"; +import PromptEditor from "./PromptEditor.svelte"; + +// Fakes for the injected ports. + +function fakeLoadVariables() { + return vi.fn(async () => ({ ok: true, variables: [] }) as const); +} + +function fakeLoadDefaultPrompt(template = "You are a helpful assistant.") { + return vi.fn(async () => ({ ok: true, template }) as const); +} + +/** A capturing saveConfig that resolves ok, echoing the merged config shape. */ +function fakeSaveConfig(): { + calls: HeartbeatConfigPatch[]; + impl: SaveHeartbeatConfig; +} { + const calls: HeartbeatConfigPatch[] = []; + const impl: SaveHeartbeatConfig = async (patch) => { + calls.push(patch); + // Echo a config that reflects the persisted patch (so onSaved sync is realistic). + const config = { + enabled: false, + inactiveOnly: true, + systemPrompt: patch.systemPrompt ?? "", + taskPrompt: patch.taskPrompt ?? "", + intervalMinutes: 30, + model: "openai/gpt-4o", + reasoningEffort: null, + }; + return { ok: true, config } satisfies HeartbeatConfigResult; + }; + return { calls, impl }; +} + +const baseProps = (overrides: Record<string, unknown> = {}) => ({ + systemPrompt: "", + taskPrompt: "", + loadVariables: fakeLoadVariables(), + loadDefaultPrompt: fakeLoadDefaultPrompt(), + saveConfig: fakeSaveConfig().impl, + onSaved: vi.fn(), + onClose: vi.fn(), + ...overrides, +}); + +describe("PromptEditor save flow", () => { + it("persists an edited system prompt and clears the unsaved state (regression: save flickered + reverted)", async () => { + const user = userEvent.setup(); + const save = fakeSaveConfig(); + const onSaved = vi.fn(); + render(PromptEditor, { + props: baseProps({ + // Start inheriting (empty override); the default pre-fills. + systemPrompt: "", + saveConfig: save.impl, + onSaved, + }), + }); + + // Wait for the default to load + pre-fill the system textarea. + const systemBox = await screen.findByLabelText("Heartbeat system prompt"); + expect(systemBox).toHaveValue("You are a helpful assistant."); + + // Save is disabled while it matches the default (no explicit edit). + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + + // Edit the system prompt → an override. + await user.clear(systemBox); + await user.type(systemBox, "custom override"); + + // Save is now enabled. + const saveBtn = screen.getByRole("button", { name: "Save" }); + expect(saveBtn).toBeEnabled(); + await user.click(saveBtn); + + // The save port was called with the override persisted verbatim. + expect(save.calls).toHaveLength(1); + expect(save.calls[0]?.systemPrompt).toBe("custom override"); + expect(onSaved).toHaveBeenCalledWith("custom override", ""); + + // THE REGRESSION: after save, hasChanges must clear (Save disabled again) + // and the "Saved." confirmation shows — NOT "Unsaved changes". + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + expect(screen.getByText("Saved.")).toBeInTheDocument(); + expect(screen.queryByText(/Unsaved changes/i)).not.toBeInTheDocument(); + }); + + it("persisting text that matches the default sends '' (inherit) and clears unsaved state", async () => { + const user = userEvent.setup(); + const save = fakeSaveConfig(); + render(PromptEditor, { + props: baseProps({ + // Start with an override. + systemPrompt: "old override", + saveConfig: save.impl, + }), + }); + + const systemBox = await screen.findByLabelText("Heartbeat system prompt"); + expect(systemBox).toHaveValue("old override"); + + // Reset to default → text matches the default → saving inherits (""). + await user.click(screen.getByRole("button", { name: "Reset to default" })); + expect(systemBox).toHaveValue("You are a helpful assistant."); + + const saveBtn = screen.getByRole("button", { name: "Save" }); + expect(saveBtn).toBeEnabled(); + await user.click(saveBtn); + + expect(save.calls).toHaveLength(1); + expect(save.calls[0]?.systemPrompt).toBe(""); // inherit + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + expect(screen.getByText("Saved.")).toBeInTheDocument(); + }); + + it("editing the task prompt saves + clears unsaved state", async () => { + const user = userEvent.setup(); + const save = fakeSaveConfig(); + render(PromptEditor, { + props: baseProps({ saveConfig: save.impl }), + }); + + const taskBox = await screen.findByLabelText("Heartbeat task prompt"); + await user.type(taskBox, "do the thing"); + + const saveBtn = screen.getByRole("button", { name: "Save" }); + expect(saveBtn).toBeEnabled(); + await user.click(saveBtn); + + expect(save.calls[0]?.taskPrompt).toBe("do the thing"); + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + expect(screen.getByText("Saved.")).toBeInTheDocument(); + }); + + it("a failed save surfaces the error and keeps the edit unsaved", async () => { + const user = userEvent.setup(); + const failingSave: SaveHeartbeatConfig = async () => ({ ok: false, error: "boom" }); + render(PromptEditor, { + props: baseProps({ saveConfig: failingSave }), + }); + + const systemBox = await screen.findByLabelText("Heartbeat system prompt"); + await user.clear(systemBox); + await user.type(systemBox, "custom"); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(screen.getByText("boom")).toBeInTheDocument(); + // Still unsaved (Save stays enabled), no success badge. + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + expect(screen.queryByText("Saved.")).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/heartbeat/ui/RunModal.svelte b/src/features/heartbeat/ui/RunModal.svelte new file mode 100644 index 0000000..92068ae --- /dev/null +++ b/src/features/heartbeat/ui/RunModal.svelte @@ -0,0 +1,176 @@ +<script lang="ts"> + import { tick } from "svelte"; + import { ChatView } from "../../chat"; + import type { ChatStore } from "../../chat"; + import type { HeartbeatRunView } from "../logic/view-model"; + import type { StopHeartbeatRun } from "../logic/types"; + + let { + run, + openChat, + closeChat, + stopRun, + onClose, + apiBaseUrl = "", + }: { + /** The run to display (its conversation's chat is shown live). */ + run: HeartbeatRunView; + /** + * Open a live watch on a conversation (the store's `watchConversation`): + * returns a {@link ChatStore} subscribed to the conversation's turn stream + * + history loaded. The modal owns the watch lifecycle — calls + * `closeChat` on unmount. + */ + openChat: (conversationId: string) => ChatStore; + /** Dispose + unsubscribe the watch opened by `openChat`. */ + closeChat: (conversationId: string) => void; + /** Stop the heartbeat run (`POST .../runs/:runId/stop`). */ + stopRun: StopHeartbeatRun; + onClose: () => void; + /** + * The HTTP API base URL, to resolve persisted image chunk URLs + * (`/images/…`) in the run's transcript. Defaults to "" (root-relative). + */ + apiBaseUrl?: string; + } = $props(); + + // Open the live watch ONCE on mount (the modal is keyed per run.id, so a run + // switch remounts it). `untrack` avoids re-running if the prop fn identity + // changes — `run.conversationId` is the real dependency, captured once here. + let chat = $state<ChatStore | null>(null); + $effect(() => { + chat = openChat(run.conversationId); + return () => closeChat(run.conversationId); + }); + + // Live scroll: keep the transcript pinned to the bottom while it streams + // (unless the reader has scrolled up — then we don't fight them). + let scrollEl = $state<HTMLDivElement | undefined>(); + let contentEl = $state<HTMLDivElement | undefined>(); + let pinned = $state(true); + + function onScroll() { + const el = scrollEl; + if (el === undefined) return; + pinned = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + } + + // Follow the bottom on new content while pinned. Reads `chunks.length` so the + // effect re-runs on every streamed append. + const chunkCount = $derived(chat?.chunks.length ?? 0); + $effect(() => { + void chunkCount; + if (!pinned) return; + void tick().then(() => { + const el = scrollEl; + if (el !== undefined) el.scrollTop = el.scrollHeight; + }); + }); + + // Stop state. + let stopping = $state(false); + let stopError = $state<string | null>(null); + + async function handleStop() { + if (stopping) return; + stopping = true; + stopError = null; + const result = await stopRun(run.id); + stopping = false; + if (result === null) return; + if (!result.ok) stopError = result.error; + } + + // The live "running" signal: the chat store's `generating` reflects the + // actual event stream (turn-start…turn-sealed). True while a turn streams — + // that is when a Stop is meaningful. Falls back to the run's status snapshot + // before the stream attaches. + const live = $derived(chat?.generating ?? run.busy); + + function handleKeydown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } +</script> + +<svelte:window onkeydown={handleKeydown} /> + +<!-- Fullscreen overlay. --> +<div class="fixed inset-0 z-50 flex flex-col bg-base-100"> + <!-- Header --> + <header class="flex items-center justify-between gap-2 border-b border-base-300 px-4 py-2"> + <div class="flex min-w-0 items-center gap-2"> + <button + type="button" + class="btn btn-ghost btn-sm" + onclick={onClose} + aria-label="Close run chat" + > + ✕ + </button> + <span class="truncate font-mono text-xs opacity-70" title="Run id">{run.id}</span> + {#if live} + <span class="badge badge-sm badge-warning gap-1"> + <span class="loading loading-spinner loading-xs"></span> + Running + </span> + {:else} + <span class="badge badge-sm badge-ghost">{run.statusLabel}</span> + {/if} + </div> + <div class="flex items-center gap-2"> + {#if stopError} + <span class="text-xs text-error">{stopError}</span> + {/if} + {#if live} + <button + type="button" + class="btn btn-sm btn-error btn-outline" + disabled={stopping} + onclick={handleStop} + > + {#if stopping} + <span class="loading loading-spinner loading-xs"></span> + Stopping… + {:else} + Stop + {/if} + </button> + {/if} + </div> + </header> + + <!-- Transcript --> + <div class="relative min-h-0 flex-1"> + <div bind:this={scrollEl} class="h-full overflow-y-auto" onscroll={onScroll}> + <div bind:this={contentEl} class="p-4"> + {#if chat === null} + <div class="flex h-full items-center justify-center"> + <span class="loading loading-spinner loading-md"></span> + </div> + {:else if chat.chunks.length === 0 && chat.pendingSync} + <div class="flex h-full items-center justify-center"> + <span class="loading loading-spinner loading-md"></span> + </div> + {:else} + <ChatView + chunks={chat.chunks} + turnMetrics={chat.turnMetrics} + hasEarlier={chat.hasEarlier} + onShowEarlier={chat.showEarlier} + thinkingKeyBase={chat.thinkingKeyBase} + providerRetry={chat.providerRetry} + apiBaseUrl={apiBaseUrl} + /> + {/if} + </div> + </div> + {#if chat !== null && chat.chunks.length === 0 && !chat.pendingSync} + <div + class="pointer-events-none absolute inset-0 flex items-center justify-center" + aria-hidden="true" + > + <span class="select-none text-2xl font-bold opacity-10">No messages</span> + </div> + {/if} + </div> +</div> diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts index f5406b2..ff3aefa 100644 --- a/src/features/markdown/index.ts +++ b/src/features/markdown/index.ts @@ -3,6 +3,6 @@ export { default as Markdown } from "./ui/Markdown.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "markdown", - description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)", + name: "markdown", + description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)", } as const; diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts index 7dbb878..54b0086 100644 --- a/src/features/markdown/logic/markdown.test.ts +++ b/src/features/markdown/logic/markdown.test.ts @@ -2,57 +2,57 @@ import { describe, expect, it } from "vitest"; import { renderMarkdown } from "./markdown"; describe("renderMarkdown", () => { - it("renders GFM markdown (headings, emphasis)", () => { - const html = renderMarkdown("# Title\n\nSome **bold** text."); - expect(html).toContain("<h1"); - expect(html).toContain("Title"); - expect(html).toContain("<strong>bold</strong>"); - }); - - it("highlights fenced code for a known language", () => { - const html = renderMarkdown("```javascript\nconst x = 1;\n```"); - expect(html).toContain("language-javascript"); - expect(html).toContain("hljs-keyword"); // `const` got highlighted - }); - - it("resolves language aliases (js -> javascript)", () => { - const html = renderMarkdown("```js\nconst x = 1;\n```"); - expect(html).toContain("hljs-keyword"); - }); - - it("escapes code for an unknown language without throwing", () => { - const html = renderMarkdown("```nope\n<b>x</b>\n```"); - expect(html).toContain("<b>"); - }); - - it("sanitizes dangerous HTML", () => { - const html = renderMarkdown("Hi <script>alert(1)</script> there"); - expect(html).not.toContain("<script>"); - expect(html).toContain("Hi"); - }); - - it("balances dangling bold emphasis while streaming", () => { - expect(renderMarkdown("a **bold", { streaming: true })).toContain("<strong>bold</strong>"); - }); - - it("does not balance delimiters when not streaming", () => { - expect(renderMarkdown("a **bold")).not.toContain("<strong>"); - }); - - it("wraps fenced code blocks with a copy button", () => { - const html = renderMarkdown("```js\nconst x = 1;\n```"); - expect(html).toContain("code-block"); - expect(html).toContain("data-copy"); - expect(html).toContain("<pre>"); - }); - - it("does not add a copy button to inline code", () => { - const html = renderMarkdown("use `npm run dev` please"); - expect(html).not.toContain("data-copy"); - expect(html).toContain("<code>npm run dev</code>"); - }); - - it("returns an empty string for empty input", () => { - expect(renderMarkdown("")).toBe(""); - }); + it("renders GFM markdown (headings, emphasis)", () => { + const html = renderMarkdown("# Title\n\nSome **bold** text."); + expect(html).toContain("<h1"); + expect(html).toContain("Title"); + expect(html).toContain("<strong>bold</strong>"); + }); + + it("highlights fenced code for a known language", () => { + const html = renderMarkdown("```javascript\nconst x = 1;\n```"); + expect(html).toContain("language-javascript"); + expect(html).toContain("hljs-keyword"); // `const` got highlighted + }); + + it("resolves language aliases (js -> javascript)", () => { + const html = renderMarkdown("```js\nconst x = 1;\n```"); + expect(html).toContain("hljs-keyword"); + }); + + it("escapes code for an unknown language without throwing", () => { + const html = renderMarkdown("```nope\n<b>x</b>\n```"); + expect(html).toContain("<b>"); + }); + + it("sanitizes dangerous HTML", () => { + const html = renderMarkdown("Hi <script>alert(1)</script> there"); + expect(html).not.toContain("<script>"); + expect(html).toContain("Hi"); + }); + + it("balances dangling bold emphasis while streaming", () => { + expect(renderMarkdown("a **bold", { streaming: true })).toContain("<strong>bold</strong>"); + }); + + it("does not balance delimiters when not streaming", () => { + expect(renderMarkdown("a **bold")).not.toContain("<strong>"); + }); + + it("wraps fenced code blocks with a copy button", () => { + const html = renderMarkdown("```js\nconst x = 1;\n```"); + expect(html).toContain("code-block"); + expect(html).toContain("data-copy"); + expect(html).toContain("<pre>"); + }); + + it("does not add a copy button to inline code", () => { + const html = renderMarkdown("use `npm run dev` please"); + expect(html).not.toContain("data-copy"); + expect(html).toContain("<code>npm run dev</code>"); + }); + + it("returns an empty string for empty input", () => { + expect(renderMarkdown("")).toBe(""); + }); }); diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts index 3a6e5a6..ad8a8bd 100644 --- a/src/features/markdown/logic/markdown.ts +++ b/src/features/markdown/logic/markdown.ts @@ -39,88 +39,88 @@ import { markedHighlight } from "marked-highlight"; // Hot set: registered eagerly so common code blocks highlight on first paint. const HOT_LANGUAGES: Record<string, LanguageFn> = { - bash, - c, - cpp, - csharp, - css, - go, - java, - javascript, - json, - markdown: markdownLang, - php, - plaintext, - python, - ruby, - rust, - shell, - sql, - typescript, - xml, - yaml, + bash, + c, + cpp, + csharp, + css, + go, + java, + javascript, + json, + markdown: markdownLang, + php, + plaintext, + python, + ruby, + rust, + shell, + sql, + typescript, + xml, + yaml, }; for (const [name, lang] of Object.entries(HOT_LANGUAGES)) { - hljs.registerLanguage(name, lang); + hljs.registerLanguage(name, lang); } // Normalize common fence aliases to canonical highlight.js names. const ALIASES: Record<string, string> = { - js: "javascript", - jsx: "javascript", - mjs: "javascript", - cjs: "javascript", - ts: "typescript", - tsx: "typescript", - py: "python", - py3: "python", - rb: "ruby", - sh: "bash", - zsh: "bash", - yml: "yaml", - "c++": "cpp", - cxx: "cpp", - "c#": "csharp", - cs: "csharp", - htm: "xml", - html: "xml", - svg: "xml", - md: "markdown", - mdx: "markdown", - golang: "go", - rs: "rust", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + ts: "typescript", + tsx: "typescript", + py: "python", + py3: "python", + rb: "ruby", + sh: "bash", + zsh: "bash", + yml: "yaml", + "c++": "cpp", + cxx: "cpp", + "c#": "csharp", + cs: "csharp", + htm: "xml", + html: "xml", + svg: "xml", + md: "markdown", + mdx: "markdown", + golang: "go", + rs: "rust", }; function normalizeLang(lang: string): string { - const lower = lang.toLowerCase().trim(); - return ALIASES[lower] ?? lower; + const lower = lang.toLowerCase().trim(); + return ALIASES[lower] ?? lower; } function escapeHtml(s: string): string { - return s - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } const md = new Marked( - markedHighlight({ - emptyLangClass: "hljs", - langPrefix: "hljs language-", - highlight(code: string, lang: string): string { - if (!lang) return escapeHtml(code); - const name = normalizeLang(lang); - if (!hljs.getLanguage(name)) return escapeHtml(code); - try { - return hljs.highlight(code, { language: name, ignoreIllegals: true }).value; - } catch { - return escapeHtml(code); - } - }, - }), - { gfm: true, breaks: true }, + markedHighlight({ + emptyLangClass: "hljs", + langPrefix: "hljs language-", + highlight(code: string, lang: string): string { + if (!lang) return escapeHtml(code); + const name = normalizeLang(lang); + if (!hljs.getLanguage(name)) return escapeHtml(code); + try { + return hljs.highlight(code, { language: name, ignoreIllegals: true }).value; + } catch { + return escapeHtml(code); + } + }, + }), + { gfm: true, breaks: true }, ); /** @@ -128,14 +128,14 @@ const md = new Marked( * partial text renders cleanly instead of flashing raw markers. */ function closeOpenDelimiters(src: string): string { - let out = src; - const fenceCount = (out.match(/^```/gm) ?? []).length; - if (fenceCount % 2 !== 0) out += "\n```"; - const boldCount = (out.match(/\*\*/g) ?? []).length; - if (boldCount % 2 !== 0) out += "**"; - const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length; - if (inlineCode % 2 !== 0) out += "`"; - return out; + let out = src; + const fenceCount = (out.match(/^```/gm) ?? []).length; + if (fenceCount % 2 !== 0) out += "\n```"; + const boldCount = (out.match(/\*\*/g) ?? []).length; + if (boldCount % 2 !== 0) out += "**"; + const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length; + if (inlineCode % 2 !== 0) out += "`"; + return out; } // Wrap each fenced code block (`<pre>…</pre>`) in a positioned container with a @@ -144,22 +144,22 @@ function closeOpenDelimiters(src: string): string { // `data-copy` is the delegation hook the component listens for; DOMPurify keeps // `<button>` + `data-*` by default. Inline `<code>` has no `<pre>`, so it's untouched. const COPY_BUTTON = - '<button type="button" data-copy aria-label="Copy code"' + - ' class="copy-btn btn btn-xs absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">Copy</button>'; + '<button type="button" data-copy aria-label="Copy code"' + + ' class="copy-btn btn btn-xs absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">Copy</button>'; function addCopyButtons(html: string): string { - return html - .replace(/<pre>/g, `<div class="code-block group relative">${COPY_BUTTON}<pre>`) - .replace(/<\/pre>/g, "</pre></div>"); + return html + .replace(/<pre>/g, `<div class="code-block group relative">${COPY_BUTTON}<pre>`) + .replace(/<\/pre>/g, "</pre></div>"); } /** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */ export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string { - const src = opts?.streaming === true ? closeOpenDelimiters(text) : text; - try { - const raw = md.parse(src) as string; - return DOMPurify.sanitize(addCopyButtons(raw)); - } catch { - return ""; - } + const src = opts?.streaming === true ? closeOpenDelimiters(text) : text; + try { + const raw = md.parse(src) as string; + return DOMPurify.sanitize(addCopyButtons(raw)); + } catch { + return ""; + } } diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte index b828ab9..72b892b 100644 --- a/src/features/markdown/ui/Markdown.svelte +++ b/src/features/markdown/ui/Markdown.svelte @@ -1,58 +1,58 @@ <script lang="ts"> - import { renderMarkdown } from "../logic/markdown"; - - let { - text, - streaming = false, - }: { - text: string; - /** Balance dangling delimiters while the message is still generating. */ - streaming?: boolean; - } = $props(); - - // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown. - const html = $derived(renderMarkdown(text, { streaming })); - - let container: HTMLElement; - - // One delegated listener on the stable container handles every code block's - // copy button — including blocks re-created when `html` changes (streaming), - // since the listener lives on the container, not the buttons. Clipboard is the - // edge effect; absent (insecure context) → no-op. - $effect(() => { - const el = container; - if (el === undefined) return; - - const onClick = (event: Event): void => { - const target = event.target; - if (!(target instanceof Element)) return; - const button = target.closest<HTMLButtonElement>("[data-copy]"); - if (button === null) return; - - const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? ""; - const clipboard = navigator.clipboard; - if (clipboard === undefined) return; - - void clipboard - .writeText(code) - .then(() => { - const prev = button.textContent; - button.textContent = "Copied"; - setTimeout(() => { - button.textContent = prev; - }, 1200); - }) - .catch(() => { - // Clipboard denied — leave the button as-is. - }); - }; - - el.addEventListener("click", onClick); - return () => el.removeEventListener("click", onClick); - }); + import { renderMarkdown } from "../logic/markdown"; + + let { + text, + streaming = false, + }: { + text: string; + /** Balance dangling delimiters while the message is still generating. */ + streaming?: boolean; + } = $props(); + + // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown. + const html = $derived(renderMarkdown(text, { streaming })); + + let container: HTMLElement; + + // One delegated listener on the stable container handles every code block's + // copy button — including blocks re-created when `html` changes (streaming), + // since the listener lives on the container, not the buttons. Clipboard is the + // edge effect; absent (insecure context) → no-op. + $effect(() => { + const el = container; + if (el === undefined) return; + + const onClick = (event: Event): void => { + const target = event.target; + if (!(target instanceof Element)) return; + const button = target.closest<HTMLButtonElement>("[data-copy]"); + if (button === null) return; + + const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? ""; + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + + void clipboard + .writeText(code) + .then(() => { + const prev = button.textContent; + button.textContent = "Copied"; + setTimeout(() => { + button.textContent = prev; + }, 1200); + }) + .catch(() => { + // Clipboard denied — leave the button as-is. + }); + }; + + el.addEventListener("click", onClick); + return () => el.removeEventListener("click", onClick); + }); </script> <div class="markdown-body" bind:this={container}> - <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. --> - {@html html} + <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. --> + {@html html} </div> diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts index e34a4af..d65b3d1 100644 --- a/src/features/markdown/ui/markdown.test.ts +++ b/src/features/markdown/ui/markdown.test.ts @@ -3,38 +3,38 @@ import { describe, expect, it, vi } from "vitest"; import Markdown from "./Markdown.svelte"; describe("Markdown", () => { - it("renders markdown into a .markdown-body container", () => { - const { container } = render(Markdown, { props: { text: "# Hello\n\n**hi**" } }); + it("renders markdown into a .markdown-body container", () => { + const { container } = render(Markdown, { props: { text: "# Hello\n\n**hi**" } }); - expect(container.querySelector(".markdown-body")).not.toBeNull(); - expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); - expect(container.querySelector("strong")?.textContent).toBe("hi"); - }); + expect(container.querySelector(".markdown-body")).not.toBeNull(); + expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); + expect(container.querySelector("strong")?.textContent).toBe("hi"); + }); - it("strips dangerous markup", () => { - const { container } = render(Markdown, { - props: { text: "before <script>alert(1)</script> after" }, - }); + it("strips dangerous markup", () => { + const { container } = render(Markdown, { + props: { text: "before <script>alert(1)</script> after" }, + }); - expect(container.querySelector("script")).toBeNull(); - expect(container.textContent).toContain("before"); - }); + expect(container.querySelector("script")).toBeNull(); + expect(container.textContent).toContain("before"); + }); - it("renders a copy button on a code block that copies the code to the clipboard", async () => { - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + it("renders a copy button on a code block that copies the code to the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); - const { container } = render(Markdown, { - props: { text: "```js\nconst x = 1;\n```" }, - }); + const { container } = render(Markdown, { + props: { text: "```js\nconst x = 1;\n```" }, + }); - const button = container.querySelector<HTMLElement>("[data-copy]"); - expect(button).not.toBeNull(); - if (button === null) throw new Error("expected a copy button"); + const button = container.querySelector<HTMLElement>("[data-copy]"); + expect(button).not.toBeNull(); + if (button === null) throw new Error("expected a copy button"); - await fireEvent.click(button); + await fireEvent.click(button); - expect(writeText).toHaveBeenCalledTimes(1); - expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;"); - }); + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;"); + }); }); diff --git a/src/features/mcp/index.ts b/src/features/mcp/index.ts new file mode 100644 index 0000000..9abc023 --- /dev/null +++ b/src/features/mcp/index.ts @@ -0,0 +1,8 @@ +export type { LoadMcpStatus, McpStatusResult } from "./logic/view-model"; +export { default as McpStatusView } from "./ui/McpStatusView.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "mcp", + description: "Per-conversation MCP (Model Context Protocol) server status", +} as const; diff --git a/src/features/mcp/logic/view-model.test.ts b/src/features/mcp/logic/view-model.test.ts new file mode 100644 index 0000000..d1a77d1 --- /dev/null +++ b/src/features/mcp/logic/view-model.test.ts @@ -0,0 +1,88 @@ +import type { McpServerInfo } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { summarizeMcpServers, viewMcpServer, viewMcpServers } from "./view-model"; + +const server = (over: Partial<McpServerInfo> = {}): McpServerInfo => ({ + id: "freecad", + state: "connected", + toolCount: 12, + ...over, +}); + +describe("viewMcpServer", () => { + it("connected → success badge, not busy, no error, passes toolCount", () => { + const v = viewMcpServer(server({ toolCount: 5 })); + expect(v.badge).toBe("success"); + expect(v.statusLabel).toBe("Connected"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + expect(v.toolCount).toBe(5); + expect(v.configSource).toBeNull(); + }); + + it("connecting → warning badge + busy (spinner)", () => { + const v = viewMcpServer(server({ state: "connecting" })); + expect(v.badge).toBe("warning"); + expect(v.statusLabel).toBe("Connecting…"); + expect(v.busy).toBe(true); + expect(v.error).toBeNull(); + }); + + it("disconnected → neutral badge, not busy", () => { + const v = viewMcpServer(server({ state: "disconnected" })); + expect(v.badge).toBe("neutral"); + expect(v.statusLabel).toBe("Disconnected"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + }); + + it("error → error badge + surfaces the reason (with a fallback)", () => { + const withReason = viewMcpServer(server({ state: "error", error: "ENOENT: npx" })); + expect(withReason.badge).toBe("error"); + expect(withReason.busy).toBe(false); + expect(withReason.error).toBe("ENOENT: npx"); + + const noReason = viewMcpServer(server({ state: "error" })); + expect(noReason.error).toBe("Failed to connect"); + }); + + it("passes through configSource when present", () => { + const v = viewMcpServer(server({ configSource: ".dispatch/mcp.json" })); + expect(v.configSource).toBe(".dispatch/mcp.json"); + }); + + it("viewMcpServers maps a list preserving order", () => { + const views = viewMcpServers([server({ id: "a" }), server({ id: "b" })]); + expect(views.map((v) => v.id)).toEqual(["a", "b"]); + }); +}); + +describe("summarizeMcpServers", () => { + it("empty list", () => { + expect(summarizeMcpServers([])).toBe("No MCP servers"); + }); + + it("counts connected / connecting / disconnected / errors", () => { + expect(summarizeMcpServers([server({ state: "connected" })])).toBe("1 connected"); + expect( + summarizeMcpServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "error" }), + ]), + ).toBe("1 connected, 1 error"); + expect( + summarizeMcpServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "connecting" }), + server({ id: "c", state: "disconnected" }), + server({ id: "d", state: "error" }), + server({ id: "e", state: "error" }), + ]), + ).toBe("1 connected, 1 connecting, 1 disconnected, 2 errors"); + }); + + it("lists only non-zero buckets", () => { + expect(summarizeMcpServers([server({ state: "disconnected" })])).toBe("1 disconnected"); + expect(summarizeMcpServers([server({ id: "a", state: "connecting" })])).toBe("1 connecting"); + }); +}); diff --git a/src/features/mcp/logic/view-model.ts b/src/features/mcp/logic/view-model.ts new file mode 100644 index 0000000..fdff78a --- /dev/null +++ b/src/features/mcp/logic/view-model.ts @@ -0,0 +1,110 @@ +import type { McpServerInfo, McpServerState } from "@dispatch/transport-contract"; + +/** + * Pure core for the mcp feature — zero DOM, zero effects, zero Svelte. + * + * The mcp feature exposes the live status of the MCP (Model Context Protocol) + * servers configured for a conversation's working directory, fetched from + * `GET /conversations/:id/mcp`. This module holds the pure logic: the mapping + * of a backend `McpServerState` to a display badge + label, and a one-line + * server summary. The effect (the HTTP get MCP status) is INJECTED via the + * `LoadMcpStatus` port below; the composition root implements it. + */ + +// ── Injected port (consumer-defines-port; the composition root adapts the +// store's HTTP call to this shape). ────────────────────────────────────────── + +/** Outcome of `GET /conversations/:id/mcp`; `null` when no real conversation is focused. */ +export type McpStatusResult = + | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly McpServerInfo[] } + | { readonly ok: false; readonly error: string }; + +export type LoadMcpStatus = () => Promise<McpStatusResult | null>; + +// ── MCP server status → display view ─────────────────────────────────────────── + +export type Badge = "success" | "warning" | "error" | "neutral"; + +export interface McpServerView { + readonly id: string; + readonly state: McpServerState; + readonly statusLabel: string; + readonly badge: Badge; + /** True while the state is transient (show a spinner). */ + readonly busy: boolean; + /** The error reason when `state === "error"`, else null. */ + readonly error: string | null; + /** Number of tools discovered from this server. */ + readonly toolCount: number; + /** Which config source the server was resolved from, else null. */ + readonly configSource: string | null; +} + +/** + * Map a server's state to a display label + badge severity + busy flag. Mirrors + * the LSP status visual treatment: `connected` → success, `connecting` (the + * transient state, analogous to LSP's `starting`) → warning + spinner, `error` + * → error, and `disconnected` (a stable idle state) → neutral. + */ +export function viewMcpServer(server: McpServerInfo): McpServerView { + let statusLabel: string; + let badge: Badge; + let busy = false; + switch (server.state) { + case "connected": + statusLabel = "Connected"; + badge = "success"; + break; + case "connecting": + statusLabel = "Connecting…"; + badge = "warning"; + busy = true; + break; + case "disconnected": + statusLabel = "Disconnected"; + badge = "neutral"; + break; + case "error": + statusLabel = "Error"; + badge = "error"; + break; + } + return { + id: server.id, + state: server.state, + statusLabel, + badge, + busy, + error: server.state === "error" ? (server.error ?? "Failed to connect") : null, + toolCount: server.toolCount, + configSource: server.configSource ?? null, + }; +} + +export function viewMcpServers(servers: readonly McpServerInfo[]): readonly McpServerView[] { + return servers.map(viewMcpServer); +} + +/** + * A short one-line summary, e.g. "2 connected" / "1 connected, 1 connecting, + * 1 error". Only non-zero buckets are listed. + */ +export function summarizeMcpServers(servers: readonly McpServerInfo[]): string { + if (servers.length === 0) return "No MCP servers"; + let connected = 0; + let connecting = 0; + let disconnected = 0; + let errored = 0; + for (const s of servers) { + if (s.state === "connected") connected++; + else if (s.state === "error") errored++; + else if (s.state === "connecting") connecting++; + else disconnected++; + } + const parts: string[] = []; + if (connected > 0) parts.push(`${connected} connected`); + if (connecting > 0) parts.push(`${connecting} connecting`); + if (disconnected > 0) parts.push(`${disconnected} disconnected`); + if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`); + return parts.join(", "); +} diff --git a/src/features/mcp/ui/McpStatusView.svelte b/src/features/mcp/ui/McpStatusView.svelte new file mode 100644 index 0000000..ad51496 --- /dev/null +++ b/src/features/mcp/ui/McpStatusView.svelte @@ -0,0 +1,133 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { + type Badge, + type LoadMcpStatus, + type McpServerView, + summarizeMcpServers, + viewMcpServers, + } from "../logic/view-model"; + + let { + cwd, + canView, + load, + }: { + /** The active conversation's cwd — the trigger to (re)load when it changes. */ + cwd: string | null; + /** Whether a real conversation is focused. */ + canView: boolean; + load: LoadMcpStatus; + } = $props(); + + const badgeClass: Record<Badge, string> = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + neutral: "badge-ghost", + }; + + let servers = $state<readonly McpServerView[]>([]); + let loading = $state(false); + let error = $state<string | null>(null); + let loadedCwd = $state<string | null>(null); + let hasLoaded = $state(false); + let summary = $state(""); + + async function refresh() { + if (!canView) return; + loading = true; + error = null; + const result = await load(); + loading = false; + if (result === null) return; + hasLoaded = true; + if (result.ok) { + servers = viewMcpServers(result.servers); + summary = summarizeMcpServers(result.servers); + loadedCwd = result.cwd; + } else { + error = result.error; + } + } + + // (Re)load on mount and whenever the conversation's cwd changes. The MCP GET + // lazily spawns/connects servers, so we avoid a redundant fetch when `cwd` + // resolves to the value we already loaded for. + $effect(() => { + const target = cwd; + const can = canView; + untrack(() => { + if (!can) return; + if (!hasLoaded || target !== loadedCwd) void refresh(); + }); + }); +</script> + +<div class="flex flex-col gap-2"> + <div class="flex items-center justify-between gap-2"> + <span class="text-xs opacity-70"> + {#if loading} + Resolving… + {:else if hasLoaded && loadedCwd !== null} + {summary} + {:else} + MCP servers + {/if} + </span> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={!canView || loading} + onclick={() => refresh()} + aria-label="Refresh MCP server status" + > + {#if loading} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Refresh + {/if} + </button> + </div> + + {#if !canView} + <p class="text-xs opacity-60">Open or start a conversation to see its MCP servers.</p> + {:else if error} + <p class="text-xs text-error">{error}</p> + {:else if hasLoaded && loadedCwd === null} + <p class="text-xs opacity-60"> + Set a working directory in the Model panel to enable MCP servers. + </p> + {:else if hasLoaded && servers.length === 0 && !loading} + <p class="text-xs opacity-60">No MCP servers configured for this directory.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each servers as server (server.id)} + <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <div class="flex items-center justify-between gap-2"> + <span class="font-medium font-mono">{server.id}</span> + <span class="badge badge-sm {badgeClass[server.badge]} gap-1"> + {#if server.busy} + <span class="loading loading-spinner loading-xs"></span> + {/if} + {server.statusLabel} + </span> + </div> + <div class="flex items-center justify-between gap-2 text-xs opacity-60"> + {#if server.configSource} + <span class="font-mono" title="Config source">{server.configSource}</span> + {:else} + <span></span> + {/if} + <span title="Discovered tools" + >{server.toolCount} tool{server.toolCount === 1 ? "" : "s"}</span + > + </div> + {#if server.error} + <span class="font-mono text-xs text-error">{server.error}</span> + {/if} + </li> + {/each} + </ul> + {/if} +</div> diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts new file mode 100644 index 0000000..3942e97 --- /dev/null +++ b/src/features/settings/index.ts @@ -0,0 +1,9 @@ +export type { ChatLimitParse, ChatLimitSaveResult, SaveChatLimit } from "./logic/view-model"; +export { chatLimitChanged, parseChatLimit } from "./logic/view-model"; +export { default as ChatLimitField } from "./ui/ChatLimitField.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "settings", + description: "FE-local settings (chat limit)", +} as const; diff --git a/src/features/settings/logic/view-model.test.ts b/src/features/settings/logic/view-model.test.ts new file mode 100644 index 0000000..93c4786 --- /dev/null +++ b/src/features/settings/logic/view-model.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks"; +import { chatLimitChanged, parseChatLimit } from "./view-model"; + +describe("parseChatLimit", () => { + it("parses a plain integer", () => { + expect(parseChatLimit("256")).toEqual({ ok: true, value: 256 }); + expect(parseChatLimit("100")).toEqual({ ok: true, value: 100 }); + }); + + it("trims surrounding whitespace", () => { + expect(parseChatLimit(" 50 ")).toEqual({ ok: true, value: 50 }); + }); + + it("floors a decimal", () => { + expect(parseChatLimit("100.9")).toEqual({ ok: true, value: 100 }); + }); + + it("clamps below the floor to MIN_CHAT_LIMIT", () => { + expect(parseChatLimit("0")).toEqual({ ok: true, value: MIN_CHAT_LIMIT }); + expect(parseChatLimit("-5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT }); + expect(parseChatLimit("5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT }); + }); + + it("clamps above the ceiling to MAX_CHAT_LIMIT", () => { + expect(parseChatLimit("99999999")).toEqual({ ok: true, value: MAX_CHAT_LIMIT }); + }); + + it("rejects an empty string", () => { + expect(parseChatLimit("")).toEqual({ ok: false, error: expect.any(String) }); + expect(parseChatLimit(" ")).toEqual({ ok: false, error: expect.any(String) }); + }); + + it("rejects non-numeric input", () => { + expect(parseChatLimit("abc")).toEqual({ ok: false, error: expect.any(String) }); + expect(parseChatLimit("twelve")).toEqual({ ok: false, error: expect.any(String) }); + }); +}); + +describe("chatLimitChanged", () => { + it("is false when the typed value normalizes to the current limit", () => { + expect(chatLimitChanged("256", 256)).toBe(false); + }); + + it("is true when the typed value differs", () => { + expect(chatLimitChanged("100", 256)).toBe(true); + expect(chatLimitChanged("256", 100)).toBe(true); + }); + + it("is false for empty / invalid input (nothing submittable)", () => { + expect(chatLimitChanged("", 256)).toBe(false); + expect(chatLimitChanged("abc", 256)).toBe(false); + }); + + it("is false when the typed value clamps to the current limit", () => { + // 5 clamps to MIN (10); current is already 10 → no change. + expect(chatLimitChanged("5", MIN_CHAT_LIMIT)).toBe(false); + // A huge value clamps to MAX; current is already MAX → no change. + expect(chatLimitChanged("99999999", MAX_CHAT_LIMIT)).toBe(false); + }); +}); diff --git a/src/features/settings/logic/view-model.ts b/src/features/settings/logic/view-model.ts new file mode 100644 index 0000000..73f6d17 --- /dev/null +++ b/src/features/settings/logic/view-model.ts @@ -0,0 +1,60 @@ +import { normalizeChatLimit } from "../../../core/chunks"; + +/** + * Pure core for the settings feature — zero DOM, zero effects, zero Svelte. + * + * The settings feature exposes FE-local, user-tunable settings. Currently the + * chat limit (max loaded chunks per conversation; the policy lives in + * `core/chunks/trim.ts`): this module is the view-model seam that parses + + * validates a typed value into a normalized limit, so the UI can disable submit + * and show an error on garbage input. The bound constants + normalization are + * OWNED by `core/chunks` (the single source of truth); this module never + * redefines them. + */ + +// ── Injected port (consumer-defines-port; the composition root adapts the +// store's localStorage persistence to this shape). ────────────────────────── + +/** Outcome of persisting a chat-limit setting. */ +export type ChatLimitSaveResult = + | { readonly ok: true; readonly chatLimit: number } + | { readonly ok: false; readonly error: string }; + +export type SaveChatLimit = (value: number) => Promise<ChatLimitSaveResult>; + +// ── chat-limit parse / validate ─────────────────────────────────────────────── + +/** Result of parsing a typed chat-limit string. */ +export type ChatLimitParse = + | { readonly ok: true; readonly value: number } + | { readonly ok: false; readonly error: string }; + +/** + * Parse a typed chat-limit string into a normalized limit (floored + clamped to + * [MIN_CHAT_LIMIT, MAX_CHAT_LIMIT] via `normalizeChatLimit`). Empty or + * non-numeric input is an ERROR (so the UI can disable submit + message), NOT a + * silent default — the default only applies at boot for an unset key, never + * from user typing. + */ +export function parseChatLimit(raw: string): ChatLimitParse { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return { ok: false, error: "Enter a number." }; + } + const n = Number(trimmed); + if (!Number.isFinite(n)) { + return { ok: false, error: "Must be a number." }; + } + return { ok: true, value: normalizeChatLimit(n) }; +} + +/** + * Whether saving `typed` would change the `current` chat limit. A no-op save + * (empty/invalid, or equal after normalization) should be disabled. This is the + * dirty-check for the input — it must NOT mutate or clamp, only compare. + */ +export function chatLimitChanged(typed: string, current: number): boolean { + const parsed = parseChatLimit(typed); + if (!parsed.ok) return false; + return parsed.value !== current; +} diff --git a/src/features/settings/ui/ChatLimitField.svelte b/src/features/settings/ui/ChatLimitField.svelte new file mode 100644 index 0000000..502212f --- /dev/null +++ b/src/features/settings/ui/ChatLimitField.svelte @@ -0,0 +1,104 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks"; + import { chatLimitChanged, parseChatLimit, type SaveChatLimit } from "../logic/view-model"; + + let { + chatLimit, + save, + }: { + /** The persisted chat limit (max loaded chunks per conversation). */ + chatLimit: number; + save: SaveChatLimit; + } = $props(); + + // Seed from the prop; the $effect below re-seeds on external changes (a live + // apply from elsewhere) but only while the field is untouched, so an in-flight + // change can't clobber what the user typed. + let value = $state(""); + let lastSeed = $state(""); + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state(false); + let savedValue = $state<number | null>(null); + + $effect(() => { + const incoming = String(chatLimit); + untrack(() => { + if (value === lastSeed) value = incoming; + lastSeed = incoming; + }); + }); + + const dirty = $derived(chatLimitChanged(value, chatLimit)); + + async function handleSave() { + if (saving || !dirty) return; + const parsed = parseChatLimit(value); + if (!parsed.ok) { + error = parsed.error; + return; + } + saving = true; + error = null; + justSaved = false; + const result = await save(parsed.value); + saving = false; + if (result.ok) { + justSaved = true; + savedValue = result.chatLimit; + // Reflect the clamped / persisted value back into the input immediately + // (the prop will also re-assert it via the effect above). + value = String(result.chatLimit); + lastSeed = value; + } else { + error = result.error; + } + } + + function onInput() { + justSaved = false; + error = null; + } +</script> + +<div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Chat limit</span> + <div class="flex items-center gap-2"> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-sm w-full font-mono text-xs" + placeholder={String(chatLimit)} + bind:value + disabled={saving} + oninput={onInput} + onkeydown={(e) => { + if (e.key === "Enter") handleSave(); + }} + aria-label="Chat limit" + /> + <button + type="button" + class="btn btn-primary btn-sm" + disabled={saving || !dirty} + onclick={handleSave} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + </div> + {#if error} + <p class="text-xs text-error">{error}</p> + {:else if justSaved && !dirty} + <p class="text-xs text-success">Saved{savedValue !== null ? `: ${savedValue}.` : "."}</p> + {:else} + <p class="text-xs opacity-50"> + Max loaded chunks per conversation ({MIN_CHAT_LIMIT}–{MAX_CHAT_LIMIT}). Lowering it unloads + older history; raise it and use "Show earlier" to page back in. + </p> + {/if} +</div> diff --git a/src/features/settings/ui/ChatLimitField.test.ts b/src/features/settings/ui/ChatLimitField.test.ts new file mode 100644 index 0000000..8f8da25 --- /dev/null +++ b/src/features/settings/ui/ChatLimitField.test.ts @@ -0,0 +1,89 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { ChatLimitSaveResult } from "../logic/view-model"; +import ChatLimitField from "./ChatLimitField.svelte"; + +// A fake save that resolves ok with the value it was given. +function fakeSave(): { + saves: number[]; + last: number | null; + impl: (value: number) => Promise<ChatLimitSaveResult>; +} { + const saves: number[] = []; + return { + saves, + last: null, + impl: async (value: number) => { + saves.push(value); + return { ok: true, chatLimit: value }; + }, + }; +} + +describe("ChatLimitField", () => { + it("seeds the input from the persisted limit and disables Set while unchanged", () => { + render(ChatLimitField, { props: { chatLimit: 256, save: fakeSave().impl } }); + expect(screen.getByLabelText("Chat limit")).toHaveValue("256"); + expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); + }); + + it("enables Set when the typed value differs (regression: number-binding coercion)", async () => { + const user = userEvent.setup(); + const save = fakeSave(); + render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } }); + + const input = screen.getByLabelText("Chat limit"); + await user.clear(input); + await user.type(input, "10"); + + expect(screen.getByRole("button", { name: "Set" })).toBeEnabled(); + }); + + it("keeps Set disabled for non-numeric input", async () => { + const user = userEvent.setup(); + const save = fakeSave(); + render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } }); + + const input = screen.getByLabelText("Chat limit"); + await user.clear(input); + await user.type(input, "abc"); + + expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); + }); + + it("clicking Set calls save with the parsed value and shows the confirmation", async () => { + const user = userEvent.setup(); + const save = fakeSave(); + const { rerender } = render(ChatLimitField, { + props: { chatLimit: 256, save: save.impl }, + }); + + const input = screen.getByLabelText("Chat limit"); + await user.clear(input); + await user.type(input, "50"); + await user.click(screen.getByRole("button", { name: "Set" })); + + expect(save.saves).toEqual([50]); + // In the real app the reactive `chatLimit` prop updates to the saved value + // (the store sets it synchronously), which clears `dirty` and reveals the + // "Saved" badge. Rerender simulates that propagation. + rerender({ chatLimit: 50, save: save.impl }); + expect(screen.getByText(/Saved/i)).toBeInTheDocument(); + }); + + it("clamps a below-floor value when saving", async () => { + const user = userEvent.setup(); + const save = fakeSave(); + render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } }); + + const input = screen.getByLabelText("Chat limit"); + await user.clear(input); + await user.type(input, "5"); // clamps to MIN (10) + await user.click(screen.getByRole("button", { name: "Set" })); + + // The save port receives the clamped value (the view-model clamps before save). + expect(save.saves).toEqual([10]); + expect(input).toHaveValue("10"); + }); +}); diff --git a/src/features/smart-scroll/index.ts b/src/features/smart-scroll/index.ts index 0d30257..73b11d6 100644 --- a/src/features/smart-scroll/index.ts +++ b/src/features/smart-scroll/index.ts @@ -1,17 +1,17 @@ export type { - ScrollCommand, - ScrollGeometry, - SmartScrollResult, - SmartScrollState, + ScrollCommand, + ScrollGeometry, + SmartScrollResult, + SmartScrollState, } from "./logic/smart-scroll"; export { - createSmartScrollState, - isNearBottom, - NEAR_BOTTOM_THRESHOLD, - onContentChange, - onReset, - onResume, - onScroll, + createSmartScrollState, + isNearBottom, + NEAR_BOTTOM_THRESHOLD, + onContentChange, + onReset, + onResume, + onScroll, } from "./logic/smart-scroll"; export type { SmartScrollController } from "./ui/controller.svelte"; export { createSmartScrollController } from "./ui/controller.svelte"; @@ -19,7 +19,7 @@ export { default as ScrollToBottom } from "./ui/ScrollToBottom.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "smart-scroll", - description: - "Keeps the transcript pinned to the bottom while it streams, unless the reader scrolls up", + name: "smart-scroll", + description: + "Keeps the transcript pinned to the bottom while it streams, unless the reader scrolls up", } as const; diff --git a/src/features/smart-scroll/logic/smart-scroll.test.ts b/src/features/smart-scroll/logic/smart-scroll.test.ts index fc3e3d1..94ba1d7 100644 --- a/src/features/smart-scroll/logic/smart-scroll.test.ts +++ b/src/features/smart-scroll/logic/smart-scroll.test.ts @@ -1,103 +1,103 @@ import { describe, expect, it } from "vitest"; import { - createSmartScrollState, - isNearBottom, - NEAR_BOTTOM_THRESHOLD, - onContentChange, - onReset, - onResume, - onScroll, - type ScrollGeometry, + createSmartScrollState, + isNearBottom, + NEAR_BOTTOM_THRESHOLD, + onContentChange, + onReset, + onResume, + onScroll, + type ScrollGeometry, } from "./smart-scroll"; // A viewport 100px tall over 1000px of content: scrollTop 900 == pinned to bottom. const atBottom: ScrollGeometry = { scrollTop: 900, scrollHeight: 1000, clientHeight: 100 }; const nearBottom: ScrollGeometry = { - scrollTop: 900 - NEAR_BOTTOM_THRESHOLD, - scrollHeight: 1000, - clientHeight: 100, + scrollTop: 900 - NEAR_BOTTOM_THRESHOLD, + scrollHeight: 1000, + clientHeight: 100, }; const scrolledUp: ScrollGeometry = { scrollTop: 200, scrollHeight: 1000, clientHeight: 100 }; describe("isNearBottom", () => { - it("is true exactly at the bottom", () => { - expect(isNearBottom(atBottom)).toBe(true); - }); + it("is true exactly at the bottom", () => { + expect(isNearBottom(atBottom)).toBe(true); + }); - it("is true within the threshold of the bottom", () => { - expect(isNearBottom(nearBottom)).toBe(true); - }); + it("is true within the threshold of the bottom", () => { + expect(isNearBottom(nearBottom)).toBe(true); + }); - it("is false just beyond the threshold", () => { - expect( - isNearBottom({ - scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1, - scrollHeight: 1000, - clientHeight: 100, - }), - ).toBe(false); - }); + it("is false just beyond the threshold", () => { + expect( + isNearBottom({ + scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1, + scrollHeight: 1000, + clientHeight: 100, + }), + ).toBe(false); + }); - it("is false when scrolled well up", () => { - expect(isNearBottom(scrolledUp)).toBe(false); - }); + it("is false when scrolled well up", () => { + expect(isNearBottom(scrolledUp)).toBe(false); + }); - it("honours a custom threshold", () => { - const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 }; - expect(isNearBottom(geom, 50)).toBe(false); - expect(isNearBottom(geom, 150)).toBe(true); - }); + it("honours a custom threshold", () => { + const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 }; + expect(isNearBottom(geom, 50)).toBe(false); + expect(isNearBottom(geom, 150)).toBe(true); + }); }); describe("smart-scroll reducer", () => { - it("starts stuck and hides the button", () => { - const s = createSmartScrollState(); - expect(s.stuck).toBe(true); - }); + it("starts stuck and hides the button", () => { + const s = createSmartScrollState(); + expect(s.stuck).toBe(true); + }); - it("onScroll up unsticks and shows the button, with no command", () => { - const r = onScroll(createSmartScrollState(), scrolledUp); - expect(r.state.stuck).toBe(false); - expect(r.showButton).toBe(true); - expect(r.command).toBeNull(); - }); + it("onScroll up unsticks and shows the button, with no command", () => { + const r = onScroll(createSmartScrollState(), scrolledUp); + expect(r.state.stuck).toBe(false); + expect(r.showButton).toBe(true); + expect(r.command).toBeNull(); + }); - it("onScroll back to the bottom re-sticks and hides the button", () => { - const up = onScroll(createSmartScrollState(), scrolledUp).state; - const r = onScroll(up, atBottom); - expect(r.state.stuck).toBe(true); - expect(r.showButton).toBe(false); - expect(r.command).toBeNull(); - }); + it("onScroll back to the bottom re-sticks and hides the button", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onScroll(up, atBottom); + expect(r.state.stuck).toBe(true); + expect(r.showButton).toBe(false); + expect(r.command).toBeNull(); + }); - it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => { - const r = onContentChange(createSmartScrollState(), atBottom); - expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); - expect(r.state.stuck).toBe(true); - }); + it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => { + const r = onContentChange(createSmartScrollState(), atBottom); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); + expect(r.state.stuck).toBe(true); + }); - it("onContentChange while unstuck emits NO command (leave the reader in place)", () => { - const up = onScroll(createSmartScrollState(), scrolledUp).state; - const r = onContentChange(up, scrolledUp); - expect(r.command).toBeNull(); - expect(r.state.stuck).toBe(false); - expect(r.showButton).toBe(true); - }); + it("onContentChange while unstuck emits NO command (leave the reader in place)", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onContentChange(up, scrolledUp); + expect(r.command).toBeNull(); + expect(r.state.stuck).toBe(false); + expect(r.showButton).toBe(true); + }); - it("onResume re-sticks and emits an ANIMATED scroll", () => { - const up = onScroll(createSmartScrollState(), scrolledUp).state; - const r = onResume(up); - expect(r.state.stuck).toBe(true); - expect(r.showButton).toBe(false); - expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true }); - }); + it("onResume re-sticks and emits an ANIMATED scroll", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onResume(up); + expect(r.state.stuck).toBe(true); + expect(r.showButton).toBe(false); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true }); + }); - it("onReset returns to stuck and snaps (non-animated) to the bottom", () => { - const up = onScroll(createSmartScrollState(), scrolledUp).state; - const r = onReset(); - void up; - expect(r.state.stuck).toBe(true); - expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); - expect(r.showButton).toBe(false); - }); + it("onReset returns to stuck and snaps (non-animated) to the bottom", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onReset(); + void up; + expect(r.state.stuck).toBe(true); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); + expect(r.showButton).toBe(false); + }); }); diff --git a/src/features/smart-scroll/logic/smart-scroll.ts b/src/features/smart-scroll/logic/smart-scroll.ts index 021b3fe..4a04812 100644 --- a/src/features/smart-scroll/logic/smart-scroll.ts +++ b/src/features/smart-scroll/logic/smart-scroll.ts @@ -6,12 +6,12 @@ /** A snapshot of a scroll container's vertical geometry (in CSS pixels). */ export interface ScrollGeometry { - /** Current scroll offset from the top. */ - readonly scrollTop: number; - /** Total scrollable content height. */ - readonly scrollHeight: number; - /** Visible viewport height. */ - readonly clientHeight: number; + /** Current scroll offset from the top. */ + readonly scrollTop: number; + /** Total scrollable content height. */ + readonly scrollHeight: number; + /** Visible viewport height. */ + readonly clientHeight: number; } /** Distance (px) from the bottom within which we still consider the view "at bottom". */ @@ -19,43 +19,43 @@ export const NEAR_BOTTOM_THRESHOLD = 64; /** True when the viewport is within `threshold` px of the content's bottom edge. */ export function isNearBottom( - geom: ScrollGeometry, - threshold: number = NEAR_BOTTOM_THRESHOLD, + geom: ScrollGeometry, + threshold: number = NEAR_BOTTOM_THRESHOLD, ): boolean { - return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold; + return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold; } /** A scroll the shell should perform on the real element. */ export interface ScrollCommand { - readonly kind: "scroll-to-bottom"; - /** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */ - readonly animate: boolean; + readonly kind: "scroll-to-bottom"; + /** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */ + readonly animate: boolean; } export interface SmartScrollState { - /** - * Whether the view is currently following the bottom. While `stuck`, new - * content keeps the view pinned to the bottom; once the user scrolls up it - * goes false and stays false until they return to the bottom (or resume). - */ - readonly stuck: boolean; + /** + * Whether the view is currently following the bottom. While `stuck`, new + * content keeps the view pinned to the bottom; once the user scrolls up it + * goes false and stays false until they return to the bottom (or resume). + */ + readonly stuck: boolean; } /** A reducer step's result: the next state, an optional command, and whether to show the button. */ export interface SmartScrollResult { - readonly state: SmartScrollState; - readonly command: ScrollCommand | null; - /** Show the "scroll to bottom" affordance exactly when not stuck. */ - readonly showButton: boolean; + readonly state: SmartScrollState; + readonly command: ScrollCommand | null; + /** Show the "scroll to bottom" affordance exactly when not stuck. */ + readonly showButton: boolean; } /** Initial state — start stuck so the first content snaps to the bottom. */ export function createSmartScrollState(): SmartScrollState { - return { stuck: true }; + return { stuck: true }; } function result(state: SmartScrollState, command: ScrollCommand | null): SmartScrollResult { - return { state, command, showButton: !state.stuck }; + return { state, command, showButton: !state.stuck }; } /** @@ -64,7 +64,7 @@ function result(state: SmartScrollState, command: ScrollCommand | null): SmartSc * command — reacting to the user's own scroll with a scroll would fight them. */ export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartScrollResult { - return result({ stuck: isNearBottom(geom) }, null); + return result({ stuck: isNearBottom(geom) }, null); } /** @@ -73,7 +73,7 @@ export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartS * they are. State is unchanged — content growth alone never flips `stuck`. */ export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry): SmartScrollResult { - return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null); + return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null); } /** @@ -81,7 +81,7 @@ export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry): * emit an animated scroll. */ export function onResume(_state: SmartScrollState): SmartScrollResult { - return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true }); + return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true }); } /** @@ -89,5 +89,5 @@ export function onResume(_state: SmartScrollState): SmartScrollResult { * Reset to stuck and snap (non-animated) to the bottom of the new content. */ export function onReset(): SmartScrollResult { - return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false }); + return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false }); } diff --git a/src/features/smart-scroll/ui/ScrollToBottom.svelte b/src/features/smart-scroll/ui/ScrollToBottom.svelte index 6fbd326..38ec1f5 100644 --- a/src/features/smart-scroll/ui/ScrollToBottom.svelte +++ b/src/features/smart-scroll/ui/ScrollToBottom.svelte @@ -1,36 +1,36 @@ <script lang="ts"> - // Thin affordance: a floating "scroll to bottom" button shown while the reader - // has scrolled up. Holds no logic — `show` and `onResume` come from the - // smart-scroll controller. - let { - show, - onResume, - }: { - show: boolean; - onResume: () => void; - } = $props(); + // Thin affordance: a floating "scroll to bottom" button shown while the reader + // has scrolled up. Holds no logic — `show` and `onResume` come from the + // smart-scroll controller. + let { + show, + onResume, + }: { + show: boolean; + onResume: () => void; + } = $props(); </script> <button - type="button" - class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200" - class:opacity-0={!show} - class:pointer-events-none={!show} - class:opacity-100={show} - onclick={onResume} - aria-label="Scroll to bottom" - aria-hidden={!show} - tabindex={show ? 0 : -1} + type="button" + class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200" + class:opacity-0={!show} + class:pointer-events-none={!show} + class:opacity-100={show} + onclick={onResume} + aria-label="Scroll to bottom" + aria-hidden={!show} + tabindex={show ? 0 : -1} > - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2.5" - class="size-4" - aria-hidden="true" - > - <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /> - </svg> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2.5" + class="size-4" + aria-hidden="true" + > + <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /> + </svg> </button> diff --git a/src/features/smart-scroll/ui/controller.svelte.ts b/src/features/smart-scroll/ui/controller.svelte.ts index dbe65d1..fee6561 100644 --- a/src/features/smart-scroll/ui/controller.svelte.ts +++ b/src/features/smart-scroll/ui/controller.svelte.ts @@ -7,134 +7,134 @@ // it on unmount. import { - createSmartScrollState, - onContentChange, - onReset, - onResume, - onScroll, - type ScrollCommand, - type ScrollGeometry, - type SmartScrollResult, - type SmartScrollState, + createSmartScrollState, + onContentChange, + onReset, + onResume, + onScroll, + type ScrollCommand, + type ScrollGeometry, + type SmartScrollResult, + type SmartScrollState, } from "../logic/smart-scroll"; export interface SmartScrollController { - /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */ - readonly showButton: boolean; - /** - * Non-reactive point-in-time query: is the view stuck to the bottom right now? - * For imperative callers (e.g. the chat-limit unload gate) that poll at event - * time rather than subscribing — reads the reducer state, not a rune. - */ - isAtBottom(): boolean; - /** - * Attach to the scroll container; returns a teardown to call on unmount. - * Pass the inner CONTENT element to also follow height changes that aren't a - * transcript update (async markdown/highlight, image loads, a collapse toggling, - * viewport reflow) via a ResizeObserver. - */ - attach(el: HTMLElement, content?: HTMLElement): () => void; - /** - * Notify that the transcript content changed (a streamed delta / new message). - * While stuck, keeps the view pinned to the bottom. - */ - contentChanged(): void; - /** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */ - reset(): void; - /** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */ - resume(): void; + /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */ + readonly showButton: boolean; + /** + * Non-reactive point-in-time query: is the view stuck to the bottom right now? + * For imperative callers (e.g. the chat-limit unload gate) that poll at event + * time rather than subscribing — reads the reducer state, not a rune. + */ + isAtBottom(): boolean; + /** + * Attach to the scroll container; returns a teardown to call on unmount. + * Pass the inner CONTENT element to also follow height changes that aren't a + * transcript update (async markdown/highlight, image loads, a collapse toggling, + * viewport reflow) via a ResizeObserver. + */ + attach(el: HTMLElement, content?: HTMLElement): () => void; + /** + * Notify that the transcript content changed (a streamed delta / new message). + * While stuck, keeps the view pinned to the bottom. + */ + contentChanged(): void; + /** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */ + reset(): void; + /** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */ + resume(): void; } function geometryOf(el: HTMLElement): ScrollGeometry { - return { - scrollTop: el.scrollTop, - scrollHeight: el.scrollHeight, - clientHeight: el.clientHeight, - }; + return { + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + }; } export function createSmartScrollController(): SmartScrollController { - let state: SmartScrollState = createSmartScrollState(); - let showButton = $state(false); - let el: HTMLElement | null = null; - // True while WE drive a programmatic scroll, so the resulting `scroll` event - // doesn't get misread as the user scrolling up. Cleared on `scrollend`. - let selfScrolling = false; - - function run(command: ScrollCommand | null): void { - if (!command || !el) return; - selfScrolling = true; - el.scrollTo({ - top: el.scrollHeight, - behavior: command.animate ? "smooth" : "instant", - }); - } - - function apply(r: SmartScrollResult): void { - state = r.state; - showButton = r.showButton; - run(r.command); - } - - function handleScroll(): void { - if (!el || selfScrolling) return; - apply(onScroll(state, geometryOf(el))); - } - - function handleScrollEnd(): void { - selfScrolling = false; - } - - return { - get showButton(): boolean { - return showButton; - }, - - isAtBottom(): boolean { - return state.stuck; - }, - - attach(node: HTMLElement, content?: HTMLElement): () => void { - el = node; - node.addEventListener("scroll", handleScroll, { passive: true }); - node.addEventListener("scrollend", handleScrollEnd); - - // A ResizeObserver keeps the view pinned through height changes that are - // NOT a transcript update — async markdown/syntax-highlight, image loads, a - // collapse toggling, font swaps, viewport reflow — which a content-count - // signal can't see. Observe the CONTENT (it grows) and the container (it - // changes on viewport resize). Routed through `onContentChange`, so it only - // scrolls while stuck and never fights the reader. The `selfScrolling` guard - // (and the fact that scrolling doesn't resize content) prevents any loop. - let ro: ResizeObserver | null = null; - if (typeof ResizeObserver !== "undefined") { - ro = new ResizeObserver(() => { - if (!el || selfScrolling) return; - apply(onContentChange(state, geometryOf(el))); - }); - if (content) ro.observe(content); - ro.observe(node); - } - - return () => { - node.removeEventListener("scroll", handleScroll); - node.removeEventListener("scrollend", handleScrollEnd); - ro?.disconnect(); - if (el === node) el = null; - }; - }, - - contentChanged(): void { - if (!el) return; - apply(onContentChange(state, geometryOf(el))); - }, - - reset(): void { - apply(onReset()); - }, - - resume(): void { - apply(onResume(state)); - }, - }; + let state: SmartScrollState = createSmartScrollState(); + let showButton = $state(false); + let el: HTMLElement | null = null; + // True while WE drive a programmatic scroll, so the resulting `scroll` event + // doesn't get misread as the user scrolling up. Cleared on `scrollend`. + let selfScrolling = false; + + function run(command: ScrollCommand | null): void { + if (!command || !el) return; + selfScrolling = true; + el.scrollTo({ + top: el.scrollHeight, + behavior: command.animate ? "smooth" : "instant", + }); + } + + function apply(r: SmartScrollResult): void { + state = r.state; + showButton = r.showButton; + run(r.command); + } + + function handleScroll(): void { + if (!el || selfScrolling) return; + apply(onScroll(state, geometryOf(el))); + } + + function handleScrollEnd(): void { + selfScrolling = false; + } + + return { + get showButton(): boolean { + return showButton; + }, + + isAtBottom(): boolean { + return state.stuck; + }, + + attach(node: HTMLElement, content?: HTMLElement): () => void { + el = node; + node.addEventListener("scroll", handleScroll, { passive: true }); + node.addEventListener("scrollend", handleScrollEnd); + + // A ResizeObserver keeps the view pinned through height changes that are + // NOT a transcript update — async markdown/syntax-highlight, image loads, a + // collapse toggling, font swaps, viewport reflow — which a content-count + // signal can't see. Observe the CONTENT (it grows) and the container (it + // changes on viewport resize). Routed through `onContentChange`, so it only + // scrolls while stuck and never fights the reader. The `selfScrolling` guard + // (and the fact that scrolling doesn't resize content) prevents any loop. + let ro: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + ro = new ResizeObserver(() => { + if (!el || selfScrolling) return; + apply(onContentChange(state, geometryOf(el))); + }); + if (content) ro.observe(content); + ro.observe(node); + } + + return () => { + node.removeEventListener("scroll", handleScroll); + node.removeEventListener("scrollend", handleScrollEnd); + ro?.disconnect(); + if (el === node) el = null; + }; + }, + + contentChanged(): void { + if (!el) return; + apply(onContentChange(state, geometryOf(el))); + }, + + reset(): void { + apply(onReset()); + }, + + resume(): void { + apply(onResume(state)); + }, + }; } diff --git a/src/features/smart-scroll/ui/controller.test.ts b/src/features/smart-scroll/ui/controller.test.ts index 614f4b0..906cc91 100644 --- a/src/features/smart-scroll/ui/controller.test.ts +++ b/src/features/smart-scroll/ui/controller.test.ts @@ -5,168 +5,168 @@ import { createSmartScrollController } from "./controller.svelte"; // geometry, scrollTo, and add/removeEventListener for "scroll"/"scrollend". // Faking this outermost edge is the sanctioned mock (no internal modules mocked). function createFakeScrollEl(opts?: { scrollHeight?: number; clientHeight?: number }) { - const listeners = new Map<string, Set<EventListener>>(); - const el = { - scrollTop: 0, - scrollHeight: opts?.scrollHeight ?? 1000, - clientHeight: opts?.clientHeight ?? 100, - scrollTo: vi.fn((arg: ScrollToOptions) => { - // Emulate the browser: jump scrollTop, then (for "instant") fire scrollend. - el.scrollTop = (arg.top ?? 0) - 0; - if (arg.behavior !== "smooth") { - fire("scroll"); - fire("scrollend"); - } - }), - addEventListener: (type: string, fn: EventListener) => { - if (!listeners.has(type)) listeners.set(type, new Set()); - listeners.get(type)?.add(fn); - }, - removeEventListener: (type: string, fn: EventListener) => { - listeners.get(type)?.delete(fn); - }, - }; - function fire(type: string): void { - for (const fn of listeners.get(type) ?? []) fn(new Event(type)); - } - // Simulate the USER scrolling to a given offset (fires scroll, not self-driven). - function userScrollTo(top: number): void { - el.scrollTop = top; - fire("scroll"); - } - return { - el: el as unknown as HTMLElement, - scrollTo: el.scrollTo, - fire, - userScrollTo, - listenerCount: () => listeners, - }; + const listeners = new Map<string, Set<EventListener>>(); + const el = { + scrollTop: 0, + scrollHeight: opts?.scrollHeight ?? 1000, + clientHeight: opts?.clientHeight ?? 100, + scrollTo: vi.fn((arg: ScrollToOptions) => { + // Emulate the browser: jump scrollTop, then (for "instant") fire scrollend. + el.scrollTop = (arg.top ?? 0) - 0; + if (arg.behavior !== "smooth") { + fire("scroll"); + fire("scrollend"); + } + }), + addEventListener: (type: string, fn: EventListener) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)?.add(fn); + }, + removeEventListener: (type: string, fn: EventListener) => { + listeners.get(type)?.delete(fn); + }, + }; + function fire(type: string): void { + for (const fn of listeners.get(type) ?? []) fn(new Event(type)); + } + // Simulate the USER scrolling to a given offset (fires scroll, not self-driven). + function userScrollTo(top: number): void { + el.scrollTop = top; + fire("scroll"); + } + return { + el: el as unknown as HTMLElement, + scrollTo: el.scrollTo, + fire, + userScrollTo, + listenerCount: () => listeners, + }; } describe("smart-scroll controller", () => { - it("starts with the button hidden", () => { - const c = createSmartScrollController(); - expect(c.showButton).toBe(false); - }); + it("starts with the button hidden", () => { + const c = createSmartScrollController(); + expect(c.showButton).toBe(false); + }); - it("contentChanged while stuck scrolls to the bottom instantly", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - c.contentChanged(); - expect(fake.scrollTo).toHaveBeenCalledWith({ - top: 1000, - behavior: "instant", - }); - expect(c.showButton).toBe(false); - }); + it("contentChanged while stuck scrolls to the bottom instantly", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + c.contentChanged(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "instant", + }); + expect(c.showButton).toBe(false); + }); - it("a user scroll up shows the button and stops auto-following", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - fake.userScrollTo(200); // far from the bottom - expect(c.showButton).toBe(true); + it("a user scroll up shows the button and stops auto-following", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); // far from the bottom + expect(c.showButton).toBe(true); - const scrollTo = fake.scrollTo; - scrollTo.mockClear(); - c.contentChanged(); // streaming more content... - expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down - expect(c.showButton).toBe(true); - }); + const scrollTo = fake.scrollTo; + scrollTo.mockClear(); + c.contentChanged(); // streaming more content... + expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down + expect(c.showButton).toBe(true); + }); - it("self-driven scrolls are not misread as the user scrolling up", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - // contentChanged drives an instant scrollTo, whose synthetic scroll event - // must NOT flip us to unstuck (selfScrolling guard). - c.contentChanged(); - expect(c.showButton).toBe(false); - }); + it("self-driven scrolls are not misread as the user scrolling up", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + // contentChanged drives an instant scrollTo, whose synthetic scroll event + // must NOT flip us to unstuck (selfScrolling guard). + c.contentChanged(); + expect(c.showButton).toBe(false); + }); - it("resume re-sticks and smooth-scrolls to the bottom", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - fake.userScrollTo(200); - expect(c.showButton).toBe(true); + it("resume re-sticks and smooth-scrolls to the bottom", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); + expect(c.showButton).toBe(true); - c.resume(); - expect(fake.scrollTo).toHaveBeenCalledWith({ - top: 1000, - behavior: "smooth", - }); - expect(c.showButton).toBe(false); - }); + c.resume(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "smooth", + }); + expect(c.showButton).toBe(false); + }); - it("reset snaps to the bottom and hides the button", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - fake.userScrollTo(200); - expect(c.showButton).toBe(true); - c.reset(); - expect(fake.scrollTo).toHaveBeenCalledWith({ - top: 1000, - behavior: "instant", - }); - expect(c.showButton).toBe(false); - }); + it("reset snaps to the bottom and hides the button", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); + expect(c.showButton).toBe(true); + c.reset(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "instant", + }); + expect(c.showButton).toBe(false); + }); - it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => { - const holder: { cb: ResizeObserverCallback | null } = { cb: null }; - const observed: unknown[] = []; - const disconnect = vi.fn(); - class FakeResizeObserver { - constructor(cb: ResizeObserverCallback) { - holder.cb = cb; - } - observe(target: Element): void { - observed.push(target); - } - unobserve(): void {} - disconnect = disconnect; - } - vi.stubGlobal("ResizeObserver", FakeResizeObserver); - try { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - const content = { id: "content" } as unknown as HTMLElement; - const teardown = c.attach(fake.el, content); + it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => { + const holder: { cb: ResizeObserverCallback | null } = { cb: null }; + const observed: unknown[] = []; + const disconnect = vi.fn(); + class FakeResizeObserver { + constructor(cb: ResizeObserverCallback) { + holder.cb = cb; + } + observe(target: Element): void { + observed.push(target); + } + unobserve(): void {} + disconnect = disconnect; + } + vi.stubGlobal("ResizeObserver", FakeResizeObserver); + try { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + const content = { id: "content" } as unknown as HTMLElement; + const teardown = c.attach(fake.el, content); - // Observes both the content (it grows) and the scroll container (viewport resize). - expect(observed).toContain(content); - expect(observed).toContain(fake.el); + // Observes both the content (it grows) and the scroll container (viewport resize). + expect(observed).toContain(content); + expect(observed).toContain(fake.el); - // Stuck → a resize keeps us pinned to the bottom. - fake.scrollTo.mockClear(); - holder.cb?.([], {} as ResizeObserver); - expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" }); + // Stuck → a resize keeps us pinned to the bottom. + fake.scrollTo.mockClear(); + holder.cb?.([], {} as ResizeObserver); + expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" }); - // Reader scrolls up → a later resize must NOT yank them down. - fake.userScrollTo(200); - fake.scrollTo.mockClear(); - holder.cb?.([], {} as ResizeObserver); - expect(fake.scrollTo).not.toHaveBeenCalled(); + // Reader scrolls up → a later resize must NOT yank them down. + fake.userScrollTo(200); + fake.scrollTo.mockClear(); + holder.cb?.([], {} as ResizeObserver); + expect(fake.scrollTo).not.toHaveBeenCalled(); - // Teardown disconnects the observer. - teardown(); - expect(disconnect).toHaveBeenCalled(); - } finally { - vi.unstubAllGlobals(); - } - }); + // Teardown disconnects the observer. + teardown(); + expect(disconnect).toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); - it("attach returns a teardown that removes both listeners", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - const teardown = c.attach(fake.el); - const before = fake.listenerCount(); - expect(before.get("scroll")?.size).toBe(1); - expect(before.get("scrollend")?.size).toBe(1); - teardown(); - expect(before.get("scroll")?.size).toBe(0); - expect(before.get("scrollend")?.size).toBe(0); - }); + it("attach returns a teardown that removes both listeners", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + const teardown = c.attach(fake.el); + const before = fake.listenerCount(); + expect(before.get("scroll")?.size).toBe(1); + expect(before.get("scrollend")?.size).toBe(1); + teardown(); + expect(before.get("scroll")?.size).toBe(0); + expect(before.get("scrollend")?.size).toBe(0); + }); }); diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts index 8f289f1..1e21e3d 100644 --- a/src/features/surface-host/index.ts +++ b/src/features/surface-host/index.ts @@ -4,6 +4,6 @@ export { default as SurfaceView } from "./ui/SurfaceView.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "surface-host", - description: "Generic renderer for backend-declared surfaces", + name: "surface-host", + description: "Generic renderer for backend-declared surfaces", } as const; diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts index ce078d9..ae91c8f 100644 --- a/src/features/surface-host/logic/message-queue.test.ts +++ b/src/features/surface-host/logic/message-queue.test.ts @@ -1,48 +1,105 @@ import type { QueuedMessage } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { parseMessageQueuePayload } from "./message-queue"; +import { + parseMessageQueuePayload, + reconcileCancelledIds, + selectVisibleMessages, +} from "./message-queue"; const msg = (id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage => ({ - id, - text, - queuedAt, + id, + text, + queuedAt, }); describe("parseMessageQueuePayload", () => { - it("parses a well-formed payload with messages", () => { - const data = parseMessageQueuePayload({ - messages: [msg("m1", "steer left"), msg("m2", "actually, go right")], - }); - expect(data).toEqual({ - messages: [msg("m1", "steer left"), msg("m2", "actually, go right")], - }); - }); - - it("parses an empty-messages payload (queue is empty)", () => { - expect(parseMessageQueuePayload({ messages: [] })).toEqual({ messages: [] }); - }); - - it("preserves message order", () => { - const data = parseMessageQueuePayload({ - messages: [msg("a", "first"), msg("b", "second"), msg("c", "third")], - }); - expect(data?.messages.map((m) => m.id)).toEqual(["a", "b", "c"]); - }); - - it.each([ - ["null", null], - ["a number", 7], - ["a string", "nope"], - ["missing messages key", { foo: [] }], - ["messages not an array", { messages: "x" }], - ["entry not an object", { messages: ["x"] }], - ["entry missing id", { messages: [{ text: "x", queuedAt: 1 }] }], - ["entry with non-string id", { messages: [{ id: 1, text: "x", queuedAt: 1 }] }], - ["entry missing text", { messages: [{ id: "m1", queuedAt: 1 }] }], - ["entry with non-string text", { messages: [{ id: "m1", text: 1, queuedAt: 1 }] }], - ["entry missing queuedAt", { messages: [{ id: "m1", text: "x" }] }], - ["entry with non-finite queuedAt", { messages: [msg("m1", "x", Number.NaN)] }], - ])("returns null for invalid payload: %s", (_label, payload) => { - expect(parseMessageQueuePayload(payload)).toBeNull(); - }); + it("parses a well-formed payload with messages", () => { + const data = parseMessageQueuePayload({ + messages: [msg("m1", "steer left"), msg("m2", "actually, go right")], + }); + expect(data).toEqual({ + messages: [msg("m1", "steer left"), msg("m2", "actually, go right")], + }); + }); + + it("parses an empty-messages payload (queue is empty)", () => { + expect(parseMessageQueuePayload({ messages: [] })).toEqual({ messages: [] }); + }); + + it("preserves message order", () => { + const data = parseMessageQueuePayload({ + messages: [msg("a", "first"), msg("b", "second"), msg("c", "third")], + }); + expect(data?.messages.map((m) => m.id)).toEqual(["a", "b", "c"]); + }); + + it.each([ + ["null", null], + ["a number", 7], + ["a string", "nope"], + ["missing messages key", { foo: [] }], + ["messages not an array", { messages: "x" }], + ["entry not an object", { messages: ["x"] }], + ["entry missing id", { messages: [{ text: "x", queuedAt: 1 }] }], + ["entry with non-string id", { messages: [{ id: 1, text: "x", queuedAt: 1 }] }], + ["entry missing text", { messages: [{ id: "m1", queuedAt: 1 }] }], + ["entry with non-string text", { messages: [{ id: "m1", text: 1, queuedAt: 1 }] }], + ["entry missing queuedAt", { messages: [{ id: "m1", text: "x" }] }], + ["entry with non-finite queuedAt", { messages: [msg("m1", "x", Number.NaN)] }], + ])("returns null for invalid payload: %s", (_label, payload) => { + expect(parseMessageQueuePayload(payload)).toBeNull(); + }); +}); + +describe("selectVisibleMessages", () => { + it("returns the snapshot unchanged when nothing is cancelled", () => { + const messages = [msg("m1", "a"), msg("m2", "b")]; + expect(selectVisibleMessages(messages, new Set())).toBe(messages); + }); + + it("hides the optimistically-cancelled row", () => { + const messages = [msg("m1", "a"), msg("m2", "b"), msg("m3", "c")]; + expect(selectVisibleMessages(messages, new Set(["m2"])).map((m) => m.id)).toEqual(["m1", "m3"]); + }); + + it("hides multiple cancelled rows", () => { + const messages = [msg("m1", "a"), msg("m2", "b"), msg("m3", "c")]; + expect(selectVisibleMessages(messages, new Set(["m1", "m3"])).map((m) => m.id)).toEqual(["m2"]); + }); + + it("tolerates a cancelled id not present in the snapshot (no-op)", () => { + const messages = [msg("m1", "a")]; + expect(selectVisibleMessages(messages, new Set(["ghost"])).map((m) => m.id)).toEqual(["m1"]); + }); +}); + +describe("reconcileCancelledIds", () => { + it("returns an empty set when nothing was cancelled", () => { + const result = reconcileCancelledIds([msg("m1", "a")], new Set()); + expect(result.size).toBe(0); + }); + + it("drops ids the surface confirmed gone (no longer queued)", () => { + // m1 still queued (cancel pending), m2 confirmed gone (left the snapshot). + const messages = [msg("m1", "a")]; + const result = reconcileCancelledIds(messages, new Set(["m1", "m2"])); + expect([...result]).toEqual(["m1"]); + }); + + it("returns the SAME set identity when nothing changed (no spurious cycle)", () => { + const messages = [msg("m1", "a"), msg("m2", "b")]; + const cancelled = new Set(["m1", "m2"]); + expect(reconcileCancelledIds(messages, cancelled)).toBe(cancelled); + }); + + it("returns an empty set when all cancels were confirmed", () => { + const messages = [msg("m1", "a")]; + expect(reconcileCancelledIds(messages, new Set(["m2", "m3"])).size).toBe(0); + }); + + it("keeps a still-queued cancelled id (cancel still pending)", () => { + const messages = [msg("m1", "a"), msg("m2", "b")]; + const result = reconcileCancelledIds(messages, new Set(["m2"])); + expect([...result]).toEqual(["m2"]); + }); }); diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts index a8e1567..7a3653e 100644 --- a/src/features/surface-host/logic/message-queue.ts +++ b/src/features/surface-host/logic/message-queue.ts @@ -14,32 +14,93 @@ import type { QueuedMessage } from "@dispatch/wire"; * payload shape. */ export interface MessageQueueData { - readonly messages: readonly QueuedMessage[]; + readonly messages: readonly QueuedMessage[]; } function isQueuedMessage(v: unknown): v is QueuedMessage { - if (typeof v !== "object" || v === null) return false; - const o = v as Record<string, unknown>; - return ( - typeof o.id === "string" && - typeof o.text === "string" && - typeof o.queuedAt === "number" && - Number.isFinite(o.queuedAt) - ); + if (typeof v !== "object" || v === null) return false; + const o = v as Record<string, unknown>; + return ( + typeof o.id === "string" && + typeof o.text === "string" && + typeof o.queuedAt === "number" && + Number.isFinite(o.queuedAt) + ); } export function parseMessageQueuePayload(payload: unknown): MessageQueueData | null { - if (typeof payload !== "object" || payload === null) return null; - const obj = payload as Record<string, unknown>; - const raw = obj.messages; - if (!Array.isArray(raw)) return null; - const messages: QueuedMessage[] = []; - for (const entry of raw) { - if (!isQueuedMessage(entry)) return null; - messages.push(entry); - } - return { messages }; + if (typeof payload !== "object" || payload === null) return null; + const obj = payload as Record<string, unknown>; + const raw = obj.messages; + if (!Array.isArray(raw)) return null; + const messages: QueuedMessage[] = []; + for (const entry of raw) { + if (!isQueuedMessage(entry)) return null; + messages.push(entry); + } + return { messages }; } /** The `rendererId` the message-queue extension's `custom` surface field uses. */ export const MESSAGE_QUEUE_RENDERER_ID = "message-queue"; + +/** + * Optimistic-removal view-model for the queue list. + * + * The `chat.queue.cancel` op is fire-and-forget + idempotent: success is + * confirmed by the `message-queue` SURFACE updating (the cancelled message + * leaves the snapshot), not by a reply. To avoid a flash of the row lingering + * for a round-trip, the renderer hides a row the instant the user clicks cancel + * (tracking the cancelled id locally), then reconciles when the surface pushes + * the post-cancel snapshot. These two pure helpers drive that — the component + * holds the cancelled-id set as a thin `$state` wrapper and delegates all + * decisions here. + */ + +/** + * The messages the renderer should show: the surface snapshot MINUS any + * optimistically-cancelled ids (a cancel whose surface confirmation hasn't + * arrived yet). Pure — no mutation of inputs. + */ +export function selectVisibleMessages( + messages: readonly QueuedMessage[], + cancelledIds: ReadonlySet<string>, +): readonly QueuedMessage[] { + if (cancelledIds.size === 0) return messages; + return messages.filter((m) => !cancelledIds.has(m.id)); +} + +/** + * Reconcile the cancelled-id set against a NEW surface snapshot: keep only the + * ids that are STILL queued (the cancel is pending — its surface confirmation + * hasn't landed). Drop ids that have left the snapshot: the server confirmed + * the removal (or the message drained as steering / the queue cleared), so the + * optimistic hide is no longer needed. This keeps the set bounded — it never + * outlives the rows it tracks. Pure — returns a NEW set (callers assign it to + * the reactive `$state`). + */ +export function reconcileCancelledIds( + messages: readonly QueuedMessage[], + cancelledIds: ReadonlySet<string>, +): ReadonlySet<string> { + if (cancelledIds.size === 0) return EMPTY_STRING_SET; + const stillQueued = new Set<string>(); + for (const m of messages) { + if (cancelledIds.has(m.id)) stillQueued.add(m.id); + } + // Same set back → return the input identity so the component's `$state` setter + // sees no change (avoids a spurious reactive cycle). + if (stillQueued.size === cancelledIds.size) { + let same = true; + for (const id of cancelledIds) { + if (!stillQueued.has(id)) { + same = false; + break; + } + } + if (same) return cancelledIds; + } + return stillQueued; +} + +const EMPTY_STRING_SET: ReadonlySet<string> = new Set<string>(); diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts index be296a7..9c8a34d 100644 --- a/src/features/surface-host/logic/plan.test.ts +++ b/src/features/surface-host/logic/plan.test.ts @@ -4,247 +4,247 @@ import { buildInvoke, groupRenderFields, planSurface } from "./plan"; import type { FieldView } from "./types"; const makeSpec = (...fields: SurfaceField[]): SurfaceSpec => ({ - id: "test-surface", - region: "test", - title: "Test Surface", - fields, + id: "test-surface", + region: "test", + title: "Test Surface", + fields, }); describe("planSurface", () => { - it("maps a toggle field to a ToggleFieldView", () => { - const plan = planSurface( - makeSpec({ kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }), - ); - expect(plan.fields).toEqual([ - { kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }, - ]); - }); - - it("maps a progress field to a ProgressFieldView", () => { - const plan = planSurface(makeSpec({ kind: "progress", label: "Loading", value: 0.42 })); - expect(plan.fields).toEqual([{ kind: "progress", label: "Loading", value: 0.42 }]); - }); - - it("maps a selector field to a SelectorFieldView", () => { - const plan = planSurface( - makeSpec({ - kind: "selector", - label: "Model", - value: "gpt-4", - options: [ - { value: "gpt-4", label: "GPT-4" }, - { value: "gpt-3.5", label: "GPT-3.5" }, - ], - action: { actionId: "set-model" }, - }), - ); - expect(plan.fields).toEqual([ - { - kind: "selector", - label: "Model", - value: "gpt-4", - options: [ - { value: "gpt-4", label: "GPT-4" }, - { value: "gpt-3.5", label: "GPT-3.5" }, - ], - action: { actionId: "set-model" }, - }, - ]); - }); - - it("maps a stat field to a StatFieldView", () => { - const plan = planSurface(makeSpec({ kind: "stat", label: "Tokens", value: "1,234" })); - expect(plan.fields).toEqual([{ kind: "stat", label: "Tokens", value: "1,234" }]); - }); - - it("maps a number field to a NumberFieldView, carrying optional hints", () => { - const plan = planSurface( - makeSpec({ - kind: "number", - label: "Interval", - value: 240, - min: 1, - step: 1, - unit: "s", - action: { actionId: "cache-warming/set-interval" }, - }), - ); - expect(plan.fields).toEqual([ - { - kind: "number", - label: "Interval", - value: 240, - min: 1, - step: 1, - unit: "s", - action: { actionId: "cache-warming/set-interval" }, - }, - ]); - }); - - it("omits absent number hints (no max key when undefined)", () => { - const plan = planSurface( - makeSpec({ - kind: "number", - label: "Interval", - value: 240, - min: 1, - action: { actionId: "set" }, - }), - ); - const field = plan.fields[0]; - expect(field).not.toHaveProperty("max"); - expect(field).not.toHaveProperty("step"); - expect(field).not.toHaveProperty("unit"); - }); - - it("maps a button field to a ButtonFieldView", () => { - const plan = planSurface( - makeSpec({ kind: "button", label: "Retry", action: { actionId: "retry" } }), - ); - expect(plan.fields).toEqual([ - { kind: "button", label: "Retry", action: { actionId: "retry" } }, - ]); - }); - - it("preserves field order", () => { - const plan = planSurface( - makeSpec( - { kind: "stat", label: "A", value: "1" }, - { kind: "toggle", label: "B", value: false, action: { actionId: "b" } }, - { kind: "progress", label: "C", value: 0.5 }, - { kind: "button", label: "D", action: { actionId: "d" } }, - ), - ); - expect(plan.fields.map((f) => ("label" in f ? f.label : null))).toEqual(["A", "B", "C", "D"]); - }); - - it("drops unknown field kinds gracefully", () => { - const plan = planSurface( - makeSpec({ kind: "stat", label: "Known", value: "ok" }, { - kind: "future-kind" as "stat", - label: "Unknown", - value: "?", - } as SurfaceField), - ); - expect(plan.fields).toHaveLength(1); - const first = plan.fields[0]; - expect(first && "label" in first ? first.label : null).toBe("Known"); - }); - - it("carries custom fields through verbatim, preserving order", () => { - const plan = planSurface( - makeSpec( - { kind: "stat", label: "Before", value: "1" }, - { kind: "custom", rendererId: "chart", payload: { data: [1, 2, 3] } }, - { kind: "stat", label: "After", value: "2" }, - ), - ); - expect(plan.fields).toHaveLength(3); - expect(plan.fields[1]).toEqual({ - kind: "custom", - rendererId: "chart", - payload: { data: [1, 2, 3] }, - }); - }); - - it("returns empty fields for an empty spec", () => { - const plan = planSurface(makeSpec()); - expect(plan.fields).toEqual([]); - }); - - it("keeps every custom field (render-time decides whether to show each)", () => { - const plan = planSurface( - makeSpec( - { kind: "custom", rendererId: "x", payload: null }, - { kind: "custom", rendererId: "y", payload: 42 }, - ), - ); - expect(plan.fields.map((f) => f.kind)).toEqual(["custom", "custom"]); - }); + it("maps a toggle field to a ToggleFieldView", () => { + const plan = planSurface( + makeSpec({ kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }), + ); + expect(plan.fields).toEqual([ + { kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }, + ]); + }); + + it("maps a progress field to a ProgressFieldView", () => { + const plan = planSurface(makeSpec({ kind: "progress", label: "Loading", value: 0.42 })); + expect(plan.fields).toEqual([{ kind: "progress", label: "Loading", value: 0.42 }]); + }); + + it("maps a selector field to a SelectorFieldView", () => { + const plan = planSurface( + makeSpec({ + kind: "selector", + label: "Model", + value: "gpt-4", + options: [ + { value: "gpt-4", label: "GPT-4" }, + { value: "gpt-3.5", label: "GPT-3.5" }, + ], + action: { actionId: "set-model" }, + }), + ); + expect(plan.fields).toEqual([ + { + kind: "selector", + label: "Model", + value: "gpt-4", + options: [ + { value: "gpt-4", label: "GPT-4" }, + { value: "gpt-3.5", label: "GPT-3.5" }, + ], + action: { actionId: "set-model" }, + }, + ]); + }); + + it("maps a stat field to a StatFieldView", () => { + const plan = planSurface(makeSpec({ kind: "stat", label: "Tokens", value: "1,234" })); + expect(plan.fields).toEqual([{ kind: "stat", label: "Tokens", value: "1,234" }]); + }); + + it("maps a number field to a NumberFieldView, carrying optional hints", () => { + const plan = planSurface( + makeSpec({ + kind: "number", + label: "Interval", + value: 240, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }), + ); + expect(plan.fields).toEqual([ + { + kind: "number", + label: "Interval", + value: 240, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }, + ]); + }); + + it("omits absent number hints (no max key when undefined)", () => { + const plan = planSurface( + makeSpec({ + kind: "number", + label: "Interval", + value: 240, + min: 1, + action: { actionId: "set" }, + }), + ); + const field = plan.fields[0]; + expect(field).not.toHaveProperty("max"); + expect(field).not.toHaveProperty("step"); + expect(field).not.toHaveProperty("unit"); + }); + + it("maps a button field to a ButtonFieldView", () => { + const plan = planSurface( + makeSpec({ kind: "button", label: "Retry", action: { actionId: "retry" } }), + ); + expect(plan.fields).toEqual([ + { kind: "button", label: "Retry", action: { actionId: "retry" } }, + ]); + }); + + it("preserves field order", () => { + const plan = planSurface( + makeSpec( + { kind: "stat", label: "A", value: "1" }, + { kind: "toggle", label: "B", value: false, action: { actionId: "b" } }, + { kind: "progress", label: "C", value: 0.5 }, + { kind: "button", label: "D", action: { actionId: "d" } }, + ), + ); + expect(plan.fields.map((f) => ("label" in f ? f.label : null))).toEqual(["A", "B", "C", "D"]); + }); + + it("drops unknown field kinds gracefully", () => { + const plan = planSurface( + makeSpec({ kind: "stat", label: "Known", value: "ok" }, { + kind: "future-kind" as "stat", + label: "Unknown", + value: "?", + } as SurfaceField), + ); + expect(plan.fields).toHaveLength(1); + const first = plan.fields[0]; + expect(first && "label" in first ? first.label : null).toBe("Known"); + }); + + it("carries custom fields through verbatim, preserving order", () => { + const plan = planSurface( + makeSpec( + { kind: "stat", label: "Before", value: "1" }, + { kind: "custom", rendererId: "chart", payload: { data: [1, 2, 3] } }, + { kind: "stat", label: "After", value: "2" }, + ), + ); + expect(plan.fields).toHaveLength(3); + expect(plan.fields[1]).toEqual({ + kind: "custom", + rendererId: "chart", + payload: { data: [1, 2, 3] }, + }); + }); + + it("returns empty fields for an empty spec", () => { + const plan = planSurface(makeSpec()); + expect(plan.fields).toEqual([]); + }); + + it("keeps every custom field (render-time decides whether to show each)", () => { + const plan = planSurface( + makeSpec( + { kind: "custom", rendererId: "x", payload: null }, + { kind: "custom", rendererId: "y", payload: 42 }, + ), + ); + expect(plan.fields.map((f) => f.kind)).toEqual(["custom", "custom"]); + }); }); describe("groupRenderFields", () => { - const stat = (label: string, value: string): FieldView => ({ kind: "stat", label, value }); - const toggle = (label: string): FieldView => ({ - kind: "toggle", - label, - value: false, - action: { actionId: label }, - }); - - it("coalesces consecutive stats into a single stats group", () => { - const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), stat("c", "3")]); - expect(groups).toHaveLength(1); - expect(groups[0]).toEqual({ - type: "stats", - stats: [ - { kind: "stat", label: "a", value: "1" }, - { kind: "stat", label: "b", value: "2" }, - { kind: "stat", label: "c", value: "3" }, - ], - }); - }); - - it("keeps non-stat fields as standalone groups and preserves order", () => { - const groups = groupRenderFields([stat("a", "1"), toggle("t"), stat("b", "2")]); - expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]); - const first = groups[0]; - const last = groups[2]; - if (first?.type !== "stats" || last?.type !== "stats") throw new Error("bad grouping"); - expect(first.stats.map((s) => s.label)).toEqual(["a"]); - expect(last.stats.map((s) => s.label)).toEqual(["b"]); - }); - - it("starts a new stats run after an interrupting field", () => { - const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), toggle("t"), stat("c", "3")]); - expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]); - }); - - it("returns no groups for an empty field list", () => { - expect(groupRenderFields([])).toEqual([]); - }); + const stat = (label: string, value: string): FieldView => ({ kind: "stat", label, value }); + const toggle = (label: string): FieldView => ({ + kind: "toggle", + label, + value: false, + action: { actionId: label }, + }); + + it("coalesces consecutive stats into a single stats group", () => { + const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), stat("c", "3")]); + expect(groups).toHaveLength(1); + expect(groups[0]).toEqual({ + type: "stats", + stats: [ + { kind: "stat", label: "a", value: "1" }, + { kind: "stat", label: "b", value: "2" }, + { kind: "stat", label: "c", value: "3" }, + ], + }); + }); + + it("keeps non-stat fields as standalone groups and preserves order", () => { + const groups = groupRenderFields([stat("a", "1"), toggle("t"), stat("b", "2")]); + expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]); + const first = groups[0]; + const last = groups[2]; + if (first?.type !== "stats" || last?.type !== "stats") throw new Error("bad grouping"); + expect(first.stats.map((s) => s.label)).toEqual(["a"]); + expect(last.stats.map((s) => s.label)).toEqual(["b"]); + }); + + it("starts a new stats run after an interrupting field", () => { + const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), toggle("t"), stat("c", "3")]); + expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]); + }); + + it("returns no groups for an empty field list", () => { + expect(groupRenderFields([])).toEqual([]); + }); }); describe("buildInvoke", () => { - it("builds an invoke message for a toggle field", () => { - const field = { kind: "toggle" as const, label: "T", value: false, action: { actionId: "t" } }; - const msg = buildInvoke("s1", field, true); - expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "t", payload: true }); - }); - - it("builds an invoke message for a selector field", () => { - const field = { - kind: "selector" as const, - label: "S", - value: "a", - options: [], - action: { actionId: "sel" }, - }; - const msg = buildInvoke("s1", field, "b"); - expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "sel", payload: "b" }); - }); - - it("builds an invoke message without payload for a button field", () => { - const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; - const msg = buildInvoke("s1", field); - expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "btn" }); - }); - - it("omits payload key when value is undefined", () => { - const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; - const msg = buildInvoke("s1", field, undefined); - expect(msg).not.toHaveProperty("payload"); - }); - - it("uses the field's actionId, not a surface-level id", () => { - const field = { - kind: "toggle" as const, - label: "X", - value: true, - action: { actionId: "custom-action-123" }, - }; - const msg = buildInvoke("surf", field, false); - expect(msg.actionId).toBe("custom-action-123"); - }); + it("builds an invoke message for a toggle field", () => { + const field = { kind: "toggle" as const, label: "T", value: false, action: { actionId: "t" } }; + const msg = buildInvoke("s1", field, true); + expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "t", payload: true }); + }); + + it("builds an invoke message for a selector field", () => { + const field = { + kind: "selector" as const, + label: "S", + value: "a", + options: [], + action: { actionId: "sel" }, + }; + const msg = buildInvoke("s1", field, "b"); + expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "sel", payload: "b" }); + }); + + it("builds an invoke message without payload for a button field", () => { + const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; + const msg = buildInvoke("s1", field); + expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "btn" }); + }); + + it("omits payload key when value is undefined", () => { + const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; + const msg = buildInvoke("s1", field, undefined); + expect(msg).not.toHaveProperty("payload"); + }); + + it("uses the field's actionId, not a surface-level id", () => { + const field = { + kind: "toggle" as const, + label: "X", + value: true, + action: { actionId: "custom-action-123" }, + }; + const msg = buildInvoke("surf", field, false); + expect(msg.actionId).toBe("custom-action-123"); + }); }); diff --git a/src/features/surface-host/logic/plan.ts b/src/features/surface-host/logic/plan.ts index 89088c3..c8f82b9 100644 --- a/src/features/surface-host/logic/plan.ts +++ b/src/features/surface-host/logic/plan.ts @@ -1,20 +1,20 @@ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; import type { - FieldView, - NumberFieldView, - RenderGroup, - StatFieldView, - SurfaceRenderPlan, + FieldView, + NumberFieldView, + RenderGroup, + StatFieldView, + SurfaceRenderPlan, } from "./types"; const KNOWN_KINDS = new Set([ - "toggle", - "progress", - "selector", - "stat", - "number", - "button", - "custom", + "toggle", + "progress", + "selector", + "stat", + "number", + "button", + "custom", ]); /** @@ -25,73 +25,73 @@ const KNOWN_KINDS = new Set([ * decision (unknown `rendererId` → skipped there), not a planning one. */ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan { - const fields: FieldView[] = []; - for (const field of spec.fields) { - if (!KNOWN_KINDS.has(field.kind)) continue; - switch (field.kind) { - case "toggle": - fields.push({ - kind: "toggle", - label: field.label, - value: field.value, - action: field.action, - }); - break; - case "progress": - fields.push({ - kind: "progress", - label: field.label, - value: field.value, - }); - break; - case "selector": - fields.push({ - kind: "selector", - label: field.label, - value: field.value, - options: field.options, - action: field.action, - }); - break; - case "stat": - fields.push({ - kind: "stat", - label: field.label, - value: field.value, - }); - break; - case "number": { - // Carry optional hints only when present (exactOptionalPropertyTypes). - const view: NumberFieldView = { - kind: "number", - label: field.label, - value: field.value, - action: field.action, - ...(field.min !== undefined ? { min: field.min } : {}), - ...(field.max !== undefined ? { max: field.max } : {}), - ...(field.step !== undefined ? { step: field.step } : {}), - ...(field.unit !== undefined ? { unit: field.unit } : {}), - }; - fields.push(view); - break; - } - case "button": - fields.push({ - kind: "button", - label: field.label, - action: field.action, - }); - break; - case "custom": - fields.push({ - kind: "custom", - rendererId: field.rendererId, - payload: field.payload, - }); - break; - } - } - return { fields }; + const fields: FieldView[] = []; + for (const field of spec.fields) { + if (!KNOWN_KINDS.has(field.kind)) continue; + switch (field.kind) { + case "toggle": + fields.push({ + kind: "toggle", + label: field.label, + value: field.value, + action: field.action, + }); + break; + case "progress": + fields.push({ + kind: "progress", + label: field.label, + value: field.value, + }); + break; + case "selector": + fields.push({ + kind: "selector", + label: field.label, + value: field.value, + options: field.options, + action: field.action, + }); + break; + case "stat": + fields.push({ + kind: "stat", + label: field.label, + value: field.value, + }); + break; + case "number": { + // Carry optional hints only when present (exactOptionalPropertyTypes). + const view: NumberFieldView = { + kind: "number", + label: field.label, + value: field.value, + action: field.action, + ...(field.min !== undefined ? { min: field.min } : {}), + ...(field.max !== undefined ? { max: field.max } : {}), + ...(field.step !== undefined ? { step: field.step } : {}), + ...(field.unit !== undefined ? { unit: field.unit } : {}), + }; + fields.push(view); + break; + } + case "button": + fields.push({ + kind: "button", + label: field.label, + action: field.action, + }); + break; + case "custom": + fields.push({ + kind: "custom", + rendererId: field.rendererId, + payload: field.payload, + }); + break; + } + } + return { fields }; } /** @@ -100,24 +100,24 @@ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan { * other field stays a standalone `field` group. Order is preserved. Pure. */ export function groupRenderFields(fields: readonly FieldView[]): RenderGroup[] { - const groups: RenderGroup[] = []; - let run: StatFieldView[] = []; - const flush = (): void => { - if (run.length > 0) { - groups.push({ type: "stats", stats: run }); - run = []; - } - }; - for (const field of fields) { - if (field.kind === "stat") { - run.push(field); - } else { - flush(); - groups.push({ type: "field", field }); - } - } - flush(); - return groups; + const groups: RenderGroup[] = []; + let run: StatFieldView[] = []; + const flush = (): void => { + if (run.length > 0) { + groups.push({ type: "stats", stats: run }); + run = []; + } + }; + for (const field of fields) { + if (field.kind === "stat") { + run.push(field); + } else { + flush(); + groups.push({ type: "field", field }); + } + } + flush(); + return groups; } /** @@ -126,13 +126,13 @@ export function groupRenderFields(fields: readonly FieldView[]): RenderGroup[] { * for button the payload is omitted. */ export function buildInvoke( - surfaceId: string, - field: Extract<FieldView, { action: unknown }>, - value?: unknown, + surfaceId: string, + field: Extract<FieldView, { action: unknown }>, + value?: unknown, ): InvokeMessage { - const base = { type: "invoke" as const, surfaceId, actionId: field.action.actionId }; - if (value !== undefined) { - return { ...base, payload: value }; - } - return base; + const base = { type: "invoke" as const, surfaceId, actionId: field.action.actionId }; + if (value !== undefined) { + return { ...base, payload: value }; + } + return base; } diff --git a/src/features/surface-host/logic/table.test.ts b/src/features/surface-host/logic/table.test.ts index e55b3f7..6fb558a 100644 --- a/src/features/surface-host/logic/table.test.ts +++ b/src/features/surface-host/logic/table.test.ts @@ -2,46 +2,46 @@ import { describe, expect, it } from "vitest"; import { parseTablePayload } from "./table"; describe("parseTablePayload", () => { - it("parses a well-formed table payload", () => { - const data = parseTablePayload({ - columns: ["Name", "Version"], - rows: [ - ["alpha", "1.0"], - ["beta", "2.3"], - ], - }); - expect(data).toEqual({ - columns: ["Name", "Version"], - rows: [ - ["alpha", "1.0"], - ["beta", "2.3"], - ], - }); - }); + it("parses a well-formed table payload", () => { + const data = parseTablePayload({ + columns: ["Name", "Version"], + rows: [ + ["alpha", "1.0"], + ["beta", "2.3"], + ], + }); + expect(data).toEqual({ + columns: ["Name", "Version"], + rows: [ + ["alpha", "1.0"], + ["beta", "2.3"], + ], + }); + }); - it("coerces numeric and boolean cells to strings", () => { - const data = parseTablePayload({ - columns: ["k", "n", "b"], - rows: [["x", 42, true]], - }); - expect(data?.rows[0]).toEqual(["x", "42", "true"]); - }); + it("coerces numeric and boolean cells to strings", () => { + const data = parseTablePayload({ + columns: ["k", "n", "b"], + rows: [["x", 42, true]], + }); + expect(data?.rows[0]).toEqual(["x", "42", "true"]); + }); - it("accepts an empty rows array", () => { - expect(parseTablePayload({ columns: ["A"], rows: [] })).toEqual({ columns: ["A"], rows: [] }); - }); + it("accepts an empty rows array", () => { + expect(parseTablePayload({ columns: ["A"], rows: [] })).toEqual({ columns: ["A"], rows: [] }); + }); - it.each([ - ["null", null], - ["a number", 7], - ["a string", "nope"], - ["missing columns", { rows: [] }], - ["missing rows", { columns: ["A"] }], - ["non-string column", { columns: [1], rows: [] }], - ["row that is not an array", { columns: ["A"], rows: ["x"] }], - ["cell of unsupported type", { columns: ["A"], rows: [[{ nested: true }]] }], - ["non-finite numeric cell", { columns: ["A"], rows: [[Number.NaN]] }], - ])("returns null for invalid payload: %s", (_label, payload) => { - expect(parseTablePayload(payload)).toBeNull(); - }); + it.each([ + ["null", null], + ["a number", 7], + ["a string", "nope"], + ["missing columns", { rows: [] }], + ["missing rows", { columns: ["A"] }], + ["non-string column", { columns: [1], rows: [] }], + ["row that is not an array", { columns: ["A"], rows: ["x"] }], + ["cell of unsupported type", { columns: ["A"], rows: [[{ nested: true }]] }], + ["non-finite numeric cell", { columns: ["A"], rows: [[Number.NaN]] }], + ])("returns null for invalid payload: %s", (_label, payload) => { + expect(parseTablePayload(payload)).toBeNull(); + }); }); diff --git a/src/features/surface-host/logic/table.ts b/src/features/surface-host/logic/table.ts index 027553c..5d2b831 100644 --- a/src/features/surface-host/logic/table.ts +++ b/src/features/surface-host/logic/table.ts @@ -9,46 +9,46 @@ */ export interface TableData { - readonly columns: readonly string[]; - readonly rows: readonly (readonly string[])[]; + readonly columns: readonly string[]; + readonly rows: readonly (readonly string[])[]; } function isStringArray(v: unknown): v is unknown[] { - return Array.isArray(v); + return Array.isArray(v); } function coerceCell(v: unknown): string | null { - if (typeof v === "string") return v; - if (typeof v === "number" && Number.isFinite(v)) return String(v); - if (typeof v === "boolean") return String(v); - return null; + if (typeof v === "string") return v; + if (typeof v === "number" && Number.isFinite(v)) return String(v); + if (typeof v === "boolean") return String(v); + return null; } export function parseTablePayload(payload: unknown): TableData | null { - if (typeof payload !== "object" || payload === null) return null; - const obj = payload as Record<string, unknown>; - - const rawColumns = obj.columns; - const rawRows = obj.rows; - if (!isStringArray(rawColumns) || !isStringArray(rawRows)) return null; - - const columns: string[] = []; - for (const col of rawColumns) { - if (typeof col !== "string") return null; - columns.push(col); - } - - const rows: string[][] = []; - for (const row of rawRows) { - if (!Array.isArray(row)) return null; - const cells: string[] = []; - for (const cell of row) { - const c = coerceCell(cell); - if (c === null) return null; - cells.push(c); - } - rows.push(cells); - } - - return { columns, rows }; + if (typeof payload !== "object" || payload === null) return null; + const obj = payload as Record<string, unknown>; + + const rawColumns = obj.columns; + const rawRows = obj.rows; + if (!isStringArray(rawColumns) || !isStringArray(rawRows)) return null; + + const columns: string[] = []; + for (const col of rawColumns) { + if (typeof col !== "string") return null; + columns.push(col); + } + + const rows: string[][] = []; + for (const row of rawRows) { + if (!Array.isArray(row)) return null; + const cells: string[] = []; + for (const cell of row) { + const c = coerceCell(cell); + if (c === null) return null; + cells.push(c); + } + rows.push(cells); + } + + return { columns, rows }; } diff --git a/src/features/surface-host/logic/todo.test.ts b/src/features/surface-host/logic/todo.test.ts index 225ecde..66ff036 100644 --- a/src/features/surface-host/logic/todo.test.ts +++ b/src/features/surface-host/logic/todo.test.ts @@ -2,66 +2,66 @@ import { describe, expect, it } from "vitest"; import { parseTodoPayload, type TodoItem } from "./todo"; const item = (content: string, status: TodoItem["status"] = "pending"): TodoItem => ({ - content, - status, + content, + status, }); describe("parseTodoPayload", () => { - it("parses a well-formed payload with items", () => { - const data = parseTodoPayload({ - todos: [ - item("Write tests", "in_progress"), - item("Ship it", "pending"), - item("Read docs", "completed"), - ], - }); - expect(data).toEqual({ - todos: [ - item("Write tests", "in_progress"), - item("Ship it", "pending"), - item("Read docs", "completed"), - ], - }); - }); + it("parses a well-formed payload with items", () => { + const data = parseTodoPayload({ + todos: [ + item("Write tests", "in_progress"), + item("Ship it", "pending"), + item("Read docs", "completed"), + ], + }); + expect(data).toEqual({ + todos: [ + item("Write tests", "in_progress"), + item("Ship it", "pending"), + item("Read docs", "completed"), + ], + }); + }); - it("parses an empty-todos payload", () => { - expect(parseTodoPayload({ todos: [] })).toEqual({ todos: [] }); - }); + it("parses an empty-todos payload", () => { + expect(parseTodoPayload({ todos: [] })).toEqual({ todos: [] }); + }); - it("preserves item order", () => { - const data = parseTodoPayload({ todos: [item("a"), item("b"), item("c")] }); - expect(data?.todos.map((t) => t.content)).toEqual(["a", "b", "c"]); - }); + it("preserves item order", () => { + const data = parseTodoPayload({ todos: [item("a"), item("b"), item("c")] }); + expect(data?.todos.map((t) => t.content)).toEqual(["a", "b", "c"]); + }); - it("accepts all four status values", () => { - const data = parseTodoPayload({ - todos: [ - item("p", "pending"), - item("i", "in_progress"), - item("c", "completed"), - item("x", "cancelled"), - ], - }); - expect(data?.todos.map((t) => t.status)).toEqual([ - "pending", - "in_progress", - "completed", - "cancelled", - ]); - }); + it("accepts all four status values", () => { + const data = parseTodoPayload({ + todos: [ + item("p", "pending"), + item("i", "in_progress"), + item("c", "completed"), + item("x", "cancelled"), + ], + }); + expect(data?.todos.map((t) => t.status)).toEqual([ + "pending", + "in_progress", + "completed", + "cancelled", + ]); + }); - it.each([ - ["null", null], - ["a number", 7], - ["a string", "nope"], - ["missing todos key", { foo: [] }], - ["todos not an array", { todos: "x" }], - ["entry not an object", { todos: ["x"] }], - ["entry missing content", { todos: [{ status: "pending" }] }], - ["entry with non-string content", { todos: [{ content: 1, status: "pending" }] }], - ["entry missing status", { todos: [{ content: "x" }] }], - ["entry with invalid status", { todos: [item("x", "done" as never)] }], - ])("returns null for invalid payload: %s", (_label, payload) => { - expect(parseTodoPayload(payload)).toBeNull(); - }); + it.each([ + ["null", null], + ["a number", 7], + ["a string", "nope"], + ["missing todos key", { foo: [] }], + ["todos not an array", { todos: "x" }], + ["entry not an object", { todos: ["x"] }], + ["entry missing content", { todos: [{ status: "pending" }] }], + ["entry with non-string content", { todos: [{ content: 1, status: "pending" }] }], + ["entry missing status", { todos: [{ content: "x" }] }], + ["entry with invalid status", { todos: [item("x", "done" as never)] }], + ])("returns null for invalid payload: %s", (_label, payload) => { + expect(parseTodoPayload(payload)).toBeNull(); + }); }); diff --git a/src/features/surface-host/logic/todo.ts b/src/features/surface-host/logic/todo.ts index e442e78..8b8a5ef 100644 --- a/src/features/surface-host/logic/todo.ts +++ b/src/features/surface-host/logic/todo.ts @@ -16,33 +16,33 @@ export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled"; export interface TodoItem { - readonly content: string; - readonly status: TodoStatus; + readonly content: string; + readonly status: TodoStatus; } export interface TodoData { - readonly todos: readonly TodoItem[]; + readonly todos: readonly TodoItem[]; } const STATUSES = new Set<string>(["pending", "in_progress", "completed", "cancelled"]); function isTodoItem(v: unknown): v is TodoItem { - if (typeof v !== "object" || v === null) return false; - const o = v as Record<string, unknown>; - return typeof o.content === "string" && typeof o.status === "string" && STATUSES.has(o.status); + if (typeof v !== "object" || v === null) return false; + const o = v as Record<string, unknown>; + return typeof o.content === "string" && typeof o.status === "string" && STATUSES.has(o.status); } export function parseTodoPayload(payload: unknown): TodoData | null { - if (typeof payload !== "object" || payload === null) return null; - const obj = payload as Record<string, unknown>; - const raw = obj.todos; - if (!Array.isArray(raw)) return null; - const todos: TodoItem[] = []; - for (const entry of raw) { - if (!isTodoItem(entry)) return null; - todos.push(entry); - } - return { todos }; + if (typeof payload !== "object" || payload === null) return null; + const obj = payload as Record<string, unknown>; + const raw = obj.todos; + if (!Array.isArray(raw)) return null; + const todos: TodoItem[] = []; + for (const entry of raw) { + if (!isTodoItem(entry)) return null; + todos.push(entry); + } + return { todos }; } /** The `rendererId` the `todo` extension's `custom` surface field uses. */ diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts index 23f8757..11c222f 100644 --- a/src/features/surface-host/logic/types.ts +++ b/src/features/surface-host/logic/types.ts @@ -2,33 +2,33 @@ import type { ActionRef, SurfaceOption } from "@dispatch/ui-contract"; /** Normalised view-model for a toggle field. */ export interface ToggleFieldView { - readonly kind: "toggle"; - readonly label: string; - readonly value: boolean; - readonly action: ActionRef; + readonly kind: "toggle"; + readonly label: string; + readonly value: boolean; + readonly action: ActionRef; } /** Normalised view-model for a progress field. */ export interface ProgressFieldView { - readonly kind: "progress"; - readonly label: string; - readonly value: number; + readonly kind: "progress"; + readonly label: string; + readonly value: number; } /** Normalised view-model for a selector field. */ export interface SelectorFieldView { - readonly kind: "selector"; - readonly label: string; - readonly value: string; - readonly options: readonly SurfaceOption[]; - readonly action: ActionRef; + readonly kind: "selector"; + readonly label: string; + readonly value: string; + readonly options: readonly SurfaceOption[]; + readonly action: ActionRef; } /** Normalised view-model for a stat field. */ export interface StatFieldView { - readonly kind: "stat"; - readonly label: string; - readonly value: string; + readonly kind: "stat"; + readonly label: string; + readonly value: string; } /** @@ -37,21 +37,21 @@ export interface StatFieldView { * the spec omits them). The renderer posts the new number as the action payload. */ export interface NumberFieldView { - readonly kind: "number"; - readonly label: string; - readonly value: number; - readonly min?: number; - readonly max?: number; - readonly step?: number; - readonly unit?: string; - readonly action: ActionRef; + readonly kind: "number"; + readonly label: string; + readonly value: number; + readonly min?: number; + readonly max?: number; + readonly step?: number; + readonly unit?: string; + readonly action: ActionRef; } /** Normalised view-model for a button field. */ export interface ButtonFieldView { - readonly kind: "button"; - readonly label: string; - readonly action: ActionRef; + readonly kind: "button"; + readonly label: string; + readonly action: ActionRef; } /** @@ -60,24 +60,24 @@ export interface ButtonFieldView { * never a surface id) and gracefully skips ids it has no renderer for. */ export interface CustomFieldView { - readonly kind: "custom"; - readonly rendererId: string; - readonly payload: unknown; + readonly kind: "custom"; + readonly rendererId: string; + readonly payload: unknown; } /** A normalised field view-model — one entry per renderable field kind. */ export type FieldView = - | ToggleFieldView - | ProgressFieldView - | SelectorFieldView - | StatFieldView - | NumberFieldView - | ButtonFieldView - | CustomFieldView; + | ToggleFieldView + | ProgressFieldView + | SelectorFieldView + | StatFieldView + | NumberFieldView + | ButtonFieldView + | CustomFieldView; /** The output of `planSurface`: the ordered list of renderable fields. */ export interface SurfaceRenderPlan { - readonly fields: readonly FieldView[]; + readonly fields: readonly FieldView[]; } /** @@ -86,5 +86,5 @@ export interface SurfaceRenderPlan { * GENERIC presentation rule keyed on field kind — it never inspects a surface id. */ export type RenderGroup = - | { readonly type: "stats"; readonly stats: readonly StatFieldView[] } - | { readonly type: "field"; readonly field: Exclude<FieldView, StatFieldView> }; + | { readonly type: "stats"; readonly stats: readonly StatFieldView[] } + | { readonly type: "field"; readonly field: Exclude<FieldView, StatFieldView> }; diff --git a/src/features/surface-host/ui/Button.svelte b/src/features/surface-host/ui/Button.svelte index 62d7acf..ee9097c 100644 --- a/src/features/surface-host/ui/Button.svelte +++ b/src/features/surface-host/ui/Button.svelte @@ -1,21 +1,21 @@ <script lang="ts"> - import type { InvokeMessage } from "@dispatch/ui-contract"; - import type { ButtonFieldView } from "../logic/types"; + import type { InvokeMessage } from "@dispatch/ui-contract"; + import type { ButtonFieldView } from "../logic/types"; - let { - field, - surfaceId, - onInvoke, - }: { field: ButtonFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = - $props(); + let { + field, + surfaceId, + onInvoke, + }: { field: ButtonFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = + $props(); - function handleClick() { - onInvoke({ - type: "invoke", - surfaceId, - actionId: field.action.actionId, - }); - } + function handleClick() { + onInvoke({ + type: "invoke", + surfaceId, + actionId: field.action.actionId, + }); + } </script> <button onclick={handleClick}>{field.label}</button> diff --git a/src/features/surface-host/ui/MessageQueueList.svelte b/src/features/surface-host/ui/MessageQueueList.svelte index 12de970..b260de2 100644 --- a/src/features/surface-host/ui/MessageQueueList.svelte +++ b/src/features/surface-host/ui/MessageQueueList.svelte @@ -1,22 +1,93 @@ <script lang="ts"> - import { parseMessageQueuePayload } from "../logic/message-queue"; + import { + parseMessageQueuePayload, + reconcileCancelledIds, + selectVisibleMessages, + } from "../logic/message-queue"; - let { payload }: { readonly payload: unknown } = $props(); + let { + payload, + onCancel, + }: { + readonly payload: unknown; + /** + * Cancel (remove) a single queued message by id (`chat.queue.cancel`). + * Required-but-nullable (not optional) so a parent can thread a + * `| undefined` callback through under `exactOptionalPropertyTypes`: + * `undefined` → a read-only list (no × affordance), e.g. a generic surface + * context with no conversation scope. The list still reconciles from the + * surface either way. + */ + readonly onCancel: ((messageId: string) => void) | undefined; + } = $props(); - // Parse defensively; an unparseable payload yields null → render nothing - // (graceful skip, per the custom-field contract). - const data = $derived(parseMessageQueuePayload(payload)); + // Parse defensively; an unparseable payload yields null → render nothing + // (graceful skip, per the custom-field contract). + const data = $derived(parseMessageQueuePayload(payload)); + + // Optimistic-removal: a cancelled id is hidden the instant the user clicks, + // ahead of the surface's post-cancel snapshot. Reconcile on every payload + // change so a confirmed-removed id (no longer in the snapshot) is dropped + // from the set — keeping it bounded (pure helpers in logic/message-queue). + let cancelledIds = $state<ReadonlySet<string>>(new Set()); + + $effect(() => { + const parsed = data; + if (parsed === null) return; + const next = reconcileCancelledIds(parsed.messages, cancelledIds); + if (next !== cancelledIds) cancelledIds = next; + }); + + const visible = $derived( + data === null ? [] : selectVisibleMessages(data.messages, cancelledIds), + ); + + function handleCancel(messageId: string): void { + // Optimistically hide the row + fire the cancel (fire-and-forget; the + // surface update reconciles). Idempotent server-side, so a double-click or + // a cancel of an already-drained message is a silent no-op — no rollback. + if (cancelledIds.has(messageId)) return; + const next = new Set(cancelledIds); + next.add(messageId); + cancelledIds = next; + onCancel?.(messageId); + } </script> {#if data !== null && data.messages.length > 0} - <ul class="flex flex-col gap-1 text-sm"> - {#each data.messages as msg (msg.id)} - <li class="rounded-box bg-base-200 px-3 py-2"> - <p class="whitespace-pre-wrap">{msg.text}</p> - <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}> - {new Date(msg.queuedAt).toLocaleTimeString()} - </time> - </li> - {/each} - </ul> + <ul class="flex flex-col gap-1 text-sm"> + {#each visible as msg (msg.id)} + <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2"> + <div class="min-w-0 flex-1"> + <p class="whitespace-pre-wrap break-words">{msg.text}</p> + <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}> + {new Date(msg.queuedAt).toLocaleTimeString()} + </time> + </div> + {#if onCancel !== undefined} + <button + type="button" + class="btn btn-ghost btn-xs btn-square shrink-0 opacity-60 hover:opacity-100" + title="Cancel this queued message" + aria-label="Cancel this queued message" + onclick={() => handleCancel(msg.id)} + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2.5" + stroke-linecap="round" + stroke-linejoin="round" + class="h-3.5 w-3.5" + > + <line x1="18" y1="6" x2="6" y2="18"></line> + <line x1="6" y1="6" x2="18" y2="18"></line> + </svg> + </button> + {/if} + </li> + {/each} + </ul> {/if} diff --git a/src/features/surface-host/ui/MessageQueueList.test.ts b/src/features/surface-host/ui/MessageQueueList.test.ts new file mode 100644 index 0000000..53044b3 --- /dev/null +++ b/src/features/surface-host/ui/MessageQueueList.test.ts @@ -0,0 +1,135 @@ +import type { QueuedMessage } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import MessageQueueList from "./MessageQueueList.svelte"; + +function msg(id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage { + return { id, text, queuedAt }; +} + +/** Build the message-queue surface field payload. */ +function payload(messages: readonly QueuedMessage[]): { messages: readonly QueuedMessage[] } { + return { messages }; +} + +describe("MessageQueueList", () => { + it("renders each queued message's text", () => { + render(MessageQueueList, { + props: { + payload: payload([msg("m1", "steer left"), msg("m2", "go right")]), + onCancel: undefined, + }, + }); + expect(screen.getByText("steer left")).toBeInTheDocument(); + expect(screen.getByText("go right")).toBeInTheDocument(); + }); + + it("renders nothing when the queue is empty", () => { + const { container } = render(MessageQueueList, { + props: { payload: payload([]), onCancel: undefined }, + }); + expect(container.querySelector("ul")).toBeNull(); + }); + + it("renders nothing for a malformed payload (graceful skip)", () => { + const { container } = render(MessageQueueList, { + props: { payload: { nope: true }, onCancel: undefined }, + }); + expect(container.querySelector("ul")).toBeNull(); + }); + + it("omits the cancel button when no onCancel is wired (read-only)", () => { + render(MessageQueueList, { + props: { payload: payload([msg("m1", "steer")]), onCancel: undefined }, + }); + expect(screen.queryByRole("button", { name: /cancel/i })).toBeNull(); + }); + + it("renders a cancel button per row when onCancel is wired", () => { + render(MessageQueueList, { + props: { payload: payload([msg("m1", "a"), msg("m2", "b")]), onCancel: vi.fn() }, + }); + expect(screen.getAllByRole("button", { name: /cancel/i })).toHaveLength(2); + }); + + it("clicking cancel fires onCancel with the row's message id and optimistically hides the row", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + render(MessageQueueList, { + props: { payload: payload([msg("m1", "keep me"), msg("m2", "cancel me")]), onCancel }, + }); + + // Both rows visible before click. + expect(screen.getByText("keep me")).toBeInTheDocument(); + expect(screen.getByText("cancel me")).toBeInTheDocument(); + + const buttons = screen.getAllByRole("button", { name: /cancel/i }); + // Cancel the SECOND row (m2). Buttons mirror row order. + const cancelM2 = buttons[1]; + if (cancelM2 === undefined) throw new Error("expected two cancel buttons"); + await user.click(cancelM2); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onCancel).toHaveBeenCalledWith("m2"); + // m2 is optimistically hidden immediately; m1 remains. + expect(screen.getByText("keep me")).toBeInTheDocument(); + expect(screen.queryByText("cancel me")).toBeNull(); + }); + + it("does not fire onCancel twice for a double-click on the same row (idempotent client-side)", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + render(MessageQueueList, { + props: { payload: payload([msg("m1", "x")]), onCancel }, + }); + + const button = screen.getByRole("button", { name: /cancel/i }); + await user.click(button); + // The row is gone after the first click; the button left the DOM, so a + // second click on the stale element is a no-op — onCancel fires once. + await user.click(button).catch(() => {}); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("reconciles from the surface: a cancelled id gone from the snapshot clears the optimistic hide", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + const { rerender } = render(MessageQueueList, { + props: { payload: payload([msg("m1", "a"), msg("m2", "b")]), onCancel }, + }); + + // Cancel m1 — it hides optimistically. + const cancelButtons = screen.getAllByRole("button", { name: /cancel/i }); + const cancelM1 = cancelButtons[0]; + if (cancelM1 === undefined) throw new Error("expected a cancel button"); + await user.click(cancelM1); + expect(screen.queryByText("a")).toBeNull(); + expect(screen.getByText("b")).toBeInTheDocument(); + + // The surface pushes the post-cancel snapshot: m1 is gone (server confirmed). + // A NEW message m3 arrives in the same snapshot. The list renders m2 + m3. + rerender({ payload: payload([msg("m2", "b"), msg("m3", "c")]), onCancel }); + expect(screen.queryByText("a")).toBeNull(); + expect(screen.getByText("b")).toBeInTheDocument(); + expect(screen.getByText("c")).toBeInTheDocument(); + }); + + it("re-shows a row if the surface snapshot still contains a cancelled id (cancel not yet confirmed)", async () => { + // Edge case: the cancel is in flight and the surface hasn't updated yet, but + // a re-render with the SAME snapshot must keep the row hidden (optimistic). + const user = userEvent.setup(); + const onCancel = vi.fn(); + const samePayload = payload([msg("m1", "a")]); + const { rerender } = render(MessageQueueList, { + props: { payload: samePayload, onCancel }, + }); + + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(screen.queryByText("a")).toBeNull(); + + // Re-render with the same (stale) snapshot — the row stays hidden. + rerender({ payload: payload([msg("m1", "a")]), onCancel }); + expect(screen.queryByText("a")).toBeNull(); + }); +}); diff --git a/src/features/surface-host/ui/Number.svelte b/src/features/surface-host/ui/Number.svelte index 0f3323d..5a67087 100644 --- a/src/features/surface-host/ui/Number.svelte +++ b/src/features/surface-host/ui/Number.svelte @@ -1,43 +1,43 @@ <script lang="ts"> - import type { InvokeMessage } from "@dispatch/ui-contract"; - import type { NumberFieldView } from "../logic/types"; + import type { InvokeMessage } from "@dispatch/ui-contract"; + import type { NumberFieldView } from "../logic/types"; - let { - field, - surfaceId, - onInvoke, - }: { field: NumberFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = - $props(); + let { + field, + surfaceId, + onInvoke, + }: { field: NumberFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = + $props(); - // Commit on change/Enter rather than every keystroke. Ignore empty/non-numeric - // input (the backend also floors/validates); send the new number as payload. - function commit(event: Event) { - const target = event.target as HTMLInputElement; - const next = target.valueAsNumber; - if (Number.isNaN(next)) return; - onInvoke({ - type: "invoke", - surfaceId, - actionId: field.action.actionId, - payload: next, - }); - } + // Commit on change/Enter rather than every keystroke. Ignore empty/non-numeric + // input (the backend also floors/validates); send the new number as payload. + function commit(event: Event) { + const target = event.target as HTMLInputElement; + const next = target.valueAsNumber; + if (Number.isNaN(next)) return; + onInvoke({ + type: "invoke", + surfaceId, + actionId: field.action.actionId, + payload: next, + }); + } </script> <label class="flex items-center justify-between gap-2 text-sm"> - <span>{field.label}</span> - <span class="flex items-center gap-1"> - <input - type="number" - class="input input-bordered input-sm w-24" - value={field.value} - min={field.min} - max={field.max} - step={field.step} - onchange={commit} - /> - {#if field.unit} - <span class="opacity-60">{field.unit}</span> - {/if} - </span> + <span>{field.label}</span> + <span class="flex items-center gap-1"> + <input + type="number" + class="input input-bordered input-sm w-24" + value={field.value} + min={field.min} + max={field.max} + step={field.step} + onchange={commit} + /> + {#if field.unit} + <span class="opacity-60">{field.unit}</span> + {/if} + </span> </label> diff --git a/src/features/surface-host/ui/Progress.svelte b/src/features/surface-host/ui/Progress.svelte index cba9e0f..e291c79 100644 --- a/src/features/surface-host/ui/Progress.svelte +++ b/src/features/surface-host/ui/Progress.svelte @@ -1,13 +1,13 @@ <script lang="ts"> - import type { ProgressFieldView } from "../logic/types"; + import type { ProgressFieldView } from "../logic/types"; - let { field }: { field: ProgressFieldView } = $props(); + let { field }: { field: ProgressFieldView } = $props(); - const percent = $derived(Math.round(field.value * 100)); + const percent = $derived(Math.round(field.value * 100)); </script> <div> - <span>{field.label}</span> - <progress max="100" value={percent}>{percent}%</progress> - <span>{percent}%</span> + <span>{field.label}</span> + <progress max="100" value={percent}>{percent}%</progress> + <span>{percent}%</span> </div> diff --git a/src/features/surface-host/ui/Selector.svelte b/src/features/surface-host/ui/Selector.svelte index 2da104f..4cb3536 100644 --- a/src/features/surface-host/ui/Selector.svelte +++ b/src/features/surface-host/ui/Selector.svelte @@ -1,32 +1,32 @@ <script lang="ts"> - import type { InvokeMessage } from "@dispatch/ui-contract"; - import type { SelectorFieldView } from "../logic/types"; + import type { InvokeMessage } from "@dispatch/ui-contract"; + import type { SelectorFieldView } from "../logic/types"; - let { - field, - surfaceId, - onInvoke, - }: { field: SelectorFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = - $props(); + let { + field, + surfaceId, + onInvoke, + }: { field: SelectorFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = + $props(); - function handleChange(event: Event) { - const target = event.target as HTMLSelectElement; - onInvoke({ - type: "invoke", - surfaceId, - actionId: field.action.actionId, - payload: target.value, - }); - } + function handleChange(event: Event) { + const target = event.target as HTMLSelectElement; + onInvoke({ + type: "invoke", + surfaceId, + actionId: field.action.actionId, + payload: target.value, + }); + } </script> <label> - {field.label} - <select onchange={handleChange}> - {#each field.options as option (option.value)} - <option value={option.value} selected={option.value === field.value}> - {option.label} - </option> - {/each} - </select> + {field.label} + <select onchange={handleChange}> + {#each field.options as option (option.value)} + <option value={option.value} selected={option.value === field.value}> + {option.label} + </option> + {/each} + </select> </label> diff --git a/src/features/surface-host/ui/StatTable.svelte b/src/features/surface-host/ui/StatTable.svelte index 415423f..c559352 100644 --- a/src/features/surface-host/ui/StatTable.svelte +++ b/src/features/surface-host/ui/StatTable.svelte @@ -1,21 +1,21 @@ <script lang="ts"> - import type { StatFieldView } from "../logic/types"; + import type { StatFieldView } from "../logic/types"; - // Renders a run of stat fields as one aligned label/value table. Headerless: - // the column semantics aren't known generically, but the two-column layout - // gives the tidy, aligned readout the stats deserve (e.g. extension → version). - let { stats }: { readonly stats: readonly StatFieldView[] } = $props(); + // Renders a run of stat fields as one aligned label/value table. Headerless: + // the column semantics aren't known generically, but the two-column layout + // gives the tidy, aligned readout the stats deserve (e.g. extension → version). + let { stats }: { readonly stats: readonly StatFieldView[] } = $props(); </script> <div class="overflow-x-auto"> - <table class="table table-sm"> - <tbody> - {#each stats as stat, i (i)} - <tr> - <th class="font-medium">{stat.label}</th> - <td class="text-right tabular-nums">{stat.value}</td> - </tr> - {/each} - </tbody> - </table> + <table class="table table-sm"> + <tbody> + {#each stats as stat, i (i)} + <tr> + <th class="font-medium">{stat.label}</th> + <td class="text-right tabular-nums">{stat.value}</td> + </tr> + {/each} + </tbody> + </table> </div> diff --git a/src/features/surface-host/ui/SurfaceTable.svelte b/src/features/surface-host/ui/SurfaceTable.svelte index 764cc36..e47c122 100644 --- a/src/features/surface-host/ui/SurfaceTable.svelte +++ b/src/features/surface-host/ui/SurfaceTable.svelte @@ -1,14 +1,14 @@ <script lang="ts"> - import Table from "../../../components/Table.svelte"; - import { parseTablePayload } from "../logic/table"; + import Table from "../../../components/Table.svelte"; + import { parseTablePayload } from "../logic/table"; - let { payload }: { readonly payload: unknown } = $props(); + let { payload }: { readonly payload: unknown } = $props(); - // Parse defensively; an unparseable payload yields null → render nothing - // (graceful skip, per the custom-field contract). - const data = $derived(parseTablePayload(payload)); + // Parse defensively; an unparseable payload yields null → render nothing + // (graceful skip, per the custom-field contract). + const data = $derived(parseTablePayload(payload)); </script> {#if data !== null} - <Table columns={data.columns} rows={data.rows} /> + <Table columns={data.columns} rows={data.rows} /> {/if} diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte index 3f92e3b..8ce8ade 100644 --- a/src/features/surface-host/ui/SurfaceView.svelte +++ b/src/features/surface-host/ui/SurfaceView.svelte @@ -1,52 +1,64 @@ <script lang="ts"> - import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; - import { groupRenderFields, planSurface } from "../logic/plan"; - import Button from "./Button.svelte"; - import MessageQueueList from "./MessageQueueList.svelte"; - import Number from "./Number.svelte"; - import Progress from "./Progress.svelte"; - import Selector from "./Selector.svelte"; - import StatTable from "./StatTable.svelte"; - import SurfaceTable from "./SurfaceTable.svelte"; - import TodoList from "./TodoList.svelte"; - import Toggle from "./Toggle.svelte"; + import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; + import { groupRenderFields, planSurface } from "../logic/plan"; + import Button from "./Button.svelte"; + import MessageQueueList from "./MessageQueueList.svelte"; + import Number from "./Number.svelte"; + import Progress from "./Progress.svelte"; + import Selector from "./Selector.svelte"; + import StatTable from "./StatTable.svelte"; + import SurfaceTable from "./SurfaceTable.svelte"; + import TodoList from "./TodoList.svelte"; + import Toggle from "./Toggle.svelte"; - let { - spec, - onInvoke, - }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props(); + let { + spec, + onInvoke, + onCancelQueuedMessage, + }: { + spec: SurfaceSpec; + onInvoke: (msg: InvokeMessage) => void; + /** + * Cancel a queued message by id — threaded ONLY to the `message-queue` + * renderer. Optional + scoped: generic surfaces (which pass nothing) keep + * rendering a read-only queue list. Kept as a typed callback (never a + * stringly-typed bus); the renderer dispatch is on `rendererId` (a renderer + * KIND), never the surface id. + */ + onCancelQueuedMessage?: (messageId: string) => void; + } = $props(); - const plan = $derived(planSurface(spec)); - // Consecutive stats render together as one aligned table; everything else is - // a standalone widget. Grouping keys on field KIND only — never the surface id. - const groups = $derived(groupRenderFields(plan.fields)); + const plan = $derived(planSurface(spec)); + // Consecutive stats render together as one aligned table; everything else is + // a standalone widget. Grouping keys on field KIND only — never the surface id. + const groups = $derived(groupRenderFields(plan.fields)); </script> <article> - <h2>{spec.title}</h2> - {#each groups as group, i (i)} - {#if group.type === "stats"} - <StatTable stats={group.stats} /> - {:else if group.field.kind === "toggle"} - <Toggle field={group.field} surfaceId={spec.id} {onInvoke} /> - {:else if group.field.kind === "progress"} - <Progress field={group.field} /> - {:else if group.field.kind === "selector"} - <Selector field={group.field} surfaceId={spec.id} {onInvoke} /> - {:else if group.field.kind === "number"} - <Number field={group.field} surfaceId={spec.id} {onInvoke} /> - {:else if group.field.kind === "button"} - <Button field={group.field} surfaceId={spec.id} {onInvoke} /> - {:else if group.field.kind === "custom"} - <!-- Dispatch on rendererId (a renderer KIND, never a surface id); - unknown ids gracefully render nothing. --> - {#if group.field.rendererId === "table"} - <SurfaceTable payload={group.field.payload} /> - {:else if group.field.rendererId === "message-queue"} - <MessageQueueList payload={group.field.payload} /> - {:else if group.field.rendererId === "todo"} - <TodoList payload={group.field.payload} /> - {/if} - {/if} - {/each} + <h2>{spec.title}</h2> + {#each groups as group, i (i)} + {#if group.type === "stats"} + <StatTable stats={group.stats} /> + {:else if group.field.kind === "toggle"} + <Toggle field={group.field} surfaceId={spec.id} {onInvoke} /> + {:else if group.field.kind === "progress"} + <Progress field={group.field} /> + {:else if group.field.kind === "selector"} + <Selector field={group.field} surfaceId={spec.id} {onInvoke} /> + {:else if group.field.kind === "number"} + <Number field={group.field} surfaceId={spec.id} {onInvoke} /> + {:else if group.field.kind === "button"} + <Button field={group.field} surfaceId={spec.id} {onInvoke} /> + {:else if group.field.kind === "custom"} + <!-- Dispatch on rendererId (a renderer KIND, never a surface id); + unknown ids gracefully render nothing. --> + {#if group.field.rendererId === "table"} + <SurfaceTable payload={group.field.payload} /> + {:else if group.field.rendererId === "message-queue"} + <MessageQueueList payload={group.field.payload} onCancel={onCancelQueuedMessage} /> + {:else if group.field.rendererId === "todo"} + <TodoList payload={group.field.payload} /> + {/if} + {/if} + {/each} </article> diff --git a/src/features/surface-host/ui/TodoList.svelte b/src/features/surface-host/ui/TodoList.svelte index b7b2183..cffefde 100644 --- a/src/features/surface-host/ui/TodoList.svelte +++ b/src/features/surface-host/ui/TodoList.svelte @@ -1,61 +1,66 @@ <script lang="ts"> - import { parseTodoPayload } from "../logic/todo"; + import { parseTodoPayload } from "../logic/todo"; - let { payload }: { readonly payload: unknown } = $props(); + let { payload }: { readonly payload: unknown } = $props(); - const data = $derived(parseTodoPayload(payload)); + const data = $derived(parseTodoPayload(payload)); </script> -{#if data !== null && data.todos.length > 0} - <ul class="flex flex-col gap-1"> - {#each data.todos as todo, i (i)} - <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2 text-sm"> - <!-- Status indicator --> - <span class="mt-0.5 shrink-0"> - {#if todo.status === "in_progress"} - <span class="block h-4 w-4 rounded-full bg-primary"></span> - {:else if todo.status === "completed"} - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="3" - stroke-linecap="round" - stroke-linejoin="round" - class="h-4 w-4 text-success" - > - <polyline points="20 6 9 17 4 12"></polyline> - </svg> - {:else if todo.status === "cancelled"} - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="3" - stroke-linecap="round" - stroke-linejoin="round" - class="h-4 w-4 text-base-content/40" - > - <line x1="18" y1="6" x2="6" y2="18"></line> - <line x1="6" y1="6" x2="18" y2="18"></line> - </svg> - {:else} - <!-- pending: empty circle --> - <span class="block h-4 w-4 rounded-full border-2 border-base-content/30"></span> - {/if} - </span> +<!-- Fixed at 30% of the viewport height so the region is consistent whether the + list is empty or overflowing — it always reserves the space and scrolls + internally (mirrors the tabs view). --> +<ul class="flex h-[30vh] flex-col gap-1 overflow-y-auto pr-1"> + {#if data !== null && data.todos.length > 0} + {#each data.todos as todo, i (i)} + <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2 text-sm"> + <!-- Status indicator --> + <span class="mt-0.5 shrink-0"> + {#if todo.status === "in_progress"} + <span class="block h-4 w-4 rounded-full bg-primary"></span> + {:else if todo.status === "completed"} + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="3" + stroke-linecap="round" + stroke-linejoin="round" + class="h-4 w-4 text-success" + > + <polyline points="20 6 9 17 4 12"></polyline> + </svg> + {:else if todo.status === "cancelled"} + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="3" + stroke-linecap="round" + stroke-linejoin="round" + class="h-4 w-4 text-base-content/40" + > + <line x1="18" y1="6" x2="6" y2="18"></line> + <line x1="6" y1="6" x2="18" y2="18"></line> + </svg> + {:else} + <!-- pending: empty circle --> + <span class="block h-4 w-4 rounded-full border-2 border-base-content/30"></span> + {/if} + </span> - <!-- Content --> - <span - class:flex-1={true} - class:line-through={todo.status === "completed" || todo.status === "cancelled"} - class:opacity-50={todo.status === "completed" || todo.status === "cancelled"} - > - {todo.content} - </span> - </li> - {/each} - </ul> -{/if} + <!-- Content --> + <span + class:flex-1={true} + class:line-through={todo.status === "completed" || todo.status === "cancelled"} + class:opacity-50={todo.status === "completed" || todo.status === "cancelled"} + > + {todo.content} + </span> + </li> + {/each} + {:else} + <li class="text-xs opacity-60">No tasks yet.</li> + {/if} +</ul> diff --git a/src/features/surface-host/ui/Toggle.svelte b/src/features/surface-host/ui/Toggle.svelte index aec8f4e..0326851 100644 --- a/src/features/surface-host/ui/Toggle.svelte +++ b/src/features/surface-host/ui/Toggle.svelte @@ -1,25 +1,25 @@ <script lang="ts"> - import type { InvokeMessage } from "@dispatch/ui-contract"; - import type { ToggleFieldView } from "../logic/types"; + import type { InvokeMessage } from "@dispatch/ui-contract"; + import type { ToggleFieldView } from "../logic/types"; - let { - field, - surfaceId, - onInvoke, - }: { field: ToggleFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = - $props(); + let { + field, + surfaceId, + onInvoke, + }: { field: ToggleFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = + $props(); - function handleChange() { - onInvoke({ - type: "invoke", - surfaceId, - actionId: field.action.actionId, - payload: !field.value, - }); - } + function handleChange() { + onInvoke({ + type: "invoke", + surfaceId, + actionId: field.action.actionId, + payload: !field.value, + }); + } </script> <label> - <input type="checkbox" checked={field.value} onchange={handleChange} /> - {field.label} + <input type="checkbox" checked={field.value} onchange={handleChange} /> + {field.label} </label> diff --git a/src/features/system-prompt/index.ts b/src/features/system-prompt/index.ts new file mode 100644 index 0000000..50d9d21 --- /dev/null +++ b/src/features/system-prompt/index.ts @@ -0,0 +1,17 @@ +export type { + LoadSystemPrompt, + LoadSystemPromptVariables, + SaveSystemPrompt, + SystemPromptLoadResult, + SystemPromptSaveResult, + SystemPromptVariablesResult, + VariableGroup, +} from "./logic/view-model"; +export { buildTag, groupVariables, insertTag, isDynamicVariable } from "./logic/view-model"; +export { default as SystemPromptBuilder } from "./ui/SystemPromptBuilder.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "system-prompt", + description: "Global system prompt template builder with variable placeholders", +} as const; diff --git a/src/features/system-prompt/logic/view-model.test.ts b/src/features/system-prompt/logic/view-model.test.ts new file mode 100644 index 0000000..223327b --- /dev/null +++ b/src/features/system-prompt/logic/view-model.test.ts @@ -0,0 +1,90 @@ +import type { SystemPromptVariable } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + buildIfNotTag, + buildIfTag, + buildTag, + groupVariables, + insertTag, + isDynamicVariable, +} from "./view-model"; + +describe("system-prompt view-model", () => { + describe("buildTag", () => { + it("builds a variable placeholder", () => { + expect(buildTag("system", "time")).toBe("[system:time]"); + }); + }); + + describe("buildIfTag", () => { + it("builds an opening conditional tag", () => { + expect(buildIfTag("file", "AGENTS.md")).toBe("[if file:AGENTS.md]"); + }); + }); + + describe("buildIfNotTag", () => { + it("builds a negated opening conditional tag", () => { + expect(buildIfNotTag("prompt", "cwd")).toBe("[if !prompt:cwd]"); + }); + }); + + describe("insertTag", () => { + it("inserts a tag at the cursor position", () => { + expect(insertTag("Hello world", "[system:time]", 5, 5)).toEqual({ + template: "Hello[system:time] world", + cursor: 18, + }); + }); + + it("replaces the selected text", () => { + const tag = buildTag("file", "README.md"); + expect(insertTag("Hello world", tag, 6, 11)).toEqual({ + template: `Hello ${tag}`, + cursor: 6 + tag.length, + }); + }); + + it("inserts at the end", () => { + const tag = buildTag("git", "branch"); + expect(insertTag("", tag, 0, 0)).toEqual({ + template: tag, + cursor: tag.length, + }); + }); + }); + + describe("groupVariables", () => { + it("groups by type in first-appearing order", () => { + const variables: SystemPromptVariable[] = [ + { type: "system", name: "time", description: "" }, + { type: "prompt", name: "cwd", description: "" }, + { type: "system", name: "date", description: "" }, + { type: "git", name: "branch", description: "" }, + ]; + const groups = groupVariables(variables); + expect(groups.map((g) => g.type)).toEqual(["system", "prompt", "git"]); + expect(groups[0]?.variables.map((v) => v.name)).toEqual(["time", "date"]); + expect(groups[1]?.variables.map((v) => v.name)).toEqual(["cwd"]); + expect(groups[2]?.variables.map((v) => v.name)).toEqual(["branch"]); + }); + + it("returns an empty array when no variables", () => { + expect(groupVariables([])).toEqual([]); + }); + }); + + describe("isDynamicVariable", () => { + it("returns true when dynamic is true", () => { + expect( + isDynamicVariable({ type: "file", name: "path", description: "", dynamic: true }), + ).toBe(true); + }); + + it("returns false when dynamic is missing or false", () => { + expect(isDynamicVariable({ type: "system", name: "time", description: "" })).toBe(false); + expect( + isDynamicVariable({ type: "system", name: "time", description: "", dynamic: false }), + ).toBe(false); + }); + }); +}); diff --git a/src/features/system-prompt/logic/view-model.ts b/src/features/system-prompt/logic/view-model.ts new file mode 100644 index 0000000..e202ee3 --- /dev/null +++ b/src/features/system-prompt/logic/view-model.ts @@ -0,0 +1,106 @@ +import type { SystemPromptVariable } from "@dispatch/transport-contract"; + +/** + * Pure core for the system prompt builder — zero DOM, zero effects, zero Svelte. + * + * The system prompt is a global template, stored on the backend, resolved once + * per conversation at first turn (then persisted for prompt-cache safety). The + * frontend builder exposes the template text plus a palette of available + * variables; clicking a variable inserts `[type:name]` at the cursor. This module + * holds the pure logic: tag construction, insertion into a template, and + * grouping of variables. The HTTP edge is injected via the ports defined below. + */ + +// ── Injected ports (composition root adapts the store to these shapes) ───────── + +export type SystemPromptLoadResult = + | { readonly ok: true; readonly template: string } + | { readonly ok: false; readonly error: string }; + +export type LoadSystemPrompt = () => Promise<SystemPromptLoadResult>; + +export type SystemPromptSaveResult = + | { readonly ok: true; readonly template: string } + | { readonly ok: false; readonly error: string }; + +export type SaveSystemPrompt = (template: string) => Promise<SystemPromptSaveResult>; + +export type SystemPromptVariablesResult = + | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] } + | { readonly ok: false; readonly error: string }; + +export type LoadSystemPromptVariables = () => Promise<SystemPromptVariablesResult>; + +// ── Template helpers ────────────────────────────────────────────────────────── + +/** Build the literal placeholder `[type:name]` for a variable. */ +export function buildTag(type: string, name: string): string { + return `[${type}:${name}]`; +} + +/** Build the literal placeholder `[if type:name]` for a conditional block. */ +export function buildIfTag(type: string, name: string): string { + return `[if ${type}:${name}]`; +} + +/** Build the literal placeholder `[if !type:name]` for a negated conditional block. */ +export function buildIfNotTag(type: string, name: string): string { + return `[if !${type}:${name}]`; +} + +export interface Insertion { + /** Template text after insertion. */ + template: string; + /** New cursor position (caret index) after insertion. */ + cursor: number; +} + +/** + * Insert `tag` into `template` at the current selection range. Replaces any + * selected text. Returns both the new template and the new cursor position so + * the caller can restore the caret after the inserted tag. + */ +export function insertTag( + template: string, + tag: string, + selectionStart: number, + selectionEnd: number, +): Insertion { + const before = template.slice(0, selectionStart); + const after = template.slice(selectionEnd); + const next = before + tag + after; + return { template: next, cursor: selectionStart + tag.length }; +} + +// ── Variable grouping ───────────────────────────────────────────────────────── + +export interface VariableGroup { + /** Variable type (e.g. `"system"`, `"file"`, `"prompt"`, `"git"`). */ + readonly type: string; + /** Variables of this type. */ + readonly variables: readonly SystemPromptVariable[]; +} + +/** + * Group variables by type, preserving the order of first appearance within each + * group and the order of first-appearing types. + */ +export function groupVariables( + variables: readonly SystemPromptVariable[], +): readonly VariableGroup[] { + const order: string[] = []; + const map = new Map<string, SystemPromptVariable[]>(); + for (const v of variables) { + if (!map.has(v.type)) { + map.set(v.type, []); + order.push(v.type); + } + map.get(v.type)?.push(v); + } + return order.map((type) => ({ type, variables: map.get(type) ?? [] })); +} + +/** Whether a variable is "dynamic" (any name is valid, e.g. `file:<path>`). */ +export function isDynamicVariable(variable: SystemPromptVariable): boolean { + return variable.dynamic === true; +} diff --git a/src/features/system-prompt/ui/SystemPromptBuilder.svelte b/src/features/system-prompt/ui/SystemPromptBuilder.svelte new file mode 100644 index 0000000..2e92e26 --- /dev/null +++ b/src/features/system-prompt/ui/SystemPromptBuilder.svelte @@ -0,0 +1,243 @@ +<script lang="ts"> + import type { SystemPromptVariable } from "@dispatch/transport-contract"; + import { tick, untrack } from "svelte"; + import { + buildTag, + groupVariables, + insertTag, + isDynamicVariable, + type LoadSystemPrompt, + type LoadSystemPromptVariables, + type SaveSystemPrompt, + } from "../logic/view-model"; + + let { + loadPrompt, + savePrompt, + loadVariables, + onClose, + }: { + loadPrompt: LoadSystemPrompt; + savePrompt: SaveSystemPrompt; + loadVariables: LoadSystemPromptVariables; + onClose: () => void; + } = $props(); + + let value = $state(""); + let loadedTemplate = $state(""); + let loading = $state(false); + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state(false); + let variables = $state<readonly SystemPromptVariable[]>([]); + let filePath = $state(""); + let textarea: HTMLTextAreaElement | null = null; + + const groups = $derived(groupVariables(variables)); + const hasChanges = $derived(value !== loadedTemplate); + + async function load() { + untrack(() => { + loading = true; + error = null; + }); + + const [templateResult, variablesResult] = await Promise.all([loadPrompt(), loadVariables()]); + + loading = false; + + if (!templateResult.ok || !variablesResult.ok) { + const parts: string[] = []; + if (!templateResult.ok) parts.push(templateResult.error); + if (!variablesResult.ok) parts.push(variablesResult.error); + error = parts.join("; "); + return; + } + + value = templateResult.template; + loadedTemplate = templateResult.template; + variables = variablesResult.variables; + } + + async function save() { + if (saving || loading) return; + saving = true; + error = null; + justSaved = false; + const result = await savePrompt(value); + saving = false; + if (!result.ok) { + error = result.error; + return; + } + loadedTemplate = result.template; + value = result.template; + justSaved = true; + } + + function reset() { + value = loadedTemplate; + error = null; + justSaved = false; + } + + async function insertTagAtCursor(tag: string) { + if (textarea === null) return; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const insertion = insertTag(value, tag, start, end); + value = insertion.template; + await tick(); + textarea.focus(); + textarea.setSelectionRange(insertion.cursor, insertion.cursor); + } + + async function insertFileVariable(type: string) { + const path = filePath.trim(); + if (path.length === 0) return; + await insertTagAtCursor(buildTag(type, path)); + filePath = ""; + } + + function onKeydown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + + // Load on mount once. + $effect(() => { + void load(); + }); +</script> + +<svelte:window onkeydown={onKeydown} /> + +<!-- svelte-ignore a11y_no_static_element_interactions --> +<div + class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" + role="dialog" + aria-modal="true" + aria-label="System prompt builder" + tabindex="-1" + onclick={onClose} + onkeydown={onKeydown} +> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div + class="flex h-[85vh] w-full max-w-6xl flex-col overflow-hidden rounded-box bg-base-100 shadow-2xl" + onclick={(e) => e.stopPropagation()} + > + <!-- Header --> + <div class="flex shrink-0 items-center justify-between border-b border-base-300 px-4 py-3"> + <div class="flex items-center gap-2"> + <h2 class="text-sm font-semibold">System Prompt</h2> + {#if loading} + <span class="loading loading-spinner loading-xs"></span> + {/if} + </div> + <button + type="button" + class="btn btn-ghost btn-sm btn-square" + onclick={onClose} + aria-label="Close system prompt builder" + > + ✕ + </button> + </div> + + <!-- Body: half editor / half variables --> + <div class="flex min-h-0 flex-1"> + <!-- Left: template editor --> + <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4"> + <textarea + bind:this={textarea} + bind:value + class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs" + placeholder={loading + ? "Loading template..." + : "Edit the global system prompt template..."} + disabled={loading} + aria-label="System prompt template"></textarea> + + <div class="flex shrink-0 items-center gap-2"> + <button + type="button" + class="btn btn-primary btn-sm" + disabled={loading || saving || !hasChanges} + onclick={save} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Save + {/if} + </button> + <button + type="button" + class="btn btn-ghost btn-sm" + disabled={loading || !hasChanges} + onclick={reset} + > + Reset + </button> + {#if justSaved && !hasChanges} + <span class="text-xs text-success">Saved.</span> + {:else if hasChanges} + <span class="text-xs opacity-60">Unsaved changes</span> + {/if} + </div> + + {#if error} + <p class="shrink-0 text-xs text-error">{error}</p> + {/if} + </div> + + <!-- Right: variable palette --> + <div class="flex w-1/2 min-w-0 flex-col overflow-y-auto p-4"> + <h3 class="mb-2 shrink-0 text-xs font-semibold uppercase opacity-60">Variables</h3> + {#if groups.length === 0 && !loading} + <p class="text-xs opacity-60">No variables available.</p> + {/if} + <div class="flex flex-col gap-3"> + {#each groups as group (group.type)} + <div class="rounded-box bg-base-200 p-3"> + <span class="text-xs font-semibold uppercase opacity-70">{group.type}</span> + <div class="mt-2 flex flex-wrap gap-1"> + {#each group.variables as variable (variable.type + variable.name)} + {#if isDynamicVariable(variable)} + <div class="flex items-center gap-1"> + <input + type="text" + class="input input-bordered input-xs w-32 font-mono" + bind:value={filePath} + placeholder={variable.name} + onkeydown={(e) => { + if (e.key === "Enter") void insertFileVariable(variable.type); + }} + /> + <button + type="button" + class="btn btn-xs" + onclick={() => void insertFileVariable(variable.type)} + > + Insert + </button> + </div> + {:else} + <button + type="button" + class="btn btn-xs" + title={variable.description} + onclick={() => void insertTagAtCursor(buildTag(variable.type, variable.name))} + > + {variable.name} + </button> + {/if} + {/each} + </div> + </div> + {/each} + </div> + </div> + </div> + </div> +</div> diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts index 699c845..7520215 100644 --- a/src/features/tabs/index.ts +++ b/src/features/tabs/index.ts @@ -1,23 +1,23 @@ export type { Tab, TabsState } from "./tabs"; export { - activeTab, - closeTab, - createTab, - deriveTitle, - initialState, - MIN_HANDLE_LENGTH, - newDraft, - selectTab, - setModel, - setTitle, - shortHandle, + activeTab, + closeTab, + createTab, + deriveTitle, + initialState, + MIN_HANDLE_LENGTH, + newDraft, + selectTab, + setModel, + setTitle, + shortHandle, } from "./tabs"; export type { TabsStorage, TabsStore } from "./tabs-store.svelte"; export { createTabsStore } from "./tabs-store.svelte"; -export { default as TabBar } from "./ui/TabBar.svelte"; +export { default as TabList } from "./ui/TabList.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "tabs", - description: "Conversation tabs with title derivation and persistence", + name: "tabs", + description: "Conversation tabs with title derivation and persistence", } as const; diff --git a/src/features/tabs/tabs-store.svelte.ts b/src/features/tabs/tabs-store.svelte.ts index 2e876f9..d044b1f 100644 --- a/src/features/tabs/tabs-store.svelte.ts +++ b/src/features/tabs/tabs-store.svelte.ts @@ -1,73 +1,73 @@ import type { Tab, TabsState } from "./tabs"; import { - initialState, - closeTab as reduceCloseTab, - createTab as reduceCreateTab, - newDraft as reduceNewDraft, - openTab as reduceOpenTab, - selectTab as reduceSelectTab, - setModel as reduceSetModel, - setTitle as reduceSetTitle, - activeTab as selectActiveTab, + initialState, + closeTab as reduceCloseTab, + createTab as reduceCreateTab, + newDraft as reduceNewDraft, + openTab as reduceOpenTab, + selectTab as reduceSelectTab, + setModel as reduceSetModel, + setTitle as reduceSetTitle, + activeTab as selectActiveTab, } from "./tabs"; export interface TabsStorage { - load(): TabsState | null; - save(state: TabsState): void; + load(): TabsState | null; + save(state: TabsState): void; } export interface TabsStore { - readonly tabs: readonly Tab[]; - readonly activeConversationId: string | null; - readonly activeTab: Tab | null; - newDraft(): void; - createTab(tab: Tab): void; - /** Add a tab WITHOUT focusing it (for `conversation.open`). No-op if already open. */ - openTab(tab: Tab): void; - selectTab(conversationId: string): void; - closeTab(conversationId: string): void; - setModel(conversationId: string, model: string): void; - setTitle(conversationId: string, title: string): void; + readonly tabs: readonly Tab[]; + readonly activeConversationId: string | null; + readonly activeTab: Tab | null; + newDraft(): void; + createTab(tab: Tab): void; + /** Add a tab WITHOUT focusing it (for `conversation.open`). No-op if already open. */ + openTab(tab: Tab): void; + selectTab(conversationId: string): void; + closeTab(conversationId: string): void; + setModel(conversationId: string, model: string): void; + setTitle(conversationId: string, title: string): void; } export function createTabsStore(storage: TabsStorage): TabsStore { - let state = $state<TabsState>(storage.load() ?? initialState()); + let state = $state<TabsState>(storage.load() ?? initialState()); - function apply(next: TabsState): void { - state = next; - storage.save(next); - } + function apply(next: TabsState): void { + state = next; + storage.save(next); + } - return { - get tabs(): readonly Tab[] { - return state.tabs; - }, - get activeConversationId(): string | null { - return state.activeConversationId; - }, - get activeTab(): Tab | null { - return selectActiveTab(state); - }, - newDraft(): void { - apply(reduceNewDraft(state)); - }, - createTab(tab: Tab): void { - apply(reduceCreateTab(state, tab)); - }, - openTab(tab: Tab): void { - apply(reduceOpenTab(state, tab)); - }, - selectTab(conversationId: string): void { - apply(reduceSelectTab(state, conversationId)); - }, - closeTab(conversationId: string): void { - apply(reduceCloseTab(state, conversationId)); - }, - setModel(conversationId: string, model: string): void { - apply(reduceSetModel(state, conversationId, model)); - }, - setTitle(conversationId: string, title: string): void { - apply(reduceSetTitle(state, conversationId, title)); - }, - }; + return { + get tabs(): readonly Tab[] { + return state.tabs; + }, + get activeConversationId(): string | null { + return state.activeConversationId; + }, + get activeTab(): Tab | null { + return selectActiveTab(state); + }, + newDraft(): void { + apply(reduceNewDraft(state)); + }, + createTab(tab: Tab): void { + apply(reduceCreateTab(state, tab)); + }, + openTab(tab: Tab): void { + apply(reduceOpenTab(state, tab)); + }, + selectTab(conversationId: string): void { + apply(reduceSelectTab(state, conversationId)); + }, + closeTab(conversationId: string): void { + apply(reduceCloseTab(state, conversationId)); + }, + setModel(conversationId: string, model: string): void { + apply(reduceSetModel(state, conversationId, model)); + }, + setTitle(conversationId: string, title: string): void { + apply(reduceSetTitle(state, conversationId, title)); + }, + }; } diff --git a/src/features/tabs/tabs-store.test.ts b/src/features/tabs/tabs-store.test.ts index 81ec8ad..bb4df98 100644 --- a/src/features/tabs/tabs-store.test.ts +++ b/src/features/tabs/tabs-store.test.ts @@ -4,154 +4,154 @@ import type { TabsStorage } from "./tabs-store.svelte"; import { createTabsStore } from "./tabs-store.svelte"; function createMemoryStorage(initial?: TabsState): TabsStorage & { data: TabsState | null } { - let data: TabsState | null = initial ?? null; - return { - get data() { - return data; - }, - set data(v: TabsState | null) { - data = v; - }, - load() { - return data; - }, - save(state: TabsState) { - data = state; - }, - }; + let data: TabsState | null = initial ?? null; + return { + get data() { + return data; + }, + set data(v: TabsState | null) { + data = v; + }, + load() { + return data; + }, + save(state: TabsState) { + data = state; + }, + }; } describe("createTabsStore", () => { - it("loads persisted state on construct", () => { - const persisted: TabsState = { - tabs: [{ conversationId: "c1", model: "m1", title: "T1" }], - activeConversationId: "c1", - }; - const storage = createMemoryStorage(persisted); - const store = createTabsStore(storage); - - expect(store.tabs).toHaveLength(1); - expect(store.activeConversationId).toBe("c1"); - expect(store.activeTab?.conversationId).toBe("c1"); - }); - - it("starts with empty draft when no persisted state", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - expect(store.tabs).toHaveLength(0); - expect(store.activeConversationId).toBeNull(); - expect(store.activeTab).toBeNull(); - }); - - it("saves after every mutation", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - store.createTab({ conversationId: "c1", model: "m1", title: "T1" }); - expect(storage.data?.tabs).toHaveLength(1); - expect(storage.data?.activeConversationId).toBe("c1"); - - store.createTab({ conversationId: "c2", model: "m2", title: "T2" }); - expect(storage.data?.tabs).toHaveLength(2); - - store.selectTab("c1"); - expect(storage.data?.activeConversationId).toBe("c1"); - - store.closeTab("c1"); - expect(storage.data?.tabs).toHaveLength(1); - expect(storage.data?.activeConversationId).toBe("c2"); - - store.setModel("c2", "new-model"); - expect(storage.data?.tabs[0]?.model).toBe("new-model"); - - store.setTitle("c2", "New Title"); - expect(storage.data?.tabs[0]?.title).toBe("New Title"); - - store.newDraft(); - expect(storage.data?.activeConversationId).toBeNull(); - }); - - it("createTab appends and activates", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - store.createTab({ conversationId: "c1", model: "m1", title: "T1" }); - expect(store.tabs).toHaveLength(1); - expect(store.activeConversationId).toBe("c1"); - - store.createTab({ conversationId: "c2", model: "m2", title: "T2" }); - expect(store.tabs).toHaveLength(2); - expect(store.activeConversationId).toBe("c2"); - }); - - it("selectTab changes active", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - store.createTab({ conversationId: "c1", model: "m1", title: "T1" }); - store.createTab({ conversationId: "c2", model: "m2", title: "T2" }); - - store.selectTab("c1"); - expect(store.activeConversationId).toBe("c1"); - }); - - it("closeTab removes and activates neighbour", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - store.createTab({ conversationId: "c1", model: "m1", title: "T1" }); - store.createTab({ conversationId: "c2", model: "m2", title: "T2" }); - store.createTab({ conversationId: "c3", model: "m3", title: "T3" }); - - store.selectTab("c2"); - store.closeTab("c2"); - expect(store.tabs).toHaveLength(2); - expect(store.activeConversationId).toBe("c1"); - }); - - it("closing the last tab returns to draft", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - store.createTab({ conversationId: "c1", model: "m1", title: "T1" }); - store.closeTab("c1"); - expect(store.tabs).toHaveLength(0); - expect(store.activeConversationId).toBeNull(); - }); - - it("setModel updates the right tab", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - store.createTab({ conversationId: "c1", model: "old", title: "T1" }); - store.createTab({ conversationId: "c2", model: "m2", title: "T2" }); - - store.setModel("c1", "new-model"); - expect(store.tabs[0]?.model).toBe("new-model"); - expect(store.tabs[1]?.model).toBe("m2"); - }); + it("loads persisted state on construct", () => { + const persisted: TabsState = { + tabs: [{ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }], + activeConversationId: "c1", + }; + const storage = createMemoryStorage(persisted); + const store = createTabsStore(storage); + + expect(store.tabs).toHaveLength(1); + expect(store.activeConversationId).toBe("c1"); + expect(store.activeTab?.conversationId).toBe("c1"); + }); + + it("starts with empty draft when no persisted state", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + expect(store.activeTab).toBeNull(); + }); + + it("saves after every mutation", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); + expect(storage.data?.tabs).toHaveLength(1); + expect(storage.data?.activeConversationId).toBe("c1"); + + store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); + expect(storage.data?.tabs).toHaveLength(2); + + store.selectTab("c1"); + expect(storage.data?.activeConversationId).toBe("c1"); + + store.closeTab("c1"); + expect(storage.data?.tabs).toHaveLength(1); + expect(storage.data?.activeConversationId).toBe("c2"); + + store.setModel("c2", "new-model"); + expect(storage.data?.tabs[0]?.model).toBe("new-model"); + + store.setTitle("c2", "New Title"); + expect(storage.data?.tabs[0]?.title).toBe("New Title"); + + store.newDraft(); + expect(storage.data?.activeConversationId).toBeNull(); + }); + + it("createTab appends and activates", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); + expect(store.tabs).toHaveLength(1); + expect(store.activeConversationId).toBe("c1"); + + store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); + expect(store.tabs).toHaveLength(2); + expect(store.activeConversationId).toBe("c2"); + }); + + it("selectTab changes active", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); + store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); + + store.selectTab("c1"); + expect(store.activeConversationId).toBe("c1"); + }); + + it("closeTab removes and activates neighbour", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); + store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); + store.createTab({ conversationId: "c3", model: "m3", title: "T3", workspaceId: "default" }); + + store.selectTab("c2"); + store.closeTab("c2"); + expect(store.tabs).toHaveLength(2); + expect(store.activeConversationId).toBe("c1"); + }); + + it("closing the last tab returns to draft", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); + store.closeTab("c1"); + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + }); + + it("setModel updates the right tab", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "old", title: "T1", workspaceId: "default" }); + store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); + + store.setModel("c1", "new-model"); + expect(store.tabs[0]?.model).toBe("new-model"); + expect(store.tabs[1]?.model).toBe("m2"); + }); - it("setTitle updates the right tab", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); + it("setTitle updates the right tab", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); - store.createTab({ conversationId: "c1", model: "m1", title: "Old" }); + store.createTab({ conversationId: "c1", model: "m1", title: "Old", workspaceId: "default" }); - store.setTitle("c1", "New Title"); - expect(store.tabs[0]?.title).toBe("New Title"); - }); + store.setTitle("c1", "New Title"); + expect(store.tabs[0]?.title).toBe("New Title"); + }); - it("newDraft clears active but keeps tabs", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); + it("newDraft clears active but keeps tabs", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); - store.createTab({ conversationId: "c1", model: "m1", title: "T1" }); - store.createTab({ conversationId: "c2", model: "m2", title: "T2" }); + store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); + store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); - store.newDraft(); - expect(store.tabs).toHaveLength(2); - expect(store.activeConversationId).toBeNull(); - expect(store.activeTab).toBeNull(); - }); + store.newDraft(); + expect(store.tabs).toHaveLength(2); + expect(store.activeConversationId).toBeNull(); + expect(store.activeTab).toBeNull(); + }); }); diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts index 3c2a8c2..ec93076 100644 --- a/src/features/tabs/tabs.test.ts +++ b/src/features/tabs/tabs.test.ts @@ -1,248 +1,225 @@ import { describe, expect, it } from "vitest"; import type { Tab, TabsState } from "./tabs"; import { - activeTab, - closeTab, - createTab, - deriveTitle, - initialState, - isStuckToEnd, - MIN_HANDLE_LENGTH, - newDraft, - selectTab, - setModel, - setTitle, - shortHandle, + activeTab, + closeTab, + createTab, + deriveTitle, + initialState, + MIN_HANDLE_LENGTH, + newDraft, + selectTab, + setModel, + setTitle, + shortHandle, } from "./tabs"; const tab = (conversationId: string, model = "default", title = "Chat"): Tab => ({ - conversationId, - model, - title, + conversationId, + model, + title, + workspaceId: "default", }); describe("initialState", () => { - it("returns empty draft state when no persisted state", () => { - const state = initialState(); - expect(state.tabs).toEqual([]); - expect(state.activeConversationId).toBeNull(); - }); - - it("returns persisted state when provided", () => { - const persisted: TabsState = { - tabs: [tab("c1")], - activeConversationId: "c1", - }; - const state = initialState(persisted); - expect(state.tabs).toHaveLength(1); - expect(state.activeConversationId).toBe("c1"); - }); + it("returns empty draft state when no persisted state", () => { + const state = initialState(); + expect(state.tabs).toEqual([]); + expect(state.activeConversationId).toBeNull(); + }); + + it("returns persisted state when provided", () => { + const persisted: TabsState = { + tabs: [tab("c1")], + activeConversationId: "c1", + }; + const state = initialState(persisted); + expect(state.tabs).toHaveLength(1); + expect(state.activeConversationId).toBe("c1"); + }); }); describe("newDraft", () => { - it("sets activeConversationId to null", () => { - const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; - const next = newDraft(state); - expect(next.activeConversationId).toBeNull(); - }); - - it("keeps existing tabs", () => { - const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; - const next = newDraft(state); - expect(next.tabs).toHaveLength(2); - }); + it("sets activeConversationId to null", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; + const next = newDraft(state); + expect(next.activeConversationId).toBeNull(); + }); + + it("keeps existing tabs", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; + const next = newDraft(state); + expect(next.tabs).toHaveLength(2); + }); }); describe("createTab", () => { - it("appends and activates", () => { - const state = initialState(); - const next = createTab(state, tab("c1")); - expect(next.tabs).toHaveLength(1); - expect(next.tabs[0]?.conversationId).toBe("c1"); - expect(next.activeConversationId).toBe("c1"); - }); - - it("does not duplicate an existing conversationId", () => { - const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; - const next = createTab(state, tab("c1")); - expect(next.tabs).toHaveLength(1); - }); - - it("activates an already-existing tab when createTab is called again", () => { - const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" }; - const next = createTab(state, tab("c1")); - expect(next.activeConversationId).toBe("c1"); - }); + it("appends and activates", () => { + const state = initialState(); + const next = createTab(state, tab("c1")); + expect(next.tabs).toHaveLength(1); + expect(next.tabs[0]?.conversationId).toBe("c1"); + expect(next.activeConversationId).toBe("c1"); + }); + + it("does not duplicate an existing conversationId", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; + const next = createTab(state, tab("c1")); + expect(next.tabs).toHaveLength(1); + }); + + it("activates an already-existing tab when createTab is called again", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" }; + const next = createTab(state, tab("c1")); + expect(next.activeConversationId).toBe("c1"); + }); }); describe("selectTab", () => { - it("changes active", () => { - const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; - const next = selectTab(state, "c2"); - expect(next.activeConversationId).toBe("c2"); - }); + it("changes active", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; + const next = selectTab(state, "c2"); + expect(next.activeConversationId).toBe("c2"); + }); }); describe("closeTab", () => { - it("removes the tab", () => { - const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; - const next = closeTab(state, "c2"); - expect(next.tabs).toHaveLength(1); - expect(next.tabs[0]?.conversationId).toBe("c1"); - }); - - it("closing the active tab activates a neighbour (previous preferred)", () => { - const state: TabsState = { - tabs: [tab("c1"), tab("c2"), tab("c3")], - activeConversationId: "c2", - }; - const next = closeTab(state, "c2"); - expect(next.activeConversationId).toBe("c1"); - }); - - it("closing the first active tab activates the next", () => { - const state: TabsState = { - tabs: [tab("c1"), tab("c2"), tab("c3")], - activeConversationId: "c1", - }; - const next = closeTab(state, "c1"); - expect(next.activeConversationId).toBe("c2"); - }); - - it("closing the last tab returns to draft (null active)", () => { - const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; - const next = closeTab(state, "c1"); - expect(next.tabs).toHaveLength(0); - expect(next.activeConversationId).toBeNull(); - }); - - it("closing a non-active tab does not change active", () => { - const state: TabsState = { - tabs: [tab("c1"), tab("c2"), tab("c3")], - activeConversationId: "c3", - }; - const next = closeTab(state, "c1"); - expect(next.activeConversationId).toBe("c3"); - }); - - it("closing a non-existent tab is a no-op", () => { - const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; - const next = closeTab(state, "missing"); - expect(next).toEqual(state); - }); + it("removes the tab", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; + const next = closeTab(state, "c2"); + expect(next.tabs).toHaveLength(1); + expect(next.tabs[0]?.conversationId).toBe("c1"); + }); + + it("closing the active tab activates a neighbour (previous preferred)", () => { + const state: TabsState = { + tabs: [tab("c1"), tab("c2"), tab("c3")], + activeConversationId: "c2", + }; + const next = closeTab(state, "c2"); + expect(next.activeConversationId).toBe("c1"); + }); + + it("closing the first active tab activates the next", () => { + const state: TabsState = { + tabs: [tab("c1"), tab("c2"), tab("c3")], + activeConversationId: "c1", + }; + const next = closeTab(state, "c1"); + expect(next.activeConversationId).toBe("c2"); + }); + + it("closing the last tab returns to draft (null active)", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; + const next = closeTab(state, "c1"); + expect(next.tabs).toHaveLength(0); + expect(next.activeConversationId).toBeNull(); + }); + + it("closing a non-active tab does not change active", () => { + const state: TabsState = { + tabs: [tab("c1"), tab("c2"), tab("c3")], + activeConversationId: "c3", + }; + const next = closeTab(state, "c1"); + expect(next.activeConversationId).toBe("c3"); + }); + + it("closing a non-existent tab is a no-op", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; + const next = closeTab(state, "missing"); + expect(next).toEqual(state); + }); }); describe("setModel", () => { - it("updates the right tab", () => { - const state: TabsState = { tabs: [tab("c1", "old"), tab("c2")], activeConversationId: "c1" }; - const next = setModel(state, "c1", "new-model"); - expect(next.tabs[0]?.model).toBe("new-model"); - expect(next.tabs[1]?.model).toBe("default"); - }); + it("updates the right tab", () => { + const state: TabsState = { tabs: [tab("c1", "old"), tab("c2")], activeConversationId: "c1" }; + const next = setModel(state, "c1", "new-model"); + expect(next.tabs[0]?.model).toBe("new-model"); + expect(next.tabs[1]?.model).toBe("default"); + }); }); describe("setTitle", () => { - it("updates the right tab", () => { - const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; - const next = setTitle(state, "c1", "Updated title"); - expect(next.tabs[0]?.title).toBe("Updated title"); - expect(next.tabs[1]?.title).toBe("Chat"); - }); + it("updates the right tab", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; + const next = setTitle(state, "c1", "Updated title"); + expect(next.tabs[0]?.title).toBe("Updated title"); + expect(next.tabs[1]?.title).toBe("Chat"); + }); }); describe("activeTab", () => { - it("returns the active tab", () => { - const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" }; - expect(activeTab(state)?.conversationId).toBe("c2"); - }); - - it("returns null when activeConversationId is null", () => { - const state: TabsState = { tabs: [tab("c1")], activeConversationId: null }; - expect(activeTab(state)).toBeNull(); - }); - - it("returns null when active tab is not found in tabs", () => { - const state: TabsState = { tabs: [tab("c1")], activeConversationId: "missing" }; - expect(activeTab(state)).toBeNull(); - }); + it("returns the active tab", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" }; + expect(activeTab(state)?.conversationId).toBe("c2"); + }); + + it("returns null when activeConversationId is null", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: null }; + expect(activeTab(state)).toBeNull(); + }); + + it("returns null when active tab is not found in tabs", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "missing" }; + expect(activeTab(state)).toBeNull(); + }); }); describe("deriveTitle", () => { - it("truncates long messages with ellipsis", () => { - const msg = "This is a very long message that should be truncated at some point"; - expect(deriveTitle(msg, 20)).toBe("This is a very long \u2026"); - }); - - it("returns full message when under max", () => { - expect(deriveTitle("Short", 40)).toBe("Short"); - }); - - it("collapses whitespace", () => { - expect(deriveTitle(" hello world ")).toBe("hello world"); - }); - - it("falls back to 'New chat' for empty input", () => { - expect(deriveTitle("")).toBe("New chat"); - expect(deriveTitle(" ")).toBe("New chat"); - }); - - it("uses default max of ~40 chars", () => { - const msg = "a".repeat(50); - const result = deriveTitle(msg); - expect(result).toBe(`${"a".repeat(40)}\u2026`); - }); -}); - -describe("isStuckToEnd", () => { - it("is false when the strip does not overflow", () => { - expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 500 })).toBe(false); - expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 400 })).toBe(false); - }); - - it("is true when overflowing and scrolled to the left", () => { - expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 1000 })).toBe(true); - }); - - it("is true when overflowing and scrolled to the middle", () => { - expect(isStuckToEnd({ scrollLeft: 250, clientWidth: 500, scrollWidth: 1000 })).toBe(true); - }); - - it("is false when overflowing but scrolled fully to the right", () => { - expect(isStuckToEnd({ scrollLeft: 500, clientWidth: 500, scrollWidth: 1000 })).toBe(false); - }); - - it("treats a 1px subpixel gap at the end as at-rest (epsilon)", () => { - expect(isStuckToEnd({ scrollLeft: 499, clientWidth: 500, scrollWidth: 1000 })).toBe(false); - }); + it("truncates long messages with ellipsis", () => { + const msg = "This is a very long message that should be truncated at some point"; + expect(deriveTitle(msg, 20)).toBe("This is a very long \u2026"); + }); + + it("returns full message when under max", () => { + expect(deriveTitle("Short", 40)).toBe("Short"); + }); + + it("collapses whitespace", () => { + expect(deriveTitle(" hello world ")).toBe("hello world"); + }); + + it("falls back to 'New chat' for empty input", () => { + expect(deriveTitle("")).toBe("New chat"); + expect(deriveTitle(" ")).toBe("New chat"); + }); + + it("uses default max of ~40 chars", () => { + const msg = "a".repeat(50); + const result = deriveTitle(msg); + expect(result).toBe(`${"a".repeat(40)}\u2026`); + }); }); describe("shortHandle", () => { - it("uses the minimum length when the id is unique", () => { - const h = shortHandle("3f9a1b2c-aaaa", ["3f9a1b2c-aaaa", "7c2d-bbbb"]); - expect(h).toBe("3f9a"); - expect(h.length).toBe(MIN_HANDLE_LENGTH); - }); - - it("grows the prefix until unique among open tabs", () => { - // two ids share the first 5 chars → handle grows to 6 to disambiguate - expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde1"); - expect(shortHandle("abcde2-yyyy", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde2"); - }); - - it("shrinks back to the minimum when the colliding sibling is gone", () => { - expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx"])).toBe("abcd"); - }); - - it("ignores the id itself when present in the list", () => { - expect(shortHandle("deadbeef", ["deadbeef"])).toBe("dead"); - }); - - it("returns the whole id when shorter than the minimum length", () => { - expect(shortHandle("ab", ["ab", "cd"])).toBe("ab"); - }); - - it("falls back to the full id when one id is a prefix of another", () => { - // "abcd" is a prefix of "abcd1234" → no unique shorter prefix exists for it - expect(shortHandle("abcd", ["abcd", "abcd1234"])).toBe("abcd"); - }); + it("uses the minimum length when the id is unique", () => { + const h = shortHandle("3f9a1b2c-aaaa", ["3f9a1b2c-aaaa", "7c2d-bbbb"]); + expect(h).toBe("3f9a"); + expect(h.length).toBe(MIN_HANDLE_LENGTH); + }); + + it("grows the prefix until unique among open tabs", () => { + // two ids share the first 5 chars → handle grows to 6 to disambiguate + expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde1"); + expect(shortHandle("abcde2-yyyy", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde2"); + }); + + it("shrinks back to the minimum when the colliding sibling is gone", () => { + expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx"])).toBe("abcd"); + }); + + it("ignores the id itself when present in the list", () => { + expect(shortHandle("deadbeef", ["deadbeef"])).toBe("dead"); + }); + + it("returns the whole id when shorter than the minimum length", () => { + expect(shortHandle("ab", ["ab", "cd"])).toBe("ab"); + }); + + it("falls back to the full id when one id is a prefix of another", () => { + // "abcd" is a prefix of "abcd1234" → no unique shorter prefix exists for it + expect(shortHandle("abcd", ["abcd", "abcd1234"])).toBe("abcd"); + }); }); diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts index 3360d3f..63cda35 100644 --- a/src/features/tabs/tabs.ts +++ b/src/features/tabs/tabs.ts @@ -1,30 +1,40 @@ export interface Tab { - readonly conversationId: string; - readonly model: string; - readonly title: string; + readonly conversationId: string; + readonly model: string; + readonly title: string; + /** The workspace this tab belongs to (the workspace's URL slug). */ + readonly workspaceId: string; } export interface TabsState { - readonly tabs: readonly Tab[]; - readonly activeConversationId: string | null; + readonly tabs: readonly Tab[]; + readonly activeConversationId: string | null; } const DEFAULT_TITLE = "New chat"; const DEFAULT_MAX_TITLE_LENGTH = 40; export function initialState(persisted?: TabsState): TabsState { - if (persisted !== undefined) return persisted; - return { tabs: [], activeConversationId: null }; + if (persisted !== undefined) { + // Migrate tabs persisted before workspaces: assign them to the "default" + // workspace (the fallback for conversations with no workspace). + const tabs = persisted.tabs.map((t) => { + const wid = (t as { workspaceId?: string }).workspaceId; + return { ...t, workspaceId: wid ?? "default" }; + }); + return { tabs, activeConversationId: persisted.activeConversationId }; + } + return { tabs: [], activeConversationId: null }; } export function newDraft(state: TabsState): TabsState { - return { ...state, activeConversationId: null }; + return { ...state, activeConversationId: null }; } export function createTab(state: TabsState, tab: Tab): TabsState { - const exists = state.tabs.some((t) => t.conversationId === tab.conversationId); - const tabs = exists ? state.tabs : [...state.tabs, tab]; - return { tabs, activeConversationId: tab.conversationId }; + const exists = state.tabs.some((t) => t.conversationId === tab.conversationId); + const tabs = exists ? state.tabs : [...state.tabs, tab]; + return { tabs, activeConversationId: tab.conversationId }; } /** @@ -33,75 +43,55 @@ export function createTab(state: TabsState, tab: Tab): TabsState { * strip but the user stays on their current tab. No-op if already open. */ export function openTab(state: TabsState, tab: Tab): TabsState { - const exists = state.tabs.some((t) => t.conversationId === tab.conversationId); - if (exists) return state; - return { tabs: [...state.tabs, tab], activeConversationId: state.activeConversationId }; + const exists = state.tabs.some((t) => t.conversationId === tab.conversationId); + if (exists) return state; + return { tabs: [...state.tabs, tab], activeConversationId: state.activeConversationId }; } export function selectTab(state: TabsState, conversationId: string): TabsState { - return { ...state, activeConversationId: conversationId }; + return { ...state, activeConversationId: conversationId }; } export function closeTab(state: TabsState, conversationId: string): TabsState { - const idx = state.tabs.findIndex((t) => t.conversationId === conversationId); - if (idx === -1) return state; + const idx = state.tabs.findIndex((t) => t.conversationId === conversationId); + if (idx === -1) return state; - const tabs = state.tabs.filter((t) => t.conversationId !== conversationId); + const tabs = state.tabs.filter((t) => t.conversationId !== conversationId); - if (state.activeConversationId !== conversationId) { - return { tabs, activeConversationId: state.activeConversationId }; - } + if (state.activeConversationId !== conversationId) { + return { tabs, activeConversationId: state.activeConversationId }; + } - if (tabs.length === 0) { - return { tabs, activeConversationId: null }; - } + if (tabs.length === 0) { + return { tabs, activeConversationId: null }; + } - // prefer previous tab, else next - const neighborIdx = idx > 0 ? idx - 1 : 0; - const neighbor = tabs[neighborIdx]; - return { tabs, activeConversationId: neighbor?.conversationId ?? null }; + // prefer previous tab, else next + const neighborIdx = idx > 0 ? idx - 1 : 0; + const neighbor = tabs[neighborIdx]; + return { tabs, activeConversationId: neighbor?.conversationId ?? null }; } export function setModel(state: TabsState, conversationId: string, model: string): TabsState { - const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t)); - return { tabs, activeConversationId: state.activeConversationId }; + const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t)); + return { tabs, activeConversationId: state.activeConversationId }; } export function setTitle(state: TabsState, conversationId: string, title: string): TabsState { - const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, title } : t)); - return { tabs, activeConversationId: state.activeConversationId }; + const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, title } : t)); + return { tabs, activeConversationId: state.activeConversationId }; } export function activeTab(state: TabsState): Tab | null { - if (state.activeConversationId === null) return null; - return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null; -} - -export interface ScrollMetrics { - readonly scrollLeft: number; - readonly clientWidth: number; - readonly scrollWidth: number; -} - -const STUCK_EPSILON = 1; - -/** - * True when a right-pinned sticky element is floating over scrolled content — the - * strip overflows horizontally AND is not scrolled fully to the right. When it is - * at rest (no overflow, or scrolled to the end so it sits at its natural position) - * this returns false. Pure: layout measurements in, boolean out. - */ -export function isStuckToEnd(m: ScrollMetrics): boolean { - const overflows = m.scrollWidth > m.clientWidth + STUCK_EPSILON; - const notAtEnd = m.scrollLeft + m.clientWidth < m.scrollWidth - STUCK_EPSILON; - return overflows && notAtEnd; + if (state.activeConversationId === null) return null; + return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null; } export function deriveTitle(message: string, max: number = DEFAULT_MAX_TITLE_LENGTH): string { - const trimmed = message.trim().replace(/\s+/g, " "); - if (trimmed.length === 0) return DEFAULT_TITLE; - if (trimmed.length <= max) return trimmed; - return `${trimmed.slice(0, max)}\u2026`; + const trimmed = message.trim().replace(/\s+/g, " "); + if (trimmed.length === 0) return DEFAULT_TITLE; + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, max)}\u2026`; } /** Minimum length of a tab handle (git-style short id). */ @@ -115,10 +105,10 @@ export const MIN_HANDLE_LENGTH = 4; * id in, the handle string out. (`allIds` may include `conversationId` itself.) */ export function shortHandle(conversationId: string, allIds: readonly string[]): string { - const others = allIds.filter((id) => id !== conversationId); - for (let len = MIN_HANDLE_LENGTH; len < conversationId.length; len++) { - const candidate = conversationId.slice(0, len); - if (!others.some((id) => id.startsWith(candidate))) return candidate; - } - return conversationId; + const others = allIds.filter((id) => id !== conversationId); + for (let len = MIN_HANDLE_LENGTH; len < conversationId.length; len++) { + const candidate = conversationId.slice(0, len); + if (!others.some((id) => id.startsWith(candidate))) return candidate; + } + return conversationId; } diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index 6cd66bd..ae2a021 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -1,213 +1,298 @@ -import { render, screen } from "@testing-library/svelte"; +import { fireEvent, render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { Tab } from "./tabs"; -import TabBar from "./ui/TabBar.svelte"; +import TabList from "./ui/TabList.svelte"; const sampleTabs: readonly Tab[] = [ - { conversationId: "c1", model: "openai/gpt-4", title: "First" }, - { conversationId: "c2", model: "anthropic/claude-3", title: "Second" }, - { conversationId: "c3", model: "google/gemini", title: "Third" }, + { conversationId: "c1", model: "openai/gpt-4", title: "First", workspaceId: "default" }, + { conversationId: "c2", model: "anthropic/claude-3", title: "Second", workspaceId: "default" }, + { conversationId: "c3", model: "google/gemini", title: "Third", workspaceId: "default" }, ]; -describe("TabBar", () => { - it("renders one role=tab element per tab showing each title", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - const tabs = screen.getAllByRole("tab"); - expect(tabs).toHaveLength(sampleTabs.length); - expect(tabs[0]).toHaveTextContent("First"); - expect(tabs[1]).toHaveTextContent("Second"); - expect(tabs[2]).toHaveTextContent("Third"); - }); - - it("applies tab-active to the active tab only", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c2", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - const tabs = screen.getAllByRole("tab"); - expect(tabs[0]).not.toHaveClass("tab-active"); - expect(tabs[1]).toHaveClass("tab-active"); - expect(tabs[2]).not.toHaveClass("tab-active"); - }); - - it("applies tab-active to New chat button when activeConversationId is null", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: null, - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - const newChat = screen.getByRole("button", { name: "New chat" }); - expect(newChat).toHaveClass("tab-active"); - }); - - it("calls onSelect with the conversationId when a tab is clicked", async () => { - const onSelect = vi.fn(); - const onClose = vi.fn(); - const user = userEvent.setup(); - - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect, - onClose, - onNewDraft: vi.fn(), - }, - }); - - const tabs = screen.getAllByRole("tab"); - const secondTab = tabs[1]; - if (!secondTab) throw new Error("second tab not found"); - await user.click(secondTab); - - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect).toHaveBeenCalledWith("c2"); - expect(onClose).not.toHaveBeenCalled(); - }); - - it("calls onClose when the close button is clicked and does not call onSelect", async () => { - const onSelect = vi.fn(); - const onClose = vi.fn(); - const user = userEvent.setup(); - - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect, - onClose, - onNewDraft: vi.fn(), - }, - }); - - const closeButtons = screen.getAllByRole("button", { name: "Close tab" }); - const firstClose = closeButtons[0]; - if (!firstClose) throw new Error("first close button not found"); - await user.click(firstClose); - - expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith("c1"); - expect(onSelect).not.toHaveBeenCalled(); - }); - - it("calls onNewDraft when the New chat button is clicked", async () => { - const onNewDraft = vi.fn(); - const user = userEvent.setup(); - - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft, - }, - }); - - const newChat = screen.getByRole("button", { name: "New chat" }); - await user.click(newChat); - - expect(onNewDraft).toHaveBeenCalledTimes(1); - }); - - it("the New chat button has the sticky class", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - const newChat = screen.getByRole("button", { name: "New chat" }); - expect(newChat).toHaveClass("sticky"); - }); - - it("shows visible 'New Chat' text when activeConversationId is null", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: null, - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - const newChat = screen.getByRole("button", { name: "New chat" }); - expect(newChat).toHaveTextContent("New Chat"); - }); - - it("does not show 'New Chat' text when a real tab is active", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - const newChat = screen.getByRole("button", { name: "New chat" }); - expect(newChat).not.toHaveTextContent("New Chat"); - }); - - it("renders a short-handle tab ID badge (shortest unique prefix) per tab", () => { - const tabs: readonly Tab[] = [ - { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha" }, - { conversationId: "7c2db4e5-2222", model: "m", title: "Beta" }, - ]; - render(TabBar, { - props: { - tabs, - activeConversationId: "3f9a1b2c-1111", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - expect(screen.getByText("3f9a")).toBeInTheDocument(); - expect(screen.getByText("7c2d")).toBeInTheDocument(); - }); - - it("renders fixed-width tabs", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - for (const t of screen.getAllByRole("tab")) { - expect(t).toHaveClass("w-48"); - } - }); +describe("TabList", () => { + it("renders one role=tab element per tab showing each title", () => { + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const tabs = screen.getAllByRole("tab"); + expect(tabs).toHaveLength(sampleTabs.length); + expect(tabs[0]).toHaveTextContent("First"); + expect(tabs[1]).toHaveTextContent("Second"); + expect(tabs[2]).toHaveTextContent("Third"); + }); + + it("marks the active tab as aria-selected", () => { + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c2", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const tabs = screen.getAllByRole("tab"); + expect(tabs[0]).toHaveAttribute("aria-selected", "false"); + expect(tabs[1]).toHaveAttribute("aria-selected", "true"); + expect(tabs[2]).toHaveAttribute("aria-selected", "false"); + }); + + it("calls onSelect with the conversationId when a tab is clicked", async () => { + const onSelect = vi.fn(); + const onClose = vi.fn(); + const user = userEvent.setup(); + + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect, + onClose, + onNewDraft: vi.fn(), + }, + }); + + const tabs = screen.getAllByRole("tab"); + const secondTab = tabs[1]; + if (!secondTab) throw new Error("second tab not found"); + await user.click(secondTab); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith("c2"); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("calls onClose when the close button is clicked and does not call onSelect", async () => { + const onSelect = vi.fn(); + const onClose = vi.fn(); + const user = userEvent.setup(); + + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect, + onClose, + onNewDraft: vi.fn(), + }, + }); + + const closeButtons = screen.getAllByRole("button", { name: "Close tab" }); + const firstClose = closeButtons[0]; + if (!firstClose) throw new Error("first close button not found"); + await user.click(firstClose); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith("c1"); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("calls onNewDraft when the New chat button is clicked", async () => { + const onNewDraft = vi.fn(); + const user = userEvent.setup(); + + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft, + }, + }); + + const newChat = screen.getByRole("button", { name: "New chat" }); + await user.click(newChat); + + expect(onNewDraft).toHaveBeenCalledTimes(1); + }); + + it("shows visible 'New Chat' text when activeConversationId is null", () => { + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: null, + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const newChat = screen.getByRole("button", { name: "New chat" }); + expect(newChat).toHaveTextContent("New Chat"); + }); + + it("does not show 'New Chat' text when a real tab is active", () => { + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const newChat = screen.getByRole("button", { name: "New chat" }); + expect(newChat).not.toHaveTextContent("New Chat"); + }); + + it("renders a short-handle tab ID badge (shortest unique prefix) per tab", () => { + const tabs: readonly Tab[] = [ + { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha", workspaceId: "default" }, + { conversationId: "7c2db4e5-2222", model: "m", title: "Beta", workspaceId: "default" }, + ]; + render(TabList, { + props: { + tabs, + activeConversationId: "3f9a1b2c-1111", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + expect(screen.getByText("3f9a")).toBeInTheDocument(); + expect(screen.getByText("7c2d")).toBeInTheDocument(); + }); + + it("renders each tab as a single vertical row (flex-col list, not a horizontal strip)", () => { + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + // The scroll region containing the tab rows is a vertical flex column. + const tabs = screen.getAllByRole("tab"); + expect(tabs.length).toBeGreaterThan(0); + const region = tabs[0]?.parentElement; + expect(region).toHaveClass("flex-col"); + }); + + it("fixes the tab list region at 40vh so a long set scrolls internally", () => { + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const tabs = screen.getAllByRole("tab"); + const region = tabs[0]?.parentElement; + expect(region).toHaveClass("h-[40vh]"); + expect(region).toHaveClass("overflow-y-auto"); + }); + + it("calls onRename when a tab title is double-clicked and committed with Enter", async () => { + const onRename = vi.fn(); + const user = userEvent.setup(); + + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + onRename, + }, + }); + + const titleButtons = screen.getAllByRole("button"); + // The inline-rename trigger is the title span (role=button) — find the one + // whose text matches the first tab's title. + const titleButton = titleButtons.find((b) => b.textContent === "First"); + if (!titleButton) throw new Error("title button not found"); + await user.dblClick(titleButton); + + const input = screen.getByRole("textbox"); + await user.clear(input); + await user.type(input, "Renamed{Enter}"); + + expect(onRename).toHaveBeenCalledTimes(1); + expect(onRename).toHaveBeenCalledWith("c1", "Renamed"); + }); + + it("copies the conversation id to the clipboard and highlights the badge text when clicked", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + const onSelect = vi.fn(); + + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect, + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const idBadge = screen.getByRole("button", { name: "Copy conversation id c1" }); + await fireEvent.click(idBadge); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith("c1"); + // The badge text is NOT swapped (no width shift) — the highlight (selection) + // is the only indicator. + expect(idBadge).toHaveTextContent("c1"); + expect(idBadge).not.toHaveTextContent("Copied"); + // The badge text is selected (highlighted) as the copy indicator. + const selection = window.getSelection(); + expect(selection?.toString()).toBe("c1"); + // Clicking the ID must NOT switch tabs (the badge stops propagation). + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("shows a loading RING for a 'queued' tab and loading DOTS for an 'active' tab", () => { + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + statusFor: (id: string) => (id === "c1" ? "queued" : id === "c2" ? "active" : undefined), + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + // c1 is queued → a ring (DaisyUI `loading-ring`), labeled "Queued". + const queuedRing = screen.getByLabelText("Queued"); + expect(queuedRing.className).toContain("loading-ring"); + expect(queuedRing.closest('[role="tab"]')).toHaveTextContent("First"); + + const tabs = screen.getAllByRole("tab"); + expect(tabs).toHaveLength(3); + const activeTab = tabs[1]; + const idleTab = tabs[2]; + if (activeTab === undefined || idleTab === undefined) throw new Error("missing tabs"); + + // c2 is active → loading dots (NOT a ring). + expect(activeTab.querySelector(".loading-dots")).not.toBeNull(); + expect(activeTab.querySelector(".loading-ring")).toBeNull(); + + // c3 has no status → no spinner at all. + expect(idleTab.querySelector(".loading")).toBeNull(); + }); }); diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabList.svelte index f783412..6d85065 100644 --- a/src/features/tabs/ui/TabBar.svelte +++ b/src/features/tabs/ui/TabList.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import type { Tab } from "../tabs"; - import { isStuckToEnd, shortHandle } from "../tabs"; + import { shortHandle } from "../tabs"; let { tabs, @@ -21,13 +21,6 @@ onRename?: (conversationId: string, title: string) => void; } = $props(); - // The new-chat button is `position: sticky; right: 0`. It floats over the tabs - // only while the strip overflows and isn't scrolled fully right; we square its - // right edge only in that "stuck" state. Pure decision (`isStuckToEnd`) + - // DOM-measurement at the edge here. - let scrollEl = $state<HTMLDivElement>(); - let stuck = $state(false); - // Git-style short handle (shortest unique prefix) per open tab — the visible // "tab ID". Derived from the set of open conversation ids; pure helper. const handles = $derived.by(() => { @@ -37,36 +30,6 @@ return map; }); - function recompute(): void { - const el = scrollEl; - if (el === undefined) { - stuck = false; - return; - } - stuck = isStuckToEnd({ - scrollLeft: el.scrollLeft, - clientWidth: el.clientWidth, - scrollWidth: el.scrollWidth, - }); - } - - $effect(() => { - const el = scrollEl; - if (el === undefined) return; - // Re-evaluate when the tab set changes (overflow may appear/disappear). - void tabs; - recompute(); - - el.addEventListener("scroll", recompute, { passive: true }); - const ro = - typeof ResizeObserver !== "undefined" ? new ResizeObserver(recompute) : undefined; - ro?.observe(el); - - return () => { - el.removeEventListener("scroll", recompute); - ro?.disconnect(); - }; - }); // Inline rename: double-click a tab's title to edit, Enter/blur to save. let editingId = $state<string | null>(null); let editValue = $state(""); @@ -92,27 +55,63 @@ function cancelRename(): void { editingId = null; } + + // Click-to-copy the agent (conversation) id: clicking the ID badge copies the + // FULL conversationId to the clipboard (the stable, useful id — the badge + // only shows a short prefix) and highlights the badge text as feedback. The + // highlight (text selection) is the indicator — no text is swapped, so the + // badge keeps a stable width. If the clipboard write fails, the selection is + // already in place so the user can Ctrl+C the text manually. + async function copyId(conversationId: string, el: HTMLElement): Promise<void> { + selectText(el); + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + try { + await clipboard.writeText(conversationId); + } catch { + // Selection already lets the user copy manually. + } + } + + function selectText(el: HTMLElement): void { + const range = document.createRange(); + range.selectNodeContents(el); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } </script> -<div bind:this={scrollEl} class="min-w-0 flex-1 overflow-x-auto"> - <div class="tabs tabs-lift min-w-max"> +<div class="flex flex-col gap-2"> + <!-- Single-column vertical tab list. Fixed at 40% of the viewport height so a + long tab set scrolls inside this region instead of growing the whole sidebar. --> + <div class="flex h-[40vh] flex-col gap-1 overflow-y-auto pr-1"> {#each tabs as tab (tab.conversationId)} <div - class="tab flex w-48 shrink-0 items-center gap-1.5" - class:tab-active={tab.conversationId === activeConversationId} + class="flex items-center gap-1.5 rounded px-2 py-1.5 text-sm hover:bg-base-300" + class:bg-base-300={tab.conversationId === activeConversationId} role="tab" tabindex="0" + aria-selected={tab.conversationId === activeConversationId} + title={tab.title} onclick={() => onSelect(tab.conversationId)} onkeydown={(e) => { if (e.key === "Enter") onSelect(tab.conversationId); }} > - <span - class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60" - title="Tab ID" + <button + type="button" + class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60 transition-colors hover:bg-primary hover:text-primary-content" + data-copy-id={tab.conversationId} + title="Click to copy conversation id" + aria-label={`Copy conversation id ${tab.conversationId}`} + onclick={(e) => { + e.stopPropagation(); + void copyId(tab.conversationId, e.currentTarget); + }} > {handles.get(tab.conversationId) ?? tab.conversationId} - </span> + </button> {#if editingId === tab.conversationId} <input bind:this={editEl} @@ -135,7 +134,7 @@ class="min-w-0 flex-1 cursor-pointer truncate text-left" role="button" tabindex="-1" - title="Double-click to rename" + title={tab.title} ondblclick={(e) => { e.stopPropagation(); startRename(tab); @@ -144,8 +143,15 @@ {tab.title} </span> {/if} - {#if statusFor?.(tab.conversationId) === "active"} - <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span> + {#if statusFor?.(tab.conversationId) === "queued"} + <!-- Waiting for a concurrency slot — a ring (vs the dots of `active`). --> + <span + class="loading loading-ring loading-xs shrink-0 text-primary" + aria-label="Queued" + title="Waiting for a concurrency slot" + ></span> + {:else if statusFor?.(tab.conversationId) === "active"} + <span class="loading loading-dots loading-xs shrink-0 text-primary"></span> {/if} <button class="btn btn-ghost btn-xs shrink-0" @@ -159,20 +165,19 @@ </button> </div> {/each} - <button - class="tab sticky right-0 z-10 bg-base-200 shadow-[-2px_0_4px_-1px_rgba(0,0,0,0.2)] {stuck - ? '!rounded-se-none !rounded-ee-none' - : ''}" - class:tab-active={activeConversationId === null} - aria-label="New chat" - onclick={() => onNewDraft()} - > - {#if activeConversationId === null} - <span class="max-w-[120px] truncate">New Chat</span> - <span class="btn btn-ghost btn-xs ml-1" aria-hidden="true">+</span> - {:else} - + - {/if} - </button> </div> + + <button + type="button" + class="btn btn-ghost btn-sm w-full border border-base-300" + class:btn-primary={activeConversationId === null} + aria-label="New chat" + onclick={() => onNewDraft()} + > + {#if activeConversationId === null} + New Chat + {:else} + + New chat + {/if} + </button> </div> diff --git a/src/features/views/index.ts b/src/features/views/index.ts index c4e7f25..164241d 100644 --- a/src/features/views/index.ts +++ b/src/features/views/index.ts @@ -1,15 +1,15 @@ export { - addPanel, - initialPanels, - type PanelsState, - removePanel, - selectKind, - type ViewPanel, + addPanel, + initialPanels, + type PanelsState, + removePanel, + selectKind, + type ViewPanel, } from "./logic/panels"; export { default as ViewSidebar } from "./ui/ViewSidebar.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "views", - description: "Sidebar view panels (dropdown picker + add / remove)", + name: "views", + description: "Sidebar view panels (dropdown picker + add / remove)", } as const; diff --git a/src/features/views/logic/panels.test.ts b/src/features/views/logic/panels.test.ts index edd7d9e..aadbf5e 100644 --- a/src/features/views/logic/panels.test.ts +++ b/src/features/views/logic/panels.test.ts @@ -2,54 +2,54 @@ import { describe, expect, it } from "vitest"; import { addPanel, initialPanels, removePanel, selectKind } from "./panels"; describe("view panels reducer", () => { - it("seeds one empty panel by default", () => { - const s = initialPanels(); - expect(s.panels).toHaveLength(1); - expect(s.panels[0]?.kind).toBeNull(); - }); + it("seeds one empty panel by default", () => { + const s = initialPanels(); + expect(s.panels).toHaveLength(1); + expect(s.panels[0]?.kind).toBeNull(); + }); - it("seeds a panel per provided kind, in order, with unique ids", () => { - const s = initialPanels(["surfaces", null]); - expect(s.panels.map((p) => p.kind)).toEqual(["surfaces", null]); - expect(new Set(s.panels.map((p) => p.id)).size).toBe(2); - }); + it("seeds a panel per provided kind, in order, with unique ids", () => { + const s = initialPanels(["surfaces", null]); + expect(s.panels.map((p) => p.kind)).toEqual(["surfaces", null]); + expect(new Set(s.panels.map((p) => p.id)).size).toBe(2); + }); - it("addPanel appends an empty panel with a fresh id", () => { - const seed = initialPanels(["surfaces"]); - const s = addPanel(seed); - expect(s.panels).toHaveLength(2); - expect(s.panels[1]?.kind).toBeNull(); - expect(s.panels[1]?.id).not.toBe(s.panels[0]?.id); - }); + it("addPanel appends an empty panel with a fresh id", () => { + const seed = initialPanels(["surfaces"]); + const s = addPanel(seed); + expect(s.panels).toHaveLength(2); + expect(s.panels[1]?.kind).toBeNull(); + expect(s.panels[1]?.id).not.toBe(s.panels[0]?.id); + }); - it("addPanel can seed a kind", () => { - const s = addPanel(initialPanels([null]), "surfaces"); - expect(s.panels[1]?.kind).toBe("surfaces"); - }); + it("addPanel can seed a kind", () => { + const s = addPanel(initialPanels([null]), "surfaces"); + expect(s.panels[1]?.kind).toBe("surfaces"); + }); - it("removePanel drops the matching id only", () => { - const seed = initialPanels(["surfaces", null]); - const firstId = seed.panels[0]?.id ?? -1; - const s = removePanel(seed, firstId); - expect(s.panels).toHaveLength(1); - expect(s.panels[0]?.kind).toBeNull(); - }); + it("removePanel drops the matching id only", () => { + const seed = initialPanels(["surfaces", null]); + const firstId = seed.panels[0]?.id ?? -1; + const s = removePanel(seed, firstId); + expect(s.panels).toHaveLength(1); + expect(s.panels[0]?.kind).toBeNull(); + }); - it("selectKind updates only the targeted panel", () => { - const seed = initialPanels([null, null]); - const targetId = seed.panels[1]?.id ?? -1; - const s = selectKind(seed, targetId, "surfaces"); - expect(s.panels[0]?.kind).toBeNull(); - expect(s.panels[1]?.kind).toBe("surfaces"); - }); + it("selectKind updates only the targeted panel", () => { + const seed = initialPanels([null, null]); + const targetId = seed.panels[1]?.id ?? -1; + const s = selectKind(seed, targetId, "surfaces"); + expect(s.panels[0]?.kind).toBeNull(); + expect(s.panels[1]?.kind).toBe("surfaces"); + }); - it("is pure — never mutates the input state", () => { - const seed = initialPanels(["surfaces"]); - const snapshot = JSON.stringify(seed); - const id = seed.panels[0]?.id ?? -1; - addPanel(seed); - removePanel(seed, id); - selectKind(seed, id, null); - expect(JSON.stringify(seed)).toBe(snapshot); - }); + it("is pure — never mutates the input state", () => { + const seed = initialPanels(["surfaces"]); + const snapshot = JSON.stringify(seed); + const id = seed.panels[0]?.id ?? -1; + addPanel(seed); + removePanel(seed, id); + selectKind(seed, id, null); + expect(JSON.stringify(seed)).toBe(snapshot); + }); }); diff --git a/src/features/views/logic/panels.ts b/src/features/views/logic/panels.ts index 38c28fb..fa22be6 100644 --- a/src/features/views/logic/panels.ts +++ b/src/features/views/logic/panels.ts @@ -10,14 +10,14 @@ */ export interface ViewPanel { - readonly id: number; - /** Selected view-kind id, or `null` while the panel still reads "Select a view". */ - readonly kind: string | null; + readonly id: number; + /** Selected view-kind id, or `null` while the panel still reads "Select a view". */ + readonly kind: string | null; } export interface PanelsState { - readonly panels: readonly ViewPanel[]; - readonly nextId: number; + readonly panels: readonly ViewPanel[]; + readonly nextId: number; } /** @@ -25,25 +25,25 @@ export interface PanelsState { * a single preset panel, or `[null]` for one empty "Select a view" panel. */ export function initialPanels(kinds: readonly (string | null)[] = [null]): PanelsState { - let nextId = 0; - const panels = kinds.map((kind) => ({ id: nextId++, kind })); - return { panels, nextId }; + let nextId = 0; + const panels = kinds.map((kind) => ({ id: nextId++, kind })); + return { panels, nextId }; } export function addPanel(state: PanelsState, kind: string | null = null): PanelsState { - return { - panels: [...state.panels, { id: state.nextId, kind }], - nextId: state.nextId + 1, - }; + return { + panels: [...state.panels, { id: state.nextId, kind }], + nextId: state.nextId + 1, + }; } export function removePanel(state: PanelsState, id: number): PanelsState { - return { ...state, panels: state.panels.filter((p) => p.id !== id) }; + return { ...state, panels: state.panels.filter((p) => p.id !== id) }; } export function selectKind(state: PanelsState, id: number, kind: string | null): PanelsState { - return { - ...state, - panels: state.panels.map((p) => (p.id === id ? { ...p, kind } : p)), - }; + return { + ...state, + panels: state.panels.map((p) => (p.id === id ? { ...p, kind } : p)), + }; } diff --git a/src/features/views/ui/ViewSidebar.svelte b/src/features/views/ui/ViewSidebar.svelte index e4a3ee6..e9e8682 100644 --- a/src/features/views/ui/ViewSidebar.svelte +++ b/src/features/views/ui/ViewSidebar.svelte @@ -1,97 +1,95 @@ <script lang="ts"> - import { type Snippet, untrack } from "svelte"; - import { - addPanel, - initialPanels, - type PanelsState, - removePanel, - selectKind, - } from "../logic/panels"; + import { type Snippet, untrack } from "svelte"; + import { + addPanel, + initialPanels, + type PanelsState, + removePanel, + selectKind, + } from "../logic/panels"; - interface ViewKind { - readonly id: string; - readonly label: string; - } + interface ViewKind { + readonly id: string; + readonly label: string; + } - let { - kinds, - content, - initial, - onChange, - }: { - /** The view kinds offered in every panel's dropdown. */ - kinds: readonly ViewKind[]; - /** Renders a panel body for the given (non-null) view-kind id. */ - content: Snippet<[string]>; - /** Optional seed of panel kinds; defaults to one panel of the first kind. */ - initial?: readonly (string | null)[]; - /** Called whenever the panel layout changes (add/remove/select). */ - onChange?: (kinds: readonly (string | null)[]) => void; - } = $props(); + let { + kinds, + content, + initial, + onChange, + }: { + /** The view kinds offered in every panel's dropdown. */ + kinds: readonly ViewKind[]; + /** Renders a panel body for the given (non-null) view-kind id. */ + content: Snippet<[string]>; + /** Optional seed of panel kinds; defaults to one panel of the first kind. */ + initial?: readonly (string | null)[]; + /** Called whenever the panel layout changes (add/remove/select). */ + onChange?: (kinds: readonly (string | null)[]) => void; + } = $props(); - // Local UI composition state, owned by this unit and folded through the pure - // reducer — never reached from elsewhere (no ambient store). Seeded ONCE from - // the props (untrack makes that one-time read explicit, not reactive). - let state = $state<PanelsState>( - untrack(() => initialPanels(initial ?? [kinds[0]?.id ?? null])), - ); + // Local UI composition state, owned by this unit and folded through the pure + // reducer — never reached from elsewhere (no ambient store). Seeded ONCE from + // the props (untrack makes that one-time read explicit, not reactive). + let state = $state<PanelsState>(untrack(() => initialPanels(initial ?? [kinds[0]?.id ?? null]))); - function notify(): void { - onChange?.(state.panels.map((p) => p.kind)); - } + function notify(): void { + onChange?.(state.panels.map((p) => p.kind)); + } </script> <div class="flex min-h-0 flex-col gap-2"> - {#each state.panels as panel, idx (panel.id)} - <div class="flex flex-col rounded-lg bg-base-200 p-3"> - <div class="flex items-center gap-1"> - <select - class="select select-bordered select-sm flex-1" - aria-label="Select a view" - value={panel.kind ?? ""} - onchange={(e) => { - const v = e.currentTarget.value; - state = selectKind(state, panel.id, v === "" ? null : v); - notify(); - }} - > - <option value="" disabled>Select a view</option> - {#each kinds as kind (kind.id)} - <option value={kind.id}>{kind.label}</option> - {/each} - </select> - {#if idx > 0} - <button - type="button" - class="btn btn-square btn-ghost btn-sm shrink-0" - aria-label="Remove view" - onclick={() => { - state = removePanel(state, panel.id); - notify(); - }} - > - ✕ - </button> - {/if} - </div> + {#each state.panels as panel, idx (panel.id)} + <div class="flex flex-col rounded-lg bg-base-200 p-3"> + <div class="flex items-center gap-1"> + <select + class="select select-bordered select-sm flex-1" + aria-label="Select a view" + value={panel.kind ?? ""} + onchange={(e) => { + const v = e.currentTarget.value; + state = selectKind(state, panel.id, v === "" ? null : v); + notify(); + }} + > + <option value="" disabled>Select a view</option> + {#each kinds as kind (kind.id)} + <option value={kind.id}>{kind.label}</option> + {/each} + </select> + {#if idx > 0} + <button + type="button" + class="btn btn-square btn-ghost btn-sm shrink-0" + aria-label="Remove view" + onclick={() => { + state = removePanel(state, panel.id); + notify(); + }} + > + ✕ + </button> + {/if} + </div> - {#if panel.kind !== null} - <div class="mt-2"> - {@render content(panel.kind)} - </div> - {/if} - </div> - {/each} + {#if panel.kind !== null} + <div class="mt-2"> + {@render content(panel.kind)} + </div> + {/if} + </div> + {/each} - <button - type="button" - class="btn w-full border-none bg-base-200 text-lg hover:bg-base-300" - aria-label="Add view" - onclick={() => { - state = addPanel(state); - notify(); - }} - > - + - </button> + <button + type="button" + class="btn w-full border-none bg-base-200 text-lg hover:bg-base-300" + aria-label="Add view" + onclick={() => { + state = addPanel(state); + notify(); + }} + > + + + </button> </div> diff --git a/src/features/views/ui/ViewSidebar.test.ts b/src/features/views/ui/ViewSidebar.test.ts index 8a0049c..0618506 100644 --- a/src/features/views/ui/ViewSidebar.test.ts +++ b/src/features/views/ui/ViewSidebar.test.ts @@ -5,54 +5,54 @@ import { describe, expect, it } from "vitest"; import ViewSidebar from "./ViewSidebar.svelte"; const kinds = [ - { id: "surfaces", label: "Surfaces" }, - { id: "tasks", label: "Tasks" }, + { id: "surfaces", label: "Surfaces" }, + { id: "tasks", label: "Tasks" }, ]; // A raw snippet that echoes the kind it was rendered for, so tests can assert // which view-kind content each panel shows. const content = createRawSnippet<[string]>((kind) => ({ - render: () => `<div data-testid="view-content">kind:${kind()}</div>`, + render: () => `<div data-testid="view-content">kind:${kind()}</div>`, })); describe("ViewSidebar", () => { - it("opens one panel seeded with the first kind and renders its content", () => { - render(ViewSidebar, { props: { kinds, content } }); - expect(screen.getAllByRole("combobox")).toHaveLength(1); - expect(screen.getByTestId("view-content")).toHaveTextContent("kind:surfaces"); - }); + it("opens one panel seeded with the first kind and renders its content", () => { + render(ViewSidebar, { props: { kinds, content } }); + expect(screen.getAllByRole("combobox")).toHaveLength(1); + expect(screen.getByTestId("view-content")).toHaveTextContent("kind:surfaces"); + }); - it("the first panel has no remove button", () => { - render(ViewSidebar, { props: { kinds, content } }); - expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull(); - }); + it("the first panel has no remove button", () => { + render(ViewSidebar, { props: { kinds, content } }); + expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull(); + }); - it("the add button appends a new empty panel", async () => { - const user = userEvent.setup(); - render(ViewSidebar, { props: { kinds, content } }); - await user.click(screen.getByRole("button", { name: "Add view" })); - expect(screen.getAllByRole("combobox")).toHaveLength(2); - // the new panel is empty → only the first panel renders content - expect(screen.getAllByTestId("view-content")).toHaveLength(1); - }); + it("the add button appends a new empty panel", async () => { + const user = userEvent.setup(); + render(ViewSidebar, { props: { kinds, content } }); + await user.click(screen.getByRole("button", { name: "Add view" })); + expect(screen.getAllByRole("combobox")).toHaveLength(2); + // the new panel is empty → only the first panel renders content + expect(screen.getAllByTestId("view-content")).toHaveLength(1); + }); - it("non-first panels can be removed", async () => { - const user = userEvent.setup(); - render(ViewSidebar, { props: { kinds, content } }); - await user.click(screen.getByRole("button", { name: "Add view" })); - const removeButtons = screen.getAllByRole("button", { name: "Remove view" }); - expect(removeButtons).toHaveLength(1); - const target = removeButtons[0]; - if (target === undefined) throw new Error("expected a remove button"); - await user.click(target); - expect(screen.getAllByRole("combobox")).toHaveLength(1); - }); + it("non-first panels can be removed", async () => { + const user = userEvent.setup(); + render(ViewSidebar, { props: { kinds, content } }); + await user.click(screen.getByRole("button", { name: "Add view" })); + const removeButtons = screen.getAllByRole("button", { name: "Remove view" }); + expect(removeButtons).toHaveLength(1); + const target = removeButtons[0]; + if (target === undefined) throw new Error("expected a remove button"); + await user.click(target); + expect(screen.getAllByRole("combobox")).toHaveLength(1); + }); - it("selecting a kind renders that kind's content", async () => { - const user = userEvent.setup(); - render(ViewSidebar, { props: { kinds, content, initial: [null] } }); - expect(screen.queryByTestId("view-content")).toBeNull(); - await user.selectOptions(screen.getByRole("combobox"), "tasks"); - expect(screen.getByTestId("view-content")).toHaveTextContent("kind:tasks"); - }); + it("selecting a kind renders that kind's content", async () => { + const user = userEvent.setup(); + render(ViewSidebar, { props: { kinds, content, initial: [null] } }); + expect(screen.queryByTestId("view-content")).toBeNull(); + await user.selectOptions(screen.getByRole("combobox"), "tasks"); + expect(screen.getByTestId("view-content")).toHaveTextContent("kind:tasks"); + }); }); diff --git a/src/features/vision/index.ts b/src/features/vision/index.ts new file mode 100644 index 0000000..6956d75 --- /dev/null +++ b/src/features/vision/index.ts @@ -0,0 +1,30 @@ +export type { + CompactionModelOption, + ImageLimitParse, + LoadVisionSettings, + LoadVisionSettingsResult, + SaveVisionSettings, + SaveVisionSettingsResult, + VisionSettings, + VisionSettingsPatch, +} from "./logic/view-model"; +export { + AUTO_COMPACTION_MODEL, + compactionModelChanged, + compactionModelFromValue, + compactionModelOptions, + DEFAULT_IMAGE_LIMIT, + imageLimitChanged, + imageLimitLabel, + MAX_IMAGE_LIMIT, + normalizeVisionSettings, + parseImageLimit, + selectedCompactionValue, +} from "./logic/view-model"; +export { default as VisionSettingsView } from "./ui/VisionSettingsView.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "vision", + description: "Global vision settings (image compaction limit + model)", +} as const; diff --git a/src/features/vision/logic/view-model.test.ts b/src/features/vision/logic/view-model.test.ts new file mode 100644 index 0000000..b1db822 --- /dev/null +++ b/src/features/vision/logic/view-model.test.ts @@ -0,0 +1,198 @@ +import type { ModelMetadata } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + AUTO_COMPACTION_MODEL, + compactionModelChanged, + compactionModelFromValue, + compactionModelOptions, + DEFAULT_IMAGE_LIMIT, + imageLimitChanged, + imageLimitLabel, + MAX_IMAGE_LIMIT, + normalizeVisionSettings, + parseImageLimit, + selectedCompactionValue, +} from "./view-model"; + +describe("normalizeVisionSettings", () => { + it("returns the value as-is for a well-formed body", () => { + expect(normalizeVisionSettings({ imageLimit: 5, compactionModel: "kimi/k2" })).toEqual({ + imageLimit: 5, + compactionModel: "kimi/k2", + }); + }); + + it("coerces a null compactionModel to null", () => { + expect(normalizeVisionSettings({ imageLimit: 10, compactionModel: null })).toEqual({ + imageLimit: 10, + compactionModel: null, + }); + }); + + it("defaults imageLimit when absent or non-numeric", () => { + expect(normalizeVisionSettings({ compactionModel: "kimi/k2" })).toEqual({ + imageLimit: DEFAULT_IMAGE_LIMIT, + compactionModel: "kimi/k2", + }); + expect(normalizeVisionSettings({ imageLimit: "oops", compactionModel: null })).toEqual({ + imageLimit: DEFAULT_IMAGE_LIMIT, + compactionModel: null, + }); + }); + + it("floors a fractional imageLimit", () => { + expect(normalizeVisionSettings({ imageLimit: 7.9, compactionModel: null }).imageLimit).toBe(7); + }); + + it("defaults for a null/non-object body (never crashes)", () => { + expect(normalizeVisionSettings(null)).toEqual({ + imageLimit: DEFAULT_IMAGE_LIMIT, + compactionModel: null, + }); + expect(normalizeVisionSettings("nope")).toEqual({ + imageLimit: DEFAULT_IMAGE_LIMIT, + compactionModel: null, + }); + }); + + it("treats a negative imageLimit as the default", () => { + expect(normalizeVisionSettings({ imageLimit: -3, compactionModel: null }).imageLimit).toBe( + DEFAULT_IMAGE_LIMIT, + ); + }); + + it("treats an empty compactionModel string as null", () => { + expect( + normalizeVisionSettings({ imageLimit: 10, compactionModel: "" }).compactionModel, + ).toBeNull(); + }); +}); + +describe("parseImageLimit", () => { + it("parses a non-negative integer", () => { + expect(parseImageLimit("5")).toEqual({ ok: true, value: 5 }); + expect(parseImageLimit("0")).toEqual({ ok: true, value: 0 }); + }); + + it("floors a fractional value", () => { + expect(parseImageLimit("7.9")).toEqual({ ok: true, value: 7 }); + }); + + it("trims whitespace", () => { + expect(parseImageLimit(" 12 ")).toEqual({ ok: true, value: 12 }); + }); + + it("errors on empty input", () => { + expect(parseImageLimit("")).toEqual({ ok: false, error: "Enter a number." }); + }); + + it("errors on non-numeric input", () => { + expect(parseImageLimit("lots").ok).toBe(false); + }); + + it("errors on a negative value", () => { + expect(parseImageLimit("-1").ok).toBe(false); + }); + + it("errors when above the max", () => { + expect(parseImageLimit(String(MAX_IMAGE_LIMIT + 1)).ok).toBe(false); + }); + + it("accepts the max", () => { + expect(parseImageLimit(String(MAX_IMAGE_LIMIT))).toEqual({ ok: true, value: MAX_IMAGE_LIMIT }); + }); +}); + +describe("imageLimitChanged", () => { + it("is true when the typed value differs after normalization", () => { + expect(imageLimitChanged("5", 10)).toBe(true); + }); + + it("is false when equal", () => { + expect(imageLimitChanged("10", 10)).toBe(false); + }); + + it("is false for invalid input", () => { + expect(imageLimitChanged("abc", 10)).toBe(false); + expect(imageLimitChanged("", 10)).toBe(false); + }); + + it("compares after flooring", () => { + expect(imageLimitChanged("7.9", 7)).toBe(false); + }); +}); + +describe("compactionModelOptions", () => { + const modelInfo: Record<string, ModelMetadata> = { + "kimi/k2": { vision: true }, + "kimi/k1.5": { vision: true }, + "umans/glm-5.2": { vision: false }, + "openai/gpt-4": { contextWindow: 128000 }, + }; + + it("includes the Auto option first", () => { + const opts = compactionModelOptions([], {}); + expect(opts).toHaveLength(1); + expect(opts[0]?.auto).toBe(true); + expect(opts[0]?.value).toBe(AUTO_COMPACTION_MODEL); + }); + + it("includes only vision-capable models, in catalog order", () => { + const models = ["umans/glm-5.2", "kimi/k2", "openai/gpt-4", "kimi/k1.5"]; + const opts = compactionModelOptions(models, modelInfo); + expect(opts.map((o) => o.label)).toEqual(["Auto (server-selected)", "kimi/k2", "kimi/k1.5"]); + }); + + it("excludes non-vision models even with metadata present", () => { + const opts = compactionModelOptions(["umans/glm-5.2"], modelInfo); + expect(opts).toHaveLength(1); // only Auto + }); +}); + +describe("compactionModel value round-trip", () => { + it("selectedCompactionValue maps null to the auto sentinel", () => { + expect(selectedCompactionValue(null)).toBe(AUTO_COMPACTION_MODEL); + }); + + it("selectedCompactionValue maps a model name to itself", () => { + expect(selectedCompactionValue("kimi/k2")).toBe("kimi/k2"); + }); + + it("compactionModelFromValue maps the auto sentinel back to null", () => { + expect(compactionModelFromValue(AUTO_COMPACTION_MODEL)).toBeNull(); + }); + + it("compactionModelFromValue maps a model name to itself", () => { + expect(compactionModelFromValue("kimi/k2")).toBe("kimi/k2"); + }); +}); + +describe("compactionModelChanged", () => { + it("is true when the value maps to a different model", () => { + expect(compactionModelChanged("kimi/k2", null)).toBe(true); + expect(compactionModelChanged(AUTO_COMPACTION_MODEL, "kimi/k2")).toBe(true); + }); + + it("is false when equal (null vs auto, or same model)", () => { + expect(compactionModelChanged(AUTO_COMPACTION_MODEL, null)).toBe(false); + expect(compactionModelChanged("kimi/k2", "kimi/k2")).toBe(false); + }); +}); + +describe("imageLimitLabel", () => { + it("labels null as loading", () => { + expect(imageLimitLabel(null)).toBe("Loading…"); + }); + + it("labels 0 as disabled", () => { + expect(imageLimitLabel(0)).toBe("0 (compaction disabled)"); + }); + + it("labels the default with (default)", () => { + expect(imageLimitLabel(DEFAULT_IMAGE_LIMIT)).toBe(`${DEFAULT_IMAGE_LIMIT} (default)`); + }); + + it("labels other values plainly", () => { + expect(imageLimitLabel(7)).toBe("7"); + }); +}); diff --git a/src/features/vision/logic/view-model.ts b/src/features/vision/logic/view-model.ts new file mode 100644 index 0000000..97a7093 --- /dev/null +++ b/src/features/vision/logic/view-model.ts @@ -0,0 +1,189 @@ +import type { ModelMetadata } from "@dispatch/transport-contract"; +import { isVisionModel } from "../../chat"; + +/** + * Pure core for the vision settings feature — zero DOM, zero effects, zero Svelte. + * + * The global vision configuration (`GET`/`PUT /settings/vision`) controls image + * compaction: how many native images a vision model keeps per turn before the + * oldest are transcribed to text (`imageLimit`), and which vision model does the + * transcribing (`compactionModel`, null = auto). This module is the view-model + * seam: typed settings, parse/validate, dirty-check, network normalization, and + * the vision-capable model option list. The composition root adapts the store's + * HTTP calls to the injected ports. + */ + +// ── Types (owned locally — consumer-defines-port; the contract shapes are a +// plain REST surface not in a shared contract package version bump, mirroring +// the heartbeat/mcp pattern). If the backend promotes these to a shared +// package, swap the local types for the imports + re-mirror. ────────────── + +/** The global vision settings (mirrors `VisionSettingsResponse`). */ +export interface VisionSettings { + /** Max native images per turn (default 10); 0 disables compaction. */ + readonly imageLimit: number; + /** Which model transcribes old images (`<key>/<model>`), or null = auto. */ + readonly compactionModel: string | null; +} + +/** A partial update (mirrors `SetVisionSettingsRequest`). */ +export interface VisionSettingsPatch { + readonly imageLimit?: number; + readonly compactionModel?: string | null; +} + +// ── Injected ports (consumer-defines-port). ─────────────────────────────────── + +/** Outcome of loading the vision settings. */ +export type LoadVisionSettingsResult = + | { readonly ok: true; readonly settings: VisionSettings } + | { readonly ok: false; readonly error: string }; + +/** Outcome of saving a partial vision-settings update. */ +export type SaveVisionSettingsResult = + | { readonly ok: true; readonly settings: VisionSettings } + | { readonly ok: false; readonly error: string }; + +export type LoadVisionSettings = () => Promise<LoadVisionSettingsResult>; +export type SaveVisionSettings = (patch: VisionSettingsPatch) => Promise<SaveVisionSettingsResult>; + +// ── Constants ──────────────────────────────────────────────────────────────── + +/** The backend's default image limit when none is persisted. */ +export const DEFAULT_IMAGE_LIMIT = 10; +/** Upper bound for the image limit input (defensive — the backend owns real clamping). */ +export const MAX_IMAGE_LIMIT = 1000; + +// ── Network normalization (pure; coerces untyped JSON at the seam). ────────── + +/** + * Coerce an untyped JSON body (from `GET /settings/vision`) into a valid + * `VisionSettings`. A malformed/partial response can never crash the renderer: + * `imageLimit` falls back to the default; `compactionModel` coerces to null. + */ +export function normalizeVisionSettings(body: unknown): VisionSettings { + if (body === null || typeof body !== "object") { + return { imageLimit: DEFAULT_IMAGE_LIMIT, compactionModel: null }; + } + const raw = body as Record<string, unknown>; + const limitRaw = raw.imageLimit; + const imageLimit = + typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw >= 0 + ? Math.floor(limitRaw) + : DEFAULT_IMAGE_LIMIT; + const modelRaw = raw.compactionModel; + const compactionModel = typeof modelRaw === "string" && modelRaw.length > 0 ? modelRaw : null; + return { imageLimit, compactionModel }; +} + +// ── imageLimit parse / validate ─────────────────────────────────────────────── + +/** Result of parsing a typed image-limit string. */ +export type ImageLimitParse = + | { readonly ok: true; readonly value: number } + | { readonly ok: false; readonly error: string }; + +/** + * Parse a typed image-limit string into a non-negative integer (floored, + * clamped to [0, MAX_IMAGE_LIMIT]). Empty or non-numeric input is an ERROR + * (so the UI can disable submit + message), NOT a silent default. + */ +export function parseImageLimit(raw: string): ImageLimitParse { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return { ok: false, error: "Enter a number." }; + } + const n = Number(trimmed); + if (!Number.isFinite(n) || n < 0) { + return { ok: false, error: "Must be 0 or a positive number." }; + } + const floored = Math.floor(n); + if (floored > MAX_IMAGE_LIMIT) { + return { ok: false, error: `Must be at most ${MAX_IMAGE_LIMIT}.` }; + } + return { ok: true, value: floored }; +} + +/** + * Whether saving `typed` would change the `current` image limit. A no-op save + * (empty/invalid, or equal) should be disabled. This is the dirty-check for the + * input — it must NOT mutate or clamp, only compare. + */ +export function imageLimitChanged(typed: string, current: number): boolean { + const parsed = parseImageLimit(typed); + if (!parsed.ok) return false; + return parsed.value !== current; +} + +// ── compactionModel option list ─────────────────────────────────────────────── + +/** + * The sentinel value for the "Auto" option (null compactionModel — the server + * auto-selects a vision model). Used as the `<option value>` for the auto row. + */ +export const AUTO_COMPACTION_MODEL = "__auto__"; + +/** A selectable compaction-model option. */ +export interface CompactionModelOption { + /** The value to send (`<key>/<model>`, or `AUTO_COMPACTION_MODEL` for auto). */ + readonly value: string; + /** The human-readable label. */ + readonly label: string; + /** Whether this is the "Auto" sentinel. */ + readonly auto: boolean; +} + +/** + * Build the compaction-model dropdown options: the "Auto" entry (null) plus + * every vision-capable model from the catalog (those with + * `modelInfo[name].vision === true`), in catalog order. Non-vision models are + * excluded — they cannot transcribe images. + */ +export function compactionModelOptions( + models: readonly string[], + modelInfo: Readonly<Record<string, ModelMetadata>>, +): CompactionModelOption[] { + const options: CompactionModelOption[] = [ + { value: AUTO_COMPACTION_MODEL, label: "Auto (server-selected)", auto: true }, + ]; + for (const name of models) { + if (isVisionModel(modelInfo, name)) { + options.push({ value: name, label: name, auto: false }); + } + } + return options; +} + +/** + * The `<option value>` to mark selected for the current `compactionModel`: + * `AUTO_COMPACTION_MODEL` when null (auto), else the model name itself. + */ +export function selectedCompactionValue(compactionModel: string | null): string { + return compactionModel ?? AUTO_COMPACTION_MODEL; +} + +/** + * Convert a selected `<option value>` back into a `VisionSettingsPatch` + * `compactionModel` (the auto sentinel → null). Returns the patch alone so the + * caller can merge it with other fields. + */ +export function compactionModelFromValue(value: string): string | null { + return value === AUTO_COMPACTION_MODEL ? null : value; +} + +/** + * Whether choosing `value` would change the current `compactionModel`. + */ +export function compactionModelChanged(value: string, current: string | null): boolean { + return compactionModelFromValue(value) !== current; +} + +// ── Labels ──────────────────────────────────────────────────────────────────── + +/** A human-readable label for the current image limit (for the status line). */ +export function imageLimitLabel(imageLimit: number | null): string { + if (imageLimit === null) return "Loading…"; + if (imageLimit === 0) return "0 (compaction disabled)"; + if (imageLimit === DEFAULT_IMAGE_LIMIT) return `${imageLimit} (default)`; + return String(imageLimit); +} diff --git a/src/features/vision/ui/VisionSettingsView.svelte b/src/features/vision/ui/VisionSettingsView.svelte new file mode 100644 index 0000000..2b4ebba --- /dev/null +++ b/src/features/vision/ui/VisionSettingsView.svelte @@ -0,0 +1,192 @@ +<script lang="ts"> + import type { ModelMetadata } from "@dispatch/transport-contract"; + import { + compactionModelChanged, + compactionModelFromValue, + compactionModelOptions, + DEFAULT_IMAGE_LIMIT, + imageLimitChanged, + imageLimitLabel, + parseImageLimit, + selectedCompactionValue, + type LoadVisionSettings, + type SaveVisionSettings, + type VisionSettings, + } from "../logic/view-model"; + + let { + models, + modelInfo = {}, + load, + save, + }: { + /** The model catalog (`GET /models` `models`) — for the compaction-model dropdown. */ + models: readonly string[]; + /** Per-model metadata — to filter the dropdown to vision-capable models. */ + modelInfo?: Readonly<Record<string, ModelMetadata>>; + /** Load the global vision settings (`GET /settings/vision`). */ + load: LoadVisionSettings; + /** Save a partial vision-settings update (`PUT /settings/vision`). */ + save: SaveVisionSettings; + } = $props(); + + let settings = $state<VisionSettings | null>(null); + let loadError = $state<string | null>(null); + + // imageLimit input state. + let imageLimitInput = $state(""); + let savingImageLimit = $state(false); + let imageLimitError = $state<string | null>(null); + let imageLimitSaved = $state(false); + + // compactionModel select state. + let compactionSaving = $state(false); + let compactionError = $state<string | null>(null); + let compactionSaved = $state(false); + + // Load on mount. + $effect(() => { + void refresh(); + }); + + async function refresh(): Promise<void> { + const result = await load(); + if (result.ok) { + settings = result.settings; + imageLimitInput = String(result.settings.imageLimit); + loadError = null; + imageLimitError = null; + imageLimitSaved = false; + compactionError = null; + compactionSaved = false; + } else { + loadError = result.error; + } + } + + const options = $derived(compactionModelOptions(models, modelInfo)); + const selectedCompaction = $derived( + settings ? selectedCompactionValue(settings.compactionModel) : selectedCompactionValue(null), + ); + const limitLabel = $derived(imageLimitLabel(settings?.imageLimit ?? null)); + + const canSaveImageLimit = $derived( + settings !== null && imageLimitChanged(imageLimitInput, settings.imageLimit), + ); + + async function handleSaveImageLimit(): Promise<void> { + if (settings === null || savingImageLimit) return; + const parsed = parseImageLimit(imageLimitInput); + if (!parsed.ok) { + imageLimitError = parsed.error; + imageLimitSaved = false; + return; + } + savingImageLimit = true; + imageLimitError = null; + imageLimitSaved = false; + const result = await save({ imageLimit: parsed.value }); + savingImageLimit = false; + if (result.ok) { + settings = result.settings; + imageLimitInput = String(result.settings.imageLimit); + imageLimitSaved = true; + } else { + imageLimitError = result.error; + } + } + + async function handleCompactionChange(e: Event): Promise<void> { + if (settings === null || compactionSaving) return; + const value = (e.currentTarget as HTMLSelectElement).value; + if (!compactionModelChanged(value, settings.compactionModel)) return; + compactionSaving = true; + compactionError = null; + compactionSaved = false; + const result = await save({ compactionModel: compactionModelFromValue(value) }); + compactionSaving = false; + if (result.ok) { + settings = result.settings; + compactionSaved = true; + } else { + compactionError = result.error; + } + } +</script> + +<div class="flex flex-col gap-3"> + {#if loadError} + <p class="text-xs text-error">{loadError}</p> + {/if} + + {#if settings === null && !loadError} + <p class="text-xs opacity-60">Loading vision settings…</p> + {:else if settings !== null} + <!-- imageLimit --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Image limit</span> + <div class="flex items-center gap-2"> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-sm w-24" + placeholder={String(DEFAULT_IMAGE_LIMIT)} + bind:value={imageLimitInput} + disabled={savingImageLimit} + aria-label="Image limit (max native images per turn)" + /> + <button + type="button" + class="btn btn-sm btn-outline" + disabled={!canSaveImageLimit || savingImageLimit} + onclick={handleSaveImageLimit} + > + {#if savingImageLimit} + <span class="loading loading-spinner loading-xs"></span> + Saving… + {:else} + Save + {/if} + </button> + </div> + <p class="text-xs opacity-50"> + Current: {limitLabel} + <br /> + Max native images per turn before the oldest are transcribed to text. + 0 disables compaction. Default is {DEFAULT_IMAGE_LIMIT}. + </p> + {#if imageLimitError} + <p class="text-xs text-error">{imageLimitError}</p> + {:else if imageLimitSaved} + <p class="text-xs text-success">Saved.</p> + {/if} + </section> + + <!-- compactionModel --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Compaction model</span> + <select + class="select select-bordered select-sm w-full" + value={selectedCompaction} + disabled={compactionSaving} + onchange={handleCompactionChange} + aria-label="Compaction model (which vision model transcribes old images)" + > + {#each options as opt (opt.value)} + <option value={opt.value}>{opt.label}</option> + {/each} + </select> + {#if compactionSaving} + <p class="text-xs opacity-60">Saving…</p> + {/if} + <p class="text-xs opacity-50"> + The vision-capable model that transcribes old images to text. "Auto" lets the server choose. + </p> + {#if compactionError} + <p class="text-xs text-error">{compactionError}</p> + {:else if compactionSaved} + <p class="text-xs text-success">Saved.</p> + {/if} + </section> + {/if} +</div> diff --git a/src/features/vision/ui/VisionSettingsView.test.ts b/src/features/vision/ui/VisionSettingsView.test.ts new file mode 100644 index 0000000..48afc71 --- /dev/null +++ b/src/features/vision/ui/VisionSettingsView.test.ts @@ -0,0 +1,241 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { + LoadVisionSettingsResult, + SaveVisionSettingsResult, + VisionSettings, +} from "../logic/view-model"; +import VisionSettingsView from "./VisionSettingsView.svelte"; + +const SETTINGS: VisionSettings = { imageLimit: 10, compactionModel: null }; + +function fakeLoad(settings: VisionSettings = SETTINGS): { + calls: number; + impl: () => Promise<LoadVisionSettingsResult>; +} { + let calls = 0; + return { + get calls() { + return calls; + }, + impl: async () => { + calls += 1; + return { ok: true, settings }; + }, + }; +} + +function fakeSaveOk(): { + patches: object[]; + impl: (patch: object) => Promise<SaveVisionSettingsResult>; +} { + const patches: object[] = []; + return { + get patches() { + return patches; + }, + impl: async (patch) => { + patches.push(patch); + // Merge into the current settings to simulate the server echo. + const next: VisionSettings = { + imageLimit: + "imageLimit" in patch ? (patch as VisionSettings).imageLimit : SETTINGS.imageLimit, + compactionModel: + "compactionModel" in patch + ? (patch as VisionSettings).compactionModel + : SETTINGS.compactionModel, + }; + return { ok: true, settings: next }; + }, + }; +} + +describe("VisionSettingsView", () => { + it("loads settings on mount and seeds the imageLimit input", async () => { + const load = fakeLoad({ imageLimit: 7, compactionModel: "kimi/k2" }); + render(VisionSettingsView, { + props: { + models: ["kimi/k2"], + modelInfo: { "kimi/k2": { vision: true } }, + load: load.impl, + save: fakeSaveOk().impl, + }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Image limit/)).toHaveValue("7"); + }); + // "Auto" is selected (compactionModel was kimi/k2 here actually) + expect(screen.getByLabelText(/Compaction model/)).toHaveValue("kimi/k2"); + }); + + it("disables Save until the imageLimit input differs", async () => { + const load = fakeLoad(); + const save = fakeSaveOk(); + const user = userEvent.setup(); + render(VisionSettingsView, { + props: { models: [], modelInfo: {}, load: load.impl, save: save.impl }, + }); + + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + + const input = screen.getByLabelText(/Image limit/); + await user.clear(input); + await user.type(input, "5"); + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + }); + + it("saves the imageLimit on click and confirms", async () => { + const load = fakeLoad(); + const save = fakeSaveOk(); + const user = userEvent.setup(); + render(VisionSettingsView, { + props: { models: [], modelInfo: {}, load: load.impl, save: save.impl }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Image limit/)).toHaveValue("10"); + }); + + const input = screen.getByLabelText(/Image limit/); + await user.clear(input); + await user.type(input, "3"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await vi.waitFor(() => { + expect(save.patches).toEqual([{ imageLimit: 3 }]); + }); + expect(screen.getByText(/Saved/i)).toBeInTheDocument(); + }); + + it("shows an error for a non-numeric imageLimit on save", async () => { + const load = fakeLoad(); + const save = fakeSaveOk(); + const user = userEvent.setup(); + render(VisionSettingsView, { + props: { models: [], modelInfo: {}, load: load.impl, save: save.impl }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Image limit/)).toHaveValue("10"); + }); + + const input = screen.getByLabelText(/Image limit/); + await user.clear(input); + await user.type(input, "abc"); + // Save is disabled for invalid input, so no save fires; the error surfaces + // only on a submit attempt — but the button is disabled, so just assert that. + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + expect(save.patches).toEqual([]); + }); + + it("renders the compaction-model dropdown with Auto + vision-capable models", async () => { + const load = fakeLoad(); + render(VisionSettingsView, { + props: { + models: ["kimi/k2", "umans/glm-5.2", "kimi/k1.5"], + modelInfo: { + "kimi/k2": { vision: true }, + "kimi/k1.5": { vision: true }, + "umans/glm-5.2": { vision: false }, + }, + load: load.impl, + save: fakeSaveOk().impl, + }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Compaction model/)).toBeInTheDocument(); + }); + const select = screen.getByLabelText(/Compaction model/) as HTMLSelectElement; + const optionTexts = Array.from(select.options).map((o) => o.textContent ?? ""); + expect(optionTexts).toEqual(["Auto (server-selected)", "kimi/k2", "kimi/k1.5"]); + // Non-vision glm-5.2 is excluded. + expect(optionTexts.some((t) => t.includes("glm-5.2"))).toBe(false); + }); + + it("saves the compactionModel on change (Auto → a vision model)", async () => { + const load = fakeLoad({ imageLimit: 10, compactionModel: null }); + const save = fakeSaveOk(); + const user = userEvent.setup(); + render(VisionSettingsView, { + props: { + models: ["kimi/k2"], + modelInfo: { "kimi/k2": { vision: true } }, + load: load.impl, + save: save.impl, + }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Compaction model/)).toBeInTheDocument(); + }); + + await user.selectOptions(screen.getByLabelText(/Compaction model/), "kimi/k2"); + + await vi.waitFor(() => { + expect(save.patches).toEqual([{ compactionModel: "kimi/k2" }]); + }); + expect(screen.getByText(/Saved/i)).toBeInTheDocument(); + }); + + it("saves null (Auto) when the auto option is chosen", async () => { + const load = fakeLoad({ imageLimit: 10, compactionModel: "kimi/k2" }); + const save = fakeSaveOk(); + const user = userEvent.setup(); + render(VisionSettingsView, { + props: { + models: ["kimi/k2"], + modelInfo: { "kimi/k2": { vision: true } }, + load: load.impl, + save: save.impl, + }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Compaction model/)).toHaveValue("kimi/k2"); + }); + + await user.selectOptions(screen.getByLabelText(/Compaction model/), "__auto__"); + + await vi.waitFor(() => { + expect(save.patches).toEqual([{ compactionModel: null }]); + }); + }); + + it("surfaces a load error", async () => { + const load = vi.fn(async () => ({ ok: false, error: "vision unavailable" }) as const); + render(VisionSettingsView, { + props: { models: [], modelInfo: {}, load, save: fakeSaveOk().impl }, + }); + + await vi.waitFor(() => { + expect(screen.getByText("vision unavailable")).toBeInTheDocument(); + }); + }); + + it("surfaces a save error", async () => { + const load = fakeLoad(); + const save = vi.fn(async () => ({ ok: false, error: "boom" }) as const); + const user = userEvent.setup(); + render(VisionSettingsView, { + props: { models: [], modelInfo: {}, load: load.impl, save }, + }); + + await vi.waitFor(() => { + expect(screen.getByLabelText(/Image limit/)).toHaveValue("10"); + }); + + const input = screen.getByLabelText(/Image limit/); + await user.clear(input); + await user.type(input, "3"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await vi.waitFor(() => { + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/features/workspace/logic/view-model.test.ts b/src/features/workspace/logic/view-model.test.ts deleted file mode 100644 index a06edeb..0000000 --- a/src/features/workspace/logic/view-model.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { LspServerInfo } from "@dispatch/transport-contract"; -import { describe, expect, it } from "vitest"; -import { - cwdChanged, - isSubmittableCwd, - normalizeCwd, - summarizeServers, - viewLspServer, - viewLspServers, -} from "./view-model"; - -const server = (over: Partial<LspServerInfo> = {}): LspServerInfo => ({ - id: "typescript", - name: "TypeScript", - root: "/home/me/project", - extensions: [".ts", ".tsx"], - state: "connected", - ...over, -}); - -describe("cwd helpers", () => { - it("normalizeCwd trims surrounding whitespace", () => { - expect(normalizeCwd(" /a/b ")).toBe("/a/b"); - expect(normalizeCwd("\t/x\n")).toBe("/x"); - }); - - it("isSubmittableCwd is false for empty / whitespace-only", () => { - expect(isSubmittableCwd("")).toBe(false); - expect(isSubmittableCwd(" ")).toBe(false); - expect(isSubmittableCwd("/a")).toBe(true); - }); - - it("cwdChanged: true only when a non-empty trimmed value differs from current", () => { - expect(cwdChanged("/a/b", null)).toBe(true); - expect(cwdChanged("/a/b", "/a/b")).toBe(false); - expect(cwdChanged(" /a/b ", "/a/b")).toBe(false); // trim-equal → no change - expect(cwdChanged("/a/c", "/a/b")).toBe(true); - expect(cwdChanged("", "/a/b")).toBe(false); // empty is not a change (can't clear) - expect(cwdChanged(" ", null)).toBe(false); - }); -}); - -describe("viewLspServer", () => { - it("connected → success badge, not busy, no error", () => { - const v = viewLspServer(server({ state: "connected" })); - expect(v.badge).toBe("success"); - expect(v.statusLabel).toBe("Connected"); - expect(v.busy).toBe(false); - expect(v.error).toBeNull(); - expect(v.extensionsLabel).toBe(".ts .tsx"); - }); - - it("starting / not-started → busy (spinner) with warning / neutral badge", () => { - const starting = viewLspServer(server({ state: "starting" })); - expect(starting.badge).toBe("warning"); - expect(starting.busy).toBe(true); - - const notStarted = viewLspServer(server({ state: "not-started" })); - expect(notStarted.badge).toBe("neutral"); - expect(notStarted.busy).toBe(true); - }); - - it("error → error badge + surfaces the reason (with a fallback)", () => { - const withReason = viewLspServer(server({ state: "error", error: "ENOENT" })); - expect(withReason.badge).toBe("error"); - expect(withReason.busy).toBe(false); - expect(withReason.error).toBe("ENOENT"); - - const noReason = viewLspServer(server({ state: "error" })); - expect(noReason.error).toBe("Failed to start"); - }); - - it("viewLspServers maps a list preserving order", () => { - const views = viewLspServers([server({ id: "a" }), server({ id: "b" })]); - expect(views.map((v) => v.id)).toEqual(["a", "b"]); - }); -}); - -describe("summarizeServers", () => { - it("empty list", () => { - expect(summarizeServers([])).toBe("No language servers"); - }); - - it("counts connected / starting / errors", () => { - expect(summarizeServers([server({ state: "connected" })])).toBe("1 connected"); - expect( - summarizeServers([ - server({ id: "a", state: "connected" }), - server({ id: "b", state: "error" }), - ]), - ).toBe("1 connected, 1 error"); - expect( - summarizeServers([ - server({ id: "a", state: "connected" }), - server({ id: "b", state: "starting" }), - server({ id: "c", state: "error" }), - server({ id: "d", state: "error" }), - ]), - ).toBe("1 connected, 1 starting, 2 errors"); - }); -}); diff --git a/src/features/workspace/ui/CwdField.svelte b/src/features/workspace/ui/CwdField.svelte deleted file mode 100644 index bd8b870..0000000 --- a/src/features/workspace/ui/CwdField.svelte +++ /dev/null @@ -1,96 +0,0 @@ -<script lang="ts"> - import { untrack } from "svelte"; - import { cwdChanged, normalizeCwd, type SaveCwd } from "../logic/view-model"; - - let { - cwd, - canEdit, - save, - }: { - /** The active conversation's persisted cwd, or null when unset. */ - cwd: string | null; - /** Whether a real conversation is focused (a draft can't persist a cwd yet). */ - canEdit: boolean; - save: SaveCwd; - } = $props(); - - // Start empty; the $effect below seeds from the (async-loaded) cwd prop. (Reading - // the prop directly into initial $state would only capture its first value.) - let value = $state(""); - let lastSeed = $state(""); - let saving = $state(false); - let error = $state<string | null>(null); - let justSaved = $state(false); - - // Seed the input from the persisted cwd (it loads async). Only reseed while the - // field is untouched, so an in-flight load can't clobber what the user typed. - // Re-mounted per conversation, so there is no cross-tab bleed. - $effect(() => { - const incoming = cwd ?? ""; - untrack(() => { - if (value === lastSeed) value = incoming; - lastSeed = incoming; - }); - }); - - const dirty = $derived(cwdChanged(value, cwd)); - - async function handleSave() { - if (saving || !canEdit || !dirty) return; - saving = true; - error = null; - justSaved = false; - const result = await save(normalizeCwd(value)); - saving = false; - if (result === null) return; - if (result.ok) { - justSaved = true; - } else { - error = result.error; - } - } - - function onInput() { - justSaved = false; - error = null; - } -</script> - -<div class="flex flex-col gap-1"> - <span class="text-xs font-semibold uppercase opacity-60">Working directory</span> - <div class="flex items-center gap-2"> - <input - type="text" - class="input input-bordered input-sm w-full font-mono text-xs" - placeholder={canEdit ? "/abs/path/to/project" : "Open a conversation first"} - bind:value - disabled={!canEdit || saving} - oninput={onInput} - onkeydown={(e) => { - if (e.key === "Enter") handleSave(); - }} - aria-label="Working directory" - /> - <button - type="button" - class="btn btn-primary btn-sm" - disabled={!canEdit || saving || !dirty} - onclick={handleSave} - > - {#if saving} - <span class="loading loading-spinner loading-xs"></span> - {:else} - Set - {/if} - </button> - </div> - {#if !canEdit} - <p class="text-xs opacity-60">Start or open a conversation to set its working directory.</p> - {:else if error} - <p class="text-xs text-error">{error}</p> - {:else if justSaved && !dirty} - <p class="text-xs text-success">Saved.</p> - {:else} - <p class="text-xs opacity-50">Defaults each turn's cwd; drives the language servers below.</p> - {/if} -</div> diff --git a/src/features/workspace/ui/LspStatusView.svelte b/src/features/workspace/ui/LspStatusView.svelte deleted file mode 100644 index 77603a1..0000000 --- a/src/features/workspace/ui/LspStatusView.svelte +++ /dev/null @@ -1,127 +0,0 @@ -<script lang="ts"> - import { untrack } from "svelte"; - import { - type Badge, - type LoadLspStatus, - type LspServerView, - summarizeServers, - viewLspServers, - } from "../logic/view-model"; - - let { - cwd, - canView, - load, - }: { - /** The active conversation's cwd — the trigger to (re)load when it changes. */ - cwd: string | null; - /** Whether a real conversation is focused. */ - canView: boolean; - load: LoadLspStatus; - } = $props(); - - const badgeClass: Record<Badge, string> = { - success: "badge-success", - warning: "badge-warning", - error: "badge-error", - neutral: "badge-ghost", - }; - - let servers = $state<readonly LspServerView[]>([]); - let loading = $state(false); - let error = $state<string | null>(null); - let loadedCwd = $state<string | null>(null); - let hasLoaded = $state(false); - let summary = $state(""); - - async function refresh() { - if (!canView) return; - loading = true; - error = null; - const result = await load(); - loading = false; - if (result === null) return; - hasLoaded = true; - if (result.ok) { - servers = viewLspServers(result.servers); - summary = summarizeServers(result.servers); - loadedCwd = result.cwd; - } else { - error = result.error; - } - } - - // (Re)load on mount and whenever the conversation's cwd changes. The LSP GET - // lazily spawns servers, so we avoid a redundant fetch when `cwd` resolves to - // the value we already loaded for. - $effect(() => { - const target = cwd; - const can = canView; - untrack(() => { - if (!can) return; - if (!hasLoaded || target !== loadedCwd) void refresh(); - }); - }); -</script> - -<div class="flex flex-col gap-2"> - <div class="flex items-center justify-between gap-2"> - <span class="text-xs opacity-70"> - {#if loading} - Resolving… - {:else if hasLoaded && loadedCwd !== null} - {summary} - {:else} - Language servers - {/if} - </span> - <button - type="button" - class="btn btn-ghost btn-xs" - disabled={!canView || loading} - onclick={() => refresh()} - aria-label="Refresh language server status" - > - {#if loading} - <span class="loading loading-spinner loading-xs"></span> - {:else} - Refresh - {/if} - </button> - </div> - - {#if !canView} - <p class="text-xs opacity-60">Open or start a conversation to see its language servers.</p> - {:else if error} - <p class="text-xs text-error">{error}</p> - {:else if hasLoaded && loadedCwd === null} - <p class="text-xs opacity-60"> - Set a working directory in the Model panel to enable language servers. - </p> - {:else if hasLoaded && servers.length === 0 && !loading} - <p class="text-xs opacity-60">No language servers configured for this directory.</p> - {:else} - <ul class="flex flex-col gap-2"> - {#each servers as server (server.id)} - <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> - <div class="flex items-center justify-between gap-2"> - <span class="font-medium">{server.name}</span> - <span class="badge badge-sm {badgeClass[server.badge]} gap-1"> - {#if server.busy} - <span class="loading loading-spinner loading-xs"></span> - {/if} - {server.statusLabel} - </span> - </div> - {#if server.extensionsLabel} - <span class="font-mono text-xs opacity-60">{server.extensionsLabel}</span> - {/if} - <span class="truncate font-mono text-xs opacity-50" title={server.root}>{server.root}</span> - {#if server.error} - <span class="font-mono text-xs text-error">{server.error}</span> - {/if} - </li> - {/each} - </ul> - {/if} -</div> diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts new file mode 100644 index 0000000..18d8939 --- /dev/null +++ b/src/features/workspaces/adapter/http.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi } from "vitest"; +import { createWorkspaceHttp } from "./http"; + +/** Build a fake `fetch` returning a canned Response. */ +function fakeFetch(responses: Array<{ status?: number; body?: unknown } | Error>): typeof fetch { + let i = 0; + return vi.fn(async () => { + const next = responses[i++]; + if (next === undefined) throw new Error("fakeFetch: no more canned responses"); + if (next instanceof Error) throw next; + const status = next.status ?? 200; + const body = next.body; + return { + ok: status >= 200 && status < 300, + status, + async json() { + return body; + }, + } as Response; + }) as unknown as typeof fetch; +} + +const BASE = "http://x"; + +describe("createWorkspaceHttp", () => { + it("list returns the workspaces", async () => { + const fetchImpl = fakeFetch([ + { + body: { + workspaces: [ + { + id: "a", + title: "A", + defaultCwd: null, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 3, + }, + ], + }, + }, + ]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const list = await http.list(); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe("a"); + expect(list[0]?.conversationCount).toBe(3); + expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`); + }); + + it("list returns [] on a failed response (non-fatal)", async () => { + const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 500 }])); + expect(await http.list()).toEqual([]); + }); + + it("list returns [] on a network error", async () => { + const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")])); + expect(await http.list()).toEqual([]); + }); + + it("ensure PUTs the id + returns the workspace", async () => { + const ws = { id: "my-ws", title: "my-ws", defaultCwd: null, createdAt: 10, lastActivityAt: 10 }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.ensure("my-ws"); + expect(result).toEqual({ ok: true, value: ws }); + expect(fetchImpl).toHaveBeenCalledWith( + `${BASE}/workspaces/my-ws`, + expect.objectContaining({ method: "PUT" }), + ); + }); + + it("ensure surfaces the backend error on a 400 (invalid slug)", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 400, body: { error: "invalid slug" } }]), + ); + const result = await http.ensure("UPPER"); + expect(result).toEqual({ ok: false, error: "invalid slug" }); + }); + + it("get returns null on 404", async () => { + const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }])); + expect(await http.get("nope")).toBeNull(); + }); + + it("get returns the workspace on 200", async () => { + const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 }; + const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }])); + expect(await http.get("x")).toEqual(ws); + }); + + it("setTitle PUTs the title", async () => { + const ws = { id: "a", title: "Renamed", defaultCwd: null, createdAt: 1, lastActivityAt: 2 }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.setTitle("a", "Renamed"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/title`); + expect(JSON.parse(call?.[1]?.body)).toEqual({ title: "Renamed" }); + }); + + it("setDefaultCwd PUTs null to clear", async () => { + const ws = { id: "a", title: "A", defaultCwd: null, createdAt: 1, lastActivityAt: 2 }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + await http.setDefaultCwd("a", null); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/default-cwd`); + expect(JSON.parse(call?.[1]?.body)).toEqual({ defaultCwd: null }); + }); + + it("delete returns closedCount", async () => { + const fetchImpl = fakeFetch([{ body: { workspaceId: "a", closedCount: 4 } }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.delete("a"); + expect(result).toEqual({ ok: true, value: { closedCount: 4 } }); + expect(fetchImpl).toHaveBeenCalledWith( + `${BASE}/workspaces/a`, + expect.objectContaining({ method: "DELETE" }), + ); + }); + + it("delete surfaces 409 for 'default'", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 409, body: { error: "cannot delete default" } }]), + ); + const result = await http.delete("default"); + expect(result).toEqual({ ok: false, error: "cannot delete default" }); + }); + + it("star PUTs /star with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: true, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.star("a"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/star`); + expect(call?.[1]).toEqual({ method: "PUT" }); + }); + + it("unstar DELETEs /star with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.unstar("a"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/star`); + expect(call?.[1]).toEqual({ method: "DELETE" }); + }); + + it("star surfaces a 400 for an invalid slug", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 400, body: { error: "invalid slug" } }]), + ); + const result = await http.star("UPPER"); + expect(result).toEqual({ ok: false, error: "invalid slug" }); + }); + + it("unstar surfaces the backend error on failure", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 500, body: { error: "Failed to unstar workspace" } }]), + ); + const result = await http.unstar("a"); + expect(result).toEqual({ ok: false, error: "Failed to unstar workspace" }); + }); +}); diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts new file mode 100644 index 0000000..5673881 --- /dev/null +++ b/src/features/workspaces/adapter/http.ts @@ -0,0 +1,187 @@ +import type { + DeleteWorkspaceResponse, + EnsureWorkspaceRequest, + SetWorkspaceDefaultComputerRequest, + SetWorkspaceDefaultCwdRequest, + SetWorkspaceTitleRequest, + Workspace, + WorkspaceEntry, + WorkspaceListResponse, + WorkspaceResponse, +} from "@dispatch/transport-contract"; + +/** + * Workspace HTTP effects — the injected edge that talks to the backend's + * workspace endpoints. Mirrors the store's fetch pattern: `httpBase` + an + * injected `fetchImpl` (so it is testable without the network). Returns typed + * `WorkspaceResult<T>` (`{ok,value}` | `{ok:false,error}`) for mutating ops so a + * caller can surface the backend's `{ error }` reason; reads return data or a + * safe empty/null on failure (non-fatal — the UI falls back gracefully). + * + * Endpoints ([email protected]): + * - `GET /workspaces` → list + * - `PUT /workspaces/:id` (create-on-miss, idempotent) → ensure + * - `GET /workspaces/:id` (404 → null) → get + * - `PUT /workspaces/:id/title` → rename + * - `PUT /workspaces/:id/default-cwd` → set/clear default cwd + * - `PUT /workspaces/:id/default-computer` → set/clear default computer (SSH handoff #2) + * - `PUT /workspaces/:id/star` (create-on-miss) → star (concurrency priority) + * - `DELETE /workspaces/:id/star` (create-on-miss) → unstar + * - `DELETE /workspaces/:id` (409 for "default") → delete + */ +export type WorkspaceResult<T> = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: string }; + +export interface WorkspaceHttp { + list(): Promise<readonly WorkspaceEntry[]>; + ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; + get(id: string): Promise<Workspace | null>; + setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>; + setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>; + setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>; + /** Star a workspace (concurrency priority). Create-on-miss; no body. */ + star(id: string): Promise<WorkspaceResult<Workspace>>; + /** Unstar a workspace. Create-on-miss; no body. */ + unstar(id: string): Promise<WorkspaceResult<Workspace>>; + delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; +} + +async function errText(res: Response): Promise<string> { + try { + const body = (await res.json()) as { error?: string }; + return body.error ?? `HTTP ${res.status}`; + } catch { + return `HTTP ${res.status}`; + } +} + +export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): WorkspaceHttp { + return { + async list(): Promise<readonly WorkspaceEntry[]> { + try { + const res = await fetchImpl(`${httpBase}/workspaces`); + if (!res.ok) return []; + const data = (await res.json()) as WorkspaceListResponse; + return data.workspaces; + } catch { + return []; + } + }, + + async ensure(id, body): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Workspace request failed", + }; + } + }, + + async get(id): Promise<Workspace | null> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`); + if (res.status === 404 || !res.ok) return null; + return (await res.json()) as WorkspaceResponse; + } catch { + return null; + } + }, + + async setTitle(id, title): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/title`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title } satisfies SetWorkspaceTitleRequest), + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Rename failed" }; + } + }, + + async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(id)}/default-cwd`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ defaultCwd } satisfies SetWorkspaceDefaultCwdRequest), + }, + ); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Set default cwd failed" }; + } + }, + + async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(id)}/default-computer`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId } satisfies SetWorkspaceDefaultComputerRequest), + }, + ); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set default computer failed", + }; + } + }, + + async star(id): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/star`, { + method: "PUT", + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Star failed" }; + } + }, + + async unstar(id): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/star`, { + method: "DELETE", + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Unstar failed" }; + } + }, + + async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + const data = (await res.json()) as DeleteWorkspaceResponse; + return { ok: true, value: { closedCount: data.closedCount } }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Delete failed" }; + } + }, + }; +} diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts new file mode 100644 index 0000000..dab1dec --- /dev/null +++ b/src/features/workspaces/index.ts @@ -0,0 +1,21 @@ +export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; +export { createWorkspaceHttp } from "./adapter/http"; +export type { Route } from "./logic/route"; +export { + DEFAULT_WORKSPACE_ID, + isValidSlug, + parsePath, + WORKSPACE_SLUG_RE, + workspacePath, +} from "./logic/route"; +export { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./logic/view-model"; +export type { WorkspaceStore } from "./store.svelte"; +export { createWorkspaceStore } from "./store.svelte"; +export { default as WorkspaceCard } from "./ui/WorkspaceCard.svelte"; +export { default as WorkspacesHome } from "./ui/WorkspacesHome.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "workspaces", + description: "URL-driven conversation grouping with a backend-owned default cwd", +} as const; diff --git a/src/features/workspaces/logic/route.test.ts b/src/features/workspaces/logic/route.test.ts new file mode 100644 index 0000000..96e0ff3 --- /dev/null +++ b/src/features/workspaces/logic/route.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_WORKSPACE_ID, + isValidSlug, + parsePath, + WORKSPACE_SLUG_RE, + workspacePath, +} from "./route"; + +describe("parsePath", () => { + it("treats the root path as home", () => { + expect(parsePath("/")).toEqual({ kind: "home" }); + expect(parsePath("")).toEqual({ kind: "home" }); + }); + + it("trims surrounding slashes", () => { + expect(parsePath("//")).toEqual({ kind: "home" }); + expect(parsePath("/my-ws/")).toEqual({ kind: "workspace", id: "my-ws" }); + }); + + it("parses a single segment as a workspace id", () => { + expect(parsePath("/default")).toEqual({ kind: "workspace", id: "default" }); + expect(parsePath("/my-workspace")).toEqual({ kind: "workspace", id: "my-workspace" }); + expect(parsePath("/ws1")).toEqual({ kind: "workspace", id: "ws1" }); + }); + + it("takes only the first segment of a deeper path", () => { + expect(parsePath("/foo/bar")).toEqual({ kind: "workspace", id: "foo" }); + expect(parsePath("/foo/bar/baz")).toEqual({ kind: "workspace", id: "foo" }); + }); + + it("URL-decodes the segment", () => { + expect(parsePath("/my%20ws")).toEqual({ kind: "workspace", id: "my ws" }); + }); + + it("does not validate the slug — an invalid id is still a workspace route", () => { + expect(parsePath("/UPPER")).toEqual({ kind: "workspace", id: "UPPER" }); + expect(parsePath("/has space")).toEqual({ kind: "workspace", id: "has space" }); + }); +}); + +describe("isValidSlug", () => { + it("accepts lowercase alphanumeric + internal hyphens", () => { + expect(isValidSlug("default")).toBe(true); + expect(isValidSlug("my-workspace")).toBe(true); + expect(isValidSlug("a")).toBe(true); + expect(isValidSlug("ws-1")).toBe(true); + }); + + it("accepts up to 40 chars", () => { + expect(isValidSlug("a".repeat(40))).toBe(true); + }); + + it("rejects empty and too-long", () => { + expect(isValidSlug("")).toBe(false); + expect(isValidSlug("a".repeat(41))).toBe(false); + }); + + it("rejects uppercase, spaces, and leading/trailing hyphens", () => { + expect(isValidSlug("MyWS")).toBe(false); + expect(isValidSlug("has space")).toBe(false); + expect(isValidSlug("-leading")).toBe(false); + expect(isValidSlug("trailing-")).toBe(false); + expect(isValidSlug("double--hyphen")).toBe(true); // internal doubles are allowed by the regex + }); + + it("WORKSPACE_SLUG_RE matches the default id", () => { + expect(WORKSPACE_SLUG_RE.test(DEFAULT_WORKSPACE_ID)).toBe(true); + }); +}); + +describe("workspacePath", () => { + it("builds the URL path for a workspace id", () => { + expect(workspacePath("default")).toBe("/default"); + expect(workspacePath("my-ws")).toBe("/my-ws"); + }); +}); diff --git a/src/features/workspaces/logic/route.ts b/src/features/workspaces/logic/route.ts new file mode 100644 index 0000000..015c6d6 --- /dev/null +++ b/src/features/workspaces/logic/route.ts @@ -0,0 +1,63 @@ +/** + * Pure routing logic for the workspaces feature — zero DOM, zero effects, zero Svelte. + * + * The app is URL-driven: the root path `/` is the workspaces HOME (lists all + * workspaces); a single path segment `/<id>` opens the workspace with that id + * (the slug). This module holds the pure mapping from a pathname to a `Route`, + * plus the slug-validation rules mirrored from the backend's `PUT /workspaces/:id`. + */ + +/** + * The workspace slug regex (mirrors the backend's `PUT /workspaces/:id` + * validation): 1–40 chars, lowercase alphanumeric with internal hyphens only + * (no leading/trailing hyphen). `"default"` matches and is a valid (but + * non-deletable) id. + */ +export const WORKSPACE_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/; + +/** A route derived from the URL path. */ +export type Route = { readonly kind: "home" } | { readonly kind: "workspace"; readonly id: string }; + +/** The reserved id of the always-present fallback workspace. */ +export const DEFAULT_WORKSPACE_ID = "default"; + +/** + * Parse a pathname into a `Route`. `/` (or empty) → home; a leading segment → + * the workspace with that id (URL-decoded, surrounding slashes trimmed). Deeper + * paths take their FIRST segment (nested routes are not used in v1). Pure: + * pathname in, route out. Does NOT validate the slug — an invalid id still + * produces a `workspace` route; the backend's ensure call rejects it (the FE + * surfaces the error). + */ +export function parsePath(pathname: string): Route { + const trimmed = pathname.replace(/^\/+|\/+$/g, ""); + if (trimmed === "") return { kind: "home" }; + const first = trimmed.split("/")[0] ?? ""; + const id = safeDecode(first); + if (id === "") return { kind: "home" }; + return { kind: "workspace", id }; +} + +/** + * Whether a slug is valid for a NEW workspace (the form the backend accepts). + * Used by the home view's "new workspace" input before navigating. + */ +export function isValidSlug(slug: string): boolean { + return WORKSPACE_SLUG_RE.test(slug); +} + +/** + * Build the URL path for a workspace id. Used when navigating to / linking a + * workspace. Pure: id in, path string out. + */ +export function workspacePath(id: string): string { + return `/${id}`; +} + +function safeDecode(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts new file mode 100644 index 0000000..44d2f31 --- /dev/null +++ b/src/features/workspaces/logic/view-model.test.ts @@ -0,0 +1,179 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./view-model"; + +describe("relativeTime", () => { + const now = 1_000_000_000_000; // 2001-09-09 + + it("is 'now' within a minute", () => { + expect(relativeTime(now, now)).toBe("now"); + expect(relativeTime(now - 59_000, now)).toBe("now"); + }); + + it("is minutes under an hour", () => { + expect(relativeTime(now - 5 * 60_000, now)).toBe("5m"); + expect(relativeTime(now - 59 * 60_000, now)).toBe("59m"); + }); + + it("is hours under a day", () => { + expect(relativeTime(now - 2 * 60 * 60_000, now)).toBe("2h"); + }); + + it("is days under a week", () => { + expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d"); + }); + + it("is a short date beyond a week", () => { + // 7+ days ago: just check it is a MM/DD string. + const s = relativeTime(now - 10 * 24 * 60 * 60_000, now); + expect(s).toMatch(/^\d{2}\/\d{2}$/); + }); +}); + +describe("pageTitle", () => { + // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed). + const ws = (id: string, title: string): WorkspaceEntry => ({ + id, + title, + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); + + it("is 'Dispatch' for the home route", () => { + expect(pageTitle({ kind: "home" }, [])).toBe("Dispatch"); + expect(pageTitle({ kind: "home" }, [ws("default", "Default")])).toBe("Dispatch"); + }); + + it("is 'Dispatch: {title}' for a workspace with a display title", () => { + const list = [ws("default", "Default"), ws("my-ws", "My Workspace")]; + expect(pageTitle({ kind: "workspace", id: "my-ws" }, list)).toBe("Dispatch: My Workspace"); + }); + + it("falls back to the slug (id) until the list has loaded the workspace", () => { + expect(pageTitle({ kind: "workspace", id: "pending" }, [])).toBe("Dispatch: pending"); + }); + + it("uses the id as the title when it was never customized (defaults to id)", () => { + const list = [ws("default", "default")]; + expect(pageTitle({ kind: "workspace", id: "default" }, list)).toBe("Dispatch: default"); + }); + + it("matches by id, not title", () => { + const list = [ws("a", "shared-title"), ws("b", "shared-title")]; + expect(pageTitle({ kind: "workspace", id: "b" }, list)).toBe("Dispatch: shared-title"); + }); +}); + +describe("sortWorkspaces", () => { + const entry = (id: string, starred: boolean, lastActivityAt: number): WorkspaceEntry => ({ + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + starred, + createdAt: 0, + lastActivityAt, + conversationCount: 0, + }); + + it("puts starred workspaces before unstarred", () => { + const list = [entry("plain", false, 9_000), entry("star", true, 1_000)]; + expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["star", "plain"]); + }); + + it("within the starred group, sorts by lastActivityAt desc", () => { + const list = [ + entry("old-star", true, 1_000), + entry("new-star", true, 5_000), + entry("plain", false, 9_000), + ]; + expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["new-star", "old-star", "plain"]); + }); + + it("within the unstarred group, sorts by lastActivityAt desc", () => { + const list = [ + entry("star", true, 1_000), + entry("old-plain", false, 1_000), + entry("new-plain", false, 5_000), + ]; + expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["star", "new-plain", "old-plain"]); + }); + + it("returns a new array (does not mutate the input)", () => { + const list = [entry("plain", false, 9_000), entry("star", true, 1_000)]; + const sorted = sortWorkspaces(list); + expect(sorted).not.toBe(list); + // Input order is preserved (not mutated). + expect(list.map((w) => w.id)).toEqual(["plain", "star"]); + expect(sorted.map((w) => w.id)).toEqual(["star", "plain"]); + }); + + it("handles an empty list", () => { + expect(sortWorkspaces([])).toEqual([]); + }); + + it("is stable for equal lastActivityAt within a group", () => { + const list = [ + entry("first", false, 5_000), + entry("second", false, 5_000), + entry("third", false, 5_000), + ]; + expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["first", "second", "third"]); + }); +}); + +describe("applyStarred", () => { + const entry = (id: string, starred: boolean): WorkspaceEntry => ({ + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + starred, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); + + it("sets the named workspace's starred flag", () => { + const list = [entry("a", false), entry("b", false)]; + const next = applyStarred(list, "b", true); + expect(next.map((w) => [w.id, w.starred])).toEqual([ + ["a", false], + ["b", true], + ]); + }); + + it("returns a new array (does not mutate the input)", () => { + const list = [entry("a", false)]; + const next = applyStarred(list, "a", true); + expect(next).not.toBe(list); + expect(list[0]?.starred).toBe(false); + expect(next[0]?.starred).toBe(true); + }); + + it("leaves other entries referentially unchanged (only the target is replaced)", () => { + const a = entry("a", false); + const b = entry("b", false); + const next = applyStarred([a, b], "b", true); + expect(next[0]).toBe(a); + expect(next[1]).not.toBe(b); + }); + + it("leaves the list unchanged when the id is absent (not yet loaded)", () => { + const list = [entry("a", false)]; + const next = applyStarred(list, "missing", true); + expect(next.map((w) => [w.id, w.starred])).toEqual([["a", false]]); + }); + + it("can revert by re-applying the previous value", () => { + const list = [entry("a", false)]; + const optimistic = applyStarred(list, "a", true); + expect(optimistic[0]?.starred).toBe(true); + const reverted = applyStarred(optimistic, "a", false); + expect(reverted[0]?.starred).toBe(false); + }); +}); diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts new file mode 100644 index 0000000..b994b7c --- /dev/null +++ b/src/features/workspaces/logic/view-model.ts @@ -0,0 +1,71 @@ +/** + * Pure view-model helpers for the workspaces feature — zero DOM, zero effects. + */ + +import type { WorkspaceEntry } from "@dispatch/wire"; +import type { Route } from "./route"; + +/** + * The browser tab / page (`document.title`) text for a route. The home route + * (`/`) is "Dispatch"; a workspace route (`/<id>`) is "Dispatch: {title}", + * using the workspace's display title and falling back to the URL slug (`id`) + * until the workspace list has loaded it — the backend defaults a workspace's + * title to its id, so the slug is the correct transient value. Pure: route + + * workspaces in, string out. + */ +export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]): string { + if (route.kind === "home") return "Dispatch"; + const ws = workspaces.find((w) => w.id === route.id); + return `Dispatch: ${ws?.title ?? route.id}`; +} + +/** + * Sort workspaces for display: starred first, then most-recently-active. Pure: + * the list in, a NEW sorted array out (the input is not mutated). Starred + * workspaces jump to the top (the FE-side echo of their concurrency-priority); + * within each group (starred / not) `lastActivityAt` desc breaks ties, matching + * the backend's list ordering. Stable for equal `lastActivityAt`. + */ +export function sortWorkspaces<T extends WorkspaceEntry>(workspaces: readonly T[]): T[] { + return [...workspaces].sort((a, b) => { + if (a.starred !== b.starred) return a.starred ? -1 : 1; + return b.lastActivityAt - a.lastActivityAt; + }); +} + +/** + * Return a NEW list with the one workspace's `starred` flag set (immutably — + * the entry is replaced, the rest keep their identity). Pure: the optimistic + * star/unstar transformation shared by the apply + the error revert. A missing + * `id` (not yet in the list — e.g. starring a workspace the home view hasn't + * loaded) leaves the list unchanged; the backend's create-on-miss still applies + * server-side and a subsequent refresh reconciles. + */ +export function applyStarred<T extends WorkspaceEntry>( + workspaces: readonly T[], + id: string, + starred: boolean, +): T[] { + return workspaces.map((w) => (w.id === id ? { ...w, starred } : w)); +} + +/** + * Format an epoch-ms timestamp as a short relative string ("now", "3m", "2h", + * "5d", or a date). Pure: `now` + `then` in, string out. Future timestamps + * (a workspace just created) read as "now". + */ +export function relativeTime(then: number, now: number): string { + const diff = now - then; + if (diff < 60_000) return "now"; + const mins = Math.floor(diff / 60_000); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d`; + // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests. + const d = new Date(then); + const month = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${month}/${day}`; +} diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts new file mode 100644 index 0000000..22d73b5 --- /dev/null +++ b/src/features/workspaces/store.svelte.ts @@ -0,0 +1,114 @@ +import type { EnsureWorkspaceRequest } from "@dispatch/transport-contract"; +import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; +import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; +import { applyStarred, sortWorkspaces } from "./logic/view-model"; + +/** + * Workspace store — a thin reactive wrapper over the pure HTTP edge. Owns the + * list + loading/error state; mutations call the injected `WorkspaceHttp` and + * refresh the list. State is per-instance (no ambient store); subscriptions are + * owned by the composition root. + */ +export interface WorkspaceStore { + /** + * All workspaces, sorted for display: starred first (their FE-side echo of + * concurrency-priority), then most-recently-active. The backing list is the + * backend's `lastActivityAt`-desc order; this getter re-sorts reactively. + */ + readonly list: readonly WorkspaceEntry[]; + readonly loading: boolean; + readonly error: string | null; + /** Refresh the list from the backend. */ + refresh(): Promise<void>; + /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */ + ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; + /** Rename a workspace (display only; id unchanged). */ + rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>; + /** Set/clear a workspace's default cwd. */ + setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>; + /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */ + setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>; + /** + * Toggle a workspace's star (concurrency priority). Optimistic: the local + * `starred` flag flips immediately and the sorted list re-orders; on error it + * reverts to the prior value. No full refresh on success (avoids flicker). + */ + setStarred(id: string, starred: boolean): Promise<WorkspaceResult<Workspace>>; + /** Delete a workspace (closes its conversations, reassigns to "default"). */ + remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; +} + +export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { + let list = $state<readonly WorkspaceEntry[]>([]); + // Sorted view (starred first, then most-recent) — derived so it recomputes + // only when the backing list changes, not on every read. + let sorted = $derived(sortWorkspaces(list)); + let loading = $state(false); + let error = $state<string | null>(null); + + return { + get list(): readonly WorkspaceEntry[] { + return sorted; + }, + get loading(): boolean { + return loading; + }, + get error(): string | null { + return error; + }, + + async refresh(): Promise<void> { + loading = true; + error = null; + try { + list = await http.list(); + } catch (err) { + error = err instanceof Error ? err.message : "Failed to load workspaces"; + } finally { + loading = false; + } + }, + + async ensure(id, body): Promise<WorkspaceResult<Workspace>> { + const result = await http.ensure(id, body); + if (result.ok) void this.refresh(); + return result; + }, + + async rename(id, title): Promise<WorkspaceResult<Workspace>> { + const result = await http.setTitle(id, title); + if (result.ok) void this.refresh(); + return result; + }, + + async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> { + const result = await http.setDefaultCwd(id, defaultCwd); + if (result.ok) void this.refresh(); + return result; + }, + + async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> { + const result = await http.setDefaultComputer(id, computerId); + if (result.ok) void this.refresh(); + return result; + }, + + async setStarred(id, starred): Promise<WorkspaceResult<Workspace>> { + const prev = list.find((w) => w.id === id)?.starred ?? false; + // Optimistic: flip immediately (the sorted getter re-orders reactively). + list = applyStarred(list, id, starred); + const result = starred ? await http.star(id) : await http.unstar(id); + if (!result.ok) { + // Revert the optimistic flip on failure. + list = applyStarred(list, id, prev); + } + return result; + }, + + async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> { + const result = await http.delete(id); + if (result.ok) void this.refresh(); + return result; + }, + }; +} diff --git a/src/features/workspaces/store.test.ts b/src/features/workspaces/store.test.ts new file mode 100644 index 0000000..4caac9f --- /dev/null +++ b/src/features/workspaces/store.test.ts @@ -0,0 +1,145 @@ +import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceResult } from "./adapter/http"; +import { createWorkspaceStore } from "./store.svelte"; + +function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 0, + ...overrides, + }; +} + +/** A fake `WorkspaceHttp` with stubbed star/unstar + a controllable list. */ +function fakeHttp(opts: { + list?: readonly WorkspaceEntry[]; + star?: (id: string) => Promise<WorkspaceResult<Workspace>>; + unstar?: (id: string) => Promise<WorkspaceResult<Workspace>>; +}) { + return { + list: vi.fn(async (): Promise<readonly WorkspaceEntry[]> => opts.list ?? []), + ensure: vi.fn(), + get: vi.fn(), + setTitle: vi.fn(), + setDefaultCwd: vi.fn(), + setDefaultComputer: vi.fn(), + star: + opts.star ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id, starred: true }), + }), + ), + unstar: + opts.unstar ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id, starred: false }), + }), + ), + delete: vi.fn(), + }; +} + +describe("createWorkspaceStore — setStarred", () => { + it("optimistically flips starred to true before the request resolves", async () => { + const http = fakeHttp({ list: [entry({ id: "a", starred: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + let observedDuringCall = false; + http.star = vi.fn(async (_id: string): Promise<WorkspaceResult<Workspace>> => { + // While the request is in flight, the store already shows the new state. + observedDuringCall = store.list[0]?.starred === true; + return { ok: true, value: entry({ id: "a", starred: true }) }; + }); + + await store.setStarred("a", true); + + expect(observedDuringCall).toBe(true); + expect(http.star).toHaveBeenCalledWith("a"); + expect(store.list[0]?.starred).toBe(true); + }); + + it("calls unstar (DELETE) when starring false", async () => { + const http = fakeHttp({ list: [entry({ id: "a", starred: true })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + await store.setStarred("a", false); + + expect(http.unstar).toHaveBeenCalledWith("a"); + expect(http.star).not.toHaveBeenCalled(); + expect(store.list[0]?.starred).toBe(false); + }); + + it("reverts the optimistic flip on error", async () => { + const http = fakeHttp({ list: [entry({ id: "a", starred: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + http.star = vi.fn( + async (): Promise<WorkspaceResult<Workspace>> => ({ ok: false, error: "boom" }), + ); + + const result = await store.setStarred("a", true); + + expect(result).toEqual({ ok: false, error: "boom" }); + // Reverted to the prior value. + expect(store.list[0]?.starred).toBe(false); + }); + + it("does not set the store-wide load error on a star failure", async () => { + const http = fakeHttp({ list: [entry({ id: "a", starred: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + http.star = vi.fn( + async (): Promise<WorkspaceResult<Workspace>> => ({ ok: false, error: "boom" }), + ); + await store.setStarred("a", true); + + expect(store.error).toBeNull(); + }); + + it("re-sorts so starred workspaces bubble to the top", async () => { + const http = fakeHttp({ + list: [ + entry({ id: "plain", starred: false, lastActivityAt: 9_000 }), + entry({ id: "star", starred: false, lastActivityAt: 1_000 }), + ], + }); + const store = createWorkspaceStore(http); + await store.refresh(); + + // Before: backend order (most-active first). + expect(store.list.map((w) => w.id)).toEqual(["plain", "star"]); + + await store.setStarred("star", true); + + // After: starred jumps above the more-recent unstarred workspace. + expect(store.list.map((w) => w.id)).toEqual(["star", "plain"]); + }); + + it("treats a missing id as not-starred and still calls through (create-on-miss)", async () => { + const http = fakeHttp({ list: [] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + const result = await store.setStarred("ghost", true); + + expect(result.ok).toBe(true); + expect(http.star).toHaveBeenCalledWith("ghost"); + // The list is unchanged (the workspace wasn't loaded); a refresh reconciles. + expect(store.list).toHaveLength(0); + }); +}); diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte new file mode 100644 index 0000000..6de4109 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceCard.svelte @@ -0,0 +1,279 @@ +<script lang="ts"> + import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire"; + import { untrack } from "svelte"; + import type { WorkspaceStore } from "../store.svelte"; + import { relativeTime } from "../logic/view-model"; + import { workspacePath } from "../logic/route"; + import ComputerSelect from "../../computer/ui/ComputerSelect.svelte"; + + let { + ws, + store, + onNavigate, + computers, + hasActive, + }: { + ws: WorkspaceEntry; + store: WorkspaceStore; + onNavigate: (path: string) => void; + /** Discovered computers (`GET /computers`), for the default-computer dropdown. */ + computers: readonly ComputerEntry[]; + /** + * Optional port: returns whether the workspace has at least one active + * (generating / queued) conversation — drives a loading-dots indicator on + * the card. Wired by the composition root to the app store's + * `workspaceHasActiveConversations`. Absent → no indicator (e.g. tests). + */ + hasActive?: (workspaceId: string) => boolean; + } = $props(); + + // Whether at least one conversation in this workspace is currently active + // (generating). Reactive: the composition-root port reads the app store's + // reactive tab set + lifecycle statuses, so this re-derives on change. + const active = $derived(hasActive?.(ws.id) ?? false); + + // ── Title: double-click to rename inline ────────────────────────────────── + let editingTitle = $state(false); + let titleDraft = $state(""); + let titleInput = $state<HTMLInputElement | undefined>(); + let titleError = $state<string | null>(null); + + function startEditTitle(): void { + titleDraft = ws.title; + titleError = null; + editingTitle = true; + queueMicrotask(() => titleInput?.focus()); + } + + async function saveTitle(): Promise<void> { + if (!editingTitle) return; + const title = titleDraft.trim(); + editingTitle = false; + if (title === "" || title === ws.title) return; + const result = await store.rename(ws.id, title); + if (!result.ok) titleError = result.error; + } + + function cancelTitle(): void { + editingTitle = false; + titleError = null; + } + + // ── Default cwd: inline input ───────────────────────────────────────────── + let cwdDraft = $state(untrack(() => ws.defaultCwd ?? "")); + // Reseed when the backend value changes (e.g., after a save or an external + // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered. + $effect(() => { + cwdDraft = ws.defaultCwd ?? ""; + }); + + const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? "")); + let savingCwd = $state(false); + let cwdError = $state<string | null>(null); + + async function saveCwd(): Promise<void> { + if (!cwdDirty || savingCwd) return; + savingCwd = true; + cwdError = null; + const cwd = cwdDraft.trim(); + const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd); + savingCwd = false; + if (!result.ok) cwdError = result.error; + } + + // ── Default computer: dropdown (Local / discovered SSH aliases) ──────────── + let savingComputer = $state(false); + let computerError = $state<string | null>(null); + + async function saveComputer(computerId: string | null): Promise<void> { + if (savingComputer) return; + // No-op when unchanged (the select only fires on a real change, but guard). + if (computerId === (ws.defaultComputerId ?? null)) return; + savingComputer = true; + computerError = null; + const result = await store.setDefaultComputer(ws.id, computerId); + savingComputer = false; + if (!result.ok) computerError = result.error; + } + + // ── Star (concurrency priority) ────────────────────────────────────────────── + let savingStar = $state(false); + let starError = $state<string | null>(null); + + async function toggleStar(): Promise<void> { + if (savingStar) return; + savingStar = true; + starError = null; + try { + // Optimistic: the store flips `starred` immediately and re-sorts; revert + // on error is handled there. We read `ws.starred` for the target value. + const result = await store.setStarred(ws.id, !ws.starred); + if (!result.ok) starError = result.error; + } catch (err) { + // A throw (e.g. a rejected effect) — surface it; the store already + // reverted the optimistic flip if it got far enough to apply it. + starError = err instanceof Error ? err.message : "Star toggle failed"; + } finally { + savingStar = false; + } + } + + // ── Delete ───────────────────────────────────────────────────────────────── + let deleting = $state(false); + + async function handleDelete(): Promise<void> { + if ( + !window.confirm( + `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`, + ) + ) { + return; + } + deleting = true; + await store.remove(ws.id); + deleting = false; + } +</script> + +<li class="flex flex-col gap-2 rounded-box border border-primary bg-primary/10 p-3"> + <div class="flex items-center gap-2"> + {#if editingTitle} + <input + bind:this={titleInput} + bind:value={titleDraft} + class="input input-bordered input-sm flex-1" + aria-label="Workspace title" + onkeydown={(e) => { + if (e.key === "Enter") saveTitle(); + else if (e.key === "Escape") cancelTitle(); + }} + onblur={saveTitle} + /> + {:else} + <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> + <span + class="flex-1 cursor-default truncate font-semibold" + title="Double-click to rename" + ondblclick={startEditTitle}>{ws.title}</span + > + {/if} + {#if active} + <span + class="loading loading-dots loading-xs shrink-0 text-primary" + aria-label="Workspace has active conversations" + title="A conversation in this workspace is generating"></span + > + {/if} + <span class="font-mono text-xs opacity-50">/{ws.id}</span> + <button + type="button" + class="btn btn-ghost btn-xs px-1" + disabled={savingStar} + aria-pressed={ws.starred} + aria-label={ws.starred ? "Unstar workspace" : "Star workspace"} + title={ws.starred + ? "Starred — its agents get concurrency priority. Click to unstar." + : "Star this workspace to give its agents concurrency priority."} + onclick={toggleStar} + > + {#if savingStar} + <span class="loading loading-spinner loading-xs"></span> + {:else if ws.starred} + <span class="text-warning" aria-hidden="true">★</span> + {:else} + <span class="opacity-40" aria-hidden="true">☆</span> + {/if} + </button> + <span class="ml-auto text-xs opacity-50"> + {ws.conversationCount} + {ws.conversationCount === 1 ? "conversation" : "conversations"} + · {relativeTime(ws.lastActivityAt, Date.now())} + </span> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={deleting} + title="Delete workspace" + aria-label="Delete workspace" + onclick={handleDelete} + > + {#if deleting} + <span class="loading loading-spinner loading-xs"></span> + {:else} + ✕ + {/if} + </button> + </div> + + {#if titleError} + <p class="text-xs text-error">{titleError}</p> + {/if} + + {#if starError} + <p class="text-xs text-error">{starError}</p> + {/if} + + <div class="flex items-center gap-2"> + <span class="w-8 shrink-0 text-xs opacity-60">cwd</span> + <input + type="text" + class="input input-bordered input-sm flex-1 font-mono text-xs" + placeholder="inherits the server default" + bind:value={cwdDraft} + aria-label="Default working directory" + onkeydown={(e) => { + if (e.key === "Enter") saveCwd(); + }} + /> + <button + type="button" + class="btn btn-primary btn-xs" + disabled={!cwdDirty || savingCwd} + onclick={saveCwd} + > + {#if savingCwd} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + </div> + + <div class="flex items-center gap-2"> + <span class="w-8 shrink-0 text-xs opacity-60">ssh</span> + <ComputerSelect + value={ws.defaultComputerId} + {computers} + disabled={savingComputer} + onSelect={saveComputer} + /> + {#if savingComputer} + <span class="loading loading-spinner loading-xs shrink-0"></span> + {/if} + </div> + + <div class="flex justify-start"> + <a + class="btn" + href={workspacePath(ws.id)} + onclick={(e) => { + e.preventDefault(); + onNavigate(workspacePath(ws.id)); + }} + > + Open + </a> + </div> + + {#if cwdError} + <p class="text-xs text-error">{cwdError}</p> + {:else if !cwdDirty && !ws.defaultCwd} + <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p> + {/if} + + {#if computerError} + <p class="text-xs text-error">{computerError}</p> + {:else if !ws.defaultComputerId} + <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p> + {/if} +</li> diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts new file mode 100644 index 0000000..72f4e60 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceCard.test.ts @@ -0,0 +1,305 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceResult } from "../adapter/http"; +import type { WorkspaceStore } from "../store.svelte"; +import WorkspaceCard from "./WorkspaceCard.svelte"; + +function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 3, + ...overrides, + }; +} + +/** A fake store that records calls + resolves ok. */ +function fakeStore() { + return { + rename: vi.fn( + async (id: string, _title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, title: _title }), + }), + ), + setDefaultCwd: vi.fn( + async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultCwd }), + }), + ), + setDefaultComputer: vi.fn( + async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultComputerId: computerId }), + }), + ), + setStarred: vi.fn( + async (id: string, starred: boolean): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, starred }), + }), + ), + remove: vi.fn( + async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({ + ok: true, + value: { closedCount: 0 }, + }), + ), + }; +} + +describe("WorkspaceCard", () => { + it("renders the title, slug, and an Open link", () => { + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, + }); + expect(screen.getByText("My Workspace")).toBeInTheDocument(); + expect(screen.getByText("/my-ws")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "/my-ws"); + }); + + it("double-clicking the title reveals an edit input", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, + }); + + await user.dblClick(screen.getByText("My Workspace")); + expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace"); + }); + + it("renames via the store on Enter", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, + }); + + await user.dblClick(screen.getByText("My Workspace")); + const input = screen.getByLabelText("Workspace title"); + await user.clear(input); + await user.type(input, "Renamed{Enter}"); + + expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed"); + }); + + it("enables Set only when the cwd differs, then saves it", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, + }); + + const input = screen.getByLabelText("Default working directory"); + expect(input).toHaveValue("/old"); + expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); + + await user.clear(input); + await user.type(input, "/new/path"); + expect(screen.getByRole("button", { name: "Set" })).toBeEnabled(); + + await user.click(screen.getByRole("button", { name: "Set" })); + expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", "/new/path"); + }); + + it("clears the cwd to null when saved empty (inherits the server default)", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, + }); + + const input = screen.getByLabelText("Default working directory"); + await user.clear(input); + await user.click(screen.getByRole("button", { name: "Set" })); + + expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null); + }); + + it("the Open link navigates to the workspace in the same tab (SPA navigation, no new tab)", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + const onNavigate = vi.fn(); + render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } }); + + const open = screen.getByRole("link", { name: "Open" }); + // Still a real link (progressive enhancement): href points at the workspace. + expect(open).toHaveAttribute("href", "/my-ws"); + // But it no longer opens a new browser tab. + expect(open).not.toHaveAttribute("target", "_blank"); + // Clicking navigates in-place via the SPA callback. + await user.click(open); + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate).toHaveBeenCalledWith("/my-ws"); + }); + + // ── Active indicator (loading dots) ────────────────────────────────────── + + it("shows no loading-dots when no hasActive port is given", () => { + const store = fakeStore() as unknown as WorkspaceStore; + const { container } = render(WorkspaceCard, { + props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, + }); + expect(container.querySelector(".loading-dots")).toBeNull(); + }); + + it("shows no loading-dots when hasActive returns false", () => { + const store = fakeStore() as unknown as WorkspaceStore; + const { container } = render(WorkspaceCard, { + props: { + ws: fakeEntry(), + store, + onNavigate: vi.fn(), + computers: [], + hasActive: () => false, + }, + }); + expect(container.querySelector(".loading-dots")).toBeNull(); + }); + + it("shows loading-dots when hasActive returns true", () => { + const store = fakeStore() as unknown as WorkspaceStore; + const { container } = render(WorkspaceCard, { + props: { + ws: fakeEntry(), + store, + onNavigate: vi.fn(), + computers: [], + hasActive: () => true, + }, + }); + const dots = container.querySelector(".loading-dots"); + expect(dots).not.toBeNull(); + // Accessible label ties the indicator to the workspace-active concept. + expect(dots?.getAttribute("aria-label")).toBe("Workspace has active conversations"); + }); + + it("forwards the workspace id to hasActive", () => { + const store = fakeStore() as unknown as WorkspaceStore; + const seen: string[] = []; + render(WorkspaceCard, { + props: { + ws: fakeEntry({ id: "proj-x" }), + store, + onNavigate: vi.fn(), + computers: [], + hasActive: (id: string) => { + seen.push(id); + return false; + }, + }, + }); + expect(seen).toEqual(["proj-x"]); + }); + + it("renders an outline star button for an unstarred workspace", () => { + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, + }); + const star = screen.getByRole("button", { name: "Star workspace" }); + expect(star).toHaveAttribute("aria-pressed", "false"); + }); + + it("renders a filled star button for a starred workspace", () => { + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] }, + }); + const star = screen.getByRole("button", { name: "Unstar workspace" }); + expect(star).toHaveAttribute("aria-pressed", "true"); + }); + + it("toggles the star via the store on click", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, + }); + + await user.click(screen.getByRole("button", { name: "Star workspace" })); + expect(store.setStarred).toHaveBeenCalledWith("my-ws", true); + }); + + it("clicking a starred workspace's star calls setStarred(id, false)", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] }, + }); + + await user.click(screen.getByRole("button", { name: "Unstar workspace" })); + expect(store.setStarred).toHaveBeenCalledWith("my-ws", false); + }); + + it("renders no star error on a successful toggle", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, + }); + + await user.click(screen.getByRole("button", { name: "Star workspace" })); + expect(screen.queryByText(/Star toggle failed/i)).not.toBeInTheDocument(); + }); + + it("shows an inline error when setStarred fails (result.ok false)", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + // The store reverts the optimistic flip on failure, so the entry's + // `starred` stays false — fake the revert by returning ok:false unchanged. + store.setStarred = vi.fn( + async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }), + ); + render(WorkspaceCard, { + props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, + }); + + await user.click(screen.getByRole("button", { name: "Star workspace" })); + + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + + it("re-enables the star button after a failure (savingStar resets)", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + store.setStarred = vi.fn( + async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }), + ); + render(WorkspaceCard, { + props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, + }); + + const star = screen.getByRole("button", { name: "Star workspace" }); + await user.click(star); + // After the failed toggle, the button is NOT disabled (savingStar reset). + expect(star).not.toBeDisabled(); + }); + + it("re-enables the star button even when setStarred throws", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + store.setStarred = vi.fn(async (): Promise<WorkspaceResult<WorkspaceEntry>> => { + throw new Error("network"); + }); + render(WorkspaceCard, { + props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, + }); + + const star = screen.getByRole("button", { name: "Star workspace" }); + await user.click(star); + // savingStar must reset via try/finally even on a throw. + expect(star).not.toBeDisabled(); + expect(screen.getByText("network")).toBeInTheDocument(); + }); +}); diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte new file mode 100644 index 0000000..d97eab7 --- /dev/null +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -0,0 +1,110 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import type { ComputerEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import { isValidSlug, workspacePath } from "../logic/route"; + import WorkspaceCard from "./WorkspaceCard.svelte"; + + let { + store, + onNavigate, + computers, + hasActive, + }: { + store: WorkspaceStore; + onNavigate: (path: string) => void; + computers: readonly ComputerEntry[]; + /** + * Optional port forwarded to each {@link WorkspaceCard}: whether the + * workspace has at least one active (generating / queued) conversation. + * Wired by the composition root to the app store. Absent → no indicator. + */ + hasActive?: (workspaceId: string) => boolean; + } = $props(); + + onMount(() => { + void store.refresh(); + }); + + let newSlug = $state(""); + let slugError = $state<string | null>(null); + + const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug)); + + function createWorkspace(): void { + const slug = newSlug.trim(); + if (!isValidSlug(slug)) { + slugError = "Lowercase letters, digits, and hyphens (1–40 chars)."; + return; + } + slugError = null; + newSlug = ""; + onNavigate(workspacePath(slug)); + } +</script> + +<div class="mx-auto flex h-screen w-full max-w-3xl flex-col gap-4 p-6"> + <header class="flex items-center justify-between"> + <h1 class="text-2xl font-bold">Workspaces</h1> + <a + href="/default" + class="btn btn-ghost btn-sm" + onclick={(e) => { + e.preventDefault(); + onNavigate("/default"); + }} + > + Default + </a> + </header> + + <form + class="flex items-end gap-2" + onsubmit={(e) => { + e.preventDefault(); + createWorkspace(); + }} + > + <div class="flex-1"> + <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60" + >New workspace</label + > + <input + id="new-ws" + class="input input-bordered w-full" + placeholder="my-workspace" + bind:value={newSlug} + autocomplete="off" + spellcheck="false" + /> + </div> + <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button> + </form> + {#if slugError} + <p class="text-xs text-error">{slugError}</p> + {/if} + + <div class="flex-1 overflow-y-auto"> + {#if store.loading && store.list.length === 0} + <div class="flex h-32 items-center justify-center"> + <span class="loading loading-spinner loading-sm opacity-60"></span> + </div> + {:else if store.list.length === 0} + <p class="py-8 text-center text-sm opacity-60"> + No workspaces yet. Create one above or visit <code>/your-name</code> in the URL. + </p> + {:else} + <ul class="flex flex-col gap-2"> + {#each store.list as ws (ws.id)} + <WorkspaceCard + {ws} + {store} + {onNavigate} + {computers} + {...(hasActive ? { hasActive } : {})} + /> + {/each} + </ul> + {/if} + </div> +</div> diff --git a/src/main.ts b/src/main.ts index 9ebef3a..565c0d6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,7 @@ import "./app.css"; const target = document.getElementById("app"); if (!target) { - throw new Error("missing #app mount target"); + throw new Error("missing #app mount target"); } export default mount(App, { target }); diff --git a/svelte.config.js b/svelte.config.js index f77d881..d0e6448 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,5 +1,5 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; export default { - preprocess: vitePreprocess(), + preprocess: vitePreprocess(), }; diff --git a/tsconfig.json b/tsconfig.json index 02d6f89..b5a5eb5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,18 +1,18 @@ { - "extends": "@tsconfig/svelte/tsconfig.json", - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "types": ["vite/client"], - "strict": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "exactOptionalPropertyTypes": true, - "verbatimModuleSyntax": true, - "isolatedModules": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src/**/*.ts", "src/**/*.svelte", "src/**/*.d.ts", "vitest-setup.ts"] + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["vite/client"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.svelte", "src/**/*.d.ts", "vitest-setup.ts"] } diff --git a/vite.config.ts b/vite.config.ts index bdfabe6..3229445 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,31 +7,31 @@ import { defineConfig } from "vitest/config"; // Dev server on the reserved FRONTEND_PORT (24204). Vitest config lives here too // (jsdom + globals) so component tests run without extra config. export default defineConfig({ - // svelteTesting() forces Svelte's `browser` resolve condition under vitest so - // component render()/mount() works in jsdom (a plain test.resolve.conditions - // does not propagate to Vite's SSR resolution — sveltejs/svelte#11394). - plugins: [tailwindcss(), svelte(), svelteTesting()], - // Bind all interfaces + accept any Host header so the dev server is reachable over a LAN / - // Tailscale. Safe for LOCAL-NETWORK-ONLY use (NOT internet-exposed): `allowedHosts: true` - // disables Vite's DNS-rebinding host check. (The WS URL still runs in the browser — set - // VITE_WS_URL to the backend's reachable host when browsing from another device.) - server: { port: 24204, host: true, allowedHosts: true }, - define: { - // Bake the 5-char git short hash at build time so it survives bundling - // (incl. arch package deploys). Falls back to "dev" when not in a git repo. - __APP_VERSION__: JSON.stringify(getGitShortHash()), - }, - test: { - environment: "jsdom", - globals: true, - setupFiles: ["./vitest-setup.ts"], - }, + // svelteTesting() forces Svelte's `browser` resolve condition under vitest so + // component render()/mount() works in jsdom (a plain test.resolve.conditions + // does not propagate to Vite's SSR resolution — sveltejs/svelte#11394). + plugins: [tailwindcss(), svelte(), svelteTesting()], + // Bind all interfaces + accept any Host header so the dev server is reachable over a LAN / + // Tailscale. Safe for LOCAL-NETWORK-ONLY use (NOT internet-exposed): `allowedHosts: true` + // disables Vite's DNS-rebinding host check. (The WS URL still runs in the browser — set + // VITE_WS_URL to the backend's reachable host when browsing from another device.) + server: { port: 24204, host: true, allowedHosts: true }, + define: { + // Bake the 5-char git short hash at build time so it survives bundling + // (incl. arch package deploys). Falls back to "dev" when not in a git repo. + __APP_VERSION__: JSON.stringify(getGitShortHash()), + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./vitest-setup.ts"], + }, }); function getGitShortHash(): string { - try { - return execSync("git rev-parse --short=5 HEAD", { encoding: "utf-8" }).trim(); - } catch { - return "dev"; - } + try { + return execSync("git rev-parse --short=5 HEAD", { encoding: "utf-8" }).trim(); + } catch { + return "dev"; + } } diff --git a/vitest-setup.ts b/vitest-setup.ts index 10a3160..143ac96 100644 --- a/vitest-setup.ts +++ b/vitest-setup.ts @@ -5,12 +5,12 @@ import "fake-indexeddb/auto"; // controller uses both against the real transcript element when App mounts. Stub // the outermost edges so component tests can render without throwing. if (typeof Element !== "undefined" && typeof Element.prototype.scrollTo !== "function") { - Element.prototype.scrollTo = () => {}; + Element.prototype.scrollTo = () => {}; } if (typeof globalThis.ResizeObserver === "undefined") { - globalThis.ResizeObserver = class { - observe(): void {} - unobserve(): void {} - disconnect(): void {} - }; + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }; } |
