diff options
329 files changed, 44808 insertions, 7008 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 0582d94..eaa09a2 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -1,100 +1,27 @@ -# `@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). Regenerate whenever -> it changes. -> -> **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`. +# `@dispatch/transport-contract` — in-repo reference +Mirrored from the authorized public transport contract on 2026-10-24 for `feature/text-file-embeds`. +Package version remains 0.26.0; local `file:` dependency unchanged. + +Additive text-file seam: `TextFileQuery`, `TextFileResponse`, and `TextFileContextResponse` +for guarded, no-store local text display. Existing additive messages: `chat.accepted` after opening-message storage or queue insertion, +`chat.subscription-rejected` for a nonfatal watcher limit, and `requestId` on queue requests +and chat errors. Correlation tokens are not idempotency keys; clients must not automatically +retry uncertain sends. The server retains the 256 combined subscription bound. + +```ts /** * Transport contract — the typed description of Dispatch's client–server API * (HTTP + WebSocket). * - * This package is types-only (zero runtime). It is the single shared surface + * This package defines types plus pure conversation-ID boundary validation. + * That deliberate runtime exception is not a shared codec. It is the shared surface * every client imports to know how to talk to the backend — the CLI, the web * frontend (in its own repo), any third-party client — and the transport-http / * transport-ws servers import to know what they must accept and emit. * * Each side owns its OWN (de)serialization: there is deliberately no shared - * parse/serialize helper here (isolation-over-DRY). The contract is the SHAPES, + * wire parse/serialize helper here (isolation-over-DRY). The contract is the SHAPES, * not the codec. The streaming response payload is the kernel's `AgentEvent` * union, re-exported here so a client has one import for the whole wire. * @@ -105,39 +32,47 @@ * is unchanged. */ +import type { LayoutTree } from "@dispatch/layout-contract"; +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; import type { - AgentEvent, - Computer, - ComputerEntry, - ConversationMeta, - ConversationStatus, - ImageInput, - QueuedMessage, - ReasoningEffort, - StoredChunk, - TurnMetrics, - Workspace, - WorkspaceEntry, + AgentEvent, + Computer, + ComputerEntry, + ConversationMeta, + ConversationStatus, + ImageInput, + NamedAgent, + NamedAgentSelectionSnapshot, + QueuedMessage, + ReasoningEffort, + StoredChunk, + TurnMetrics, + Workspace, + WorkspaceEntry, } from "@dispatch/wire"; +export type { LayoutTree } from "@dispatch/layout-contract"; export type { - AgentEvent, - CompactionResult, - Computer, - ComputerEntry, - ConversationMeta, - ConversationStatus, - ImageChunk, - ImageInput, - QueuedMessage, - ReasoningEffort, - StepMetrics, - StoredChunk, - TurnMetrics, - Workspace, - WorkspaceEntry, + AgentEvent, + CompactionResult, + Computer, + ComputerEntry, + ConversationMeta, + ConversationStatus, + ImageChunk, + ImageInput, + NamedAgent, + NamedAgentSelectionSnapshot, + QueuedMessage, + ReasoningEffort, + StepMetrics, + StoredChunk, + TurnMetrics, + Workspace, + WorkspaceEntry, } from "@dispatch/wire"; +export { isValidConversationId, parseConversationId } from "./conversation-id.js"; /** * Request body for `POST /chat` (sent as JSON). @@ -147,68 +82,99 @@ export type { * response header (useful when `conversationId` was omitted). */ export interface ChatRequest { - /** - * The conversation to continue. Omit to start a fresh conversation — the - * server mints an id and returns it via the `X-Conversation-Id` header. - */ - readonly conversationId?: string; - - /** The user's message text for this turn. */ - 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. - */ - readonly model?: string; - - /** - * Working directory for this turn's tool execution. Defaults server-side when - * omitted. Forwarded to tools for path resolution; never part of the model - * prompt (so it does not affect prompt caching). - */ - readonly cwd?: string; - - /** - * Reasoning-effort override for THIS turn only (does not persist). When - * omitted, the server resolves the conversation's persisted value, falling - * back to `"high"`. Must be one of the `ReasoningEffort` levels; an - * 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; + /** + * The conversation to continue. Omit to start a fresh conversation — the + * server mints an id and returns it via the `X-Conversation-Id` header. + * When supplied, must match `^[A-Za-z0-9_-]{1,128}$` (UUIDs and legacy IDs). + * Empty, null, and non-string values are rejected, not treated as absent. + */ + readonly conversationId?: string; + + /** The user's message text for this turn. */ + 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). + */ + 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. + */ + readonly model?: string; + + /** + * Working directory for this turn's tool execution. Defaults server-side when + * omitted. Forwarded to tools for path resolution; never part of the model + * prompt (so it does not affect prompt caching). + */ + readonly cwd?: 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; + + /** + * Reasoning-effort override for THIS turn only (does not persist). When + * omitted, the server resolves the conversation's persisted value, falling + * back to `"high"`. Must be one of the `ReasoningEffort` levels; an + * 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; + + /** + * A human-readable title for the conversation tab — persisted at creation + * time, after the new-conversation workspace setup resolves (so workspace + * assignment and first-turn system-prompt construction are not skipped) and + * before the first message append (so the append's auto-derived title does + * not overwrite it). The tab shows it instead of the default derived from + * the first message (`"Untitled"` until the first append). Omit to keep the + * auto-derived title. When present, the value is trimmed server-side; a + * whitespace-only value is treated as absent (auto-derive). A non-string + * value → HTTP 400 `{ error }`. + * + * Backward compatible — clients that omit it are unaffected. Mirrors the + * dedicated `PUT /conversations/:id/title` endpoint but is atomic with the + * turn (no second round-trip from the client). + */ + readonly title?: string; + + /** + * Named Agent selection snapshot to persist on the conversation at creation. + * + * When the client starts a conversation via the synthetic **Named Agents** + * provider, it resolves the selected Named Agent to its raw `model` and sends + * that **plus** this snapshot so the conversation freezes how it was selected + * (snapshot semantics — see `NamedAgentSelectionSnapshot` in `@dispatch/wire`). + * The server persists it alongside the model; later edits/deletes of the Named + * Agent do not change this conversation's selection. Omit for a raw-model-only + * conversation (backward compatible). Ignored/overwritten by a subsequent + * `PUT /conversations/:id/model` that carries its own snapshot. + */ + readonly namedAgent?: NamedAgentSelectionSnapshot; } /** @@ -221,21 +187,63 @@ export interface ChatRequest { * read `models` are unaffected. */ export interface ModelsResponse { - readonly models: readonly string[]; - readonly modelInfo?: Readonly<Record<string, ModelMetadata>>; + readonly models: readonly string[]; + readonly modelInfo?: Readonly<Record<string, ModelMetadata>>; } /** 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; + 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; +} + +// ─── Named Agents ──────────────────────────────────────────────────────────── + +/** + * Response body for `GET /named-agents` — the list of configured Named Agents. + * + * Each record is a user-curated model preset (`id`, friendly `name`, canonical + * `<credential>/<model>`). This is the local configuration that drives the + * "Named Agents" synthetic provider in the frontend model selector and the + * `list_agent_models`/`summon_agent` agent tools — NOT the raw model catalog + * (which `GET /models` continues to serve unchanged). + */ +export interface NamedAgentsResponse { + readonly namedAgents: readonly NamedAgent[]; +} + +/** Response body for `POST`/`PUT /named-agents(/:id)` — the canonical saved record. */ +export interface NamedAgentResponse { + readonly namedAgent: NamedAgent; +} + +/** Request body for `POST /named-agents` — create a Named Agent. */ +export interface CreateNamedAgentRequest { + /** User-chosen friendly name (trimmed; unique case-insensitively). */ + readonly name: string; + /** Canonical `<credential>/<model>` identifier. */ + readonly model: string; +} + +/** Request body for `PUT /named-agents/:id` — full update of a Named Agent. */ +export interface UpdateNamedAgentRequest { + readonly name: string; + readonly model: string; +} + +/** + * Response body for `DELETE /named-agents/:id` — confirms removal of the record + * with the stable id. + */ +export interface DeleteNamedAgentResponse { + readonly id: string; } /** @@ -259,17 +267,24 @@ export interface ModelMetadata { * tail sync = `?sinceSeq=<cursor>` (no limit); page older history in = * `?beforeSeq=<oldestLoadedSeq>&limit=<k>`. * - * Seq numbering is **1-based and gap-free** (a CONTRACTUAL GUARANTEE — see - * `StoredChunk` in `@dispatch/wire`): a client can derive "older chunks exist" - * purely from `oldestLoaded.seq > 1`; there is deliberately no - * `earliestSeq`/`hasOlder` response field. + * Seq is positive and never reused within a conversation, even when history is + * replaced. The retained log need not start at 1, so `oldestLoaded.seq > 1` + * does not prove older chunks exist. An empty/short backfill page can establish + * that no earlier chunks remain. + * + * `historyEpoch` is the persisted replacement count (missing storage value = 0). + * Every successful replacement, including an empty one, increments it once. + * On a different epoch, clients invalidate their conversation cache and refetch + * with `sinceSeq=0` (optionally windowed), even if this response has no chunks. * * `chunks` is the RAW, append-order, seq-ordered slice of the conversation log * selected + windowed as above. It is NOT reconciled: a dangling tool-call is * returned as-is (rendered as an interrupted call). Reconciliation is a * turn-path concern — the server repairs history only when it feeds a provider, * never on this read path — which is what preserves the per-chunk `seq` cursor - * invariant (a synthesized repair chunk would have no seq). + * invariant (a synthesized repair chunk would have no seq). Provider-opaque + * thinking replay fields (`signature` and `redactedData`) are omitted from + * this client-facing response; chunk presence, text, order, and seq are intact. * * `latestSeq` is the `seq` of the LAST chunk in this response, or — when the * slice is empty (the client is already caught up) — the requested `sinceSeq` @@ -284,8 +299,9 @@ export interface ModelMetadata { * the store contract.) */ export interface ConversationHistoryResponse { - readonly chunks: readonly StoredChunk[]; - readonly latestSeq: number; + readonly chunks: readonly StoredChunk[]; + readonly latestSeq: number; + readonly historyEpoch: number; } /** @@ -304,15 +320,15 @@ export interface ConversationHistoryResponse { * absent until then. */ export interface ConversationMetricsResponse { - readonly turns: readonly TurnMetrics[]; + 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; + 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`. */ @@ -326,16 +342,16 @@ export type ThroughputPeriod = "day" | "week" | "month"; * waits). */ export interface ThroughputModelStat { - /** The model name in `<credentialName>/<model>` form (as selected). */ - readonly model: string; - /** Token-weighted average tokens/second over the period. */ - readonly tokensPerSecond: number; - /** Total output tokens generated across the period's turns. */ - readonly totalOutputTokens: number; - /** Total pure generation time across the period's turns, in milliseconds. */ - readonly totalGenMs: number; - /** Number of turns that contributed. */ - readonly turns: number; + /** The model name in `<credentialName>/<model>` form (as selected). */ + readonly model: string; + /** Token-weighted average tokens/second over the period. */ + readonly tokensPerSecond: number; + /** Total output tokens generated across the period's turns. */ + readonly totalOutputTokens: number; + /** Total pure generation time across the period's turns, in milliseconds. */ + readonly totalGenMs: number; + /** Number of turns that contributed. */ + readonly turns: number; } /** @@ -349,21 +365,91 @@ export interface ThroughputModelStat { * `tokensPerSecond` descending. */ export interface ThroughputResponse { - readonly period: ThroughputPeriod; - readonly date: string; - /** Inclusive start of the window, epoch-ms. */ - readonly start: number; - /** Exclusive end of the window, epoch-ms. */ - readonly end: number; - readonly models: readonly ThroughputModelStat[]; + readonly period: ThroughputPeriod; + readonly date: string; + /** Inclusive start of the window, epoch-ms. */ + readonly start: number; + /** Exclusive end of the window, epoch-ms. */ + readonly end: number; + readonly models: readonly ThroughputModelStat[]; +} + +// ─── Live conversation text files ──────────────────────────────────────────── + +/** + * Response body for `GET /conversations/:id/text-file-context` — the effective, + * authoritative conversation/workspace context used by the text-file endpoint, + * not merely the conversation's persisted cwd/computer overrides. + * + * This minimal preflight shares context resolution with the file read, without + * starting/querying LSP or reading any files. An unknown conversation → HTTP 404. + * It is live context, not a snapshot; responses use `Cache-Control: no-store`. + * Clients should load it before requesting a text file and supply BOTH returned + * values as guards in `TextFileQuery` (encode null as the empty string). + */ +export interface TextFileContextResponse { + readonly conversationId: string; + /** Effective authoritative cwd, or null when unset (no process-cwd fallback). */ + readonly cwd: string | null; + /** Effective authoritative computer target, or null for local execution. */ + readonly computerId: string | null; +} + +/** + * Query parameters for `GET /conversations/:id/text-file`. + * + * Pass the RAW literal path to `URLSearchParams`; only the URL query decoder + * decodes it. The server MUST NOT percent-decode the resulting path a second + * time (a literal `%20` in a filename stays `%20`, not a space). + * + * Resolution uses the authoritative conversation/workspace context, never a + * client-supplied context override. A relative path requires an authoritative + * cwd; there is no process-cwd fallback. Only local regular files are supported, + * not ordinary network/protocol fetching. A remote computer target → HTTP 422. + * + * Guards compare against the effective context used for this read. An omitted + * guard skips that comparison; an empty string asserts null (cwd unset or + * computer local). A mismatch → HTTP 409. Browsers should supply BOTH guards + * after loading `TextFileContextResponse` to detect context changes. + */ +export interface TextFileQuery { + /** Literal absolute or cwd-relative POSIX file path, not a URL to fetch. */ + readonly path: string; + /** Expected effective cwd; empty string asserts unset, omitted skips guard. */ + readonly expectedCwd?: string; + /** Expected effective computer; empty string asserts local, omitted skips guard. */ + readonly expectedComputerId?: string; +} + +/** + * Response body for `GET /conversations/:id/text-file` — a live file read, NOT a + * persisted snapshot or attachment. Responses use `Cache-Control: no-store`. + * + * Only regular files containing strict UTF-8 without NUL or binary control + * characters are accepted. Reads are bounded to 256 KiB (262144 bytes) under + * the current policy, with no truncation. Invalid paths, nonregular files, or + * nontext content → HTTP 400; unknown conversations or missing files → HTTP + * 404; context-guard mismatch → HTTP 409; oversized files → HTTP 413; remote + * computer targets (unsupported) → HTTP 422. + */ +export interface TextFileResponse { + /** Resolved absolute POSIX file path. */ + readonly path: string; + /** Complete decoded UTF-8 file content, never truncated. */ + readonly text: string; + readonly format: "markdown" | "text"; + /** Original UTF-8 file size in bytes (not JavaScript string length). */ + readonly bytes: number; + /** Maximum accepted file size in bytes; currently 262144 (256 KiB). */ + readonly maxBytes: number; } // ─── Per-conversation working directory (cwd) ───────────────────────────────── /** Response of `GET /conversations/:id/cwd`. `cwd` is null when never set. */ export interface CwdResponse { - readonly conversationId: string; - readonly cwd: string | null; + readonly conversationId: string; + readonly cwd: string | null; } /** @@ -377,8 +463,8 @@ export interface CwdResponse { * `"default"` if none). */ export interface SetCwdRequest { - readonly cwd: string; - readonly workspaceId?: string; + readonly cwd: string; + readonly workspaceId?: string; } // ─── Per-conversation reasoning effort ──────────────────────────────────────── @@ -389,8 +475,8 @@ export interface SetCwdRequest { * `"high"`). */ export interface ReasoningEffortResponse { - readonly conversationId: string; - readonly reasoningEffort: ReasoningEffort | null; + readonly conversationId: string; + readonly reasoningEffort: ReasoningEffort | null; } /** @@ -400,19 +486,27 @@ export interface ReasoningEffortResponse { * unrecognized level → HTTP 400 `{ error }`. */ export interface SetReasoningEffortRequest { - readonly reasoningEffort: ReasoningEffort; + readonly reasoningEffort: ReasoningEffort; } -// ─── Per-conversation model persistence ─────────────────────────────────────── +// ─── Per-conversation model ────────────────────────────────────────────────── /** * 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). + * + * `namedAgent` is the Named Agent selection snapshot recorded when the + * conversation was started/selected via a Named Agent. Present only for + * conversations that were created through a Named Agent (snapshot semantics: it + * freezes the friendly id/name + resolved model at selection time, so later + * edits to the Named Agent do not change this conversation's selection). A + * conversation holding only a raw model carries no `namedAgent`. */ export interface ModelResponse { - readonly conversationId: string; - readonly model: string | null; + readonly conversationId: string; + readonly model: string | null; + readonly namedAgent?: NamedAgentSelectionSnapshot; } /** @@ -421,9 +515,16 @@ export interface ModelResponse { * `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). + * + * `namedAgent` (optional) is a Named Agent selection snapshot to persist with + * the model — sent when the client sets this conversation's model via the + * synthetic **Named Agents** provider so the conversation freezes the friendly + * selection. When `model` is `null` (clear), the persisted snapshot is also + * cleared. Omit entirely for a raw-model selection (backward compatible). */ export interface SetModelRequest { - readonly model: string | null; + readonly model: string | null; + readonly namedAgent?: NamedAgentSelectionSnapshot; } // ─── Conversation close (explicit tab close) ────────────────────────────────── @@ -443,9 +544,9 @@ export interface SetModelRequest { * `abortedTurn: false`. */ export interface CloseConversationResponse { - readonly conversationId: string; - /** True when an in-flight turn existed and was aborted by this close. */ - readonly abortedTurn: boolean; + readonly conversationId: string; + /** True when an in-flight turn existed and was aborted by this close. */ + readonly abortedTurn: boolean; } // ─── System prompt template ─────────────────────────────────────────────────── @@ -460,8 +561,8 @@ export interface CloseConversationResponse { * 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; + /** The template text (may be empty — then no system prompt is sent). */ + readonly template: string; } /** @@ -472,7 +573,7 @@ export interface SystemPromptTemplateResponse { * conversations use the new template on their first turn. */ export interface SetSystemPromptTemplateRequest { - readonly template: string; + readonly template: string; } /** @@ -481,43 +582,39 @@ export interface SetSystemPromptTemplateRequest { * 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; + /** 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[]; + readonly variables: readonly SystemPromptVariable[]; } -// ─── Vision settings (global) ─────────────────────────────────────────────── +// ─── 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; + readonly imageLimit: number; + 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; + readonly imageLimit?: number; + readonly compactionModel?: string | null; } // ─── Message queue (steering) ───────────────────────────────────────────────── @@ -535,15 +632,18 @@ export interface SetVisionSettingsRequest { * works regardless of generation state. The resolved queue + whether a turn was * started are returned in `QueueResponse`. * - * `text` must be non-empty (after trim) → HTTP 400 `{ error }` otherwise. + * `text` must be a string; empty/whitespace text requires at least one image. + * Invalid bodies return HTTP 400 `{ error }`. */ 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; + readonly images?: readonly ImageInput[]; + readonly computerId?: 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; } /** @@ -555,9 +655,30 @@ export interface QueueRequest { * the chat channel as usual. */ export interface QueueResponse { - readonly conversationId: string; - readonly startedTurn: boolean; - readonly queue: readonly QueuedMessage[]; + readonly conversationId: string; + readonly startedTurn: boolean; + 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 ────────────────────────────────────────────── @@ -567,39 +688,39 @@ export type LspServerState = "connected" | "starting" | "error" | "not-started"; /** One language server's status as reported to the frontend. */ export interface LspServerInfo { - /** Stable server id, e.g. "typescript", "luau-lsp". */ - readonly id: string; - /** Human-readable display name. */ - readonly name: string; - /** The resolved workspace root the server is (or would be) rooted at (absolute). */ - readonly root: string; - /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */ - readonly extensions: readonly string[]; - /** Current connection state. */ - 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; + /** Stable server id, e.g. "typescript", "luau-lsp". */ + readonly id: string; + /** Human-readable display name. */ + readonly name: string; + /** The resolved workspace root the server is (or would be) rooted at (absolute). */ + readonly root: string; + /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */ + readonly extensions: readonly string[]; + /** Current connection state. */ + 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 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[]; + readonly conversationId: string; + /** + * 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 ────────────────────────────────────────────────────── @@ -608,29 +729,29 @@ export type McpServerState = "connecting" | "connected" | "error" | "disconnecte /** 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; + /** 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[]; + 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[]; } /** @@ -644,17 +765,17 @@ export interface McpStatusResponse { * prefix is byte-identical to a real turn (which is what makes the cache hit). */ export interface WarmRequest { - /** The conversation whose prompt cache to warm. */ - readonly conversationId: string; + /** The conversation whose prompt cache to warm. */ + readonly conversationId: string; - /** - * The model name in `<credentialName>/<model>` form the conversation uses, so - * the warm resolves the same provider + prefix. Omit to use the server default. - */ - readonly model?: string; + /** + * The model name in `<credentialName>/<model>` form the conversation uses, so + * the warm resolves the same provider + prefix. Omit to use the server default. + */ + readonly model?: string; - /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */ - readonly cwd?: string; + /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */ + readonly cwd?: string; } /** @@ -666,26 +787,26 @@ export interface WarmRequest { * server responds `409` with `{ error }` instead of this body. */ export interface WarmResponse { - readonly inputTokens: number; - readonly outputTokens: number; - readonly cacheReadTokens: number; - readonly cacheWriteTokens: number; - /** - * **Cache rate** — what fraction of THIS request's prompt was served from cache: - * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`). - * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].) - */ - readonly cachePct: number; - /** - * **Expected cache (retention)** — of the cacheable prefix this warm touched, how - * much was still warm and read back vs. had to be (re)written: - * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the - * sum is 0). For a healthy warm this is ~**100%** (the whole prefix was still - * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite - * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's - * tiny fresh probe makes ~equal, but which on a real turn reflects new content). - */ - readonly expectedCacheRate: number; + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens: number; + readonly cacheWriteTokens: number; + /** + * **Cache rate** — what fraction of THIS request's prompt was served from cache: + * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`). + * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].) + */ + readonly cachePct: number; + /** + * **Expected cache (retention)** — of the cacheable prefix this warm touched, how + * much was still warm and read back vs. had to be (re)written: + * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the + * sum is 0). For a healthy warm this is ~**100%** (the whole prefix was still + * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite + * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's + * tiny fresh probe makes ~equal, but which on a real turn reflects new content). + */ + readonly expectedCacheRate: number; } // ─── WebSocket chat ops ─────────────────────────────────────────────────────── @@ -700,7 +821,9 @@ export interface WarmResponse { * `AgentEvent`s (each carries `conversationId`). */ export interface ChatSendMessage extends ChatRequest { - readonly type: "chat.send"; + readonly type: "chat.send"; + /** Optional client correlation token, echoed by admission replies. Not an idempotency key. */ + readonly requestId?: string; } /** @@ -710,8 +833,25 @@ export interface ChatSendMessage extends ChatRequest { * carrier. */ export interface ChatDeltaMessage { - readonly type: "chat.delta"; - readonly event: AgentEvent; + readonly type: "chat.delta"; + readonly event: AgentEvent; +} + +/** + * Replay or a single live payload exceeded the WS delivery budget. No partial + * replay/placeholder payload is sent; live subscription remains active. The + * client must refetch committed history over HTTP and reconcile provisional + * content by turn/step identity (not append duplicates). While this turn is + * active, use a bounded single-flight history refresh (step-complete may precede + * persistence), and a final refresh on turn-ended including aborted/failed + * terminals, so omitted data committed after this notice is recovered. + * This is not a transport failure and must not trigger reconnect/resubscribe. + */ +export interface ChatResyncRequiredMessage { + readonly type: "chat.resync-required"; + readonly conversationId: string; + readonly turnId?: string; + readonly reason: "replay-limit" | "payload-limit"; } /** @@ -720,9 +860,39 @@ export interface ChatDeltaMessage { * `TurnErrorEvent` inside a `chat.delta`.) */ export interface ChatErrorMessage { - readonly type: "chat.error"; - readonly conversationId?: string; - readonly message: string; + readonly type: "chat.error"; + readonly conversationId?: string; + readonly requestId?: string; + readonly message: string; +} + +/** Sent to the sender only AFTER its opening message is stored or queue insertion completes. */ +export interface ChatAcceptedMessage { + readonly type: "chat.accepted"; + readonly conversationId: string; + readonly requestId: string; +} + +/** A rejected watcher does not disconnect the socket or reject chat input. */ +export interface ChatSubscriptionRejectedMessage { + readonly type: "chat.subscription-rejected"; + readonly conversationId: string; + readonly reason: "subscription-limit"; +} + +/** + * Accepted chat.send deferred behind compaction, sent only to its sender AFTER + * storage/insertion. Not a transcript event: remove the correlated optimistic + * opening message and display the authoritative message-queue surface instead. + * Live user-message/steering and history resync remain transcript-authoritative. + * Old clients safely ignore this additive message. Ordinary starts use chat.accepted. + */ +export interface ChatQueuedMessage { + readonly type: "chat.queued"; + readonly conversationId: string; + readonly reason: "compaction"; + readonly messageId: string; + readonly requestId?: string; } /** @@ -736,14 +906,17 @@ export interface ChatErrorMessage { * `turn-start`), then streams subsequent live events. If no turn is in-flight, * nothing is replayed (the client relies on `GET /conversations/:id` history). * A client infers "generating" from a replayed `turn-start` with no matching - * `done`/`turn-sealed` yet. Idempotent per `(connection, conversationId)`. + * `done`/`turn-sealed` yet. A bounded transport may replace the WHOLE replay + * with chat.resync-required; in that case its turnId identifies an active turn, + * and the client must recover committed content over HTTP (see that message). + * Idempotent per `(connection, conversationId)`. * - * NOTE: `chat.send` auto-subscribes the sending connection, so a client only needs - * `chat.subscribe` for conversations it is viewing but did not send to. + * NOTE: `chat.send` tries to auto-subscribe the sender within the shared watcher budget. + * Watcher rejection is nonfatal and never gates input admission. */ export interface ChatSubscribeMessage { - readonly type: "chat.subscribe"; - readonly conversationId: string; + readonly type: "chat.subscribe"; + readonly conversationId: string; } /** @@ -753,30 +926,99 @@ export interface ChatSubscribeMessage { * the socket closes — again WITHOUT aborting any in-flight turn. */ export interface ChatUnsubscribeMessage { - readonly type: "chat.unsubscribe"; - readonly conversationId: string; + readonly type: "chat.unsubscribe"; + readonly conversationId: string; } /** * Client → server: enqueue a message onto a conversation's message queue while * a turn is generating (steering). The WebSocket counterpart of the HTTP - * `POST /conversations/:id/queue` (`QueueRequest`). Fire-and-forget: success is - * confirmed by the message-queue SURFACE updating (the FE renders the queue - * from the surface, not from a reply here); a failure (malformed/empty text, + * `POST /conversations/:id/queue` (`QueueRequest`). With `requestId`, acceptance is + * confirmed by `chat.accepted` after queue insertion or opening-message storage. + * The queue surface remains authoritative for its current contents. Without a + * correlation token, legacy clients use surface/stream updates. A failure (malformed/empty text, * unknown conversation) arrives as a `chat.error`. When no turn is active, the * enqueue starts a new turn (the turn's events stream as `chat.delta`s), so a * client reuses this op for both "queue while generating" and "send" (the * latter being equivalent to `chat.send`). */ 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; + readonly requestId?: string; + readonly images?: readonly ImageInput[]; + readonly computerId?: string; + 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; +} + +/** + * Client → server: declare THIS connection's active workspace (plan 06 + * §6.4). Workspace switching is instant and client-side, so the server + * must be told explicitly — on receipt the server records the + * connection's workspace and RE-PUSHES the surface catalog filtered to + * that workspace (surfaces contributed by plugins that are not usable in + * the workspace are omitted). Clients that never send it keep the + * unfiltered (global) catalog — backward compatible. Owned by plan 06 + * (backend contract + server handling); plan 07 sends it from the client. + */ +export interface WorkspaceSetMessage { + readonly type: "workspace.set"; + readonly workspaceId: string; +} + +/** + * Broadcast to all connected WS clients after every successful layout write + * (plan 02 §2.5 — the `layout-chat` provider's PUT / setTree / DELETE / ops). + * Carries the FULL tree for ONE workspace (decision 7 — no patch protocol); + * the broadcast is a global fan-out and CLIENTS FILTER by their active + * workspace (the workspaceId is the payload, not a subscription). Additive to + * `WsServerMessage` — next to `extensions-changed`, a contract addition, not + * core layout knowledge (D-L6: no core extension or transport learns the + * layout domain; the transport ferries the message verbatim). + */ +export interface LayoutChangedMessage { + readonly type: "layout"; + readonly workspaceId: string; + readonly tree: LayoutTree; +} + +/** + * Broadcast to all connected WS clients after every successful home + * tile-layout write (the `home-tile-layout` plugin's PUT / DELETE). Carries + * the FULL document for ONE device orientation (`mode`: "wide" = landscape/ + * desktop, "tall" = portrait/mobile — two independent shared layouts); the + * broadcast is a global fan-out and CLIENTS FILTER by their active mode. + * Additive to `WsServerMessage`, the same verbatim-ferry pattern as + * `LayoutChangedMessage` — no core extension or transport learns the tile + * domain. + */ +export interface TileLayoutChangedMessage { + readonly type: "tile-layout"; + readonly mode: TileGeometryMode; + readonly data: TileLayoutData; } /** @@ -784,40 +1026,69 @@ export interface ChatQueueMessage { * ops. A server discriminates on `type`. */ export type WsClientMessage = - | SurfaceClientMessage - | ChatSendMessage - | ChatSubscribeMessage - | ChatUnsubscribeMessage - | ChatQueueMessage; + | SurfaceClientMessage + | ChatSendMessage + | ChatSubscribeMessage + | ChatUnsubscribeMessage + | ChatQueueMessage + | ChatQueueCancelMessage + | WorkspaceSetMessage; /** * Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat * ops. A client discriminates on `type`. */ export type WsServerMessage = - | SurfaceServerMessage - | ChatDeltaMessage - | ChatErrorMessage - | ConversationOpenMessage - | ConversationStatusChangedMessage - | ConversationCompactedMessage; + | SurfaceServerMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage + | ConversationOpenMessage + | ConversationTabGroupChangedMessage + | ConversationStatusChangedMessage + | ConversationCompactedMessage + | ExtensionsChangedMessage + | LayoutChangedMessage + | TileLayoutChangedMessage; // ─── Conversation list + metadata ──────────────────────────────────────────── +/** A visual grouping change, globally broadcast to every connected client. */ +export interface ConversationTabGroupChangedMessage { + readonly type: "conversation.tabGroupChanged"; + readonly conversationId: string; + /** The conversation's persisted workspace id. */ + readonly workspaceId: string; + /** The new EFFECTIVE tab group parent; null = ungrouped. */ + readonly tabGroupParentId: string | null; +} + /** * Broadcast to all connected WS clients when a conversation is "opened" (e.g. * via the CLI `--open` flag). The frontend decides whether to open/focus a tab * — the backend just signals. Additive to `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; + readonly type: "conversation.open"; + /** Effective visual group parent; absent = ungrouped. */ + readonly tabGroupParentId?: string; + 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; + /** + * The conversation's persisted per-conversation model when set (absent = + * default provider model), so a frontend can stamp the new tab with the + * conversation's ACTUAL model rather than the globally-selected one + * (fix-toolcall-duplicate-keys plan Part 4). + */ + readonly model?: string; } /** @@ -826,28 +1097,306 @@ export interface ConversationOpenMessage { * devices in real time. */ 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; + 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; } /** * Broadcast to all connected WS clients when a conversation's history has been - * compacted (summarized). The frontend should reload the conversation history - * via `GET /conversations/:id` to reflect the compacted state. + * compacted (summarized). `historyEpoch` is the persisted replacement count: + * missing storage value = 0; every successful replacement increments it once, + * including empty replacements. On a different epoch, clients invalidate their + * conversation cache and refetch `GET /conversations/:id?sinceSeq=0`. */ export interface ConversationCompactedMessage { - readonly type: "conversation.compacted"; - readonly conversationId: string; - readonly newConversationId: string; - readonly messagesSummarized: number; - readonly messagesKept: number; + readonly type: "conversation.compacted"; + readonly conversationId: string; + readonly newConversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; + readonly historyEpoch: number; +} + +// ─── Plugin manager (plan 05) ──────────────────────────────────────────────── +// +// The wire shapes for the plugin management API (HTTP routes + the +// `extensions-changed` broadcast). Tier/trust mirror the kernel's +// `PluginTier`/`TrustLevel` (stable three-value enums — the manager's +// manifest→row mapping is the drift detector: any kernel-side change fails +// the assignment there at compile time). + +/** Classification tier of a plugin (mirror of the kernel's `PluginTier`). */ +export type PluginTier = "core" | "providers" | "standard"; + +/** Trust level of a plugin (mirror of the kernel's `TrustLevel`). */ +export type PluginTrustLevel = "bundled" | "local" | "external"; + +/** How a persisted override came to be (absent in plan-04-era records). */ +export type PluginOverrideKind = "unloaded" | "disabled"; + +/** The persisted user override for a discovered plugin (wire shape). */ +export interface PluginOverrideInfo { + readonly kind?: PluginOverrideKind; + readonly reason?: string; + readonly disabledAt?: string; +} + +/** A plugin currently loaded and live — the manager's `loaded` table rows. */ +export interface LoadedPluginRow { + readonly id: string; + readonly name: string; + readonly version: string; + readonly tier: PluginTier; + readonly trust: PluginTrustLevel; + /** True for tier "core" — the server refuses to unload it. */ + readonly locked: boolean; + readonly dependsOn: readonly string[]; + /** + * The plugin's frontend section (plan 03), present when the loaded plugin + * declares one AND its entry file is servable (resolved inside the plugin + * directory). `apiVersion`/`entry` mirror the manifest; `bust` is the entry + * file's mtimeMs — the SAME value the backend loader uses as its `?v=` + * cache-bust query — so clients can build content-versioned import URLs. + */ + readonly frontend?: LoadedPluginFrontend; +} + +/** The wire shape of a servable frontend section (plan 03). */ +export interface LoadedPluginFrontend { + readonly apiVersion: string; + readonly entry: string; + readonly bust: string; +} + +/** A plugin the system refused or failed to load — disabled-with-reason. */ +export interface DisabledWithReasonRow { + readonly id: string; + readonly name: string; + readonly version: string; + readonly tier: PluginTier; + readonly trust: PluginTrustLevel; + readonly reason: string; +} + +/** A plugin present on disk but not loaded — the manager's second table. */ +export interface DiscoveredPluginRow { + readonly id: string; + readonly name: string; + readonly version: string; + readonly tier: PluginTier; + readonly trust: PluginTrustLevel; + /** Absolute plugin directory. */ + readonly dir: string; + /** + * The persisted user override, if any. Absent = a fresh discovery the + * consent gate renders UNCHECKED; present rows distinguish a deliberate + * unload (`kind: "unloaded"`) from an explicit disable (`"disabled"`). + */ + readonly override?: PluginOverrideInfo; +} + +/** An override record whose plugin directory is gone — a greyed-out row. */ +export interface MissingPluginRow { + readonly id: string; + readonly reason?: string; + readonly disabledAt?: string; +} + +/** Response for `GET /plugins` — the two manager tables + the missing strip. */ +export interface PluginListResponse { + readonly loaded: readonly LoadedPluginRow[]; + readonly disabledWithReason: readonly DisabledWithReasonRow[]; + readonly discovered: readonly DiscoveredPluginRow[]; + readonly missing: readonly MissingPluginRow[]; +} + +/** One directory that failed scan validation (manifest missing/unparsable/invalid). */ +export interface InvalidPluginDir { + readonly dir: string; + readonly errors: readonly string[]; +} + +/** One directory whose (valid) manifest is incompatible with the kernel. */ +export interface IncompatiblePluginDir { + readonly dir: string; + readonly id: string; + readonly reason: string; +} + +/** + * Response for `POST /plugins/scan` — the manual scan's product (§5): newly + * discovered plugins are UNCHECKED (the consent gate — the scan never + * auto-loads), broken directories report their validation errors. + */ +export interface PluginScanResponse { + /** The plugins root that was scanned. */ + readonly root: string; + /** Fresh discoveries: loadable, not loaded, no override. */ + readonly newPlugins: readonly DiscoveredPluginRow[]; + /** Directories that failed manifest/entry validation. */ + readonly invalid: readonly InvalidPluginDir[]; + /** Valid manifests incompatible with the running kernel. */ + readonly incompatible: readonly IncompatiblePluginDir[]; +} + +/** The mutation a plugin-management route performed. */ +export type PluginAction = "load" | "unload" | "reload" | "forget"; + +/** Why a plugin operation was refused (HTTP 409/404 — see the route docs). */ +export type PluginBlockReason = + /** Core plugins cannot be unloaded (never force-able). */ + | "core-locked" + /** Unloading would leave zero loaded providers (never force-able). */ + | "last-provider" + /** Loaded dependents exist — retry with `force` (the dependents are listed). */ + | "cascade-needs-force" + /** The target plugin is not currently loaded. */ + | "not-loaded" + /** No such plugin was found (no loadable directory with that id). */ + | "not-found" + /** The target is already loaded (load; use reload). */ + | "already-loaded" + /** The engine refused or failed the operation (reason in `message`). */ + | "engine-refused"; + +/** Successful plugin-management operation (HTTP 200). */ +export interface PluginOpSuccess { + readonly ok: true; + readonly action: PluginAction; + readonly id: string; + /** Forced unload: the dependents that were unloaded before the target. */ + readonly cascade?: readonly string[]; + /** Forced reload: dependents unloaded around the target (engine report). */ + readonly unloadedDependents?: readonly string[]; + /** Forced reload: dependents reloaded after the target (engine report). */ + readonly reloadedDependents?: readonly string[]; + /** Whether a saved override record was removed (load / reload / forget). */ + readonly clearedOverride?: boolean; + /** Forced ops stop ALL agents first — how many in-flight turns aborted. */ + readonly stoppedAgents?: number; +} + +/** Refused plugin-management operation (HTTP 404 / 409). */ +export interface PluginOpBlocked { + readonly ok: false; + readonly action: PluginAction; + readonly id: string; + readonly reason: PluginBlockReason; + /** Human-readable reason (server-log grammar) for the UI to surface. */ + readonly message: string; + /** For `cascade-needs-force`: the loaded dependents a force would remove. */ + readonly dependents?: readonly string[]; +} + +/** Response for the plugin-management mutation routes. */ +export type PluginOpResponse = PluginOpSuccess | PluginOpBlocked; + +/** + * Broadcast to ALL connected WS clients after every plugin-manager mutation + * (load / unload / reload / scan / forget) so every connected frontend + * refetches the extension list AND the surface catalog (§11 multi-client + * coherence). The payload is intentionally minimal — the client refreshes + * from `GET /plugins` + the surface catalog rather than diffing here. + */ +export interface ExtensionsChangedMessage { + readonly type: "extensions-changed"; + readonly action: "load" | "unload" | "reload" | "scan" | "forget"; + /** The plugin the action applied to (absent for a scan). */ + readonly pluginId?: string; +} + +// ─── Per-workspace plugin enablement (plan 06 §6.6) ───────────────────────── +// +// The workspace modal's read+write contract for "is this plugin used HERE". +// The routes are thin shells over the workspace-plugin-config core service; +// the state-transition + validation logic lives THERE (pure, unit-tested). + +/** + * The wire form of a per-workspace plugin state: `"enabled"` / `"disabled"` + * — an explicit record — or the string `"undefined"` for NO record (the + * blank slate, §7). The triple is spelled out on the wire (rather than + * omitting the field) so a client can render all three states uniformly. + */ +export type WorkspacePluginStateValue = "enabled" | "disabled" | "undefined"; + +/** + * One row of `GET /workspaces/:id/plugins` — a plugin's effective state in + * the workspace plus the metadata the modal needs (unloaded → greyed; + * core → locked). + */ +export interface WorkspacePluginRow { + readonly pluginId: string; + readonly tier: PluginTier; + readonly state: WorkspacePluginStateValue; + /** Whether the plugin is currently loaded globally (plan 05). */ + readonly loaded: boolean; + /** True for tier "core" — the per-workspace state cannot be changed. */ + readonly locked: boolean; +} + +/** + * Response for `GET /workspaces/:id/plugins` — this workspace's effective + * plugin states. READING triggers read-through materialization (§6.1): + * the first read snapshots inherited records from the default workspace + * (Core → `enabled` always). `enabledProviderCount` is folded in for + * convenience; the dedicated `providers-status` route reports the same + * signal without parsing the full list. + */ +export interface WorkspacePluginsResponse { + readonly workspaceId: string; + readonly plugins: readonly WorkspacePluginRow[]; + /** Loaded provider-tier plugins effectively enabled here (§6.5 guardrail). */ + readonly enabledProviderCount: number; +} + +/** + * Request body for `PUT /workspaces/:id/plugins/:pluginId` — set/clear + * this workspace's state for one plugin. `"unset"` returns it to the + * blank slate (no record). The server refuses (`core-locked` / + * `unloaded`, HTTP 409) changes for CORE plugins and for plugins that are + * not currently loaded — strict layering: only the global plugin-manager + * loads code (§7). + */ +export interface SetWorkspacePluginStateRequest { + readonly state: "enabled" | "disabled" | "unset"; +} + +/** Response for `PUT /workspaces/:id/plugins/:pluginId` (HTTP 200). */ +export interface SetWorkspacePluginStateResponse { + readonly workspaceId: string; + readonly pluginId: string; + /** The resulting effective state (`"undefined"` after `"unset"`). */ + readonly state: WorkspacePluginStateValue; +} + +/** Why a `PUT /workspaces/:id/plugins/:pluginId` was refused (HTTP 409). */ +export type SetWorkspacePluginStateBlockReason = "core-locked" | "unloaded"; + +/** Refused per-workspace state change (HTTP 409). */ +export interface SetWorkspacePluginStateBlocked { + readonly workspaceId: string; + readonly pluginId: string; + readonly reason: SetWorkspacePluginStateBlockReason; + /** Human-readable reason (server-log grammar) for the UI to surface. */ + readonly message: string; +} + +/** Response for `GET /workspaces/:id/plugins/providers-status` (§6.5). */ +export interface WorkspacePluginProvidersStatusResponse { + readonly workspaceId: string; + /** + * Loaded provider-tier plugins effectively `enabled` in this workspace. + * `0` is the first-boot guardrail signal (§7): show a visible "no + * providers enabled" prompt instead of a silently empty model picker. + */ + readonly enabledProviderCount: number; } /** @@ -857,7 +1406,7 @@ export interface ConversationCompactedMessage { * Optional `?q=` query param filters by id prefix (short-id resolution). */ export interface ConversationListResponse { - readonly conversations: readonly ConversationMeta[]; + readonly conversations: readonly ConversationMeta[]; } /** @@ -867,9 +1416,9 @@ export interface ConversationListResponse { * `turnId` is the turn that produced the message (absent if no turn ran). */ export interface LastMessageResponse { - readonly conversationId: string; - readonly content: string; - readonly turnId?: string; + readonly conversationId: string; + readonly content: string; + readonly turnId?: string; } /** @@ -877,22 +1426,33 @@ export interface LastMessageResponse { * signal was broadcast to connected WS clients. */ export interface OpenConversationResponse { - readonly conversationId: string; + readonly conversationId: string; +} + +/** Body for `PUT /conversations/:id/tab-group`. null = remove from its group (ungroup). */ +export interface SetTabGroupRequest { + readonly parentId: string | null; +} + +/** Response for `PUT /conversations/:id/tab-group` — the new EFFECTIVE value. */ +export interface TabGroupResponse { + readonly conversationId: string; + readonly tabGroupParentId: string | null; } /** * Request body for `PUT /conversations/:id/title` — set a human-readable title. */ export interface SetTitleRequest { - readonly title: string; + readonly title: string; } /** * Response for `GET/PUT /conversations/:id/title` — the current title. */ export interface TitleResponse { - readonly conversationId: string; - readonly title: string; + readonly conversationId: string; + readonly title: string; } /** @@ -900,10 +1460,10 @@ export interface TitleResponse { * history was compacted (old messages summarized, recent messages retained). */ export interface CompactResponse { - readonly conversationId: string; - readonly newConversationId: string; - readonly messagesSummarized: number; - readonly messagesKept: number; + readonly conversationId: string; + readonly newConversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; } /** @@ -911,15 +1471,15 @@ export interface CompactResponse { * at which automatic compaction triggers (0 = manual only). */ export interface CompactPercentResponse { - readonly conversationId: string; - readonly threshold: number; + readonly conversationId: string; + readonly threshold: number; } /** * Request body for `PUT /conversations/:id/compact-percent`. */ export interface SetCompactPercentRequest { - readonly threshold: number; + readonly threshold: number; } // ─── Workspaces ─────────────────────────────────────────────────────────────── @@ -930,28 +1490,33 @@ export interface SetCompactPercentRequest { * 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; + /** 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. */ +/** + * Response of `GET /workspaces` — the workspaces sorted by `lastActivityAt` + * desc. The optional `?archived=` query selects which workspaces are included: + * `"true"` → archived only, `"all"` → every workspace; absent (or any other + * value) → non-archived only (the home grid's default view). + */ export interface WorkspaceListResponse { - readonly workspaces: readonly WorkspaceEntry[]; + readonly workspaces: readonly WorkspaceEntry[]; } /** Body of `PUT /workspaces/:id/title` — rename (display only; id unchanged). */ export interface SetWorkspaceTitleRequest { - readonly title: string; + readonly title: string; } /** Body of `PUT /workspaces/:id/default-cwd` — set or clear the default cwd. */ export interface SetWorkspaceDefaultCwdRequest { - readonly defaultCwd: string | null; + readonly defaultCwd: string | null; } /** @@ -960,12 +1525,12 @@ export interface SetWorkspaceDefaultCwdRequest { * 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; + readonly workspaceId: string; + /** Conversations that were closed (status → "closed") by this delete. */ + readonly closedCount: number; } -// ─── Computers (SSH handoff #2) ───────────────────────────────────────────── +// ─── Computers ─────────────────────────────────────────────────────────────── /** * Response of `GET /computers` — every remote computer discovered from the @@ -975,7 +1540,7 @@ export interface DeleteWorkspaceResponse { * block to `~/.ssh/config` and Dispatch discovers it on the next read. */ export interface ComputerListResponse { - readonly computers: readonly ComputerEntry[]; + readonly computers: readonly ComputerEntry[]; } /** @@ -992,10 +1557,10 @@ export interface ComputerResponse extends Computer {} * `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; + readonly alias: string; + readonly state: "disconnected" | "connecting" | "connected" | "error"; + readonly error?: string; + readonly knownHost: boolean; } /** @@ -1007,7 +1572,7 @@ export interface ComputerStatusResponse { * a 400). Mirrors the cwd/model PUT clear semantics. */ export interface SetConversationComputerRequest { - readonly computerId: string | null; + readonly computerId: string | null; } /** @@ -1016,8 +1581,8 @@ export interface SetConversationComputerRequest { * the workspace default → local). Parallel to `CwdResponse`. */ export interface ConversationComputerResponse { - readonly conversationId: string; - readonly computerId: string | null; + readonly conversationId: string; + readonly computerId: string | null; } /** @@ -1027,7 +1592,7 @@ export interface ConversationComputerResponse { * `computerId` of their own inherit this. */ export interface SetWorkspaceDefaultComputerRequest { - readonly computerId: string | null; + readonly computerId: string | null; } /** @@ -1037,25 +1602,129 @@ export interface SetWorkspaceDefaultComputerRequest { * failure reason (e.g. auth refused, host unreachable) when `ok` is false. */ export interface TestComputerResponse { - readonly alias: string; - readonly ok: boolean; - readonly error?: string; + readonly alias: string; + readonly ok: boolean; + readonly error?: string; +} + +// ─── Heartbeat ─────────────────────────────────────────────────────────────── + +/** + * The per-workspace Heartbeat config — a single record stored per workspace. + * + * A heartbeat is an AI that runs on a configurable loop per workspace: when + * `enabled`, the backend summons a NEW conversation every `intervalMinutes` + * minutes, gives the heartbeat AI `systemPrompt` + sends `taskPrompt` as the + * opening user message, and inherits the workspace's computer (SSH) + cwd + + * standard tool kit. The main purpose is monitoring (e.g. checking if chats + * are stuck). + * + * `model` is a model name in `<credentialName>/<model>` form (one of the strings + * from `GET /models`), or the empty string to use the server default. + * `reasoningEffort` is `null` to inherit the workspace default, or an explicit + * level. The scheduler resets its timer after each run completes (not a fixed + * wall-clock schedule); on backend restart it resumes scheduling for enabled + * heartbeats. + * + * `inactiveOnly` (default `true`) gates each fire on the configured workspace + * having NO active agents (no conversation with status `"active"` or `"queued"`): + * the heartbeat stays quiet while the user is actively working, and only fires + * when the workspace is idle. Set `false` to fire unconditionally. + */ +export interface HeartbeatConfig { + /** Whether the heartbeat loop is active for this workspace. */ + readonly enabled: boolean; + /** + * When `true` (the default), the heartbeat SKIPS a fire when the configured + * workspace has any active agents — conversations whose persisted status is + * `"active"` (driving a turn) or `"queued"` (waiting on the message queue). + * The fire is silently skipped (no run is recorded); the scheduler re-arms + * and tries again at the next interval. When `false`, the heartbeat fires + * unconditionally regardless of workspace activity. The spawned heartbeat + * conversation lives in the DEDICATED heartbeat workspace, so it never + * counts as an "active agent" of the configured workspace (no self-block). + */ + readonly inactiveOnly: boolean; + /** Custom system prompt for the heartbeat AI (empty = no system prompt). */ + readonly systemPrompt: string; + /** Task prompt sent as the first user message when the heartbeat fires. */ + readonly taskPrompt: string; + /** How often to fire, in minutes (default 30). */ + readonly intervalMinutes: number; + /** Model name (`<credentialName>/<model>`), or empty string = server default. */ + readonly model: string; + /** Reasoning-effort level, or `null` = inherit the workspace default. */ + readonly reasoningEffort: ReasoningEffort | null; +} + +/** + * Body of `PUT /workspaces/:id/heartbeat` — a partial update. All fields are + * optional; only the provided fields are applied. `reasoningEffort` accepts + * `null` to clear to the workspace default. `intervalMinutes` must be a positive + * integer (clamped server-side to a minimum of 1). An unrecognized + * `reasoningEffort` → HTTP 400. + */ +export interface UpdateHeartbeatRequest { + readonly enabled?: boolean; + readonly inactiveOnly?: boolean; + readonly systemPrompt?: string; + readonly taskPrompt?: string; + readonly intervalMinutes?: number; + readonly model?: string; + readonly reasoningEffort?: ReasoningEffort | null; +} + +/** The status of a single heartbeat run. */ +export type HeartbeatRunStatus = "running" | "completed" | "stopped"; + +/** + * One heartbeat run — created each time the heartbeat fires. A run tracks the + * conversation it spawned and its lifecycle. `triggeredAt` is an ISO-8601 + * timestamp. `status` is `"running"` while the turn is in flight, + * `"completed"` when the turn sealed normally, or `"stopped"` when the user + * stopped it via the stop endpoint (the turn is aborted and seals `"stopped"`). + */ +export interface HeartbeatRun { + readonly id: string; + readonly conversationId: string; + readonly triggeredAt: string; + readonly status: HeartbeatRunStatus; } -// ── Provider concurrency limits ([email protected]) ─────────────────── +/** Response of `GET /workspaces/:id/heartbeat/runs` — runs, most-recent first. */ +export interface HeartbeatRunsResponse { + readonly runs: readonly HeartbeatRun[]; +} + +/** Response of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */ +export interface StopHeartbeatRunResponse { + readonly ok: true; +} + +// ─── Search backend selection ───────────────────────────────────────────────── + +/** + * Re-exported wire types for the global search-backend selection + * (`GET/PUT /search/config`). The web frontend consumes these directly. + */ +export type { + SearchBackendAvailability, + SearchBackendConfigResponse, + UpdateSearchConfigRequest, +} from "@dispatch/wire"; + +// ─── Provider concurrency limits ────────────────────────────────────────────── /** * 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 + * "opencode-1") 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; - }[]; + 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 @@ -1063,13 +1732,15 @@ export interface ConcurrencyLimitsResponse { * sent immediately. */ export interface SetConcurrencyLimitRequest { - readonly limit: number; + readonly limit: number; } + /** Response of `GET/PUT /concurrency/limits/:providerId` — the configured limit. */ export interface ConcurrencyLimitResponse { - readonly providerId: string; - readonly limit: number; + readonly providerId: string; + readonly limit: number; } + /** * One provider's live concurrency status. * @@ -1090,23 +1761,24 @@ export interface ConcurrencyLimitResponse { * 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; + 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[]; + readonly providers: readonly ConcurrencyStatusEntry[]; } // ─── Provider concurrency cooldown ──────────────────────────────────────────── @@ -1118,8 +1790,8 @@ export interface ConcurrencyStatusResponse { * When no cooldown was explicitly set, the server default (350ms) is returned. */ export interface ConcurrencyCooldownResponse { - readonly providerId: string; - readonly cooldownMs: number; + readonly providerId: string; + readonly cooldownMs: number; } /** @@ -1129,7 +1801,152 @@ export interface ConcurrencyCooldownResponse { * subsequently recycled slots. */ export interface SetConcurrencyCooldownRequest { - readonly cooldownMs: number; + readonly cooldownMs: number; } -``` +// ─── Account usage (Claude / Codex subscription quotas) ─────────────────────── +// +// Subscription quota usage polled from each provider's first-party account +// API (Claude `GET /api/oauth/usage`, Codex `GET /backend-api/wham/usage`), +// cached server-side. Distinct from per-turn token `Usage`: these are the +// account's plan allowance windows, never estimated from token counts. + +/** + * The subscription accounts the usage service reports on. `hyper` is a daily + * Hypercredit allowance (Hyper's API reports only the balance — the allowance + * and reset time are user settings, see `AccountUsageDailyCredits`). + */ +export type AccountUsageProviderId = "claude" | "codex" | "hyper"; + +/** + * Freshness/availability of one provider's report: + * - `fresh` — the last refresh succeeded and is within the cache TTL. + * - `stale` — the latest refresh FAILED; `limits` are the last good + * report (≤ 24h old), see `fetchedAt` / `error`. + * - `unavailable` — refresh failed and there is no last-good report. + * - `unsupported` — a credential exists but cannot query subscription usage + * (e.g. a plain Anthropic API key). + * - `not-configured` — no credential / the auth extension is not loaded. + */ +export type AccountUsageState = + | "fresh" + | "stale" + | "unavailable" + | "unsupported" + | "not-configured"; + +/** Severity of a single limit: `warning` ≥ 90% used, `exhausted` ≥ 100%. */ +export type AccountUsageLimitStatus = "ok" | "warning" | "exhausted" | "unknown"; + +/** + * One quota meter. Percent meters have `unit: "percent"`, `limit: 100`. + * Spend meters (`kind: "spend"`, `unit: "usd"`) may have `limit: null` when the + * cap is unlimited/unset — never treat that as a zero-dollar cap. + */ +export interface AccountUsageLimit { + /** Stable id, e.g. `claude:5h`, `claude:7d:opus`, `codex:primary`, `codex:spark:secondary`. */ + readonly id: string; + /** Human label, e.g. "5 Hour", "7 Day (Opus)", "Spark · 5 hours". */ + readonly label: string; + readonly kind: "window" | "spend"; + /** Window id, e.g. `5h`, `7d`, `30d`, `extra`. */ + readonly windowId: string; + /** True for account-wide windows that gate every request. */ + readonly shared: boolean; + /** Model family / metered-feature slug for scoped meters, else null. */ + readonly tier: string | null; + /** Window length in ms (null when unknown, e.g. spend meters). */ + readonly durationMs: number | null; + /** Epoch-ms when the window resets (null when unknown). */ + readonly resetsAt: number | null; + /** `credits` = Hypercredits (`limit` = the daily allowance). */ + readonly unit: "percent" | "usd" | "credits"; + readonly used: number; + readonly limit: number | null; + readonly remaining: number | null; + /** 0..1 (null when there is no cap). */ + readonly usedFraction: number | null; + readonly remainingFraction: number | null; + readonly status: AccountUsageLimitStatus; +} + +/** Codex paid-credit details (from `/wham/usage` `credits` + `spend_control`). */ +export interface AccountUsageCredits { + readonly hasCredits: boolean | null; + readonly unlimited: boolean | null; + readonly overageLimitReached: boolean | null; + /** Remaining credit balance as reported (string or number upstream; normalized to number). */ + readonly balance: number | null; + readonly spendControlReached: boolean | null; +} + +/** One provider's usage report as served to clients. Never carries credentials. */ +export interface AccountUsageReport { + readonly provider: AccountUsageProviderId; + /** Display name: "Claude" / "Codex". */ + readonly label: string; + readonly state: AccountUsageState; + readonly account: { + readonly accountId: string | null; + readonly email: string | null; + readonly planType: string | null; + } | null; + readonly limits: readonly AccountUsageLimit[]; + /** + * Plan-level verdict (Codex): `limitReached` = plan allowance consumed; + * `canServe` = the account can still serve requests (credits may fund + * overage). Null when unknown (Claude reports per-window only). + */ + readonly limitReached: boolean | null; + readonly canServe: boolean | null; + /** True when Codex credits fund requests past an exhausted plan window. */ + readonly creditOverage: boolean; + readonly credits: AccountUsageCredits | null; + /** Daily credit allowance details (Hyper only; null for other providers). */ + readonly dailyCredits: AccountUsageDailyCredits | null; + /** Epoch-ms of the report's data (last successful fetch), null if never. */ + readonly fetchedAt: number | null; + /** Epoch-ms of the last refresh attempt (success or failure), null if never. */ + readonly lastAttemptAt: number | null; + /** Epoch-ms after which the server will refresh on the next request. */ + readonly nextRefreshAt: number | null; + /** Last refresh failure message (sanitized, no secrets), null on success. */ + readonly error: string | null; +} + +/** + * Daily credit allowance (Hyper). Hyper's `GET /v1/credits` returns only the + * balance, so `allowance` (default 250) and the reset time are settings: + * - `resetAnchor`: an epoch-ms instant of ANY past or future refresh; resets + * recur every 24h from it (`resetsAt` on the limit is the next one). + * - `resetSource`: `manual` (user-set), `detected` (inferred from an observed + * balance refill — approximate, ± the poll interval), or null (unknown ⇒ no + * `resetsAt`, so no pace marker). + */ +export interface AccountUsageDailyCredits { + /** Current balance as reported (may exceed `allowance` with purchased bundles). */ + readonly balance: number; + readonly allowance: number; + readonly resetAnchor: number | null; + readonly resetSource: "manual" | "detected" | null; +} + +/** + * Body of `PUT /usage/:provider/settings` (only `hyper` has settings). Omitted + * fields are unchanged. `dailyAllowance` must be a positive integer; + * `resetAnchor` is an epoch-ms refresh instant, or `null` to clear the manual + * time (falling back to a detected one, if any). Responds with the provider's + * refreshed `AccountUsageReport`; 400 invalid body, 404 provider without settings. + */ +export interface UpdateAccountUsageSettingsRequest { + readonly dailyAllowance?: number; + readonly resetAnchor?: number | null; +} + +/** Response of `GET /usage` — one report per provider, in fixed order (claude, codex, hyper). */ +export interface AccountUsageResponse { + readonly providers: readonly AccountUsageReport[]; + /** Server clock (epoch-ms) when this response was built — use it to compute window progress. */ + readonly servedAt: number; +} +``` diff --git a/.dispatch/ui-contract.reference.md b/.dispatch/ui-contract.reference.md index d751af8..15bb785 100644 --- a/.dispatch/ui-contract.reference.md +++ b/.dispatch/ui-contract.reference.md @@ -8,6 +8,13 @@ > **Orchestrator:** this is a SNAPSHOT of `[email protected]` — regenerate it whenever > `ui-contract` changes. > +> **2026-09-03 delta (plugin system — plan 06):** `SurfaceCatalogEntry` gains an OPTIONAL +> `pluginId?: string` — the id of the plugin contributing the surface (plan 06's +> surface→plugin attribution for the per-workspace catalog filter). Absent on older +> backends / host-contributed surfaces — treat as "no plugin" (never filtered). The +> FE does not branch on it yet (the catalog is server-filtered via `workspace.set` +> + `extensions-changed` re-pushes); it is available for future attribution UI. +> > **2026-06-12 delta (CR-2/CR-4 handoff — package bumped `0.1.0` → `0.2.0`):** adds the optional > `scope?: "global" | "conversation"` to `SurfaceCatalogEntry` so a client can skip re-subscribing > GLOBAL surfaces on a conversation switch. ABSENT means assume conversation-scoped (the diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index b430a45..777a2ba 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -1,11 +1,50 @@ # `@dispatch/wire` — in-repo reference (read THIS, not node_modules) +> **2026-10-22 delta (reasoning block boundaries — ADDITIVE, no version bump):** `AgentEvent` +> gains `TurnReasoningEndEvent` `{ type: "reasoning-end", conversationId, turnId }`. The kernel emits +> one per provider reasoning block (Anthropic signs each thinking block separately), carrying NO +> opaque payload (signature/redacted data never leave the server). A live consumer splits thinking +> exactly as HTTP history persists it: the open thinking chunk closes; with none open (a redacted +> empty block) an empty thinking chunk is implied. Providers without block structure never emit it. +> The FE reducer mirrors the kernel's chunk builder; the old merged-run alignment stays as a fallback +> for older servers/replays. See `backend-handoff.md` and the backend's +> `notes/reasoning-end-live-event-plan.md`. +> +> **2026-10-20 review 3.5:** history replacements preserve monotonic seqs (retained history need not start at 1). HTTP history and WS compaction carry required `historyEpoch`; unknown cached epochs must be reloaded. Consumed by review 3.6. +> > MIRRORS the backend's `@dispatch/wire` package source so headless FE agents can read the wire > 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]` (workspaces + computers + provider-retry + concurrency-`queued` status). Regenerate whenever `@dispatch/wire` changes. > +> **2026-09-20 delta (workspace archiving — ADDITIVE to `[email protected]`, NO version bump):** `Workspace` gains a +> required `archived: boolean` (defaults to `false` on creation; legacy rows normalize to `false`). An archived +> workspace is HIDDEN from the home grid WITHOUT deleting its data (conversations/settings untouched) and can be +> unarchived at any time. Toggled via dedicated `PUT`/`DELETE /workspaces/:id/archive` endpoints (no body; both +> create-on-miss and return the updated `Workspace`; both reject `"default"` with 409). `GET /workspaces` takes an +> optional `?archived=` query: `true` → archived only, `all` → every workspace, absent/other → non-archived only +> (the home grid's default). `PUT /workspaces/:id` does NOT accept an `archived` field. Implemented in BOTH repos +> of the `feature/archive-workspaces` worktree (see `backend-handoff.md`). +> +> **2026-08-25 delta (per-conversation model on ConversationMeta — ADDITIVE, no version bump):** +> `ConversationMeta` gains an optional `model?: string` (`<credential>/<model>`, absENT = default +> provider model). Surfaced on `GET /conversations` and the `conversation.open` WS broadcast so the +> FE can stamp a tab with the conversation's ACTUAL model instead of the globally-selected one +> (fix-toolcall-duplicate-keys plan Part 4 — prevents the stored model being overwritten by +> `activeModel`). See `transport-contract.reference.md` for the `ConversationOpenMessage.model` +> addition. +> +> **2026-08-20 delta (Named Agents — ADDITIVE, no version bump):** `@dispatch/wire` now exports two +> named-agent shapes the FE consumes (`named-agents-plan.md`): `NamedAgent` +> `{ id, name, model }` (a user-curated model preset; `model` = canonical `<provider>/<model>`) and +> `NamedAgentSelectionSnapshot` `{ namedAgentId, namedAgentName, model }` (the snapshot a conversation +> retains when selected via a Named Agent — plan §7). These mirror the backend's `packages/named-agents`. +> The FE re-exports them from this package (`features/named-agents/logic/types.ts`) rather than +> duplicating. Transport-contract also carries the `/named-agents` HTTP request/response types + +> `ChatRequest.namedAgent` / `ModelResponse.namedAgent` / `SetModelRequest.namedAgent` (see +> `transport-contract.reference.md`). +> > **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 @@ -231,26 +270,37 @@ export interface ChatMessage { /** * A persisted chunk plus its sync metadata. The append-only conversation log - * stamps every chunk with a **1-based**, monotonic, gap-free, per-conversation + * stamps every chunk with a positive, monotonic, per-conversation * `seq` (the sync cursor, assigned in append order) and records the `role` of * the message it belongs to. This makes a flat seq-ordered stream both * incrementally syncable ("give me chunks after seq N") and regroupable into * messages by the client. * - * The 1-based start is a CONTRACTUAL GUARANTEE (not an implementation detail): - * a conversation's first chunk is always `seq === 1` and numbering never skips, - * so a client holding only a windowed suffix of the log can derive "older - * chunks exist server-side" purely from `oldestLoaded.seq > 1` — no separate - * has-older flag is needed (or provided). `chunk` is the content unit — `Chunk` carries no storage/sync cursor + * Seq is never reused within a conversation, including after compaction replaces + * its history. The retained log need not start at 1 or be gap-free; a client + * cannot infer that older chunks exist solely from `oldestLoaded.seq > 1`. + * `chunk` is the content unit — `Chunk` carries no storage/sync cursor * (`seq` lives here on the envelope, not on the chunk, since it is assigned by * the store and the provider has no use for it). A chunk MAY still carry * generation provenance assigned at production time (e.g. a tool chunk's * `stepId`), which is intrinsic to the content and so travels with it. */ export interface StoredChunk { - readonly seq: number; - readonly role: Role; - readonly chunk: Chunk; + readonly seq: number; + readonly role: Role; + readonly chunk: Chunk; + /** + * The turn that produced this chunk — the same `turnId` carried on every + * live `AgentEvent` of that turn (the turn's opening user message, any + * mid-turn steering message, and every assistant/tool chunk the turn + * generated). The join key between transcript content and the turn's + * `TurnMetrics`, so a client places per-turn metrics EXACTLY (no positional + * guessing) — including while a turn is in flight and when older history has + * been unloaded. Optional: absent on rows persisted before this field + * existed and on chunks written outside any turn (e.g. a compaction summary). + * A consumer must tolerate its absence. + */ + readonly turnId?: string; } // ─── Reasoning effort ─────────────────────────────────────────────────────── @@ -374,6 +424,7 @@ export type AgentEvent = | TurnInputEvent | TurnTextDeltaEvent | TurnReasoningDeltaEvent + | TurnReasoningEndEvent | TurnToolCallEvent | TurnToolResultEvent | TurnToolOutputEvent @@ -434,6 +485,21 @@ export interface TurnReasoningDeltaEvent { readonly delta: string; } +/** + * End of one reasoning / thinking block. Emitted once per block by providers with + * block-structured reasoning (e.g. Anthropic, whose consecutive thinking blocks — each + * separately signed — persist as separate thinking chunks). Lets a live consumer split + * thinking exactly as history does: the open thinking chunk closes; if none is open (a + * redacted/empty block with no deltas) an EMPTY thinking chunk is implied. Carries no + * content: the opaque replay payloads (signature / redacted data) never leave the + * server. Providers without block structure never emit it (deltas merge as before). + */ +export interface TurnReasoningEndEvent { + readonly type: "reasoning-end"; + readonly conversationId: string; + readonly turnId: string; +} + /** The model has requested a tool to be run. */ export interface TurnToolCallEvent { readonly type: "tool-call"; @@ -670,6 +736,15 @@ export interface ConversationMeta { * that have never been compacted. */ readonly compactedFrom?: string; + /** + * The persisted per-conversation model name (`<credential>/<model>`), when + * one has been set. Absent/null means the conversation uses the default + * provider model. Surfaced on the open-conversation list + `conversation.open` + * broadcast so a client can stamp a tab with the conversation's ACTUAL model + * instead of the currently-selected global model (fix-toolcall-duplicate-keys + * plan Part 4). + */ + readonly model?: string; } // ─── Compaction ────────────────────────────────────────────────────────────── @@ -722,6 +797,14 @@ export interface Workspace { * Defaults to `false` on creation. */ readonly starred: boolean; + /** + * Whether the workspace is archived by the user. Archived workspaces are + * HIDDEN from the home grid (`GET /workspaces` excludes them by default; + * `?archived=` opts back in) without deleting their data — the workspace, + * its conversations, and its settings are untouched, and it can be + * unarchived at any time. Defaults to `false` on creation. + */ + readonly archived: boolean; /** Epoch-ms when the workspace was first created. */ readonly createdAt: number; /** Epoch-ms of the most recent conversation activity in this workspace. */ @@ -778,3 +861,33 @@ export interface ComputerEntry extends Computer { readonly usageCount: number; } ``` + +--- + +## 2026-09-24 delta (search-backend-selection — ADDITIVE) + +The global search-backend selection wire types (`GET/PUT /search/config`, GLOBAL — not +workspace-scoped) shipped in `@dispatch/wire` (transport-contract re-exports them): + +```ts +/** The selected backend id. `"none"` = search off (scrape/crawl keep working). */ +export type SearchBackendId = "firecrawl" | "umans" | "none"; + +/** One dropdown entry; `configured` is the availability gate (umans: UMANS_API_KEY resolves; firecrawl: always). */ +export interface SearchBackendAvailability { + readonly id: string; + readonly label: string; + readonly configured: boolean; +} + +/** Response of `GET /search/config` — the selection + every registered backend's availability. */ +export interface SearchBackendConfigResponse { + readonly backend: string; + readonly available: readonly SearchBackendAvailability[]; +} + +/** Body of `PUT /search/config` — select a backend by id (or `"none"`). */ +export interface UpdateSearchConfigRequest { + readonly backend: string; +} +``` @@ -145,6 +145,16 @@ reasoning-effort selector (Model view, under the provider/model dropdowns; stick `GET`/`PUT /reasoning-effort`, `null` ⇒ "high (default)"). Plan in `../backend/notes/frontend-design.md` §10. +NAMED AGENTS DONE + committed (feature/named-agents): a collapsible **Named Agents** sidebar view +(cards with name/provider/model controls, auto-save statuses incl. debounced name edits + the +monotonic stale-response guard, Remove/Add/empty state), the synthetic **Named Agents** provider as +the ALWAYS-FIRST option in the regular model selector (selecting one resolves it to its raw model and +records a `NamedAgentSelectionSnapshot` on the conversation — plan §7), full CRUD over +`GET/POST/PUT/DELETE /named-agents`, and snapshot semantics retained on tabs (survive rename/remove + +persist to localStorage). Backend contract (this worktree) carries `NamedAgent`/`NamedAgentSelectionSnapshot` +(`@dispatch/wire`) + `/named-agents` types and `ChatRequest.namedAgent`/`ModelResponse.namedAgent`/ +`SetModelRequest.namedAgent` (`@dispatch/transport-contract`); see `backend-handoff.md`. + ## Reports Optionally record a finished milestone in `reports/<name>.md` (gitignored): what you built, the public surface, verification output, and any contract gaps / backend-contract CRs. diff --git a/GLOSSARY.md b/GLOSSARY.md index 4ca083c..70660d9 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -30,6 +30,8 @@ | **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 | +| **Named Agent** | A user-curated, persisted model preset: a friendly `name` bound to one canonical `<provider-or-credential>/<model>` identifier (`NamedAgent` = `{ id, name, model }`). NOT a running agent or conversation — it is configuration. Editable in a sidebar view; drives the synthetic "Named Agents" provider in the model picker + the `list_agent_models`/`summon_agent` agent tools. Global user config (not per-workspace). | preset, model shortcut, aliased model | +| **Named Agent snapshot** | The `NamedAgentSelectionSnapshot` (`{ namedAgentId, namedAgentName, model }`) a conversation retains when it was selected via a Named Agent. `model` is the raw provider/model the conversation uses; the id/name freeze HOW it was selected so later edits/removal of the shared Named Agent do NOT retroactively change an existing conversation (plan §7). Absent for a raw-model selection. | frozen selection, selection record | ## Frontend-specific | Term | Meaning | Aliases to avoid | @@ -41,6 +43,7 @@ | **surface catalog** | The list of available surfaces (metadata) the FE fetches to discover them (`GET /surfaces`). | capability manifest | | **view** | RESERVED — the old-Dispatch sidebar affordance (settings / feature views); a FUTURE FE concept, NOT a surface. | (do not reuse) | | **tab** | A FE workspace slot in the tab strip that *references* one open conversation — holds its `conversationId`, the selected **model name**, and a derived title. Distinct from the conversation itself (the backend thread): closing a tab forgets it LOCALLY (drops the slot + evicts its FE cache); the conversation persists server-side. Open tabs + the active tab are persisted locally. | (do not conflate with **conversation**) | +| **subagent group** / **tab group parent** | The tab grid's purely VISUAL nesting: a tab whose `tabGroupParentId` (backend-owned, EFFECTIVE value — an explicit user override, else the lineage `parentConversationId`) names another open tab renders inside that tab's subagent group (a darker box; the group parent's card is double-width). Users regroup via the tab menu (right-click / touch-and-hold) → `PUT /conversations/:id/tab-group`; synced by `conversation.tabGroupChanged`. Never affects lineage, manageability or agent behaviour. | (do not conflate with lineage **parentConversationId**) | | **feature module** | A self-contained FE feature (chat, history explorer, …); feature-as-a-library, composed at the root. | — | | **composition root** | The single place (`src/app/`) that imports + wires feature modules + the surface host. | — | | **surface interpreter** | The generic renderer: field kind → component. Knows kinds, never surface ids. | — | @@ -50,4 +53,5 @@ | **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) | +| **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 home grid renders starred workspaces with a tint (the display echo of their concurrency priority); the FE toggles optimistically with error revert. | favorited, pinned (and do NOT call it "priority" — priority is the EFFECT, starred is the FLAG) | +| **home tile** | One workspace's cell on the home page's Win8-Start-style **tile grid** (`features/workspaces`, branch `feature/win8-tiles-home`) — NOT the layout feature's nested split-tree tile (`features/layout` TileRoot). Rendered from an orientation-independent persisted layout (localStorage `dispatch.workspaces.tiles`: `{ version: 1, tiles: [{ id, colSpan, rowSpan, order }] }` — manual order + requested spans only, NEVER positions): `geometryFor(width, height)` picks WIDE (4 fixed ~120px rows, column-wise flow, 4×2 span cap) or TALL (2 columns, row-wise flow, 2×2 cap), and `pack` re-derives every position (the only order→coordinates transform). Spans render-clamped per geometry; the persisted span is untouched. Drag-to-reorder edits the ORDER; resize uses an insert-table picker; a tile's click opens the workspace, right-click/long-press selects it (action bar). | (do not conflate with the layout split-tree tile), card | @@ -0,0 +1,85 @@ +# Feature: local-image-markdown + +**Goal:** Agents can show the user images in chat purely via Markdown — `` or `file:///…` — with no tool call. The chat UI renders them inline. + +**Repos:** backend AND frontend (separate worktrees of the same feature branch `feature/local-image-markdown`; the API contract below is FIXED — do not change it unilaterally). + +**Out of scope / decisions already made:** +- **Raster images only** — png, jpg, jpeg, webp, gif, bmp. **SVG is intentionally excluded** (XSS vector via embedded scripts). +- No auth changes on the route (the API is unauthenticated today; the extension whitelist + magic-byte sniff bounds the exposure to actual image files). +- Stored custom system-prompt templates are NOT migrated — only `DEFAULT_TEMPLATE` changes. If a stored template exists it overrides the default; just note this in the summary, do not attempt migration. + +--- + +## Backend (packages: `transport-http`, `system-prompt`) + +### 1. New route `GET /local-image` in `packages/transport-http/src/app.ts` + +Place it next to the existing tmp-image route (`app.get("/images/:conversationId/:imageId", …)`), mirroring its style (dynamic `node:path` / `node:fs/promises` imports, same response shape). + +**Contract (pinned):** +- Query param: `path` — a URL-encoded absolute filesystem path to an image. + - Expand a leading `~` to the OS home dir (agents often write `~/…`). + - Reject with `400 { error: … }` if the decoded path is not absolute (after `~` expansion) or is empty. +- Extension whitelist (lowercase, after decode): `.png .jpg .jpeg .webp .gif .bmp` → MIME map (`image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/bmp`). Any other extension → `400`. +- **Magic-byte sniff** before serving (cheap header check per format, e.g. PNG `89 50 4E 47`, JPEG `FF D8 FF`, GIF `GIF8`, WEBP `RIFF…WEBP`, BMP `BM`). Mismatch → `400`. This guarantees the endpoint can never serve a non-image file regardless of extension. +- Read failure (missing/unreadable) → `404 { error: "Image not found" }` (match the existing route's shape). +- Success: `Response(buf, { headers: { "Content-Type": <mime>, "Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff" } })`. +- The endpoint intentionally serves ANY absolute image path on the machine — that is the feature (the agent already has shell/read access to the same machine). Do not add a root allowlist. + +**Tests:** follow the existing transport-http app test conventions — 200 + correct MIME for a real temp image file per format, 400 for non-image extension, 400 for magic-byte mismatch (e.g. PNG bytes named `.png` vs text bytes named `.png`), 400 for relative path, 404 for missing file, `~` expansion. + +### 2. System-prompt default template — `packages/system-prompt/src/service.ts` + +Add to `DEFAULT_TEMPLATE` (after the cwd line) a paragraph telling agents the capability, e.g.: + +``` +You can show the user images directly in chat by embedding them in your Markdown +replies, e.g. . Local absolute paths +(and file:// URLs) are rendered inline by the chat UI; relative paths resolve +against the current working directory. Supported formats: png, jpg, jpeg, webp, +gif, bmp. No tool call is needed — just the Markdown image syntax. +``` + +(Exact wording may be polished; the capability, the syntax, and the format list must be present.) Update `service.test.ts` structure assertions if needed. + +--- + +## Frontend (feature: `src/features/markdown`, wiring in `src/features/chat`) + +### 3. Local-path image rewriting in `src/features/markdown/logic/markdown.ts` + +Today `` and `` render broken: DOMPurify strips `file:` srcs, and a root-relative `/home/…` src 404s against the frontend origin. Remote `http(s)` images already work and must keep working. + +**Required behavior — `renderMarkdown`, `renderMarkdownTail`, and `renderOpenFence` must all rewrite image srcs consistently (all three funnel through `sanitize()`):** + +For each `<img>` produced by Markdown image syntax ``: +- `file:///path` → strip the scheme → local absolute path. +- `/absolute/path` → local absolute path. +- Relative path (no scheme, no leading `/`, not `data:`/`http(s)`) → resolve against the **conversation cwd** (threaded in as a render option; empty/absent cwd → leave as-is / no rewrite). +- `http(s)://…`, `data:` → leave untouched. + +Rewritten src = `` `${apiBase}/local-image?path=${encodeURIComponent(absPath)}` `` — reuse the join-without-double-slash semantics of `src/core/chunks/image-url.ts` (`apiBase` may be empty → root-relative `/local-image?…`). `apiBase` and `cwd` are per-conversation render options, not module constants — thread them through as an opts parameter (e.g. `{ image?: { apiBase: string; cwd?: string | null } }`) from the render entry points. Whether you implement this via a DOMPurify `afterSanitizeAttributes` IMG hook (the module already uses this pattern for link `target=_blank`) or a pre-parse rewrite of the Markdown source is your call — cover Markdown image syntax at minimum; raw-HTML `<img>` tags in Markdown are best-effort. + +### 4. Component threading — `Markdown.svelte` + `ChatView.svelte` + +- `Markdown.svelte` (`src/features/markdown/ui/Markdown.svelte`): new optional prop carrying `{ apiBase: string; cwd?: string | null }`, forwarded into all render calls (including the streaming path via `advance`/`createStreamingMarkdown` — check `logic/streaming-markdown.ts` for how render opts flow). +- `ChatView.svelte` (renders `<Markdown text={…} streaming={…} />` around line 190): pass `apiBase` (the store already exposes the HTTP base, see how `resolveImageUrl` callers get it) and the conversation `cwd` (already reactive state in the app store — `$state cwd`, fetched via `GET /conversations/:id/cwd`). + +### 5. Tests + +Extend `src/features/markdown/logic/markdown.test.ts` (and streaming tests if the tail path is affected): +- `file:///home/u/pic.png` + apiBase `http://localhost:24203` → src `http://localhost:24203/local-image?path=%2Fhome%2Fu%2Fpic.png`. +- `/home/u/pic.png` with empty apiBase → root-relative `/local-image?path=…`. +- Relative `shot.png` with cwd `/w/foo` → `…/local-image?path=%2Fw%2Ffoo%2Fshot.png`; without cwd → untouched. +- `https://example.com/a.png` and `data:` URLs → untouched. +- Streaming tail render rewrites too. + +--- + +## Definition of done (both repos) + +- Backend: `bun install` then the repo's verification commands from AGENTS.md (typecheck / test / check / build) all green; route + template changes committed to `feature/local-image-markdown`. +- Frontend: same — all green, committed to `feature/local-image-markdown`. +- Manual smoke (report only, don't block): with the stack running, an agent message containing `` shows the image inline. +- Do NOT merge or push. Commit to the feature branch only. 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-workspaces-reply.md b/backend-handoff-workspaces-reply.md deleted file mode 100644 index 2ddfaf5..0000000 --- a/backend-handoff-workspaces-reply.md +++ /dev/null @@ -1,216 +0,0 @@ -# Backend handoff — Workspaces (backend → FE) — courier doc - -> **From:** arch-rewrite orchestrator · **To:** dispatch-web orchestrator · **Courier:** the user. -> Response to `backend-handoff-workspaces.md`. This doc finalizes the contract shapes -> the backend will implement. The FE should re-pin `@dispatch/wire` and -> `@dispatch/transport-contract` `file:` deps and re-mirror any `.dispatch/*.reference.md`. - -## Version bumps - -| Package | From | To | Notes | -|---|---|---|---| -| `@dispatch/wire` | `0.11.0` | `0.12.0` | Additive: `Workspace`, `WorkspaceEntry`, `ConversationMeta.workspaceId` | -| `@dispatch/transport-contract` | `0.15.0` | `0.16.0` | Additive: workspace endpoints + `workspaceId` on chat/queue ops | -| `@dispatch/ui-contract` | `0.2.0` | `0.2.0` | **Unchanged** | - ---- - -## 1. Final types — `@dispatch/[email protected]` - -```ts -/** - * 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`. - */ -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; - /** 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 — a `Workspace` plus a conversation count. - */ -export interface WorkspaceEntry extends Workspace { - /** Number of conversations assigned to this workspace. */ - readonly conversationCount: number; -} -``` - -`ConversationMeta` gains a required `workspaceId`: - -```ts -export interface ConversationMeta { - readonly id: string; - readonly createdAt: number; - readonly lastActivityAt: number; - readonly title: string; - readonly status: ConversationStatus; - /** Always present; "default" for legacy/unspecified conversations. */ - readonly workspaceId: string; - readonly compactedFrom?: string; -} -``` - ---- - -## 2. Final types — `@dispatch/[email protected]` - -### Additive fields on existing request types - -```ts -export interface ChatRequest { - readonly conversationId?: string; - readonly message: string; - readonly model?: string; - readonly cwd?: string; - readonly reasoningEffort?: ReasoningEffort; - /** Workspace to assign the conversation to. Default "default". Auto-creates if missing. */ - readonly workspaceId?: string; -} - -export interface QueueRequest { - readonly text: string; - /** Default "default". Auto-creates if missing. */ - readonly workspaceId?: string; -} - -export interface ChatQueueMessage { - readonly type: "chat.queue"; - readonly conversationId: string; - readonly text: string; - /** Default "default". Auto-creates if missing. */ - readonly workspaceId?: string; -} -``` - -### Workspace endpoint types - -```ts -/** Body of `PUT /workspaces/:id` (all fields optional — the ensure/create call). */ -export interface EnsureWorkspaceRequest { - /** Display title. Default: the workspace id. Only used on create; ignored if workspace exists. */ - 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`. */ -export interface SetWorkspaceTitleRequest { - readonly title: string; -} - -/** Body of `PUT /workspaces/:id/default-cwd`. null/absent = clear to server default. */ -export interface SetWorkspaceDefaultCwdRequest { - readonly defaultCwd: string | null; -} - -/** Response of `DELETE /workspaces/:id`. */ -export interface DeleteWorkspaceResponse { - readonly workspaceId: string; - /** Conversations that were closed (status → "closed") by this delete. */ - readonly closedCount: number; -} -``` - ---- - -## 3. Final endpoint list - -| Method & Path | Body | Returns | Notes | -|---|---|---|---| -| `GET /workspaces` | — | `WorkspaceListResponse` | Sorted by `lastActivityAt` desc. Includes `conversationCount`. | -| `PUT /workspaces/:id` | `EnsureWorkspaceRequest?` | `WorkspaceResponse` | **Create-on-miss** (idempotent). Creates with `title=id`, `defaultCwd=null` if missing. Returns existing as-is if present. Slug validated. | -| `GET /workspaces/:id` | — | `WorkspaceResponse` | Pure read. 404 if missing. | -| `PUT /workspaces/:id/title` | `SetWorkspaceTitleRequest` | `WorkspaceResponse` | Rename (display only; id unchanged). | -| `PUT /workspaces/:id/default-cwd` | `SetWorkspaceDefaultCwdRequest` | `WorkspaceResponse` | Set/clear workspace default cwd. | -| `DELETE /workspaces/:id` | — | `DeleteWorkspaceResponse` | **Closes all conversations** (status → "closed"), reassigns them to "default", then deletes the workspace. 409 for `"default"`. | -| `GET /conversations` | `?workspaceId=`, `?status=`, `?q=` | `ConversationListResponse` | Additive `?workspaceId=` filter, composable with existing filters. | -| `DELETE /conversations/:id/cwd` | — | `CwdResponse` | Clears explicit conversation cwd (returns `cwd: null`). | - -### Existing endpoints (semantic note, no type change) - -- `GET /conversations/:id/cwd` — unchanged: returns the **explicit** conversation cwd (`null` = inheriting workspace default). -- `GET /conversations/:id/lsp` — now roots LSP at the **effective** cwd; `LspStatusResponse.cwd` returns the effective cwd. - ---- - -## 4. cwd resolution (backend-owned) - -``` -effectiveCwd = conversationStore.getCwd(conversationId) // explicit per-conversation -if (effectiveCwd == null) { - workspaceId = conversationStore.getWorkspaceId(conversationId) // "default" fallback - workspace = conversationStore.getWorkspace(workspaceId) - effectiveCwd = workspace?.defaultCwd ?? null -} -if (effectiveCwd == null) effectiveCwd = serverDefaultCwd // process.cwd() today -``` - -- `GET /conversations/:id/cwd` → explicit cwd only (`null` = inherit). -- `GET /conversations/:id/lsp` → effective cwd. -- Turn start (`runTurn` / `warm`) → effective cwd. - ---- - -## 5. `DELETE /workspaces/:id` semantics - -1. Close all conversations in that workspace (set `status = "closed"`). -2. Reassign their `workspaceId` to `"default"` (so no dangling reference). -3. Delete the workspace entity. -4. Return `{ workspaceId, closedCount }`. -5. `DELETE /workspaces/default` → HTTP 409. - -Closed conversations are hidden from tab-restore (`?status=active,idle` excludes `closed`). - ---- - -## 6. Workspace lifecycle / auto-creation - -- **Auto-create on turn start:** if `workspaceId` is provided and doesn't exist, the backend auto-creates it (`title = id`, `defaultCwd = null`). -- **`PUT /workspaces/:id` create-on-miss:** if absent, creates with optional `title`/`defaultCwd` from the body (defaults: `title = id`, `defaultCwd = null`). If present, returns existing as-is. -- **Slug validation:** `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$` (1–40 chars, lowercase, digits, internal hyphens only). Reject invalid with 400. No normalization. `"default"` allowed but non-deletable. -- **`"default"` workspace:** always synthesized if not persisted; guaranteed in `GET /workspaces` list. -- **`lastActivityAt`:** updates when a conversation in the workspace appends, or on first creation. Does NOT update on title/default-cwd changes. -- **Compaction:** post-compaction conversations inherit the original's `workspaceId`. - ---- - -## 7. Answers to FE open questions (Q1–Q8) - -| # | Decision | -|---|---| -| Q1 | **Close all conversations** in the workspace (status → "closed"), reassign to "default", then delete the workspace. Return `closedCount`. | -| Q2 | **Add `DELETE /conversations/:id/cwd`** to clear explicit cwd (fall back to workspace default). `PUT` validation unchanged (empty string still 400). | -| Q3 | **Deferred to v1** — no WS lifecycle push. Fetch-on-mount + manual refresh sufficient. Can add `workspace.created/updated/deleted` later, additively. | -| Q4 | **`PUT /workspaces/:id`** is the create-on-miss entry point (idempotent, 200). `GET /workspaces/:id` is a pure read (404 if missing). | -| Q5 | Slug regex `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$`. Reject, don't normalize. `"default"` non-deletable. | -| Q6 | `Workspace` in `@dispatch/wire`. Request/response bodies in `@dispatch/transport-contract`. | -| Q7 | Confirmed — backend does nothing beyond `workspaceId` on `ConversationMeta` + `?workspaceId=` filter. | -| Q8 | Yes — post-compaction conversations inherit `workspaceId`. `forkHistory` copies it. | - ---- - -## 8. Gaps resolved (from FE handoff §3) - -1. **Unknown workspaceId on turn start** → auto-create (title = id, defaultCwd = null). Typos can be deleted. -2. **PUT /workspaces/:id initial state** → body accepts optional `title`/`defaultCwd` with defaults (`title = id`, `defaultCwd = null`). Only applied on create; existing workspace returned as-is. -3. **lastActivityAt on title/default-cwd changes** → no. -4. **LSP cwd field** → returns effective cwd. -5. **Conversation count in list** → yes, included as `WorkspaceEntry.conversationCount`. diff --git a/backend-handoff-workspaces.md b/backend-handoff-workspaces.md deleted file mode 100644 index dc27c0e..0000000 --- a/backend-handoff-workspaces.md +++ /dev/null @@ -1,157 +0,0 @@ -# Backend handoff — Workspaces (FE → backend) — courier doc - -> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user. -> Companion to the living `backend-handoff.md` (new open ask, §2). 2026-06-23. -> `lsp references` does NOT span the two repos, so this is the cross-repo channel. -> FE is current on `[email protected]` / `[email protected]` / `[email protected]` (686 tests green). -> **This is a design ask, not a bug report.** Please review, analyze, propose an implementation plan, -> and surface any gaps / questions back. The FE will adapt to whatever shapes you land on. - ---- - -## 1. What we're building (the FE product behavior) - -A **workspace** is a named, URL-driven grouping of **conversations** that owns a **default cwd**. -Every conversation belongs to exactly one workspace; a workspace's default cwd is used by its -conversations that haven't set their own per-conversation cwd. - -Routing (net-new on the FE — there is no router today): -- **`/`** (no path) — home: lists all workspaces (title, slug, last activity). The user can **delete** a - workspace here. -- **`/<workspace-id>`** — opens that workspace and loads its tabs. -- **Visiting `/<id>` when the workspace doesn't exist → it is created at that point** (title defaults to - the id; the user can rename the title later). The **id (the URL slug) is immutable**; the **title is - display-only and editable**. - -Workspaces are **backend-owned** (so cross-device just works): the workspace entity (title, default-cwd), -and each conversation's `workspaceId`, live server-side. The FE is a thin client over your contracts and -ships no business logic you don't expose (per the FE's constitution). - -### Naming note (FE-internal, NO backend impact) -The FE already has a `features/workspace` module — but it is the **per-conversation cwd-field + LSP-status** -module (it consumes `GET`/`PUT /conversations/:id/cwd` + `GET /conversations/:id/lsp`), NOT this concept. -We are renaming that module to `features/cwd-lsp` so the new feature takes `workspaces`. Flagged only so -the backend isn't confused by the FE module name; it has **zero contract impact**. - ---- - -## 2. What the FE needs from the backend (the proposed contract surface) - -Everything below is the FE's *requirement + proposed shape*. **You own the final shapes** — pick the -verbs/field-names/type-homes that fit the backend, and ask back where a requirement is unclear or conflicts -with your design. - -### 2.1 Workspace entity + conversation assignment -- A `Workspace` type (your call: `@dispatch/wire` alongside `ConversationMeta`, or `@dispatch/transport-contract` - with the endpoints): `{ id: string (the URL slug, immutable), title: string (defaults to id on creation, - editable), defaultCwd: string | null, createdAt: number, lastActivityAt: number }`. -- `ConversationMeta` gains an additive **`workspaceId: string`**. Conversations created with no workspace - ⇒ `"default"` (the fallback). Legacy conversations (no `workspaceId` persisted) should be treated as - `"default"` — ideally **no backfill needed**; `ConversationMeta.workspaceId` reads as `"default"` for them. - -### 2.2 Conversation creation carries the workspace -Conversation-creating ops gain an optional additive `workspaceId` (default `"default"`) so the backend -stamps the conversation's workspace at creation: -- `ChatRequest` (HTTP `POST /chat`) and `ChatSendMessage` (WS `chat.send`). -- The queue ops that can start a turn: `POST /conversations/:id/queue` (`QueueRequest`) and `chat.queue` (WS). - -Note the FE mints `conversationId`s client-side and sends them on `chat.send`; the `workspaceId` travels -alongside. **Existing invariant preserved:** `chat.send` still **omits `cwd`** (sends `undefined`) — the -backend resolves the effective cwd from the workspace default (see 2.4). - -### 2.3 Workspace endpoints (your call on exact verbs/shapes) -- **`GET /workspaces`** — list all (for the `/` home). Sorted by `lastActivityAt` desc. Enough for a picker - (id, title, timestamps; a conversation count would be nice but optional). -- **`GET /workspaces/:id`** — **create-on-miss**: if absent, create it (`title = id`, `defaultCwd = null`) - and return it; if present, return it. Idempotent. This is the route-enter action when the user visits - `/<id>`. (We note GET-with-side-effects; you already lazy-spawn LSP on `GET /conversations/:id/lsp`, so - there's precedent — but feel free to propose `PUT`/upsert instead; see open Q4.) -- **`PUT /workspaces/:id/title`** (body `{ title }`) — rename (display only; id/URL unchanged). -- **`PUT /workspaces/:id/default-cwd`** (body `{ defaultCwd }` or `{ cwd }`) — set the workspace default cwd. - `null`/empty = "no default; fall through to the server default." -- **`DELETE /workspaces/:id`** — delete a workspace. **Open question (Q1): what happens to its conversations.** - -### 2.4 cwd resolution (backend-owned — the FE does NOT re-implement) -At turn time, resolve cwd as: **explicit conversation cwd (`GET /conversations/:id/cwd`) -> `workspace.defaultCwd` > server default.** -- `GET /conversations/:id/cwd` should keep returning the **explicit** conversation cwd (`null` = - "inheriting the workspace default"), so the FE can render "inherited from workspace X" vs "explicit." - The FE reads `workspace.defaultCwd` separately to show the inherited value. -- `GET /conversations/:id/lsp` must root at the **effective** cwd (conversation cwd ?? `workspace.defaultCwd`), - so LSP spawns against the workspace default when the conversation hasn't set its own. - -### 2.5 The `default` workspace -- Always present, **non-deletable**, id `"default"`. It is the fallback for unassigned conversations. The FE - navigates to `/default` for it. **Please confirm** the backend guarantees its existence on boot and rejects - `DELETE /workspaces/default`. - -### 2.6 Conversation list filtered by workspace -- `GET /conversations` gains an additive **`?workspaceId=<id>`** filter (composable with the existing - `?status=` and `?q=`). Used by the FE to restore a workspace's active/idle conversations as tabs on a new - device (the existing cross-device tab-restore path, now workspace-scoped). The FE is **not** building a - full conversation-browser sidebar in this iteration — just the restore query. - ---- - -## 3. Open questions for the backend to analyze + decide (FE will adapt) - -These are implementation decisions the FE defers to the backend. Please analyze each, pick an approach, -and ask back wherever the FE's requirement is unclear or conflicts with your design: - -1. **Delete a workspace → fate of its conversations.** Reassign them to `"default"`? Block deletion while - non-empty? Delete them? The FE's only hard requirement: deleting a workspace must **not orphan/hide** - conversations (they must remain reachable). You decide semantics; the FE renders whatever you return. -2. **"Clear to inherit" for conversation cwd.** With inheritance, a user may want to unset an explicit - conversation cwd to fall back to the workspace default. Today `PUT /conversations/:id/cwd` with `""` ⇒ - `400 { error }`. Do you want to (a) treat `null`/empty as "inherit" (relax the 400), (b) add - `DELETE /conversations/:id/cwd`, or (c) defer the affordance (v1: a conversation cwd, once set, can be - changed but not unset)? The FE is fine with any. -3. **Workspace lifecycle over WS.** For live cross-device refresh of the home list (a workspace - created/renamed/deleted on device A appears on device B's `/`), should you broadcast - `workspace.created` / `workspace.updated` / `workspace.deleted` (mirroring `conversation.statusChanged`), - or is the FE's fetch-on-mount + manual refresh sufficient for v1? The FE can ship either way; WS push is - a nice-to-have, not a blocker. -4. **create-on-miss vs. explicit create.** Is `GET /workspaces/:id` create-on-miss acceptable, or would you - prefer a distinct `POST /workspaces` / `PUT /workspaces/:id` (upsert) the FE calls on route-enter? The FE - just needs "visit URL ⇒ workspace exists" in one round-trip. -5. **Slug validation.** The id is a URL path segment. FE proposes URL-safe `[a-z0-9-]`, lowercase, - length-bounded. Please define the canonical validation + whether you normalize or reject. Also: should - creation reject slugs colliding with reserved names (`default`)? -6. **Where do the types live?** `Workspace` in `@dispatch/wire` (like `ConversationMeta`) or - `@dispatch/transport-contract` (with the endpoints)? And the request/response shapes for the workspace - endpoints — your call; mirror the existing `TitleResponse`/`SetTitleRequest` style if it fits. -7. **Cross-device tab set (confirm the FE's model).** The *open-tab set* stays **per-device** (FE - localStorage); cross-device sync covers workspaces + conversations + assignments, and a fresh device - restores a workspace's active/idle conversations as tabs via - `GET /conversations?workspaceId=<id>&status=active,idle`. Does the backend need to do anything beyond - exposing `workspaceId` on `ConversationMeta` + the `?workspaceId=` filter? (FE assumes **no**.) -8. **Compaction interaction.** `conversation.compacted` yields a `newConversationId` (see - `ConversationCompactedMessage`/`CompactionResult`). Should the new (post-compaction) conversation inherit - the original's `workspaceId`? The FE assumes **yes**. - ---- - -## 4. How the FE will consume it (so you can shape the contract) - -- Route enter `/<id>` → `GET /workspaces/:id` (create-on-miss) → render the workspace view + its tabs. -- `/` home → `GET /workspaces` → list; delete via `DELETE /workspaces/:id`; (maybe) a "new workspace" input - that navigates to `/<slug>` (creation via the visit). -- New conversation in workspace W → `chat.send` / `POST /chat` with `workspaceId: W.id` (cwd still omitted). -- The CwdField (the renamed `cwd-lsp` module) shows explicit cwd (`GET /conversations/:id/cwd`) vs inherited - (`workspace.defaultCwd`). -- On workspace entry, restore active/idle conversations as tabs: - `GET /conversations?workspaceId=<id>&status=active,idle`. - ---- - -## 5. Priority / sequencing - -Not a hard blocker — the FE can begin FE-only scaffolding (the routing adapter + a workspace-view shell -with provisional local types) in parallel, but the **cross-repo types must land before the FE wires real -data.** Please bump `wire` / `transport-contract` / `ui-contract` as needed and note the changes in the -reply so the FE re-pins the `file:` deps + re-mirrors the relevant `.dispatch/*.reference.md`. - -**Asks back to you, the backend:** (a) a review of the above for feasibility/fit; (b) a concrete -implementation plan (final types, endpoints, resolution mechanics); (c) answers/decisions on Q1–Q8; and -(d) any gaps or questions YOU have about the FE's requirements. The FE will then build against the landed -contract. diff --git a/backend-handoff.md b/backend-handoff.md index 4e3c6df..f35c379 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -1,1209 +1,737 @@ -# 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:** `../backend` 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-27 (FE-only slice: **workspace-active indicator** — loading-dots on -workspace cards when a workspace has ≥1 active/queued conversation. New `AppStore.workspaceHasActiveConversations(workspaceId)` derives from the existing open-tab set (every active/queued -conversation has an open tab stamped with its `workspaceId`) × the backend lifecycle statuses; a -`hasActive?: (workspaceId: string) => boolean` port on `WorkspacesHome`/`WorkspaceCard` is wired at -`src/App.svelte`. DaisyUI `loading-dots` (same as the tab/composer active indicator). **No backend / -contract change** — no re-pin/re-mirror. typecheck 0/0, 1029 tests green (+10), biome clean, build OK.)_ -_Last updated: 2026-06-27 (§2j-update-3 — concurrency-fixes: configurable + persisted per-provider release -cooldown, adaptive headroom auto-reduce banner, usage gate. ADDITIVE to `[email protected]`, NO version -bump — `ConcurrencyStatusEntry` gained `cooldownMs`/`autoReduced`/`autoReducedFrom?`/`notice?`; NEW -`ConcurrencyCooldownResponse`/`SetConcurrencyCooldownRequest` + `GET`/`PUT /concurrency/cooldown/:providerId`. -FE re-synced the `file:` dep + re-mirrored `.dispatch/transport-contract.reference.md`; built the cooldown -view-model + inline-edit row, a dismissible auto-reduce banner with "Restore to N", store `getConcurrencyCooldown`/ -`setConcurrencyCooldown`, + 49 new tests. typecheck 0/0, 1050 tests green (run TWICE), biome clean, build OK. -Worktree env note: an untracked `dispatch-backend → backend` symlink was created in the worktree parent so the -canonical `file:../dispatch-backend/...` paths resolve — NOT committed.) Prior: §2j (vision consult_vision + image -storage)._ -_Last updated: 2026-06-27 (workspace starring — backend `feature/workspace-star` shipped `Workspace.starred: boolean` -(additive to `[email protected]`, NO version bump) + `PUT`/`DELETE /workspaces/:id/star` endpoints (no body; create-on-miss; -return the updated `Workspace`). FE consumed: `adapter/http` `star`/`unstar`, pure `sortWorkspaces`/`applyStarred`, -`store.setStarred` (optimistic + revert, `$derived`-sorted list), `WorkspaceCard` star toggle (filled gold ★ / outline ☆). -Re-mirrored `.dispatch/wire.reference.md`. typecheck 0/0, 1045 tests green (+27), biome clean, build OK. No open backend asks.)_ -**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9** -_Last updated: 2026-06-26 (backend: concurrency limits now PERSISTED across reboots — no API contract change, no FE -re-pin/re-mirror needed; §2j updated. FE: brief "Saved." confirmation on the limit row after a successful save. 926 tests -green.) Prior: CR-13 (`"queued"` ConversationStatus) RESOLVED; dev merged (e81df4c)._ -_Last updated: 2026-06-26 (§2j UPDATED — Image storage: persisted `ImageChunk.url`s are now compact relative HTTP -paths (`/images/<conv>/<uuid>.png`) served by `GET /images/:conversationId/:imageId` (images stored on disk under tmp, -not SQLite). FE resolves relative urls against the API base via a new pure `resolveImageUrl` helper; the optimistic -echo's data URL passes through unchanged; `ChatRequest.images` (send) is unchanged. typecheck 0/0, 959 tests green -(+11), biome clean, build OK. §2i unchanged.)_ -**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9** -(`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump). The SSH-divergence -(§2d) is RESOLVED. CR-13 (`"queued"` ConversationStatus) is RESOLVED. -Backend shipped CR-10 (workspace id on `conversation.open` / `conversation.statusChanged`), CR-11 -(per-conversation model persistence), and CR-12 (`GET /conversations/:id/mcp`); FE has consumed all three. -The backend also added the transient `provider-retry` `AgentEvent` (retry-with-backoff warning) to -`[email protected]` on `dev` (additive — no version bump); FE consumed + re-mirrored `.dispatch/wire.reference.md` — see §2c. -FE re-pinned + re-mirrored `transport-contract`; `selectModel` persists to -`PUT /conversations/:id/model` and conversation focus recalls the persisted model via `GET /conversations/:id/model`. -FE consumes the MCP status slice (`GET /conversations/:id/mcp`, mirroring `/lsp`) — see §2b. +# Backend handoff — Dispatch Web (tracked; kept current at every milestone) + +> This living document is the cross-repo courier between the FE (this repo) and the BACKEND +> (`../backend`, a separate repo). `lsp` does not span the seam — the USER carries updates across. +> Per AGENTS.md "Backend seam": FE contract changes are edited + consumers updated here; BACKEND +> contract changes are REPORTED UP here, and on the new version the FE re-pins the `file:` dep, +> re-mirrors `.dispatch/*.reference.md`, and updates consumers. NEVER edit the backend repo. +> +> **NOTE (tiled-layout worktree, 2026-09-04):** plans 01–04 of the layout series are being +> implemented in BOTH repos of this worktree by one task (branch `feature/tiled-layout`), so the +> "never edit the backend repo" rule is superseded by that task's scope — the backend contract +> changes below were made in the same branch and the FE consumes them via the `file:` deps +> (dists rebuilt). Listed here for the record. +> +> **NOTE (archive-workspaces worktree, 2026-09-20):** the archive-workspaces feature was likewise +> implemented in BOTH repos of one worktree (branch `feature/archive-workspaces`), so the backend +> contract change (`Workspace.archived`, `PUT/DELETE /workspaces/:id/archive`, `GET /workspaces +> ?archived=`) was made in the same branch and the FE consumes it via the `file:` deps (dists +> rebuilt). Recorded below. + +## Milestone: Current frontend privacy cleanup (`feature/privacy-cleanup`, 2026-10-24) + +**Slice status:** COMPLETE; frontend-only cleanup verified. + +- Personal machine, tailnet DNS, IPv4 and IPv6 literals in network-policy and URL + resolver fixtures are replaced with synthetic names and documentation-only addresses + (`dev-node.tailnet.example`, `192.0.2.10`, `2001:db8::10`). The Vite guard fixture + uses the same reserved examples. Host/Origin, mapped-address, interface-selection, + fail-closed and env-override coverage is retained; runtime network policy is unchanged. +- Ownership-repair documentation resolves the current user's name/group instead of + naming a personal local account. Generic home-directory fixtures, intentional public + repository-owner references, human identity and legitimate product/model references remain. +- The conversation-specific diagnostic is now `scripts/probe-tab.ts <conversationId>`; + it requires an explicit target before any request and reports the actual chunk count. + Regression comments describe the duplicate-emission pattern without a real conversation id. +- Audited tracked current files, including hidden config, contract mirrors and the lockfile. + No application defaults, stored user data, endpoint contracts or dependency pins changed. + Existing `file:../backend/packages/*` dependencies and additive mirrors remain unchanged +- History/backups and removal of AI-attribution text from historical commit messages + belong to the coordinating task; this slice does not rewrite refs, merge or push. + Legitimate AI functionality/product references are not attribution and are retained. + +**Verification:** `bun install` succeeds without lockfile changes; `bun run typecheck` +0 errors/0 warnings; `bun run test` 2,587 passed/2 opt-in skipped twice; +`bun run check` clean (309 files); `bun run build` succeeds with existing Svelte +capture and chunk-size advisories. Diagnostic usage fails safely without a target; +an ephemeral loopback HTTP fixture verifies its two requests and dynamic chunk count. +Final current-file scan has no known private literals. Report: `reports/privacy-cleanup.md` +(gitignored). +No browser-facing runtime or wire changes; no shared backend process started or modified. + +## Milestone: Consistent composer action width (`dev`, 2026-10-24) + +- Send, Queue, Stop, and Flush now share the same fixed `w-20` width; Flush no longer + widens the composer or changes text wrapping. Button actions and labels are unchanged. +- Added regression coverage for the shared width across all four labels. +- Verified: typecheck (0 errors/warnings), tests (2,591 passed; 2 skipped), Biome, and build. +- Backend contracts, pins, and mirrors unchanged; no backend work required. + +## Milestone: Close-tab cascade subagents (`feature/close-tab-cascade-subagents`, 2026-10-24) + +**Slice status: COMPLETE across the isolated frontend + backend feature branches.** + +- Frontend integrated local `dev` at `913cc7b` (merge `b462f17`), preserving the new + tab colors, 70vh grid, FLIP close/reflow animations and current reliability fixes. +- Closing a tab with a nonempty **visible tab-group** subtree prompts once: + **Yes, close all N** closes the snapshotted transitive subtree; **No, keep them open** + closes only the parent; Cancel/Escape/backdrop close nothing. Leaf tabs close directly. + New tabs arriving after confirmation opens are not silently added to its close set. + This is visual `tabGroupParentId` grouping, NOT backend lineage traversal or an implicit + server cascade. Other workspaces and unopened conversations are outside this close set. +- Native modal dialog renders in the browser top layer (not clipped by animated/sidebar + containers), traps keyboard focus, starts on Cancel, restores focus on dismissal, and + wraps actions at mobile widths. Remote target closure/workspace switch dismisses stale UI. +- Each id uses the existing `AppStore.closeTab` → single `POST /conversations/:id/close` + route; real-store component regressions cover persistence + workspace isolation for + all/parent/cancel. No double-close workaround or new FE cross-unit port. + +**Backend race discovered and FIXED, not waived:** explicit Close aborted the turn, but +its finalization unconditionally persisted/published `idle` afterward, reopening the tab. +The original companion symlink to main was replaced (with user authorization) by a real +backend `feature/close-tab-cascade-subagents` worktree at local `dev` `4bf4dd9`. +Commit **`fd07259`** preserves Close through deferred lifecycle work and settlement, +keeps Stop → idle + explicit reopen behavior, suppresses stale broadcasts/auto-compaction, +and cleans per-turn lifecycle tracking. Five real-store regressions cover the race. +See backend `notes/close-tab-cascade-subagents.md` for reproducible direct HTTP steps. +Neither main checkout nor the user's running backend process was edited/restarted. + +**Contract pins/mirrors:** unchanged `file:../backend/packages/*` deps; `[email protected]`, +`[email protected]`, `[email protected]` (existing additive mirrors retained). +Dependencies were refreshed after replacing the companion with its real worktree. +No API/type/version delta to mirror; no remaining backend asks. + +**Verification:** +- FE: `bun run typecheck` **0 errors/0 warnings**; full suite **2,516 passed ×2** + (127 files; an earlier pass before the final focus regression was 2,515); + `bun run check` clean; `bun run build` succeeds (existing Svelte capture/chunk-size warnings). +- BE: typecheck clean; Vitest **4,214 passed / 10 skipped**; Biome clean; + Bun suite final combined run **188 passed**, also all seven groups pass independently. + Two earlier combined Bun runs hit native Bun 1.3.14 crashes; subsequent combined run passed. +- Writable live verification used ONLY a private source backend at **24303/24305**, with + fresh SQLite/trace/journal/image/plugin paths under `/tmp/close-tab-isolated` and + inherited real credentials (never printed or replaced). Standard live probe **22/22**. +- Direct HTTP active-turn close now returns `abortedTurn: true`, seals/ends aborted and + stays absent from active/idle list after stream completion. Real Chromium end-to-end + cascade does the same for the nested active child, leaves unrelated tabs and stays + closed after reload. Desktop 1280px + mobile 420px checks pass: all/parent-only, + Cancel/Escape/backdrop, leaf close, remote target close, top-layer modal, focus containment + and restoration, mobile bounds; no uncaught browser exceptions. +- Fixture conversations closed/workspaces archived. Reports, screenshots and probe harnesses: + `reports/close-tab-cascade/` (gitignored). Private server + collector and browser/Vite + processes are cleaned up after verification. No merge back or push. + +**Human smoke check still requested:** open a real parent with nested subagent tabs; +close → Cancel/Escape, close → No (children stay), then close another group → Yes +(all descendants stop/close). At mobile width open the sidebar first; confirm all three +buttons remain reachable and the dialog is not clipped. Browser automation passed this +flow, but theme/layout should still be confirmed in the user's own browser. + +## Milestone: reasoning block boundaries on the live stream (2026-10-22) + +FE slice consuming a new ADDITIVE backend wire event. The kernel now emits one payload-free +`reasoning-end` `AgentEvent` per provider reasoning block (no version bump; opaque +signature/redacted data never leave the server). The pure chunk reducer mirrors the kernel's +builder — an open thinking chunk is flushed as its own provisional chunk; with none open it +appends an empty thinking chunk — so live thinking splits identically to HTTP history and the +replay/history de-dup lines up 1:1. The old merged-run alignment (`mergedRun`) stays as a +fallback for older servers/replays. The cache timer treats `reasoning-end` as resumed output; +`assertAgentEventExhaustive` covers it. Mirrored in `.dispatch/wire.reference.md` +(`TurnReasoningEndEvent`). No backend asks. + +## Milestone: Delivery warning display delay + Flush label (2026-10-21) + +Frontend-only: each keyed pending-input warning waits 1000 ms on mount (including +restored/rejected copies); removal/unmount cancels it. Storage, delivery, receipts, +and recovery remain immediate and unchanged. The queued Stop action now reads +**Flush** (text + aria-label), retaining its tooltip and existing stop action. +No contract/version/mirror changes or backend asks. Verification and scope: +`reports/delivery-warning-delay.md`; no deployment or live probe. + +## Milestone: Subscription and input delivery regression (2026-10-21) + +The user authorized one agent to implement both repositories in paired worktrees on +`feature/subscription-delivery-fix`. + +- Discovery remains global for metadata and lifecycle status. Only the focused chat and + explicit modal viewers subscribe to transcripts. Reopening a background chat catches up + history and metrics. Chat watchers and surfaces each own at most 128 of the server's + 256 shared slots; unsubscribes precede replacement subscriptions. +- Backend overflow rejects a watcher with `chat.subscription-rejected` (or a scoped surface + error) while preserving the socket and independent message admission. Logs include the + connection ID, subscription counts, and close code/reason. +- `chat.send` and `chat.queue` use correlated receipts. `chat.accepted` follows opening + message storage or queue insertion; `chat.queued` confirms compaction admission. Correlated + `chat.error` identifies rejected input. Ordinary stream echoes are not durable receipts. +- Inputs and images are saved per conversation in localStorage before transmission. Saving + failures preserve the composer and prevent transmission. A reload retains local-only tabs + with saved input; unconfirmed/rejected copies are visible and can be discarded explicitly. + There is no automatic retry: `requestId` is a correlation token, not an idempotency key. +- Reconnect recovery is limited to live viewers. Discovery and per-chat metrics requests + coalesce; metrics permit at most one catch-up request behind an in-flight request. +- Contracts remain transport-contract 0.26.0 and wire 0.13.0. Built declarations were refreshed + into the unchanged local file dependencies and the transport mirror was regenerated. +- Validation: frontend typecheck/check/build passed; **2,439 tests / 124 files**, twice. + Backend typecheck/check passed; **4,150 Vitest + 184 Bun integration tests** passed. + The real socket fixture validates 339 requests, admission at capacity, storage-before-receipt, + and disconnect before receipt using isolated test ports and in-memory storage. Details in + `reports/subscription-delivery-fix.md`. No merge or deployment is part of this task. + +## Milestone: Review 3.6 — history epochs (2026-10-20) + +- Consumed backend Step 3.5: required `historyEpoch` on HTTP history and WS compaction; + unchanged `file:../backend/packages/*` links refreshed with `bun install`. + Versions remain transport-contract **0.26.0**, wire **0.13.0**. Relevant declarations + and documentation re-mirrored in `.dispatch/{transport-contract,wire}.reference.md`. +- IndexedDB meta persists epoch, including empty history; legacy missing epochs are + untrusted and reloaded from cursor 0. Epoch changes clear transcript/watermarks and + cache before a windowed reload. Broadcasts reuse the existing chat store and invalidate + in-flight responses, including modal watches; unseen conversations stay lazy. +- Tail triggers coalesce into sequential reruns. Backfill validates epoch; earlier-history + availability uses a known lower boundary / short-page exhaustion, not `seq > 1`. + With no server `hasOlder` field, a full final page may require one empty-page probe. +- Public FE seams: cache/storage `historyEpoch` + `reset`, optional epoch on cache `commit`, + optional epoch on chat `resync`, lazy transcript `invalidateHistory`, explicit earliest + boundary on `selectHasEarlier`. +- Verification: typecheck/check/build green; full suite **2,186 tests / 115 files** + (run twice on final files; see `reports/review-3.6.md`). Backend health unavailable + on localhost:24203, so no live probe/server started. No new backend asks. + +## Milestone: Local-path images in Markdown (`feature/local-image-markdown`, 2026-10-19) + +**Slice status:** COMPLETE (frontend half; the backend route is implemented in parallel in +`../backend` from the same pinned contract). + +- `features/markdown/logic/markdown.ts`: `renderMarkdown` / `renderMarkdownTail` accept per-conversation + `image: { apiBase, cwd? }`. A custom marked renderer rewrites Markdown image token hrefs: + `file://…` → decoded absolute path, `/abs/path` → itself, relative → joined to `cwd`, `~/…` passed + through for backend expansion; `http(s)://`, `data:`, and other schemes are untouched. Local paths + become `${apiBase}/local-image?path=<encodeURIComponent>` via `resolveImageUrl` (empty `apiBase` → + root-relative). Rewriting happens on the token stream, so fenced/inline code is never touched. +- `logic/streaming-markdown.ts`: `advance(state, text, opts)` threads the same options through the + settled, tail, and open-fence pipelines. The cache is keyed on `apiBase`/`cwd` so an async cwd load + invalidates and re-renders settled blocks; string identity is preserved while the options are stable. +- `ui/Markdown.svelte`: new optional `image` prop. `ChatView` gains `cwd` and passes + `image={{ apiBase: apiBaseUrl, cwd }}`; `ChatPane` supplies `store.httpBase` + `store.cwd`. +- Tests: `file://`, absolute, relative-with/without-cwd, empty-`apiBase`, http(s), `data:`, fenced-code + exemption, streaming tail + settled prefix + options-change invalidation, and ChatView threading. +- Backend contract: expects `GET /local-image?path=` exactly as pinned in `PLAN.md` (absolute path, + `~` expansion, raster whitelist, magic-byte sniff). No FE contract/type changes; **no backend asks**. +- Verification: typecheck **0 errors / 0 warnings**; tests **2,070 passed** (114 files); + `bun run check` clean; build succeeds (existing bundle-size warning only). No wire/browser-effect + adapter changes → no live probe required for this slice; manual inline-image smoke remains user-run. + +## Milestone: Agent tab grid + subagent groups (`feature/agent-tab-grid`, 2026-10-15) +- FE: the tabs view is now a 2-column grid of identical fixed-height cards (`TabCard`); a tab with + visible subagents is double-width and wrapped with them in a darker, nestable box + (`TabGroupBox`, recursive). Boxes bleed outward (negative margin + equal padding) so every card + stays on the same two columns at any depth. Pure model in `features/tabs/tab-groups.ts` + (forest build, cycle-tolerant, candidates, bleed/gutter). +- Tab menu (`TabMenu`): right-click / touch-and-hold (500 ms, 10 px tolerance) / ContextMenu key → + "Remove from group", "Ungroup N subagents", "Add to group under…" (excludes self, descendants, + current parent). `AppStore.setTabGroup` is optimistic → `PUT /conversations/:id/tab-group` + → reconciles to the response; reverts + reports on failure. +- CONSUMED (backend, same branch — plan `../backend/notes/agent-tab-grid-plan.md`): + `ConversationMeta.tabGroupParentId?` (effective), `ConversationOpenMessage.tabGroupParentId?`, + `ConversationTabGroupChangedMessage` (`conversation.tabGroupChanged`, null = ungrouped), + `SetTabGroupRequest` / `TabGroupResponse`. + +## Milestone: Tool-call cards final visual follow-up (`feature/tool-call-cards`, 2026-10-14) + +**Slice status:** COMPLETE (frontend-only). + +- Failed `edit_file` entries no longer carry header diffstats; attempted old/new content remains + available in the expanded Call section. Regression covers pending edit → failed result. +- Orphan explanation is a muted italic note, without a Call heading: “This call is earlier in the + conversation. Show earlier messages to see it.” No storage jargon in the user-facing copy. +- Pending headers now show muted `→ running…` on the right; it is replaced by the result on arrival. +- `edit_file` call detail is now a unified diff: one tinted row per line with its own −/+ gutter + marker (classes `edit-diff*` — NOT `.diff`, which collides with DaisyUI 5's `diff` component). + The output toggle is a neutral `btn-soft btn-xs`; the ↑ glyph shows only on "Show fewer lines". +- Reverted the `!scratch` / `!reports` Biome exclusions at the user's request. Moved only our + generated TS/JSON probe artifacts from gitignored reports to `/tmp/tool-call-polish-artifacts`; + the temporary harness has since been deleted. +- Verification: typecheck **0 errors/0 warnings**; full tests **1,931 passed ×2** (111 files); + build succeeds (existing warnings only). `bun run check` reports ONLY `scratch/harness.ts` + formatting/non-null assertions, (harness since deleted; check is now fully clean apart from the two existing config infos). + Separate Biome check of all `src` and root source/config files passes with no errors/warnings. +- Screenshots at 1280px and 420px confirm the failed edit, orphan note, and pending header. +- Backend, contracts, mirrors and open asks: unchanged; no backend asks. + +## Milestone: Tool-call card visual polish (`feature/tool-call-cards`, 2026-10-14) + +**Slice status:** COMPLETE (frontend-only follow-up to the user's headless-browser visual pass). + +- Card-width container queries prioritize call summaries: diffstats hide on narrow cards, + response summaries switch to complete compact facts (never mid-word CSS truncation), and + extremely narrow cards omit secondary response text. Error headers are simply `failed` + (or `timed out`); full errors remain in the response body. Wide response labels are also + budgeted by choosing a shorter whole fact instead of slicing arbitrary text. +- Edit badges now carry typed success/error tones. Orphans read “Call in earlier history” + in muted italic text, with a natural expanded explanation and de-emphasized success glyph. +- Output folding uses a high-contrast primary button; in full-output mode it moves ABOVE + the output and sticks to the transcript viewport while scrolling (no new scroll box). +- Call/Response are stronger heading elements with separators; generic input fields align + key/value columns. Short scalar arrays render inline; long/nested values retain full JSON. +- Regression coverage expanded for compact facts, error privacy in headers, orphan wording, + semantic badges/headings/fields, array formatting, and the output-control placement. +- Biome excludes local `scratch` fixtures and gitignored `reports` explicitly, so temporary + visual harnesses/artifacts do not affect the source-code check. User fixture files untouched. +- Verification: typecheck 0 errors/0 warnings; tests **1,929 passed ×2** (111 files); check + clean (existing 2 config infos only); build succeeds (existing app-store/bundle warnings). +- Browser: screenshots reviewed at **1280px and 420px**, collapsed and expanded, using the + user's running fixture harness. CDP measured no horizontal viewport overflow and confirmed + “Show fewer lines” remains at y=8px after scrolling 1,050px through the 111-line output; + actual mouse clicks fold it without closing the card at both widths. Local captures in + `reports/tool-call-polish/` (gitignored); scratch harness remains available on :24214. +- Backend, contract pins, mirrors, and open asks: **UNCHANGED; no backend asks**. + +## Milestone: Tool-call cards (`feature/tool-call-cards`, 2026-10-14) + +**Slice status:** COMPLETE (frontend-only, plan Steps 1–5 implemented; automated verification below). + +- `core/chunks/groups.ts`: `ToolEntry` replaces `ToolBatchEntry`; every call becomes an independent + `tool` render group, paired by `toolCallId` (including legacy chunks without `stepId`). Duplicate + calls/results are deduped first-occurrence-wins; results whose calls were unloaded remain visible + as orphan cards. Call/result provisional flags and step/turn provenance are retained. +- `core/metrics/place.ts`: metrics consume the new group shape; step metrics follow the LAST + independent tool card of the step, with turn placement and legacy attribution unchanged. +- `features/chat/logic/tool-summary.ts`: pure built-in summary table + generic/MCP fallback, + defensive unknown/JSON-string input normalization, call details, response facts derived from + the existing result strings (including failures/timeouts/truncation/replacements/bytes), and + first-six/last-six output folding above 12 lines (one terminal newline is not an extra line). +- `features/chat/ui/ToolCard.svelte`: one lazy, default-collapsed dropdown per bubble, including + errors; spinner/✓/✗ alongside wrench/name + call/response summaries. Expanded body holds the + call AND response. Long output has a `⋯ N more lines` toggle, full-output mode and a collapse-back + button. Stable `t<toolCallId>` keys preserve card/output expansion through arrival/sealing and + orphan-call page-in. Header truncation keeps long names/paths/results on one line. +- Contracts/pins/mirrors: **UNCHANGED** (`file:../backend/packages/{wire,transport-contract, + ui-contract,layout-contract,tile-layout-contract}`); consumes existing `ToolCallChunk` and + `ToolResultChunk` from `.dispatch/wire.reference.md`. No backend changes, contract asks or + roadblocks. Richer structured result facts remain an optional future backend change, not needed + for this feature. No network/storage/browser-effect adapter changes; no live network probe needed. +- Human visual pass still required: single shell call; parallel calls as separate bubbles; pending + spinner → ✓/✗; collapsed errors + red response; edit old/new; >12-line preview + expand/back; + narrow-layout truncation; reload mid-turn, open a pending card, and confirm sealing preserves it. + Use the worktree's `bin/up` from its parent only after stopping the main stack (shared ports); + backend startup remains user-owned. + +### Verification + +- `bun install`: succeeds (lockfile unchanged). +- `bun run typecheck`: 0 errors / 0 warnings. +- `bun run test` ×2: 1,915 passed per run (111 files). +- `bun run check`: clean (0 errors; existing Biome config-version/deprecation infos only). +- `bun run build`: succeeds (existing app-store initial-value and bundle-size warnings only). + +## Milestone: Account usage view (`feature/account-usage`) + +**Slice status:** COMPLETE + committed (both repos, `feature/account-usage`; implemented in BOTH +repos of one worktree like archive-workspaces). + +- Backend contract `[email protected]` (additive): `GET /usage` (+ `GET /usage/:provider`, + `?refresh=1`) → `AccountUsageResponse` (Claude + Codex subscription quota windows, cached + server-side). Full contract: `../backend/notes/account-usage-frontend-handoff.md`; mirror + re-generated in `.dispatch/transport-contract.reference.md`. +- FE: `src/features/account-usage` (pure `logic/view-model.ts` + `ui/AccountUsageView.svelte`, + `ui/UsageBar.svelte`), store `accountUsage()`, sidebar view `account-usage` ("Usage"). + Each limit renders as a DaisyUI `<progress class="progress">` with a **pace marker** dot at the + window's elapsed fraction (`(now − (resetsAt − durationMs)) / durationMs`) — the usage level + that is "on track" to use the allowance exactly by the reset. Spend meters (no window) show no + marker. Polls every 60 s (backend cache hit); Refresh button sends `?refresh=1`. +- Hyper (`[email protected]`): a third card with a `hyper:daily` credits bar (default 250/day) + and an inline editor (allowance + local reset time → `PUT /usage/hyper/settings`). Hyper's API only + reports the balance, so the reset time is manual or auto-detected from an observed refill. +- Open asks: none. Backend-side, inference responses from the claude/codex providers also update + the cached windows between polls (kernel `providerResponseHeaders` event) — no FE change needed. + +## Milestone: Workspace archiving (`feature/archive-workspaces`) + +**Slice status:** COMPLETE + committed (both repos, `feature/archive-workspaces`). + +### The contract change (made in the backend worktree, same branch) +- `@dispatch/wire` `Workspace.archived: boolean` (required; defaults `false`; legacy rows → `false`). +- `PUT /workspaces/:id/archive` + `DELETE /workspaces/:id/archive` — no body, create-on-miss, + return the updated `Workspace`, **409 for `"default"`**. +- `GET /workspaces?archived=` — `true` → archived only, `all` → every workspace, absent/other → + non-archived only (the home grid's default). +- Mirror: `.dispatch/wire.reference.md` updated with the 2026-09-20 delta. + +### What the FE built +- `adapter/http.ts` — `archive(id)`/`unarchive(id)`; `list(archived?: "true"|"false"|"all")` passes + the `?archived=` query. +- `logic/view-model.ts` — pure `applyArchived(list, id, archived)` (mirrors `applyStarred`). +- `store.svelte.ts` — `setArchived(id, archived)` (optimistic + revert-on-error + a reconciling + `refresh()` so the archived tile leaves / the unarchived tile re-enters the active list); + `archivedList` + `refreshArchived()` for the archived view. +- `ui/TileActionBar.svelte` — new **Archive** action (the right-click→select action bar). +- `ui/HomeHeader.svelte` — new **Archived** button opens the archived view. +- `ui/ArchivedWorkspaces.svelte` (new) — a modal listing archived workspaces (title, id, + conversation count, last-activity) with **Unarchive** and **View** (navigates to `/<id>`). +- `ui/WorkspacesHome.svelte` — wires the Archive action + the Archived modal. + +### Verification +- Backend: `tsc -b` 0 errors; `vitest run` 3041 passed (16 new: store archived block + app archive + routes / `?archived=` filter); `biome check` clean. +- Frontend: `svelte-check` 0 errors; `vitest run` 1734 passed; `biome check` clean; `vite build` ok. + +### Note for the user +- Archiving is reversible and touches no conversations. The archived tile's layout slot is freed + (backend default filter excludes it → `tiles.sync` drops the entry); unarchiving re-appends it at + the first free cell. + + +## Milestone: Layout plan 02 — provider consumption + layout WS + localStorage migration + +**Slice status:** COMPLETE + committed (both repos, `feature/tiled-layout`). + +### What the FE built +- `features/layout/adapter/http.ts` — `createLayoutHttp(httpBase, fetchImpl)`: typed + `GET/PUT/DELETE /workspaces/:id/layout` with the 404-as-signal mapping (`notFound`) + + malformed-200 degrade; tested against a scripted fetch edge. +- `features/layout/logic/migration.ts` — the §2.7 one-way best-effort migration decision + (`migrationTree`/`storedPanelViews`/`sidePanelViews`): import the stored + `dispatch.sidebar.views` selection ONLY when the provider's tree is the untouched + DEFAULT_LAYOUT seed; the key is then never read again (bytes stay). +- `features/layout/store.svelte.ts` — `createLayoutStore(deps)`: owns the ACTIVE workspace's + tree; probes on boot / workspace switch / `extensions-changed` (§2.6); WS `layout` push via + `onServerTree` (non-active workspaces ignored); edits PUT the WHOLE tree lightly debounced + (300ms) with rollback to the last server tree on failure (a PUT 404 flips to no-provider mode + and KEEPS the edit — ephemeral, no rollback, no warning beyond the once-per-absence log); + collapse state NOT touched (`dispatch.sidebar.open` unchanged). +- `adapters/ws/logic.ts` parses `layout` (`{ type, workspaceId, tree }`, light shape checks); + `adapters/ws/index.ts` dispatches via `onLayout`; `core/wire/conformance.ts` exhaustive + guard extended. +- `app/store.svelte.ts` — the app store owns the `LayoutStore` instance + exposes + `store.layout`; wires `onLayout` → `layoutStore.onServerTree`, the boot probe + (`{ boot: true }` arms the §2.7 migration), `onWorkspaceChanged` in `setActiveWorkspace`, and + a re-probe in `onExtensionsChanged`. +- `app/App.svelte` — the tree is now `store.layout.tree` (was: local `$state` seeded from + localStorage); edits flow to `layout.onTreeChange`; the `dispatch.sidebar.views` READ (boot + seed) and WRITE (edit write-back) are RETIRED — its only reader is the one-way migration. +- **App tests updated:** the tests that pre-seeded `dispatch.sidebar.views` to make a sidebar + view visible now drive the layout through the provider edge (`fakeFetchWithLayout([...])` + serving `GET /workspaces/:id/layout`); the Extensions-view test is async (the tree arrives + via the provider GET). + +### Pinned contract versions + mirrors +- `@dispatch/transport-contract` (worktree dist rebuilt): consumed with the plan-02 types + (`LayoutChangedMessage` + the `layout` union member; `LayoutTree` re-exported from + `@dispatch/layout-contract`). Mirror `.dispatch/transport-contract.reference.md` updated with + a **2026-09-04 delta note**. + +### Findings / raises for the backend +1. **Outward events emitter is a noop in the composition root** (host-bin `createNoopEvents`): + the plan-02 WS push therefore follows the LIVE `extensions-changed` path instead — a typed + event hook (`layoutChanged` in `@dispatch/layout-provider-contract`) that transport-ws + subscribes to and broadcasts verbatim. If a generic `host.events` → WS bridge ever ships, + the kit's `emitChange` dep is the single wiring point to switch. +2. **§2.4 double-provide semantics VERIFIED from kernel source** (`kernel/src/bus/bus.ts`): + `provideService` on an already-provided handle THROWS ("Only one provider per handle is + allowed") → first-provider-wins; the host converts a throwing `activate()` into the + disabled-with-reason grammar. No kit wrapper needed (the plan's conditional). + +## Milestone: Plugin Manager UI — FE scope (plan 07, feature/plugin-system) + +**Slice status:** COMPLETE + committed (both repos on the `plugin-system` worktree; FE consumes the +plan 05/06 backend contract via the `file:` deps). + +### What the FE built +- **New feature module** `src/features/plugins/`: + - `logic/types.ts` + `logic/view-model.ts` — the `PluginNode` normalization of + `GET /plugins`' four row kinds (loaded / disabledWithReason / discovered / missing) into one + shape for the tables + graph logic; tier grouping (Core → Providers → Standard). + - `logic/cascade.ts` — `computeCascade` (client mirror of the backend's `computeUnloadCascade`: + transitive dependents over the LOADED subgraph only) for the §8 cascade-uncheck warning. + - `logic/prereqs.ts` — `autoCheckBackendPrereqs` (transitive, one-directional) for the §8 + auto-check; the manager issues the missing prereq loads before the target. + - `logic/highlight.ts` — `dependencyHighlightSets` (one-hop both directions, across BOTH tables). + - `logic/last-provider.ts` — `isLastProvider` + `unloadBlockReason` (client mirrors of the §8 locks). + - `logic/workspace-row.ts` — modal checkbox semantics (`rowChecked`/`rowSelectable`/ + `rowBlockReason`/`stateLabel`) over the plan-06 `WorkspacePluginRow`s. + - `adapter/http.ts` — typed calls to `GET /plugins`, `POST /plugins/scan`, + `POST /plugins/:id/load|unload({force})|reload`, `GET /workspaces/:id/plugins`, + `PUT /workspaces/:id/plugins/:pluginId`, `GET /workspaces/:id/plugins/providers-status`; + blocked responses surface `reason` + `dependents`. + - `store.svelte.ts` — `PluginsStore` (list/nodes, scan/load/unload/reload, per-workspace view + with stale-clear + server-authoritative reconcile on 409, `onExtensionsChanged` hook). + - `ui/` — `PluginManager` (loaded/discovered tables + missing strip, Scan, Reload, cascade + confirm, force confirm with the "stops ALL agents globally" copy, hover highlighting), + `PluginTable`/`PluginRow` (locked core rows: no checkbox, 🔒 + tooltip), `WorkspacePluginModal` + (per-workspace enable/disable; unloaded = greyed + unselectable), `UnloadConfirmModal`. +- **Composition root** (`src/app/store.svelte.ts` + `src/App.svelte`): + - the app store owns the `PluginsStore` instance + exposes `store.plugins` / + `store.enabledProviderCount`; + - sends `workspace.set` on boot (queued), on every `setActiveWorkspace`, and on reconnect + (`onReopen` — a new connection is anonymous again); + - `extensions-changed` → `pluginsStore.onExtensionsChanged()` + workspace-scoped model refresh + + provider-status refresh (the catalog itself is re-pushed by the server); + - `GET /models?workspaceId=<active>` on boot / workspace switch / extensions-changed; + - `GET /workspaces/:id/plugins/providers-status` per active workspace → the §7 first-boot + guardrail (`store.enabledProviderCount === 0` → prompt above the model picker). +- **Workspaces page** — global "Plugins" button in the header (→ `PluginManager`); per-card + "Plugins" button (→ `WorkspacePluginModal`). +- **WS adapter** (`src/adapters/ws/`) — parses `extensions-changed` (dispatch → + `onExtensionsChanged` option); `workspace.set` serializes via the existing `WsClientMessage` path. +- **Guardrail** (`features/chat/ui/ModelSelector.svelte`) — new optional `zeroProviders` prop renders + the visible prompt. **Wire/conformance** — `core/wire/conformance.ts` exhaustiveness guards + extended for the two new message types. +- **Tests:** 1356 pass (up from 1256): pure logic (cascade/prereqs/highlight/last-provider/ + view-model/workspace-row), adapter (routing + block mapping + degradation), store (fetch/refresh/ + reconcile/coherence hook), components (locked core, greyed-unloaded, cascade warn + accept, + last-provider disable + tooltip, guardrail), ws parsing. typecheck 0 errors/0 warnings; biome + clean (pre-existing store.test.ts warning + 2 config infos only); vite build succeeds. + +### Pinned contract versions + mirrors +- `@dispatch/transport-contract`: consumed with the plan 05/06 types (`WorkspaceSetMessage`, + `ExtensionsChangedMessage`, `PluginListResponse` family, `WorkspacePluginsResponse` family). + Mirror `.dispatch/transport-contract.reference.md` updated with a **2026-09-03 delta note** (the + body is NOT yet fully regenerated — the delta note tells readers to use the package sources). +- `@dispatch/ui-contract`: consumed with `SurfaceCatalogEntry.pluginId?`. Mirror + `.dispatch/ui-contract.reference.md` updated with a 2026-09-03 delta note. + +### Findings / raises for the backend +1. **§7.3-vs-§6.6 conflict resolved in favor of the shipped contract (FE mirrors the server).** Plan + 07's prose says Core rows are "checked-but-toggleable" in the workspace modal, but the SHIPPED + backend (plan 06 §6.6 + `workspace-plugin-config`) marks core rows `locked: true` and refuses + core PUTs with 409 `core-locked` (the backend's own comment says "the workspace modal renders + core rows as locked"). The modal therefore renders core rows CHECKED + LOCKED (disabled checkbox + + tooltip). If the backend ever relaxes this (plan 06's pure `isPluginUsable` already honors an + explicit core `disabled`), the modal consumes `locked` and will unlock automatically — no FE + change needed. +2. **In-use force attribution (plan-00 Q2):** the FE's force path is triggered by (a) a local + cascade (≥1 loaded dependent → warn + force) or (b) a server refusal (any block reason → the + force confirm with the global-stop copy). There is no FE-readable "in-use" flag on + `PluginOpBlocked`; if the backend later adds a dedicated reason (e.g. `in-use`), the FE flow + already handles it (any refusal → force offer). +3. **No live-probe yet:** this slice touches the wire (`workspace.set` send, `extensions-changed` + parse). The static suites + build are green; a live probe against a running backend + (`scripts/live-probe.ts` + a manual two-client check of the manager) is the remaining human step. + +### Likely next asks (plan 08 / later) +- The `pluginId` on catalog entries is not yet rendered by the FE (no attribution UI requested). +- The renderer registry / dynamic FE loading / tiling is explicitly OUT of this plan (Addendum A) — + a later dedicated agent's job; this plan's manager + modal are placement-agnostic. --- -## 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`/`TurnProviderRetryEvent`(transient retry-warning)/`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`, `Workspace`/`WorkspaceEntry`(+`defaultComputerId`,+`starred`)/`Computer`/`ComputerEntry` (SSH handoff #1) | -| `@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`) + MCP (`McpStatusResponse`/`McpServerInfo`/`McpServerState`) + concurrency (`ConcurrencyLimitsResponse`/`SetConcurrencyLimitRequest`/`ConcurrencyLimitResponse`/`ConcurrencyStatusEntry`/`ConcurrencyStatusResponse`) + 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`/`PUT /conversations/:id/model` (sticky per-conversation model persistence) · -`GET /conversations/:id/lsp` · `GET /conversations/:id/mcp` (MCP server status; mirrors `/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; carries `workspaceId`) · -WS `conversation.statusChanged` (broadcast: lifecycle status change — `active`/`idle`/`closed`; carries `workspaceId`) · -`GET /concurrency/limits` · `GET`/`PUT`/`DELETE /concurrency/limits/:providerId` · `GET /concurrency/status` -(per-provider in-flight caps + oldest-agent-first queueing + 429-pause backoff; the `concurrency` extension — when not -loaded the list + status endpoints return empty arrays, the single/PUT/DELETE return `503`). -`GET`/`PUT /concurrency/cooldown/:providerId` (per-slot release cooldown — configurable + persisted; 0 = no cooldown; -`GET` 404 when the provider has no concurrency config at all, `PUT` 400 on a non-negative-int body — see §2j-update-3). - -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. -- **`PUT /conversations/:id/cwd` sends the active `workspaceId`** (CR-8) so a relative cwd set on a - new/draft tab is assigned to the workspace BEFORE persisting — the subsequent `GET /lsp` then - resolves it against `workspace.defaultCwd`. The store reads `activeWorkspaceId` with `untrack` at - call time (never reactive inside the async). `chat.send` still omits `cwd` (the persisted cwd wins). -- **`conversation.open` and `conversation.statusChanged` WS broadcasts carry `workspaceId`** so tabs opened - by the CLI `--open` flag (or by a cross-device `active` status change) are stamped with the conversation's - actual workspace instead of the viewer's current workspace. The FE ignores any broadcast missing `workspaceId` - (parser returns null) — acceptable because the backend contract is updated in lockstep. - ---- - -## 2. Open asks FOR THE BACKEND - -### CR-9 — `system:os` variable: include WSL detection + Linux distro → **OPEN** - -The `system:os` system-prompt variable (resolved by the backend at construction time) should -return a richer OS string: - -1. **WSL detection** — when running under Windows Subsystem for Linux, the resolved `system:os` - value should indicate WSL (e.g. `"Linux (WSL)"` or `"WSL2"` rather than just `"Linux"`). - Detection: check for the presence of `/proc/sys/fs/binfmt_misc/WSLInterop` or - `Microsoft` in `/proc/version`, or the `WSL_DISTRO_NAME` environment variable. -2. **Linux distro** — on Linux, include the distribution name (e.g. `"Ubuntu 22.04"` or - `"Ubuntu"` rather than just `"Linux"`). Source: `/etc/os-release` (`PRETTY_NAME` or - `NAME`/`VERSION_ID`). - -No wire/transport-contract/ui-contract change needed — this is a backend behavior change in -how the `system:os` variable is resolved (the type shape is unchanged: it's still a `string`). -The FE is unaffected (it only inserts `[system:os]` into the template; the backend resolves it). - -### CR-13 — Per-conversation `"queued"` status for the concurrency queue → **RESOLVED ✅ (backend shipped; FE consumed + tested)** - -Small UX ask: when a conversation's request is WAITING in the per-provider concurrency queue (not yet -generating tokens), the FE shows the loading **RING** (spinner) on that tab + in the composer corner, -instead of the loading **DOTS** (dots = actively generating). - -**Backend (shipped — additive to `[email protected]`, NO version bump):** `ConversationStatus` widened to -`"active" | "queued" | "idle" | "closed"`. When the concurrency manager CANNOT grant a slot -immediately (at limit or paused), `onQueued` fires → the orchestrator broadcasts -`conversation.statusChanged` with `status: "queued"` (carrying `workspaceId`, same shape as before). -`"queued"` is BROADCAST ONLY — it is NOT persisted (the persisted status stays `"active"`; on restart -conversations show `"active"`, never stuck in `"queued"`). When the slot is granted (`acquire` -resolves), `onAcquired` fires → broadcasts `"active"` again. The existing `"idle"` on turn seal is -unchanged. A request that gets a slot immediately never emits `"queued"`. Also: `GET /conversations?status=queued` -now works ("queued" added to the valid status filter set). - -**FE (DONE + verified):** -- Re-synced the `@dispatch/wire` `file:` dep (`bun install`); `node_modules/@dispatch/wire/dist` now has - `"queued"` in `ConversationStatus`. Re-mirrored `.dispatch/wire.reference.md` (widened the type + - header delta note). -- WS parser (`src/adapters/ws/logic.ts`): accepts `"queued"` in the `conversation.statusChanged` - status set (was hard-coded to `active/idle/closed`). -- Store handler (`onConversationStatusChanged`): `"queued"` updates the status map (drives the tab - spinner) AND opens a tab for a new cross-device queued conversation (like `"active"`; `"idle"` - never opens). `closed` still removes the tab. -- `TabList.svelte`: `status === "queued"` → loading-**ring** (`loading-spinner`, `aria-label="Queued"`, - `title="Waiting for a concurrency slot"`); `status === "active"` → loading-**dots** (unchanged); no - status → no spinner. -- `Composer.svelte`: `status` type widened to `ComposerStatus = "idle" | "running" | "queued" | "error"` - (exported via `features/chat/index.ts`). `"queued"` → a loading-ring status icon (`aria-label="Queued"`) - + placeholder "Queued for a slot…". `"queued"` behaves like `"running"` for the send button - (`inFlight = running || queued` → steer/stop) — the turn is in flight, just waiting for a slot. -- `App.svelte`: `composerStatus` derived (`error > queued > running > idle`) — `conversationStatus(id) - === "queued"` wins over `generating` so the corner shows a ring 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 them). -- Tests: WS parser accepts `"queued"`; store handler sets the status + opens a cross-device tab + - transitions `queued → active → idle`; TabList renders a ring for `"queued"` + dots for `"active"`. - -**Verification:** typecheck 0/0, **925 tests green** (run TWICE — no cross-test pollution; the store -handler tests feed real WS frames through the parser), biome clean, build OK. Live probe NOT run (the -backend is the user's process; never booted headless). To confirm end-to-end: set a provider's -concurrency limit to 1, start 2 turns on that provider, watch the second tab show a ring ("Queued") -until the first finishes, then flip to dots ("active"). - -### CR-7 — Workspace cwd fallthrough bug + relative-path resolution → **RESOLVED ✅ (backend shipped; FE code unchanged)** - -Fixed backend-side (reply from arch-rewrite agent ab13). **No wire/transport-contract/ui-contract -bumps needed; FE does NOT need a re-pin or re-mirror.** - -**What was fixed:** -1. Workspace `defaultCwd` now applies when the conversation has no explicit per-conversation cwd. -2. A per-turn `cwd` (or persisted `cwd`) that is **relative** is now resolved against the workspace - `defaultCwd`, not raw → falls through to `process.cwd()`. -3. `DELETE /conversations/:id/cwd` now actually clears the persisted cwd (was a no-op stub). -4. New-conversation timing: first turn assigns the workspace before resolving cwd, so a relative - per-turn cwd on a brand-new conversation resolves against the correct workspace. - -**Resolution algorithm (backend-owned):** -``` -workspaceCwd = workspace?.defaultCwd ?? null -conversationCwd = persisted per-conversation cwd OR per-turn cwd from chat.send (null if omitted) -if (conversationCwd == null) effectiveCwd = workspaceCwd ?? serverDefaultCwd // process.cwd() -else if (conversationCwd[0] === "/") effectiveCwd = conversationCwd -else effectiveCwd = path.resolve(workspaceCwd ?? serverDefaultCwd, conversationCwd) -``` - -**FE impact:** none — the FE already sends `workspaceId` and **omits `cwd`** on `chat.send` -(`src/features/chat/store.svelte.ts`). The persisted cwd is set separately via `PUT /conversations/:id/cwd`, -and the backend resolves it at turn start. `GET /conversations/:id/cwd` still returns the raw explicit -value (e.g., `"gameplay"`) for the CwdField; `GET /conversations/:id/lsp` returns the resolved effective -cwd. - -**Optional future FE enhancement:** Add a "Clear" button to CwdField that calls -`DELETE /conversations/:id/cwd`, letting the user reset a conversation to inherit the workspace -`defaultCwd`. Not required for the fix. - ---- - -### CR-10 — `workspaceId` on `conversation.open` / `conversation.statusChanged` WS broadcasts → **RESOLVED ✅ (`[email protected]`; FE consumed)** - -**Bug:** When summoning an agent via the CLI `--open --workspace <id>` flag, the tab opened across -ALL workspaces instead of just the one it was assigned to. The backend knew the conversation's -workspace but dropped it from the broadcast — the `ConversationOpenMessage` and -`ConversationStatusChangedMessage` WS messages carried only `conversationId`. The FE then fell back -to stamping the tab with `activeWorkspaceId` (the viewer's current workspace), so the tab appeared in -every open browser tab's workspace view. - -**Backend fix (shipped):** Additive `workspaceId: string` on both broadcast messages -(`[email protected]`). The backend resolves the conversation's persisted workspace -(`"default"` fallback) at broadcast time — not the per-turn start option — and includes it in the -`conversation.open` and `conversation.statusChanged` fan-out. - -**FE fix (consumed):** -- WS parser (`src/adapters/ws/logic.ts`): parse + require `workspaceId` on both message types. -- `openConversation()` (`src/app/store.svelte.ts`): signature changed to - `(conversationId, workspaceId)`; the tab is stamped with the message's `workspaceId`, not - `activeWorkspaceId`. The `onConversationOpen` and `onConversationStatusChanged` handlers pass - `msg.workspaceId` through. -- Re-mirrored `.dispatch/transport-contract.reference.md`. -- Tests updated: `logic.test.ts`, `index.test.ts`, `conformance.test.ts`. - -**Note:** The FE parser now rejects `conversation.open` / `conversation.statusChanged` messages -missing `workspaceId` (returns null). This is acceptable because the backend contract is updated in -lockstep; a mixed-version setup (old backend + new FE) would silently drop those broadcasts. - ---- - -### CR-11 — Per-conversation model persistence → **RESOLVED ✅ (`[email protected]`; FE consumed)** - -**Backend (shipped):** -- `[email protected]` adds `ModelResponse` and `SetModelRequest`. -- New endpoints: - - `GET /conversations/:id/model` returns `{ conversationId, model: string | null }`. - - `PUT /conversations/:id/model` with body `{ model: string | null }` persists or clears the - per-conversation sticky model selection. -- The backend resolves the model per turn: explicit `ChatRequest.model` override wins, else persisted - model for the conversation, else the server default. - -**FE (consumed):** -- Imported `ModelResponse` + `SetModelRequest`; re-mirrored `.dispatch/transport-contract.reference.md`. -- Added `refreshModel()` (`src/app/store.svelte.ts`) — fetches via `GET /conversations/:id/model` on every - focus change (tab switch, workspace switch, boot, reconnect) and updates `activeModel`, the active tab's - stored model, and the active chat store's model when a non-null model is returned. -- Updated `selectModel(model)` to persist the choice via `PUT /conversations/:id/model` when a real - conversation tab is active; drafts still only update session-local state. -- Tests added (`src/app/store.test.ts`): model selection triggers a `PUT /model` with the right body, - and a persisted model is recalled when focusing a new conversation. - ---- - -### Workspaces — backend-owned conversation grouping with a default cwd → **RESOLVED ✅ (backend shipped; FE build in progress)** - -A **workspace** is a URL-driven (`/<id>`) grouping of conversations that owns a default cwd (used by its -conversations that haven't set their own). Backend-owned so cross-device just works. The backend shipped -the finalized contract (`[email protected]`/`[email protected]`): `Workspace`/`WorkspaceEntry` types, -`workspaceId` on `ConversationMeta` + `ChatRequest`/`QueueRequest`/`ChatQueueMessage`, workspace endpoints -(`GET /workspaces`, `PUT`/`GET /workspaces/:id`, `PUT .../title`, `PUT .../default-cwd`, -`DELETE /workspaces/:id`), `?workspaceId=` on `GET /conversations`, and `DELETE /conversations/:id/cwd` -(clear-to-inherit). Q1–Q8 decisions + full shapes in `backend-handoff-workspaces-reply.md`. FE re-pinned + -re-mirrored; FE feature build in progress. - -### Workspace starring (concurrency priority) → **CONSUMED ✅ (backend shipped `feature/workspace-star`; FE built + green)** - -**Backend contract change (additive to `[email protected]`, NO version bump):** `Workspace` gains a required -`starred: boolean` (defaults `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); takes effect immediately for already-queued agents (backend-owned, via -`setWorkspaceStarred` + `concurrencyService.notifyWorkspaceStarred`). Two dedicated endpoints (no body; -both create-on-miss; return the updated `Workspace`): -- `PUT /workspaces/:id/star` → star (400 on invalid slug; 200 `Workspace{starred:true}`). -- `DELETE /workspaces/:id/star` → unstar (400 on invalid slug; 200 `Workspace{starred:false}`). - -`PUT /workspaces/:id` does NOT accept a `starred` field (the dedicated endpoints are the only path). No -new `transport-contract` request/response types — both reuse `WorkspaceResponse`. - -**FE consumed (all green — typecheck 0/0, 1045 tests, biome clean, build OK):** -- `adapter/http.ts`: `star(id)`/`unstar(id)` → `WorkspaceResult<Workspace>` (PUT/DELETE `/workspaces/:id/star`, no body). -- `logic/view-model.ts`: pure `sortWorkspaces` (starred-first, then `lastActivityAt` desc, stable) + pure - `applyStarred` (the optimistic apply/revert transformation). Both unit-tested. -- `store.svelte.ts`: `setStarred(id, starred)` — optimistic flip (the `$derived` sorted list re-orders - reactively) with error revert; no full refresh on success (avoids flicker). Re-mirrored `.dispatch/wire.reference.md`. -- `ui/WorkspaceCard.svelte`: a star toggle button (filled gold ★ when starred, outline ☆ when not; spinner - while in flight; `aria-pressed`/`aria-label`; tooltip notes concurrency priority). Clicking calls - `store.setStarred(ws.id, !ws.starred)`. -- Tests: http star/unstar (5), view-model sort+applyStarred (12), store optimistic+revert+re-sort (6), WorkspaceCard star button (4). - -**No open asks for the backend.** Live probe of the star endpoints against a running backend is the only -remaining human step (the `scripts/live-probe.ts` does not cover workspace endpoints; a manual `curl` or a -click in the home view confirms the round-trip). - -### 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-12 | `GET /conversations/:id/mcp` MCP server-status endpoint (mirrors `/lsp`) | ✅ `[email protected]`; backend shipped; FE consumed + verified (3 LSP-shape diffs handled) | -| CR-10 | `workspaceId` on `conversation.open` / `conversation.statusChanged` WS broadcasts | ✅ `[email protected]`; FE consumed | -| CR-7 | Workspace cwd fallthrough bug + relative-path resolution | ✅ resolved (backend-only) | -| 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 | - ---- - -## 2b. MCP server status slice → **RESOLVED ✅ (backend shipped CR-12; FE verified)** - -Consumes the backend's `GET /conversations/:id/mcp` endpoint, mirroring `GET /conversations/:id/lsp` -exactly. Contract types in `[email protected]`: `McpServerState` -(`"connecting" | "connected" | "error" | "disconnected"`), `McpServerInfo` -(`{ id, state, error?, toolCount, configSource? }`), `McpStatusResponse` -(`{ conversationId, cwd: string|null, servers: McpServerInfo[] }`). Full backend handoff: -`../backend/frontend-mcp-status-handoff.md`; shape/behavior details: -`../backend/reports/transport-http-mcp.md`. - -**Backend (shipped CR-12):** endpoint behaves identically to `/lsp` — no persisted cwd → -`{ cwd: null, servers: [] }` (HTTP 200, empty); MCP extension not loaded → -`503 { error: "MCP service not available" }`. The FE handles both gracefully (empty/no-cwd/503 paths -never crash the renderer; the 503 `error` string surfaces in red). - -**FE (DONE + verified against the 3 LSP-shape differences the backend flagged):** -- Re-pinned `transport-contract` 0.20.0 → 0.22.0; re-mirrored `.dispatch/transport-contract.reference.md` - (added the MCP section + the previously-missing `configSource` on `LspServerInfo`). -- New feature library `src/features/mcp/`: pure `logic/view-model.ts` (`viewMcpServer`/`viewMcpServers`/ - `summarizeMcpServers`, state→badge/label/busy mapping), `ui/McpStatusView.svelte` (mirrors - `LspStatusView`'s structure — refresh button, loading, summary, server list), `index.ts` - (`McpStatusView`/`manifest`/types). 9 view-model tests green. -- `AppStore.mcpStatus()` (`src/app/store.svelte.ts`) — mirrors `lspStatus()`: normalizes the untyped - body at the network seam (`servers` guaranteed an array), returns `McpResult | null`. -- Wired into `src/app/App.svelte`: `"mcp"` view kind, `loadMcpStatus` adapter, `McpStatusView` in the - `viewContent` snippet (re-mounts per conversation via `{#key store.currentConversationId}`). - -**Three shape differences from LSP — verified handled, do NOT regress:** -1. **Fields differ.** `McpServerInfo` has only `{ id, state, error?, toolCount, configSource? }` — NO - `name`/`root`/`extensions`. The MCP row markup (`McpStatusView.svelte`) renders `id`/state badge/ - `toolCount`/`error`/`configSource` only; it does NOT reference any LSP-only field. `McpServerView` - is a distinct type from `LspServerView` (the latter carries `name`/`root`/`extensionsLabel`). Do not - reuse the LSP row component verbatim for MCP. -2. **Enum differs.** `McpServerState = "connecting" | "connected" | "error" | "disconnected"` — a - DIFFERENT enum from LSP's `"connected" | "starting" | "error" | "not-started"`. `viewMcpServer`'s - switch handles all four MCP cases (exhaustive vs the contract): `connected`→success, `connecting`→ - warning+busy(spinner), `disconnected`→neutral (NOT busy — a stable idle state), `error`→error. Do - not share the LSP badge mapping. -3. **`configSource?` is currently always absent** on this path (the `McpServerStatus` source doesn't - carry it) but the wire type leaves it optional. The FE renders it defensively: view-model coerces - `server.configSource ?? null`; the template guards `{#if server.configSource}` with an empty-`<span>` - fallback, so absence renders nothing. Verified correct for the current always-absent reality. - -**Live probe:** NOT run — the backend was not reachable on `:24203` at verify time (the backend is the -user's process; never booted headless). The unit suite (775 tests, incl. 9 mcp) is green; a live probe -against a running backend remains a nice-to-have for the actual network seam (it's a plain HTTP GET, -no WS). Start the backend and run `bun scripts/live-probe.ts` (or just open the MCP Servers sidebar view) -to confirm end-to-end. - ---- - -## 2c. Transient `provider-retry` AgentEvent → **CONSUMED ✅ (backend shipped; FE wired + tested)** - -The backend now retries retryable provider errors (e.g. "server overloaded" HTTP 429/5xx) with a stepped -backoff (5s→10s→30s→60s→5m→10m→15m→30m→repeat, up to an 8h budget). Each scheduled retry emits a NEW -**transient** `AgentEvent`: `provider-retry`. The FE renders it as a **yellow warning system-message -bubble**; the actual model reply still streams normally after a retry succeeds. Contract types are in -`[email protected]` (additive — the type was added to the existing version, no bump; the FE's `file:` dep picks -it up automatically, no re-pin needed). `TurnProviderRetryEvent`: -`{ type: "provider-retry", conversationId, turnId, attempt (0-based), delayMs, message, code? }`. - -**FE (DONE + verified):** -- Re-mirrored `.dispatch/wire.reference.md` (added `TurnProviderRetryEvent` to the `AgentEvent` union + - its interface). The FE already resolved the new type via the `file:` symlink (no re-pin). -- `src/core/chunks/types.ts`: added `providerRetry: TurnProviderRetryEvent | null` to `TranscriptState` - (mirrors the `generating` UI-indicator pattern — event-stream-derived state that is NOT a chunk). -- `src/core/chunks/reducer.ts`: `foldEvent` SETS the banner on a `provider-retry` (coalescing — latest - attempt+delay replaces previous → a single updating banner) and CLEARS it when content resumes - (`text-delta`/`reasoning-delta`/`tool-call`/`tool-result`), the turn ends (`done`/`turn-sealed`/`error`), - or a new turn starts (`turn-start`); metadata events (`status`/`usage`/`step-complete`/`tool-output`) - leave it untouched. The clearing is centralized via a `RETRY_CLEARING_EVENTS` Set in a thin `foldEvent` - wrapper over the renamed inner `reduceEvent`. `clearGenerating` (WS reconnect) also drops a stale banner. -- `src/core/wire/conformance.ts` + `.test.ts`: added the `provider-retry` case to the exhaustiveness guard - and bumped the variant count 14 → 15 (the `satisfies never` guard is what flagged the new variant). -- `src/core/chunks/selectors.ts` (`selectProviderRetry`) + new `retry-banner.ts` view-model - (`viewProviderRetry` → `{ attemptLabel: "Retry #N", delayLabel: "5s"/"30m", message, code }`, - `formatRetryDelay`), exported from `chunks/index.ts` + re-exported from `features/chat/index.ts`. -- `src/features/chat/store.svelte.ts`: `providerRetry` getter + `ChatStore` interface field (mirrors - `generating`). -- `src/features/chat/ui/ChatView.svelte`: new `providerRetry` prop → renders a DaisyUI `alert alert-warning` - yellow bubble at the end of the transcript (where the reply would appear), with `⚠ Retry #N — retrying in - {delay}…`, a `code` badge, and the endpoint error verbatim. Re-mounted per conversation. -- `src/app/App.svelte`: passes `providerRetry={store.activeChat.providerRetry}`. -- 19 new tests (reducer: set/coalesce/clear-on-resume/clear-on-turn-end/clear-on-new-turn/clear-on-reconnect/ - not-a-chunk/metadata-preserves; retry-banner: delay formatting + view-model). 775 tests green. - -**Transient / never-persisted guarantee (the critical invariant):** `provider-retry` is NEVER a `Chunk` -(it's not in the `Chunk.type` union — `assertChunkExhaustive` is unchanged). It lives only in -`TranscriptState.providerRetry`, set/cleared by `foldEvent`. It never enters `committed` (seq'd history) or -`provisional`, so it can NEVER pollute the model's prompt or be sent back as a message. On a reload/replay of -past turns it is NOT replayed (only committed seq'd chunks are history) — `providerRetry` starts null and is -set only when a NEW `provider-retry` arrives. On a WS reconnect mid-turn, `clearGenerating` drops any stale -banner (past retries aren't replayed). If the 8h budget exhausts, the existing terminal `error` `AgentEvent` -fires and seals the turn — rendered as the existing error state (the banner is cleared by the `error` case). - -**Note on `step-complete` timing:** when retries occur, the `step-complete` event's `genTotalMs` includes -the retry-sleep time (backend-side, cosmetic). The FE surfaces per-step timing unchanged — no action needed. - -**Open follow-up (optional, not blocking):** the "countdown" is a STATIC label derived from `delayMs` -("retrying in 5s…"), matching the backend's examples — NOT a live ticking timer (which would be a component -effect + re-render churn). A live ticking countdown (5,4,3,2,1…) could be added later as a Svelte -`$effect`/`setInterval` in ChatView if desired, but the static label is accurate and keeps the component thin. - -**Live probe (run, backend up):** `scripts/live-probe-provider-retry.ts` — 8/8 checks passed: -- REGRESSION: a real text turn through the REAL WS socket + the updated `foldEvent` sealed cleanly and - `providerRetry` stayed NULL throughout (no spurious banner; the `reduceEvent` wrapper didn't break streaming). - (The repo-wide `scripts/live-probe.ts` also still passes 23/23 — text/tool/metrics/CR-5 all green with the - updated reducer.) -- PARSER+REDUCER SEAM: a synthetic `provider-retry` `chat.delta` JSON frame through the REAL - `parseServerMessage` is ACCEPTED (not rejected as unknown) → `foldEvent` SETS `providerRetry` (attempt/ - delayMs/code correct) and adds NO chunk → a 2nd coalesces → a subsequent `text-delta` CLEARS it and the - reply lands as a chunk. This is the JSON-parse boundary the unit tests skip (they pass constructed events). -- NOT exercised live: the actual banner rendering in the browser (a real `provider-retry` from an overloaded - provider can't be forced from a probe — it needs a human at the page with a 429'ing provider). The full - data path (wire parse → reducer → state) IS verified live; only the Svelte render of the yellow bubble - remains a human-confirm (open the chat, trigger an overloaded provider, confirm the yellow "⚠ Retry #N — - retrying in 5s…" banner appears, updates per attempt, and clears when the reply streams). - ---- - -## 2d. SSH support — handoff #1 (wire types) → **CONSUMED ✅ (provider-retry divergence RESOLVED)** - -The first of a few incremental SSH handoffs. The backend's `@dispatch/wire` (pinned `file:` dep) gained SSH-computer -types — **additive to `[email protected]`, NO version bump** (same pattern as `provider-retry`). The full HTTP API surface -(computer endpoints, `chat.send computerId`) comes in a LATER handoff; this one is wire-types only. - -**New wire types (consumed + re-mirrored):** -- `Workspace` gained a REQUIRED `defaultComputerId: string | null` (null = local / no SSH; the computer analog of - `defaultCwd`). Resolution is SERVER-owned (per-conv `computerId` → `workspace.defaultComputerId` → `null`/local). -- `Computer` — a read-only view of a discovered `~/.ssh/config` `Host` target: - `{ alias, hostName, port, user, identityFile, knownHost }`. `alias` IS the `computerId` users select. NOT an - editable entity (no CRUD store — the user edits `~/.ssh/config` to add one). -- `ComputerEntry extends Computer` — adds `usageCount` (for `GET /computers`). - -**FE (DONE):** -- Re-synced the `@dispatch/wire` `file:` dep (worktree layout note below) so the new types resolve. -- Re-mirrored `.dispatch/wire.reference.md`: added `Workspace.defaultComputerId`, the `Computer`/`ComputerEntry` - section, and a header delta note. -- Fixed the 2 `Workspace`/`WorkspaceEntry` test literals that broke (the handoff's expected break): - `src/features/workspaces/ui/WorkspaceCard.test.ts` (`fakeEntry`) and - `src/features/workspaces/logic/view-model.test.ts` (`ws` factory) — both now supply `defaultComputerId: null`. - (The conformance test's `provider-retry` case is the unrelated divergence below — NOT a `defaultComputerId` site.) -- Added `computer` / `computerId` to `GLOSSARY.md` (backend-canonical, adopted verbatim). - -**NOT started (correctly deferred):** the `computer` feature folder, the per-conversation + workspace-default -selectors, the connection-status badge, and `chat.send computerId` — all wait on the later HTTP-API handoff. - -### `provider-retry` divergence → **RESOLVED ✅ (backend merge `de022ce`)** - -**Was:** the backend `feature/ssh-support` branch (cut from `8a74335`) was MISSING `TurnProviderRetryEvent` / -`provider-retry` (on `dev`), causing 11 FE typecheck errors. **Resolved:** backend merged `dev` into -`feature/ssh-support` (merge commit `de022ce`, in the shared `../backend` worktree); `packages/wire/dist/index.d.ts` -now exports `TurnProviderRetryEvent` (AgentEvent union line 231 + interface line 384) ALONGSIDE the SSH types -(`Workspace.defaultComputerId`, `Computer`). Backend post-merge: `tsc -b` EXIT 0, biome clean, 1730 vitest pass. -The auto-merge was clean — `computerId` threading + retry-with-backoff coexist. - -**FE (DONE, zero code changes):** re-synced both `file:` deps (`bun install`); `node_modules/@dispatch/wire` + -`@dispatch/transport-contract` both resolve `TurnProviderRetryEvent` (the transport-contract re-export confirmed). -`bun run typecheck` is now **GREEN — 0 errors, 0 warnings** (the 11 cleared with NO FE code changes; the -`provider-retry` consumption was already complete + tested). Full suite: 795/795 tests, biome clean, build OK. - -### Worktree environment note (not a contract change) -This worktree lays the repos out as `…/worktrees/ssh-support/{backend,frontend}`, but `package.json`'s canonical -`file:` paths point at `../backend` (correct for the main `dispatch/{dispatch-backend,dispatch-web}` layout). -To keep `package.json` canonical (no worktree-specific hack committed), a symlink `../backend → ../backend` -was created in the worktree parent (untracked, outside the repo), then `bun install` re-synced `node_modules/@dispatch/*`. -The backend wire `dist/` was already built + current (has the new types); no backend edit was made. - ---- - -## 2e. SSH support — handoff #2 (full computer HTTP API) → **CONSUMED ✅ (FE built; fully green post §2d merge)** - -The backend shipped the full SSH computer HTTP/WS API (transport-contract types stable; the `ssh` extension that -provides the ComputerService is the last backend wave — until it lands, `GET /computers` returns `[]` and statuses -return `disconnected`, which the FE renders gracefully). The FE mirrors the existing `cwd`/`workspaces` UI. - -**New transport-contract types consumed (additive to `[email protected]`, NO version bump):** `ComputerListResponse` -(`GET /computers`), `ComputerResponse`, `ComputerStatusResponse` (`GET /computers/:alias/status`), `TestComputerResponse` -(`POST /computers/:alias/test`), `SetConversationComputerRequest` + `ConversationComputerResponse` -(`GET`/`PUT`/`DELETE /conversations/:id/computer`), `SetWorkspaceDefaultComputerRequest` -(`PUT /workspaces/:id/default-computer`), and `computerId?: string` on `ChatRequest`/`ChatSendMessage`/`QueueRequest`. -`Computer`/`ComputerEntry` are `@dispatch/wire` (handoff #1). Re-mirrored `.dispatch/transport-contract.reference.md` -(added the Computers section + `ChatRequest.computerId`). - -**FE (DONE — mirrors cwd-lsp's consumer-defines-port pattern):** -- New feature library `src/features/computer/`: pure `logic/view-model.ts` (`viewComputer`/`viewComputerStatus`/ - `viewTestResult`/`summarizeComputers`/`formatHost`/`knownHostLabel` + state→badge mapping for the 4 - `ComputerStatusResponse.state`s + the `SaveComputer`/`LoadComputerStatus`/`TestComputer`/`LoadComputers` ports) — 20 - view-model tests green; `ui/ComputerField.svelte` (per-conversation selector: dropdown + connection-status badge + - Test-connection, polling the selected alias) + `ui/ComputerSelect.svelte` (a reusable Local/computers dropdown, shared - with the workspace default-computer control); `index.ts` (`ComputerField`/`ComputerSelect`/`manifest`/types). -- `AppStore` (`src/app/store.svelte.ts`): `computerId` reactive state + `refreshComputer()` (parallel to `refreshCwd`, - called at every focus site: boot, workspace switch, draft→tab, newDraft, selectTab, removeTabLocally) + - `setComputer(computerId: string | null)` (`PUT /conversations/:id/computer`, null = clear) + a global `computers` - catalog (`GET /computers` on boot, like `models`) + `computerStatus(alias)` + `testComputer(alias)`. New result types - `ComputerResult`/`ComputerStatusResult`/`TestComputerResult`. `chat.send` UNCHANGED (computer resolved server-side - from the persisted per-conversation value, exactly like cwd). -- `src/app/App.svelte`: `ComputerField` mounted in the "Model" sidebar view next to `CwdField`, keyed on - `currentConversationId`; adapted ports (`saveComputer`/`loadComputerStatus`/`testComputer`) wrap the store. -- Workspaces: `setDefaultComputer` added to `WorkspaceHttp` + `WorkspaceStore` (`PUT /workspaces/:id/default-computer`); - a default-computer selector (reusing `ComputerSelect`) added to `WorkspaceCard.svelte` next to the default-cwd control; - the router (`src/App.svelte`) passes `store.computers` through `WorkspacesHome` → `WorkspaceCard`. - -**Transparency invariant (held):** the computer is USER-facing only — it 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. Documented in the -feature's pure core + surfaced in the `ComputerField` helper text. - -**NOT done (correctly deferred):** a per-send `computerId` override (the MVP UI doesn't expose it; persisted per-conversation -suffices). No `chat.send` change. - -**Verification:** 795/795 tests green (50 files; +20 computer view-model); biome clean; `vite build` succeeds. `svelte-check` -reports **0 errors from the computer feature**. (The pre-existing §2d `provider-retry` divergence — 11 errors — is -now RESOLVED via the backend `dev`→`feature/ssh-support` merge `de022ce`; `bun run typecheck` is fully GREEN.) -Live probe NOT run (the backend `ssh` extension isn't live yet → `GET /computers` returns `[]` end-to-end; a live probe + -human confirm of the dropdown/badge/test should run once `ssh` is wired + the `provider-retry` divergence is merged). - ---- - -## 2f. Heartbeat (workspace autonomous-agent loop) → **CONSUMED ✅ (backend shipped; FE built)** - -The backend shipped a workspace-scoped **heartbeat** — an autonomous agent loop that 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, run history, and a per-run live chat in a new sidebar **Heartbeat** view (branch `feature/heartbeat`). - -**Backend API (plain REST — NOT a transport-contract type):** -- `GET /workspaces/:id/heartbeat` → `{ enabled, systemPrompt, taskPrompt, intervalMinutes, model, reasoningEffort }` -- `PUT /workspaces/:id/heartbeat` (partial body) → updated config -- `GET /workspaces/:id/heartbeat/runs` → `{ runs: [{ id, conversationId, triggeredAt, status }] }` (`status: running|completed|stopped`) -- `POST /workspaces/:id/heartbeat/runs/:runId/stop` → `{ ok: true }` - -**Contract note (important):** the heartbeat shapes are NOT in `@dispatch/transport-contract` / `@dispatch/wire` -(verified: no `heartbeat` symbol in either `dist/`). So the FE owns the types locally in `src/features/heartbeat/logic/types.ts` -(consumer-defines-port, mirroring the `mcp`/`computer` result-type pattern) and coerces the untyped JSON at the network -seam via pure `normalizeHeartbeatConfig`/`normalizeHeartbeatRuns` (a malformed/partial response can never crash the -renderer). **If the backend later promotes these to a shared contract package, swap the local types for the imports + -re-mirror `.dispatch/*.reference.md`.** No contract version bump on the FE side (no `file:` dep re-pin needed). - -**FE (DONE + verified):** -- New feature library `src/features/heartbeat/`: pure `logic/view-model.ts` (`viewRun`/`viewRuns`/`badgeForStatus`/ - `statusLabelFor`/`formatRunTime`/`relativeLabel`/config-form helpers `formFromConfig`/`patchFromForm`/`formDiffers`/ - `normalizeInterval` (1–1440 clamp)/network normalizers + `effortOptions` re-exported from `features/chat`) — 35 - view-model tests green; `ui/HeartbeatView.svelte` (config panel: enable toggle (saves immediately), system + task - prompt textareas, model dropdown, reasoning-effort dropdown, interval input, Save button with `hasChanges` guard; - scrolling runs list polling every 4s with a spinner when running + per-row Stop) + `ui/RunModal.svelte` (fullscreen - modal that reuses `features/chat`'s `ChatView` to render the run's conversation; live-streams via the store's - `watchConversation`/`chat.subscribe` while `generating`, with a Stop button → `POST .../runs/:runId/stop`); - `index.ts` (`HeartbeatView`/`RunModal`/`manifest`/types). The reasoning-effort ladder is REUSED from `features/chat` - (sanctioned cross-feature import through its public exports) — no drift. -- `AppStore` (`src/app/store.svelte.ts`): `heartbeatConfig`/`setHeartbeatConfig`/`heartbeatRuns`/`stopHeartbeatRun` - (workspace-scoped; read `activeWorkspaceId` with `untrack`) + **`watchConversation`/`unwatchConversation`** — a new - "watch" mechanism for the modal: reuses an open tab's `ChatStore` (already subscribed) or creates an EPHEMERAL watch - store in a new `watchStores` map (separate from tabs — never opens a tab) + subscribes via `chat.subscribe` + loads - history; deltas route to it (the `handleChatMessage` hot path now also checks `watchStores`); `onReopen` re-subscribes - + resyncs watch stores; `dispose` disposes them. `unwatchConversation` is a no-op for a tab conversation (it keeps its - store + stream) — only the ephemeral watch store is disposed + unsubscribed. -- Wired into `src/app/App.svelte`: `"heartbeat"` view kind (in `viewKinds`), `heartbeatManifest` in `loadedModules`, - thin adapter functions, the `viewContent` snippet branch, + `{#key heartbeatRun.id}` RunModal render when a run is - selected. -- Store tests (`src/app/store.test.ts`): +7 — config load/PUT-merge/error, runs load, stop POST, watch subscribe + - live-delta routing, tab-reuse (no extra subscribe) + unwatch no-op for a tab. -- Also fixed a PRE-EXISTING `WorkspaceCard.test.ts` typo (`onNavigate` shorthand used before declaration → - `onNavigate: vi.fn()`) that was red on the branch HEAD independent of heartbeat. - -**Verification:** 837/837 tests green (run TWICE — no cross-test pollution; the watch stores are plain `Map`s, not -shared globals, but the methodology's double-run is honored); `svelte-check` 0 errors; biome clean; `vite build` -succeeds. Live probe NOT run (the backend's heartbeat loop + endpoints were not reachable headless at verify time; the -unit + store tests fully cover the data path + the WS routing seam). To confirm end-to-end: start the backend, open the -Heartbeat sidebar view, toggle enable, edit+Save the config, watch a run appear + stream live in the modal, click Stop. - -**Vocabulary note (heads-up, not blocking):** `GLOSSARY.md` marks **"view"** as RESERVED (old-Dispatch sidebar -affordance, future). The heartbeat UI follows the codebase's ESTABLISHED convention — sidebar panels are already called -"views" pervasively (`viewKinds`, `ViewSidebar`, `viewContent`, "Model view"/"LSP view"/"Settings view"). The feature -module itself is named `heartbeat` (a feature module). No new term was coined. If the reserved-"view" cleanup happens -later, the heartbeat view kind renames in lockstep with the rest. - ---- - -## 2g. Heartbeat follow-up UI — prompt editor modal + hours/minutes timer → **FE BUILT; 1 BACKEND ASK** - -A follow-up to §2f (branch `feature/heartbeat`, uncommitted-to-this-handoff commit). Two UI changes on the heartbeat -config panel, each analyzed below for backend impact. **One needs a backend change (variable resolution in the -heartbeat prompts); the other needs none.** - -### Change A — Prompt editor modal (replaces the two textareas) → **1 BACKEND ASK (CR-HB-1)** - -The config panel's two inline textareas (system prompt + task prompt) are replaced by a single "Edit prompts" button -that opens a full-width modal (`src/features/heartbeat/ui/PromptEditor.svelte`): -- LEFT side: two stacked text editors (top = system prompt, bottom = task prompt). -- RIGHT side: the variable palette (grouped by type, same `[type:name]` tag insertion as the global system-prompt - builder). Clicking a variable inserts `[type:name]` at the cursor of whichever textarea is focused. -- Save persists both prompts via the existing `PUT /workspaces/:id/heartbeat` with `{ systemPrompt, taskPrompt }` - (a partial patch — no new endpoint, no data-shape change). - -**What the FE reuses (NO new endpoint needed):** -- The variable palette is sourced from the EXISTING global `GET /system-prompt/variables` endpoint — the SAME one the - global System Prompt builder uses. The heartbeat feature imports the pure helpers (`buildTag`, `groupVariables`, - `insertTag`, `isDynamicVariable`) from `features/system-prompt` (its public `index.ts` — `isDynamicVariable` was added - to that export in this slice, an additive cross-unit seam change). So there is **no new variable source or endpoint**; - the heartbeat prompt editor offers the exact same variable tags the global system prompt does. -- The persisted strings carry literal `[type:name]` placeholders (plain text), exactly like the global template. - -**CR-HB-1 — Resolve `[type:name]` variables in the heartbeat system/task prompts → ASK (needs backend confirmation + likely implementation):** - -The global system-prompt template is resolved by the backend: `[type:name]` placeholders (e.g. `[system:os]`, -`[system:date]`, `[file:path]`, `[if system:wsl]…`) are substituted with their resolved values at construction time -(once per conversation at first turn, then persisted for prompt-cache safety). **The critical question: does the -backend apply this SAME variable resolution to the heartbeat's `systemPrompt` and `taskPrompt` fields?** - -- If YES (the heartbeat prompts already flow through the same resolver as the global template): **no backend change - needed** — the FE just inserts the same `[type:name]` tags and the backend resolves them. Confirm + close. -- If NO (the heartbeat prompts are inserted into the model turn as raw text, so `[system:os]` would reach the model - literally as the string `[system:os]` rather than the resolved OS string): **the backend should resolve `[type:name]` - placeholders in the heartbeat `systemPrompt` + `taskPrompt` using the SAME resolver/variable set as the global - system prompt** (so a variable inserted in either place resolves identically). This is the expected gap — the heartbeat - prompts are a NEW surface that predates the variable system, so they likely bypass the resolver. - - - Resolution timing: mirror the global template's behavior — resolve once when a heartbeat run's turn is constructed - (NOT on every interval tick, to stay prompt-cache-safe; a stable resolved prompt keeps the cache warm across runs). - If the heartbeat re-resolves per-run anyway (intervals may want fresh `[system:time]`), that's a backend product - decision — the FE doesn't care WHEN it resolves, only THAT `[type:name]` becomes its value. - - Variable set: the SAME catalog `GET /system-prompt/variables` returns (system/file/prompt/git groups). No - heartbeat-specific variables are required for this slice. - - No wire/transport-contract/ui-contract change needed — this is a backend behavior change (the prompt strings are - still `string`; the FE is unaffected once the backend resolves them). - -**Optional future enhancement (NOT required now):** heartbeat-specific variables (e.g. `[heartbeat:runCount]`, -`[heartbeat:lastResult]`, `[heartbeat:elapsed]`). The FE's prompt editor would offer these if `GET /system-prompt/variables` -(or a new `GET /workspaces/:id/heartbeat/variables`) returned them. Defer until there's a product need. - -### Change B — Hours + minutes interval timer → **NO backend change needed** - -The interval input is split into two fields: an HOURS input (left) + a MINUTES input (right, 0–59). The conversion is -entirely FE-side: -- On load: the backend's single `intervalMinutes` is split into `{ hours, minutes }` via the pure `splitInterval` - helper (`Math.floor(total/60)` hours, remainder minutes). -- On save: the FE recombines via `joinInterval(hours, minutes)` → `hours*60 + minutes` (clamped to the 1–1440 range - = 1 min–24 h) and sends a SINGLE `intervalMinutes` integer to `PUT /workspaces/:id/heartbeat`. - -So **the backend's `intervalMinutes` field is UNCHANGED** — still one integer of total minutes. The hours/minutes split -is pure FE presentation. No endpoint, data-shape, or behavior change. (The existing clamp to 1–1440 also stands; a -0h0m entry clamps to 1 minute — the FE guards this, and the backend's own validation should too, but that's pre-existing.) - -### FE summary (this slice) -- `src/features/heartbeat/logic/view-model.ts`: `HeartbeatFormState` now carries `intervalHours` + `intervalMinutes` - (0–59); new pure `splitInterval`/`joinInterval` round-trip helpers; `formFromConfig`/`patchFromForm`/`formDiffers` - updated to split/recombine. +4 tests (split/join round-trip, form split, differs-after-edit). -- `src/features/heartbeat/ui/PromptEditor.svelte` (new): two-pane modal (system+task editors left, variable palette - right), focus-aware variable insertion, Save/Reset. Reuses `features/system-prompt`'s pure helpers. -- `src/features/heartbeat/ui/HeartbeatView.svelte`: two textareas → "Edit prompts" button + modal; interval → hours+minutes inputs. -- `src/features/system-prompt/index.ts`: added `isDynamicVariable` to the public exports (additive — needed by the - heartbeat editor's dynamic `file:<path>` row). -- `src/app/App.svelte`: passes `loadVariables={loadSystemPromptVariablesPrompt}` to `HeartbeatView`. - -**Verification:** typecheck 0/0, tests green, biome clean, build OK (see the commit). The variable-resolution behavior -(CR-HB-1) can only be confirmed end-to-end against a running backend with variable-laden heartbeat prompts — a human -should set `[system:os]` in a heartbeat prompt via the new editor, trigger a run, and confirm the resolved value (not the -literal `[system:os]`) appears in the model's context / run transcript. - ---- - -## 2h. Heartbeat system-prompt default + reset button → **FE BUILT; 1 BACKEND ASK (CR-HB-2)** - -A follow-up to §2f/§2g (branch `feature/heartbeat`). Two UX changes on the heartbeat prompt editor: -1. The system prompt now **defaults to the workspace's regular system prompt** (the global `GET /system-prompt` - template — there is no per-workspace system prompt; `Workspace` has no `systemPrompt` field, and the system prompt - is global, resolved once per conversation). When the heartbeat's `systemPrompt` is empty, the editor pre-fills the - textarea with the global default so the user can see + tweak what will run — but a pre-filled default is NOT an - explicit edit (no `hasChanges` until the user edits away from it). -2. A **"Reset to default" button** reverts the system prompt to the global default (clearing any override → inherit). - -### Semantics: heartbeat `systemPrompt` is an OVERRIDE; empty = inherit the global default - -Following the codebase's established resolution-chain pattern (cwd / reasoning-effort / model / computer — all -"persisted value OR fall back to a default; resolution is SERVER-owned"), the heartbeat's `systemPrompt` is now treated -as an **override**: -- `systemPrompt === ""` (empty) → **inherit** the global system prompt (the workspace's regular prompt). -- non-empty → an explicit override. - -The FE never duplicates the global default into the heartbeat config: when the editor's text matches the default (or is -empty), it persists `systemPrompt: ""` (inherit) — so a later change to the global default still flows through. A -distinct edit persists the override verbatim. Pure helpers in `src/features/heartbeat/logic/view-model.ts`: -`effectiveSystemPrompt(override, default)`, `isInheritingSystemPrompt(override)`, -`persistedSystemPrompt(editable, default)` (+6 tests). - -### CR-HB-2 — Resolve an empty heartbeat `systemPrompt` to the global system prompt at run time → **ASK (backend behavior change)** - -The FE sends `systemPrompt: ""` to inherit. **The backend must resolve an empty heartbeat `systemPrompt` to the GLOBAL -system prompt template (`GET /system-prompt`) at heartbeat-run construction time** — so a heartbeat with no override -actually runs the workspace's regular system prompt (not an empty one). - -- **Resolution:** when building a heartbeat run's turn, if the heartbeat's persisted `systemPrompt` is `""`, substitute - the global system prompt template (the same one `GET /system-prompt` returns / that conversations resolve). If - non-empty, use the override as-is. -- **Composes with CR-HB-1:** after resolving empty → global (CR-HB-2), the resulting prompt's `[type:name]` variable - placeholders must still be resolved (CR-HB-1). So both apply, in order: (1) empty ⇒ global template, (2) resolve - `[type:name]` placeholders in whichever prompt is in effect. (For an override, only step 2 applies.) -- **Timing / cache-safety:** mirror the global template's behavior — resolve once per heartbeat run (or once + persist, - whichever the heartbeat scheduler already does for prompt-cache safety). The FE doesn't care WHEN; only THAT empty - becomes the global prompt. -- **No wire/transport-contract/ui-contract change** — `systemPrompt` is still a `string`; empty = inherit. The FE is - unaffected once the backend resolves it. **No new endpoint needed** — the FE already reads the global default via the - existing `GET /system-prompt` (passed through as `loadDefaultPrompt`). - -### FE summary (this slice) -- `src/features/heartbeat/logic/view-model.ts`: 3 pure inheritance helpers (+6 tests). -- `src/features/heartbeat/ui/PromptEditor.svelte`: `loadDefaultPrompt` port (the global `GET /system-prompt`); loads - the default on open + pre-fills the system textarea when inheriting; `hasChanges` diffs against the EFFECTIVE prompt - (override or default) so a pre-filled default isn't an unsaved change; save persists `""` when the text matches the - default (inherit); new "Reset to default" button + "Inheriting workspace default" badge + status hint. -- `src/features/heartbeat/ui/HeartbeatView.svelte`: passes `loadDefaultPrompt` through; `onSaved` syncs the raw - override (`""` = inherit) into the form. -- `src/app/App.svelte`: passes `loadDefaultPrompt={loadSystemPromptPrompt}` (reuses the existing `store.loadSystemPrompt()` adapter) to `HeartbeatView`. - -**Verification:** typecheck 0/0, 849 tests green, biome clean, build OK. The inheritance RUNTIME behavior (CR-HB-2) can -only be confirmed against a running backend: set the heartbeat system prompt to empty (or click "Reset to default" + -Save), trigger a run, and confirm the run used the GLOBAL system prompt (not an empty one). Until CR-HB-2 ships, an -empty heartbeat `systemPrompt` would run with no system prompt — the FE flags this as the known gap. - ---- - -## 2i. Heartbeat next-run countdown timer → **FE BUILT; 1 BACKEND ASK (CR-HB-3)** - -A follow-up to §2f/§2g/§2h (branch `feature/heartbeat`). The Heartbeat sidebar view now shows a live countdown to the -next scheduled run ("Next run in 4m 32s") beneath the Enabled/Disabled status. The FE computes it from a server- -authoritative next-run timestamp + a 1s ticking clock. - -### CR-HB-3 — `GET /workspaces/:id/heartbeat/next-run` → **ASK (new endpoint)** - -**New endpoint:** -``` -GET /workspaces/:id/heartbeat/next-run -``` -**Response shape:** -```json -{ "nextRunAt": "2026-06-25T14:05:00Z" } -``` -or `null` when the heartbeat is **disabled** or **no run is scheduled**: -```json -{ "nextRunAt": null } -``` - -**Semantics:** -- `nextRunAt` is the server-authoritative timestamp of the NEXT scheduled heartbeat run (the moment the scheduler will - fire it), as an ISO 8601 string. It is DERIVED server-side from the scheduler state (the last run's start + the - configured `intervalMinutes`, or the moment `enabled` was toggled on + `intervalMinutes` for the first run) — the FE - cannot derive it accurately (it doesn't know when the last run fired relative to "now" + scheduling jitter, paused- - while-running, etc.). -- `null` when the heartbeat is disabled, or when no run is currently scheduled (e.g. a run is in flight and the next - hasn't been queued yet — the FE then shows no countdown, not a fabricated one). -- Recompute on each call (a cheap read of the scheduler's next-fire time). The FE polls it on the same 4s cadence as - the runs list, so a run completing (→ next run scheduled) reflects within ~4s. - -**Why a dedicated endpoint (not a field on the config/runs response):** the user explicitly asked for "a timestamp -endpoint." It's also the most efficient for polling (a lightweight read of just the next-fire time, vs. re-fetching the -full config or runs). The FE polls it alongside the runs list every 4s. - -**No wire/transport-contract/ui-contract change** — the response is a plain JSON object (the heartbeat API is a plain -REST surface, not a transport-contract type; the FE owns the `HeartbeatNextRunResult` type locally in -`src/features/heartbeat/logic/types.ts` and coerces the untyped body at the network seam). - -### FE behavior (this slice) — works BEFORE the backend ships the endpoint - -- The store's `heartbeatNextRun()` calls `GET /workspaces/:id/heartbeat/next-run`; on **404/error** it returns - `ok: false` (non-fatal). The FE then sets a `nextRunEndpointFailed` flag and **stops polling the endpoint** (no 404 - spam) and falls back to an APPROXIMATION: the latest run's `triggeredAt` + the configured `intervalMinutes` - (`approximateNextRunEpoch`, pure + tested). So the countdown shows (approximate) immediately, and becomes ACCURATE - once the backend ships CR-HB-3 (the FE prefers the server value when available). -- A 1s ticking clock (`now` state) recomputes the countdown locally from `effectiveNextRun` (server value, else the - approximation) — no per-second network churn. Pure `formatCountdown(remainingMs)` → "4m 32s" / "32s" / "1h 05m" / - "due" (≤0) / "—" (unknown). -- The countdown only renders when the heartbeat is **enabled** AND a next-run time is known (`effectiveNextRun !== - null`); disabled → no countdown (just "Disabled"). -- Edge: when the countdown reaches "due" (≤0), a run should be firing; the next 4s poll refreshes `nextRunAt` to the - newly-scheduled run. The approximation similarly refreshes when a new run appears in the runs list. - -### FE summary (this slice) -- `src/features/heartbeat/logic/types.ts`: `HeartbeatNextRunResult` + `LoadHeartbeatNextRun` port. -- `src/features/heartbeat/logic/view-model.ts`: pure `nextRunEpoch` (parse ISO), `formatCountdown`, `approximateNextRunEpoch` (+12 tests). -- `src/app/store.svelte.ts`: `heartbeatNextRun()` (GET `.../heartbeat/next-run`; graceful 404 → `ok:false`). -- `src/features/heartbeat/ui/HeartbeatView.svelte`: polls `nextRun` (stop-on-fail + fallback), 1s countdown clock, "Next run in …" under the status. -- `src/app/App.svelte`: `loadHeartbeatNextRun` adapter → `HeartbeatView`. - -**Verification:** typecheck 0/0, 865 tests green (+12 next-run helpers), biome clean, build OK. The accurate countdown -can only be confirmed against a running backend with CR-HB-3 shipped (enable the heartbeat, watch "Next run in …" tick -down, confirm it matches when a run actually fires). Until CR-HB-3 ships, the FE shows the APPROXIMATE countdown -(latest run + interval) — flagged as the known gap. - ---- - -## 2j. Provider concurrency limits → **CONSUMED ✅ (backend shipped; FE built)** - -The backend tracks + limits how many concurrent token-generating API requests are in flight PER -PROVIDER. When the cap is reached, further requests QUEUE and are granted slots oldest-agent-first -(a 429 backoff PAUSES a provider's queue until `pausedUntil`). The cap is per-provider, managed via a -new GLOBAL REST surface under `/concurrency/...` provided by the `concurrency` extension. **The limits -are now PERSISTED across reboots** (backend update on `feature/provider-concurrency` — `PUT`/`DELETE` -also write to storage; `GET /concurrency/limits` reads in-memory state pre-populated from storage on -boot). **No API contract change** — the endpoints, request bodies, and response shapes are identical, -so the FE needs no re-pin/re-mirror; a limit set via the UI now survives a server restart. (Earlier -backend-only changes since the last handoff, also no FE impact: a 200ms release cooldown for internal -slot recycling, and the `"queued"` `ConversationStatus` — which the FE already shipped, CR-13.) -**`[email protected]`** added the 5 types: `ConcurrencyLimitsResponse`, -`SetConcurrencyLimitRequest`, `ConcurrencyLimitResponse`, -`ConcurrencyStatusEntry`, `ConcurrencyStatusResponse`. - -**Backend API (plain REST — the types ARE in `[email protected]`):** -- `GET /concurrency/limits` → `{ limits: [{ providerId, limit }] }` (empty when extension not loaded) -- `GET /concurrency/limits/:providerId` → `{ providerId, limit }` · 404 (not configured) · 503 (not loaded) -- `PUT /concurrency/limits/:providerId` body `{ limit }` (positive int) → `{ providerId, limit }` · 400 (bad body) · 503 -- `DELETE /concurrency/limits/:providerId` → `{ ok, providerId }` · 404 · 503 -- `GET /concurrency/status` → `{ providers: [{ providerId, limit, inFlight, queued, paused, pausedUntil? }] }` (empty when not loaded) - -**Contract note:** the concurrency shapes ARE in `@dispatch/[email protected]` (a real version -bump, unlike the additive `provider-retry`/`computer` deltas). The FE re-pinned the `file:` dep -(`bun install`) + re-mirrored `.dispatch/transport-contract.reference.md` (appended the 5 types + -bumped the snapshot header). The FE imports the contract types directly (no consumer-defines-port -needed for the data shapes), exactly mirroring `mcp`/`computer`. - -**FE (DONE + verified):** -- New feature library `src/features/concurrency/`: - - `logic/types.ts` — re-exports the 5 contract types + defines the FE-owned result types - (`ConcurrencyLimitsResult`/`ConcurrencyLimitResult`/`ConcurrencyDeleteResult`/ - `ConcurrencyStatusResult`) + the 5 injected ports (`LoadConcurrencyLimits`/ - `GetConcurrencyLimit`/`SaveConcurrencyLimit`/`DeleteConcurrencyLimit`/`LoadConcurrencyStatus`). - - `logic/view-model.ts` (pure) — `viewConcurrencyStatus`/`viewConcurrencyStatuses` (badge + - busy + "2/4" in-flight + queue + `paused — resumes in Ns` countdown), `viewConcurrencyLimit`/ - `Limits`, `summarizeLimits`/`summarizeStatus`, `parseLimitInput`/`normalizeLimit` (positive-int - validation), `formatPauseDuration`/`pauseLabel`, + the network-seam normalizers - `normalizeConcurrencyLimits`/`normalizeConcurrencyLimit`/`normalizeConcurrencyStatus` (defensive - coercion — a malformed/empty `{}` body never crashes the renderer). 34 view-model tests green. - - `ui/ConcurrencyView.svelte` — a sidebar panel with TWO sections: (1) **Concurrency limits** - (config): an add form (provider id text input + positive-int limit + Add → PUT) + a list of - `ConcurrencyLimitRow` (inline-edit limit + Save → PUT + ✕ Remove → DELETE); (2) **Live status**: - a summary + per-provider cards (in-flight "2/4", queued, paused indicator with a live countdown), - polling `GET /concurrency/status` every 2s + a 1s countdown clock (both intervals disposed on - unmount). Reloads limits+status on every mutation. - - `ui/ConcurrencyLimitRow.svelte` — one editable limit row (inline-edit seeded via the ChatLimitField - pattern so a save echo / refresh re-syncs without clobbering an in-flight edit). - - `index.ts` — `ConcurrencyView`/`ConcurrencyLimitRow`/`manifest`/types/exports. -- `AppStore` (`src/app/store.svelte.ts`) — 5 methods (GLOBAL, not workspace-scoped): `concurrencyLimits()`, - `getConcurrencyLimit(providerId)`, `setConcurrencyLimit(providerId, limit)`, - `deleteConcurrencyLimit(providerId)`, `concurrencyStatus()`. Each normalizes the untyped JSON at the - network seam + surfaces HTTP errors (incl. 400/404/503) as `ok:false` with the backend's `error` - string. Interface declarations added to `AppStore`. -- `src/app/App.svelte` — new `"concurrency"` viewKind (sidebar "Concurrency"), `concurrencyManifest` - in `loadedModules`, thin passthrough adapters, + the `viewContent` branch rendering `<ConcurrencyView>` - (global — no `{#key}`, stays mounted across tab switches). -- Tests: 34 view-model + 5 component (`@testing-library/svelte`, faking the 4 ports) + 9 store - (load/empty/503/404/PUT/400/DELETE/status coerce/empty). - -**Verification:** typecheck 0/0, **914 tests green** (run TWICE — no cross-test pollution; the polling -intervals are per-component, cleaned up on unmount by `@testing-library/svelte`'s auto-cleanup), biome -clean, `vite build` succeeds (the lone build warning is PRE-EXISTING — a Tailwind/DaisyUI `file:path` / -`heartbeat:elapsed` arbitrary-CSS ambiguity, not from concurrency). Live probe NOT run (the backend -was the user's process; never booted headless). To confirm end-to-end: start the backend with the -`concurrency` extension loaded, open the Concurrency sidebar view, add a provider limit, watch the live -status poll (in-flight/queued/paused), update + remove it. If the extension isn't loaded, the limits + -status lists render empty (graceful). - -**Note (the single-provider GET):** the FE implements all 5 API client functions (incl. -`getConcurrencyLimit`, endpoint #2), but the UI uses only 4 — the limits LIST covers the configured -providers; `getConcurrencyLimit` is an API-client function for completeness/future use (a detail view). -**No `chat.send` change** — concurrency is a transport-layer concern the backend applies to outbound -provider calls; the agent/model prompt never sees it (does not affect prompt caching). - -**Worktree environment note (same as §2d):** this worktree lays the repos out as -`…/worktrees/provider-concurrency/{backend,frontend}`, but `package.json`'s canonical `file:` paths -point at `../dispatch-backend/...` (kept canonical — no worktree hack committed). An UNTRACKED symlink -`dispatch-backend → backend` was created in the worktree parent, then `bun install` re-synced -`node_modules/@dispatch/*` to pick up `[email protected]`. -## 2j. Vision & vision handoff → **CONSUMED ✅ (backend shipped; FE built + verified)** - -The backend shipped image/vision support: a user can attach images to a chat message, vision-capable -models receive them natively, and non-vision models get an auto-transcribed text description (the -"vision handoff"). The FE now pastes/picks/drops images in the composer, renders `image` chunks in the -transcript, and shows a vision badge in the model picker. Additive to `[email protected]` / -`[email protected]` (**NO version bump** — `ImageChunk`/`ImageInput` were added to the existing -versions; the FE's `file:` dep picks them up automatically, no re-pin needed). - -**New wire/transport types consumed + re-mirrored:** -- `ImageChunk` (`{ type: "image", url, mimeType? }`) — a NEW `Chunk` variant. `url` is a base64 data URL - (`data:image/…;base64,…`) OR an `http(s)://` URL. `ImageInput` (`{ url, mimeType? }`) is the transport- - facing input shape (`ChatRequest.images`); the orchestrator converts each into an `ImageChunk` on the - persisted user message. -- `ChatRequest.images?: readonly ImageInput[]` (so `ChatSendMessage` — which `extends ChatRequest` — - carries it on `chat.send`). `ChatQueueMessage` (steering) does NOT carry `images` — steering is - text-only (correctly: a mid-turn injection has no image surface). -- `ModelMetadata.vision?: boolean` — `true` when the model natively accepts images; absent/`false` → the - server's vision handoff transcribes images to text before the model sees them. -- Re-mirrored `.dispatch/wire.reference.md` (added `ImageChunk` to the `Chunk` union + the `ImageChunk`/ - `ImageInput` interfaces) and `.dispatch/transport-contract.reference.md` (added `images` to `ChatRequest`, - `vision` to `ModelMetadata`, + `ImageChunk`/`ImageInput`/`Computer`/`ComputerEntry` to the re-export). - -**FE (DONE + verified):** -- **Core (`core/chunks`):** the `assertChunkExhaustive` conformance guard caught the new `image` variant - (its purpose) → added the `case "image"` (this was the build break). `appendUserMessage(state, text, - images?)` now echoes a `[text, image, image, …]` user run (text first, then images in order; images-only - when text is empty). The `user-message` event carries ONLY text (never images — images arrive via history/ - loadSince + the optimistic echo), so its de-dup was generalized: it now scans the trailing provisional - USER run for a matching text chunk (not just the last chunk — which would be an image when images were - pasted, causing a duplicate text bubble). `applyHistory`'s during-generation de-dup was generalized to - match a multi-chunk user echo against the trailing committed user run by content equality - (`chunkContentEquals` — text/thinking/image/error/system/tool-call/tool-result), dropping the whole echo - only when fully backed (a partial match is kept until turn-seal drops all provisional wholesale). New - pure helpers `chunkContentEquals` + `trailingRun` (both internal). +16 reducer tests. -- **Transcript (`ChatView.svelte`):** a user `image` chunk renders as an `<img>` (lazy + async-decoded, - max-h-80) inside the user bubble, using the chunk's `url` directly. A non-vision model's persisted user - message keeps the original `image` chunk AND a `text` transcription (`[Image analysis (via <model>)]: …`) - in the SAME message — both render (image, then analysis text). The `read_image` tool call/result - renders like any other tool (its `toolName` is generic — no special-casing). +3 ChatView tests. -- **Composer (`Composer.svelte`):** image paste (clipboard `paste` — extracts image `File` items, - `preventDefault` only when an image is present so text paste still works), an attach-image button + - hidden `<input type=file accept=image/* multiple>`, and drag-drop onto the form. Files are read to - base64 data URLs (`FileReader.readAsDataURL`), capped at 8 MiB, staged as thumbnail previews with - remove buttons. `onSend` signature widened to `(text, images?)`; an image-only send (empty text) is - allowed; `images` is OMITTED on the wire (not `[]`) when none are staged (backward compatible). Steering - (`onQueue`) never forwards images. +6 Composer tests. -- **Store wiring:** `ChatStore.send(text, images?)` forwards `images` on the `chat.send` WS op + echoes - them; `AppStore.send(text, images?)` threads images through the draft→tab promotion; `App.svelte`'s - `handleSend(text, images?)` passes them through. The model catalog already captured `modelInfo` (now - with `vision`); `GET /models` is unchanged. +5 store/app tests. -- **Model picker (`ModelSelector.svelte` + `model-select.ts`):** new pure `isVisionModel(modelInfo, - fullName)`; the model dropdown marks vision-capable models (`" · vision"`), and an indicator below shows - "Vision — this model sees images natively" vs the handoff hint "Pasted images are auto-described". Wired - `modelInfo={store.modelInfo}` from `App.svelte`. +8 model-select/ModelSelector tests. - -**Invariants held:** -- `chat.send` STILL omits `cwd` (the persisted cwd wins) — only `images` was added to the message. -- The `user-message` event still carries only text; images are NEVER expected on it (a watcher fetches - them from history). The de-dup was made robust to the multi-chunk echo rather than reaching for images - on the event. -- `providerRetry`/`generating` unchanged; `image` is a normal committed/provisional chunk (it IS in the - `Chunk.type` union, unlike the transient `provider-retry`), so it persists + replays on reload. -- The `read_image` tool is rendered generically (no surface-id special-casing — the tool-name dispatch is - already identity-free). - -**Verification:** `svelte-check` 0/0; vitest **901/901** (run TWICE — no cross-test pollution; +34 new: -16 reducer, 3 ChatView, 6 Composer, 5 store/app, 4 model-select), biome clean, `vite build` succeeds (the -one CSS warning is PRE-EXISTING — `[file:path]`/`[heartbeat:elapsed]` attribute selectors, unrelated). -**Live probe NOT run:** the backend was not reachable headless at verify time (it is the user's process; -never booted headless). The full data path (paste → data URL → `chat.send` `images` → reducer echo → -transcript render; `GET /models` `vision` → badge; history `image` chunk → render) is covered by unit + -component + store tests. To confirm end-to-end: start the backend, paste an image into the composer with -a vision model selected (e.g. any `kimi/*`), send, confirm the image renders + the model responds to it; -then switch to a non-vision model (e.g. `umans/glm-5.2`), paste an image, send, confirm the image renders -AND a numbered placeholder `[Image N attached — call consult_vision…]` text bubble appears (the non-vision -handoff — see the update below; the old `[Image analysis (via …)]` auto-transcription is GONE); confirm -the vision badge shows/hides per model in the picker. - -### 2j-update. consult_vision tool + vision settings API → **CONSUMED ✅ (backend updated; FE built + verified)** - -A follow-up to §2j. The backend revised the vision surface: the `read_image` tool is REPLACED by -`consult_vision`, non-vision models get NUMBERED PLACEHOLDERS (not auto-transcriptions), and there is a -NEW global vision-settings API. Additive to `[email protected]` / `[email protected]` (NO version bump); -re-mirrored `.dispatch/transport-contract.reference.md` (added `VisionSettingsResponse` / -`SetVisionSettingsRequest` + a delta note). - -**What changed (backend):** -- **`read_image` → `consult_vision`** (`{ question: string (req), imageIds?: number[], path?: string }`): - opens a NEW conversation tab with a vision-capable model (Kimi), attaches the image + question, returns - the conversation id + the vision model's answer (suggests the dispatch CLI for follow-ups). Rendered like - any tool call/result (generic `toolName` dispatch — no special-casing). -- **Non-vision models get NUMBERED PLACEHOLDERS** instead of auto-transcriptions: a pasted image on a - non-vision model persists an `image` chunk + a `text` chunk `[Image N attached — call consult_vision - with imageIds=[N] and a specific question to analyze it]`. (The old `[Image analysis (via <model>)]: …` - auto-transcription is GONE.) -- **Image compaction** (transparent): when a vision model has > `imageLimit` images in history, the oldest - are transcribed to `[Compacted image]: <description>` `text` chunks (the persisted `image` chunk stays - for rendering). Both placeholder + compacted chunks are REGULAR `text` chunks — render as-is (no special - handling). -- **NEW global vision settings API:** `GET /settings/vision` → `VisionSettingsResponse` - (`{ imageLimit: number (default 10), compactionModel: string|null (null = auto) }`); `PUT /settings/vision` - ← `SetVisionSettingsRequest` (partial: `imageLimit?` non-negative int, 0 = disable compaction; - `compactionModel?` `<key>/<model>` or null). - -**FE (DONE + verified):** -- **Tool rendering:** the ChatView `read_image` test → `consult_vision` (rendering is generic — renders by - `toolName`, so no component change; only the test name/input updated). +2 ChatView tests for the - placeholder text chunk + the compacted-image text chunk (both render as regular text). -- **New `vision` feature library** (`src/features/vision/`): pure `logic/view-model.ts` (32 tests) — - `VisionSettings`/`VisionSettingsPatch` (owned locally, consumer-defines-port; the shapes are a plain REST - surface, mirroring heartbeat/mcp), `LoadVisionSettings`/`SaveVisionSettings` ports + result types, - `normalizeVisionSettings` (network-seam coercion — a malformed body can't crash the renderer), - `parseImageLimit`/`imageLimitChanged` (dirty-check), `compactionModelOptions` (filters `GET /models` to - vision-capable models via the chat feature's public `isVisionModel` export + an "Auto" sentinel), - `selectedCompactionValue`/`compactionModelFromValue` round-trip, `imageLimitLabel`; `ui/VisionSettingsView.svelte` - (imageLimit text input + Save, compactionModel dropdown with Auto + vision-capable models, load-on-mount, - save-on-change, error/saved feedback; 9 component tests); `index.ts`. -- **Cross-unit seam:** `isVisionModel` was added to `features/chat`'s public `index.ts` (additive — the - vision feature imports it through the public surface, not the chat internals). -- **Store wiring** (`src/app/store.svelte.ts`): `visionSettings` reactive state + `refreshVisionSettings()` - (`GET /settings/vision`, normalized at the seam) + `setVisionSettings(patch)` (`PUT /settings/vision`, - returns merged settings) + `VisionSettingsResult`; seeded on boot; exposed on `AppStore`. +4 store tests. -- **Mounted in `App.svelte`:** a new "Vision" sidebar view kind (`viewKinds`) + `VisionSettingsView` in the - `viewContent` snippet (not conversation-scoped — no `{#key}`); `loadVisionSettings`/`saveVisionSettings` - adapters wrap the store; `visionManifest` in `loadedModules`. - -**Verification:** `svelte-check` 0/0; vitest **948/948** (run TWICE — no cross-test pollution; +47 new -since the §2j baseline: 32 vision view-model, 9 VisionSettingsView, 4 store, +2 ChatView -placeholder/compacted, and the read_image→consult_vision test update), biome clean, `vite build` succeeds. -**Live probe NOT run** (backend not reachable headless). To confirm end-to-end: open the Vision sidebar -view, confirm the imageLimit + compactionModel load; change imageLimit → Save → confirm it persists on -reload; set compactionModel to a vision model → confirm the dropdown reflects it; paste an image with a -non-vision model → confirm the `[Image N attached — call consult_vision…]` placeholder renders (not an -auto-transcription); trigger a `consult_vision` tool call → confirm it renders like a tool. - -### 2j-update-2. Image storage (tmp, not SQLite) → **CONSUMED ✅ (backend updated; FE built + verified)** - -A follow-up to §2j. The backend no longer persists images as base64 data URLs in the conversation store — -they are saved to a tmp directory and served via HTTP. The `ImageChunk.url` field's FORMAT changed (the -TYPE is unchanged — still `string`); `GET /images/:conversationId/:imageId` is a new endpoint serving raw -bytes + the correct Content-Type. **NO wire/transport-contract type change** (behavior only); re-mirrored -the delta notes in `.dispatch/wire.reference.md` + `.dispatch/transport-contract.reference.md`. - -**What changed (backend):** -- **BEFORE:** `ImageChunk.url` was a base64 data URL (`data:image/png;base64,…`). -- **NOW:** `ImageChunk.url` for PERSISTED chunks (history/replay) is a compact relative HTTP path - (`/images/<conversationId>/<uuid>.png`), served by `GET /images/:conversationId/:imageId` (raw image - bytes + Content-Type). Images live on disk under tmp, NOT in the SQLite conversation store (keeps - payloads small). -- **`ChatRequest.images` (`ImageInput.url`) is UNCHANGED** — the FE still sends data URLs; the backend - saves them to tmp and returns compact paths in the persisted chunks. -- **Optimistic echo:** the FE's provisional echo still uses the data URL it sent (immediate render); - when the persisted chunk arrives (via `loadSince`/`syncTail`/event stream), it carries the compact path - and the FE switches to rendering via the HTTP endpoint. -- The vision settings API, `consult_vision`, and image compaction are UNCHANGED (compaction resolves - compact URLs internally). - -**FE (DONE + verified):** -- **New pure helper `resolveImageUrl(url, apiBase)`** (`core/chunks/image-url.ts`, +8 tests, exported from - `core/chunks` + re-exported from `features/chat`): a `data:` URL or an `http(s)://` URL passes through - unchanged; a relative path (`/images/…`) is prepended with the API base (no double slash; an empty base - yields a root-relative path a browser resolves against its origin). Pure — zero DOM/Svelte. -- **`ChatView.svelte`:** new `apiBaseUrl` prop (default `""`); the `<img src>` now uses - `resolveImageUrl(rendered.chunk.url, apiBaseUrl)`. The optimistic echo's data URL + any absolute URL - pass through; persisted relative paths resolve against the base. +3 ChatView tests (relative-path - resolution, data-URL pass-through with a base set, root-relative when no base). -- **Store + wiring:** `httpBase` (the resolved HTTP API base) is now exposed as a getter on `AppStore`; - `App.svelte` passes `apiBaseUrl={store.httpBase}` to `ChatView` AND to the heartbeat `RunModal` (its - `ChatView` also renders image chunks — added an `apiBaseUrl` prop there, threaded from `App.svelte`). - -**Verification:** `svelte-check` 0/0; vitest **959/959** (run TWICE — no cross-test pollution; +11 since the -prior commit: 8 `resolveImageUrl`, 3 ChatView resolution), biome clean, `vite build` succeeds. -**Live probe NOT run** (backend not reachable headless). To confirm end-to-end: paste an image with a -vision model, send, confirm the image renders immediately (data-URL echo) AND continues to render after -the turn seals (the persisted compact-path `/images/…` resolved against `httpBase`); reload the -conversation → confirm the persisted image renders from the `/images/…` endpoint (not a data URL). - ---- - -## 2k. Step-level context-window usage (progressive) → **FE BUILT; no backend change** - -The context-window usage indicator at the bottom of the screen (Composer status -bar) now updates **after each step** during a multi-step turn, instead of only -when the turn seals. Pure FE change — consumes wire events the backend ALREADY -sends (`usage` per step + `step-complete` + `done.contextSize`); no contract -change, re-pin, or re-mirror needed. - -- `selectCurrentContextSize` (`core/metrics/reducer.ts`) now, for an IN-FLIGHT - (not-done) turn, returns the most recent step WITH USAGE's - `inputTokens + outputTokens` as the live context occupancy. Per the wire - contract 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 finalized turn (`done` / durable) still wins with its authoritative - `contextSize`; durable still wins over live for a shared `turnId`. An in-flight - turn with no step usage yet falls back to the next older finalized turn (never - `0`). -- New helper `liveTurnContextSize`; updated doc on `ChatStore.currentContextSize` - + the Composer `contextSize` prop. 7 new reducer tests (35 total), 1026 green. - -### FE summary (this slice) -No backend ask. The backend already emits per-step `usage` (token counts, may -arrive mid-stream) and `step-complete` (timing) joined by `stepId`, plus -`done.contextSize` (final step's input+output) — the FE just wasn't reading the -per-step usage for the live indicator. Now it does. -## 2j-update-3. Concurrency-fixes (cooldown + adaptive headroom + usage gate) → **CONSUMED ✅ (backend shipped; FE built + verified)** - -A follow-up to §2j. The backend's per-provider concurrency surface gained (a) a configurable + persisted -per-provider release **cooldown**, (b) **adaptive headroom** — a 429 auto-reduces the provider's limit by 1 -(one-way, persisted) and the FE renders a visible banner, and (c) a **usage gate** (backend polls upstream -`concurrent_sessions` before admitting a queued agent — no FE surface). The signal rides on -`GET /concurrency/status` (no new WS push). **Additive to `[email protected]`, NO version bump** -(the FE's `file:` dep picks the new types up via re-sync, no re-pin needed). Backend commit -`feature/concurrency-fixes` `2d27666` ("usage-gate + adaptive headroom + configurable cooldown"). - -**Contract changes (re-mirrored in `.dispatch/transport-contract.reference.md`):** -- `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, covering the upstream - provider's accounting lag — configurable + persisted), `autoReduced: boolean` (REQUIRED — true when the limit - was auto-reduced by 1 after a 429, one-way + persisted; the FE renders a 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). -- NEW cooldown types: `ConcurrencyCooldownResponse` (`{ providerId, cooldownMs }`) + - `SetConcurrencyCooldownRequest` (`{ cooldownMs }` — non-negative integer, 0 = no cooldown / instant re-admission). -- NEW endpoints: `GET /concurrency/cooldown/:providerId` → `ConcurrencyCooldownResponse` (404 when the provider - has no concurrency config at all — no limit AND no cooldown; 503 when the extension isn't loaded); - `PUT /concurrency/cooldown/:providerId` ← `SetConcurrencyCooldownRequest` → `ConcurrencyCooldownResponse` - (400 on an invalid body; 503 when not loaded). Persists + applies immediately to subsequently recycled slots. -- A manual `PUT /concurrency/limits/:providerId` CLEARS `autoReduced` server-side (the restore path). - -**FE (DONE + verified):** -- **Pure core (`logic/view-model.ts`):** `DEFAULT_COOLDOWN_MS = 350`; `parseCooldownInput` (non-negative integer — - unlike the limit, **0 is valid**); `normalizeCooldown` (defensive default 350 on garbage); - `cooldownLabel` ("350ms" / "1.2s" / "0ms (off)"); `viewConcurrencyStatus` extended to carry `cooldownMs` + - `cooldownLabel` + `autoReduced` + `autoReducedFrom` (auto-reduce → `warning` badge but NOT `busy` — a reduced - limit still admits agents); `viewAutoReduce`/`autoReduceNotices` (banner view — prefers the backend `notice` - verbatim, synthesizes a fallback when absent, `fromLimit` = `autoReducedFrom` for "Restore to N"); - `summarizeStatus` gained an "N auto-reduced" fragment; `normalizeConcurrencyStatus` coerces the new fields - (builds the readonly entry immutably — `autoReducedFrom`/`notice` only when `autoReduced===true`, dropped when - false even if present in the JSON); `normalizeConcurrencyCooldown` (network-seam coercion). -- **Types (`logic/types.ts`):** re-exports the 2 new contract types + `ConcurrencyCooldownResult` + - `GetConcurrencyCooldown`/`SaveConcurrencyCooldown` ports. -- **UI:** new `ui/ConcurrencyCooldownRow.svelte` (per-provider inline-edit cooldown input + Save → PUT, seeded via - the ChatLimitField pattern so a status-poll refresh re-syncs without clobbering an in-flight edit; "Saved." - confirmation); new `ui/AutoReduceBanner.svelte` (the dismissible banner — backend `notice` + "Was N, now M." + - "Restore to N" PUT button + ✕ dismiss). `ConcurrencyView.svelte`: cooldown label per status card + - `ConcurrencyCooldownRow`; an auto-reduce banner section at the top of the panel. The banner is DISMISSIBLE + - persists while `autoReduced===true`: a dismissed provider stays hidden while still auto-reduced, and is - un-dismissed the moment a poll shows it restored (a `$effect` reconciles the dismissed set against the live - auto-reduced providers). "Restore to N" PUTs the limit back to `autoReducedFrom` via `saveLimit` → the next - status poll shows `autoReduced===false` → the banner drops automatically. -- **Store (`store.svelte.ts`):** `getConcurrencyCooldown` (`GET .../cooldown/:id`) + `setConcurrencyCooldown` - (`PUT .../cooldown/:id` ← `{ cooldownMs }`) — both surface 400/404/503 as `ok:false` with the backend's `error` - string + normalize the body at the seam. Interface declarations added. (Mirrors the §2j "FE implements all API - client functions but the UI uses a subset" note: the UI seeds cooldown from the live status `cooldownMs`, so - `getConcurrencyCooldown` is an API-client function for completeness/future use — `saveCooldown` is the wired one.) -- **Wired in `App.svelte`:** `saveConcurrencyCooldown` adapter → `ConcurrencyView`'s `saveCooldown` prop. -- **Tests:** +47 (view-model: `parseCooldownInput`/`cooldownLabel`/`normalizeConcurrencyCooldown`/`viewAutoReduce`/ - `autoReduceNotices`/`summarizeStatus` auto-reduced/`normalizeConcurrencyStatus` new-field coercion; component: - cooldown label render, cooldown PUT flow, negative-input rejection, auto-reduce banner render, restore clears - banner, dismiss-while-auto-reduced; store: `getCooldown` load/404, `setCooldown` PUT echo + 400). - -**Verification:** `svelte-check` 0/0; vitest **1050/1050** (run TWICE — no cross-test pollution; the polling -intervals are per-component, cleaned up on unmount by `@testing-library/svelte`'s auto-cleanup), biome clean, -`vite build` succeeds (the one CSS warning is PRE-EXISTING — `[file:path]`/`[heartbeat:elapsed]` attribute -selectors, unrelated). **Live probe NOT run** (the backend is the user's process; never booted headless). To -confirm end-to-end: start the backend with the `concurrency` extension loaded, open the Concurrency sidebar view, -confirm the cooldown label + edit field per provider; set a cooldown → Save → confirm it persists on reload; -trigger a 429 on a limited provider → confirm the auto-reduce banner appears (with `notice` + "Was N, now M.") → -click "Restore to N" → confirm the banner drops on the next poll. - -**Post-review fixes (folded into the same commit):** a Kimi review flagged a MEDIUM bug + 2 LOW issues, all fixed: -- **MEDIUM — restore failure gave no inline feedback:** `restoreLimit` now returns a `RestoreOutcome` - (`{ ok: true } | { ok: false; error }`) and `AutoReduceBanner.handleRestore` shows the error INLINE next to the - restore button (cleared on retry) instead of silently re-enabling the button. New `RestoreOutcome` type in - `logic/types.ts` + exported. 2 new tests (inline error on failure; error clears on a retry that succeeds). -- **LOW — a11y:** the "Restore to N" text now stays visible while loading (spinner prepended, not replacing the - text), so the button keeps its accessible name during the PUT (a spinner-only button loses its name for SR users). -- **LOW — dismissed-banner persistence:** confirmed INTENTIONAL (not a bug). The dismissed set is component-local - (resets on remount): `autoReduced` is a REAL persisted degraded state, so re-showing the banner on a fresh mount - (sidebar view switch / reload) reminds the user; persisting dismissal in localStorage would risk HIDING an ongoing - degradation (a footgun), and AGENTS.md forbids module-global ambient state. Documented in a code comment. - -**Worktree environment note (same as §2d/§2j):** this worktree lays the repos out as -`…/worktrees/concurrency-fixes/{backend,frontend}`, but `package.json`'s canonical `file:` paths point at -`../dispatch-backend/...` (kept canonical — no worktree hack committed). An UNTRACKED symlink -`dispatch-backend → backend` was created in the worktree parent, then `bun install` re-synced -`node_modules/@dispatch/*` to pick up the additive `[email protected]` types. - ---- - -## 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. +## Milestone: Named Agents — FE scope (feature/named-agents) + +**Slice status:** COMPLETE + committed. The FE Named Agents feature is implemented end-to-end against +the backend contract that shipped in parallel (both repos on the `named-agents` worktree). + +### What the FE built +- **New feature module** `src/features/named-agents/`: + - `logic/types.ts` — re-exports `NamedAgent` + `NamedAgentSelectionSnapshot` from `@dispatch/wire` + and the `/named-agents` HTTP request/response types (`NamedAgentsResponse`, `NamedAgentResponse`, + `DeleteNamedAgentResponse`, `CreateNamedAgentRequest`/`NamedAgentInput`) from + `@dispatch/transport-contract`; defines the local port/result types. + - `logic/validate.ts` — pure name/model validation (nonempty, case-insensitive-unique, canonical + `<provider>/<model>`). + - `logic/options.ts` — provider/model dropdown derivation from the `GET /models` catalog (numbered + OpenCode numeric sort + friendly labels, matching the model picker). + - `logic/snapshot.ts` — pure snapshot helpers (`snapshotFor`, `findNamedAgent`). + - `logic/normalize.ts` — network-seam normalizers for the untyped `/named-agents` JSON. + - `logic/auto-save.ts` — pure auto-save status/validity model (`idle/dirty/saving/saved/error`), + `draftValidity`, `isDirty`, `saveStatusLabel`. + - `ui/controller.svelte.ts` — the injected auto-save SHELL (debounce for name edits, immediate save + for provider/model, monotonic sequence guard so an older response never overwrites a newer edit; + create/update/remove ports injected). + - `ui/NamedAgentCard.svelte` + `ui/NamedAgentsView.svelte` — the collapsible sidebar view (cards + with name/provider/model controls + status line + Remove + Add + empty state). + - `index.ts` + `manifest`. +- **Store** (`src/app/store.svelte.ts`): `namedAgents` state + CRUD (`refreshNamedAgents` / + `createNamedAgent` / `updateNamedAgent` / `deleteNamedAgent`) over `GET/POST/PUT/DELETE /named-agents`; + `namedAgentSnapshot` reactive state; `selectModel(model, snapshot?)` persists the raw model via + `PUT /conversations/:id/model` WITH the `namedAgent` snapshot; draft promotion carries the snapshot; + `refreshModel` restores the persisted `ModelResponse.namedAgent`. +- **Chat store** (`features/chat/store.svelte.ts`): `setNamedAgent(snapshot)` so `chat.send` includes + `namedAgent` at conversation creation (draft path). +- **Tabs** (`features/tabs`): optional `Tab.snapshot` + `setSnapshot` reducer/store method (snapshot + retention survives rename/remove + persists to localStorage with tabs). +- **Model selector** (`chat/model-select.ts` + `ModelSelector.svelte`): the synthetic **Named Agents** + provider is ALWAYS first in the provider list; selecting a Named Agent resolves it to the raw model + + records the snapshot; the UI shows `Named Agents -> <name>`. A retained snapshot of a renamed/removed + agent still displays as an `(unavailable)` option (plan §7). +- **Composition root** (`src/app/App.svelte`): "Named Agents" view kind (+ loaded-modules manifest), + `NamedAgentsView` wired to store CRUD ports, ModelSelector fed `namedAgents`/`namedAgentSnapshot` + + `handleSelectNamedAgent`. +- **Tests:** pure logic (validate/options/snapshot/normalize) + auto-save status + controller + (debounce, sequence guard, create/update/remove) + ModelSelector named-agent provider + app-store + CRUD/snapshot. 1246 tests pass (up from 1226 before the slice), typecheck 0, biome clean (only the 2 + pre-existing config infos), `vite build` succeeds. + +### Pinned contract versions + mirrors +- `@dispatch/wire`: consumed (now exports `NamedAgent` + `NamedAgentSelectionSnapshot`). Mirror + `.dispatch/wire.reference.md` updated with a 2026-08-20 delta note. +- `@dispatch/transport-contract`: consumed (now exports the `/named-agents` types + + `ChatRequest.namedAgent`/`ModelResponse.namedAgent`/`SetModelRequest.namedAgent`). Mirror + `.dispatch/transport-contract.reference.md` updated with a 2026-08-20 delta note. +- `@dispatch/ui-contract`: unchanged (no surface-based named-agent mechanism — it is a regular FE view). + +### Open asks / for the backend +- **None.** The FE aligns with the backend's shipped contract (backend commit + `9039559 feat(named-agents): backend for Named Agents`). Verified: the backend's + `list_agent_models` reads ONLY the named-agents registry (not the raw catalog), and + `summon_agent` accepts `agent` (name/id) resolving locally to the raw model with an + `unknown-agent`/exactly-one-of guard; the `/named-agents` HTTP CRUD surface + (`GET/POST/PUT/DELETE`) + `ChatRequest.namedAgent`/`ModelResponse.namedAgent`/ + `SetModelRequest.namedAgent` snapshot wiring are all present in `transport-http`. + There is nothing outstanding for the backend to implement. + +### Findings +- The backend contract shipped the snapshot on `ChatRequest`/`ModelResponse`/`SetModelRequest` (richer + than the plan's "recommended HTTP surface"); the FE uses it for the model-selector + draft promotion + paths so backward/raw-model conversations are unaffected. +- **FE-only bug fix (no backend change):** row identity + duplicate-name validation in `ui/NamedAgentsView.svelte` + previously keyed editable rows by their nullable persisted `id` (or captured `agent` object/null), so + drafts (`agent === null`, `id === null`) falsely collided with their OWN name and saving/removing one of + multiple drafts could reconcile all of them. Fixed by identifying rows solely by each `Row`'s stable + numeric `key` for the other-names exclusion, the live `isNameTaken` predicate, and the save/remove + reconciliation. Direct component-level regression coverage added in `ui/NamedAgentsView.test.ts`. + +### Likely next asks +- A live probe (after the backend boots) exercising `/named-agents` CRUD + the synthetic-provider + selection round-trips, when the user has the backend up. This is a joint/user step, NOT backend work — + it needs the user to boot the backend (`bin/up`), then either repo's probe can run. +## Milestone: Win8-Start-style home page — FE-only (feature/win8-tiles-home) + +**Slice status:** COMPLETE + committed (FE only; branch `feature/win8-tiles-home`). NO backend +changes, NO contract changes — the wire types and every endpoint consumed are unchanged. + +### What the FE built +- The workspaces home page (`features/workspaces/ui/WorkspacesHome.svelte`) is rebuilt as a + Windows 8 Start-screen-style TILE GRID (one tile per workspace): + - **Pure layout core** `logic/tile-layout.ts`: `geometryFor(width, height)` → WIDE + (landscape/desktop: 4 fixed ~120px rows, column-wise flow, 4×2 span cap) or TALL + (portrait/mobile: 2 fixed columns, row-wise flow, 2×2 cap); `pack(entries, geometry)` — + the ONLY order→coordinates transform (greedy first-fit in fill order, hole-free); + `firstEmptySlot`, `insertAtFirstEmptySlot`, `mergeLayout`, `resizeTile`, `moveTile`, + `sortByRecentActivity`, `clampSpan`, `sanitizeLayout`, `fillOrderKey`. Persisted data is + ORIENTATION-INDEPENDENT `{ version: 1, tiles: [{ id, colSpan, rowSpan, order }] }` — order + + spans only, NEVER positions; spans render-clamped per geometry (persisted values untouched). + - **Persistence** via the existing injected `adapters/local-storage` adapter, key + `dispatch.workspaces.tiles` (`tile-layout-store.svelte.ts`: thin reactive wrapper, ops + sync/resize/move/placeFirstEmpty/sortByRecentActivity). + - **Drag reorder** via a PURE pointer state machine (`logic/drag-controller.ts`): mouse = + threshold-drag, click = tap-open; touch = long-press (500ms) lifts + selects, pre-lift + movement cancels so vertical scrolling never fights the gesture; FLIP-animated reflow. + - **Tile select + action bar**: right-click (desktop) / long-press (touch) → + Open · Rename · Resize · Star/Unstar · Details · Plugins · Delete. Rename is the old card's + inline semantics; Delete keeps the old confirm + store call; Details is a flyout holding the + old card's default-cwd input + default-computer select (same store calls + error states). + - **Resize**: Word-style insert-table hover picker (wide 4×2 / tall 2×2 small squares) with + arrow-key/Enter/Escape support and a transient ghost footprint preview on the real grid. + - **Header chrome** (fixed, outside the scroll region): "Dispatch" display title, a primary + "New workspace" popover (same slug validation; creates via `PUT /workspaces/:id` + create-on-miss and does NOT navigate — the new tile is placed in the FIRST EMPTY SLOT in + fill order and enters rename mode), the global Plugins button, and a Sort-by-recent-activity + overflow. Tall screens collapse the controls to icon buttons + a bottom-sheet popover. + - Wide canvas: horizontal scroll + scroll-snap, wheel pans horizontally, pan arrow + thin + scroll-position indicator. Starred workspaces render a tint (star is no longer a sort key — + the persisted manual order rules; unknown workspaces append at the end). + +### Backend notes +- The starred display semantics changed FE-side only: the home grid no longer sorts + starred-first (`sortWorkspaces` remains for the list getter). The star's MEANING is untouched — + it is still the concurrency-priority flag on `Workspace.starred`. +- The create flow now calls `ensure(id)` directly (today's navigation-implicit ensure) — same + endpoint, no new surface. + +### Follow-up: three drag-interaction bugfixes (2026-09-07, same branch, FE-only) +- **Stuck-tile drop** (`displacePlace` in `logic/tile-layout.ts`): the displacement preview + returned placements SORTED BY STREAM ORDER, so as the dragged tile crossed stream positions the + keyed `{#each}` moved its DOM node and the browser silently dropped its pointer capture — the + subsequent `pointerup` never landed and the follow transform never cleared (phase stuck + `"dragging"`). `displacePlace` now returns the INPUT render order (coordinates change only); + `placeAtCell` re-sorts to stream order itself so the commit's order-renumbering semantics are + unchanged. +- **Follow-transform grab offset** (`ui/HomeTileGrid.svelte`): the drag follow was a raw + pointer-displacement translate, so a grab away from the tile's top-left corner made the tile + jump (its origin rode the pointer instead of the grabbed point). `pointerdown` now records the + grab offset (pointer − tile rect) and the transform cancels the tile's true grid anchor, so the + GRAB POINT tracks the pointer. +- **Empty-cell hover** (`ui/HomeTileGrid.svelte`): pointer handling moved from the per-tile + elements to the GRID CONTAINER, which `setPointerCapture`s the pointer and never gets + re-parented/`pointer-events: none`/unmounted — so move/up stream to it over empty cells and off + the canvas, and capture can never be lost to a DOM move again. +- **Mobile (tall) scroll fix**: the grid's root was `flex-1` inside a BLOCK parent (the workspace + region), so it sized to its content and the scroller's `h-full` collapsed → `overflow-y-auto` + never engaged and the shell's `overflow-hidden` clipped the extra tiles. The root is now `h-full`, + giving the scroller a definite height so tall mode scrolls. Also: touch pointers are no longer + `setPointerCapture`d at pointerdown (an early touch capture can suppress native panning in + WebKit; touch already gets implicit capture and post-lift moves bubble to the container), keeping + touch scrolling reliable while drags still work. +- **Drag append bound** (`dragAppendLimit` in `logic/tile-layout.ts`; `cellAt` in + `HomeTileGrid.svelte`): the drop target is clamped to at most 3 columns (wide) / rows (tall) + past the committed content edge (span-aware, measured on the COMMITTED layout so it can't + ratchet mid-drag). The canvas stays content-driven while dragging — the preview extends it as + the tile reaches toward the edge and it shrinks back when the gesture ends, bounded by the + clamp — so the scrollbar grows/shrinks WITH the drag. (An earlier attempt pre-reserved the 3 + spaces at drag start as a fixed canvas; that made the view jump and created too much free + space — reverted.) +- Verification: `typecheck` 0 errors/0 warnings, vitest 1689/1689 (incl. updated + `tile-layout.test.ts` order assertions — coordinates unchanged), biome clean on touched files, + `build` succeeds. Browser-only (pointer capture) — needs a human at the page to confirm the + three fixes live (see commit message checklist). + +## 2026-09-18 — feature/image-attach-compression (FE-only) + +- **Attach-time image compression** in the chat composer: every staged image now carries a + per-image `Compress` toggle (default ON, next to the remove button on the thumbnail, + tooltip "PNG → JPEG + resize to 2048px"). ON re-encodes LOSSLESS sources (PNG/BMP) to + JPEG q0.85, rescales to cap the longest edge at 2048px (never upscales, high-quality + smoothing, alpha flattened onto white), and uses the result only under a smaller-wins + rule. JPEG/WebP/GIF are sent verbatim (toggle hidden — recompressing lossy/animated + images degrades them and saves nothing). OFF sends the original untouched bytes; the + conversion is cached so flipping the toggle never re-encodes. Conversion is async at + staging (the original stages immediately) and ANY failure silently falls back to the + original — sending can never be blocked or broken by compression. +- **NO wire-contract changes.** `ImageInput`/`ImageChunk` are untouched; only the bytes a + client chooses to put in `ChatRequest.images` change (still base64 data URLs, now + sometimes `data:image/jpeg;base64,…` for a source that was `image/png`). No backend ask. + This complements the backend's body-budget eviction safety net by shrinking images at + the source so that path rarely triggers. +- Verification: `typecheck` 0 errors/0 warnings; vitest 1720/1720 (twice — incl. new pure + logic tests for format selection/dimension math/smaller-wins + component tests for the + toggle default/reversibility/caching/fallback/mimeType); biome clean; `build` succeeds. + Manual browser sanity (paste a ~3MB PNG → staged size drops; uncheck → original; + re-check → no visible re-encode; send works) still needs a human at the page. + +## 2026-10-24 — feature/text-file-embeds + +Implemented saved text-file chat embeds using standalone `[Plan](text-file:notes/plan.md)`. +Markdown renders inside a tool-call-style inset well with first/last six-source-line +preview, Expand/Collapse, Refresh/Retry, loading/empty/error states. Other text/code +files remain literal. Backend adds authoritative `/text-file-context` preflight and +bounded `/text-file` read routes; `TextFileQuery`, `TextFileResponse`, and +`TextFileContextResponse` are additive transport-contract0.26.0 exports mirrored in +`.dispatch/transport-contract.reference.md` (local file dependency unchanged). +Both guards are supplied; remote computer never falls back to local. + +256KiB strict UTF-8, regular local files only, no truncation/final symlinks, live +current-disk views (not snapshots/watchers), no history injection/nested fetches. +Cache per mounted chat:32 active paths/4 loads; max8 embeds/message. Existing +Host/Origin trusted-network boundary retained (not new bearer/session auth). +Initial guidance reaches custom/default newly constructed prompts; persisted old +prompts unchanged until ordinary reconstruction. Warm parity verified. Explicit +per-turn systemPrompt bypass preserved. + +Verification: typecheck0 errors/0warnings; Biome clean308 files; Vite build succeeds; +2586 tests pass twice with `NODE_OPTIONS=--no-experimental-webstorage`; two real +cross-repo ephemeral HTTP/Svelte integration tests pass opt-in. Plain Node26 test +run has26 baseline localStorage failures independently reproduced on unmodified +HEAD archive. Backend4277 Vitest/190 Bun green, typecheck/lint/standalone compile +green; standard external-Claude bundle has existing missing @dispatch/kernel issue. +No merge/push/deployment/shared restart. Full report: +`notes/text-file-embeds-implementation.md`. Human graphical desktop/mobile check +remains; no cross-repo contract asks open for this slice. + +### Bun-native verification correction (2026-10-24) + +Supersedes the Node-backed verification paragraph above: both repository test +scripts now explicitly launch Vitest with Bun (`bun --bun .../vitest.mjs`). +`bun run test` passes under Bun with no NODE_OPTIONS: frontend2587 passed/2 +opt-in skipped repeatedly; backend4278 passed/9 existing SSH skips twice; +native Bun tests190 passed. `bun run test:text-file-live` passes2 against the +real ephemeral HTTP transport. Bun-run typecheck/lint/build pass (FE309 files, +backend754); existing build advisories/external-Claude wrapper limitation remain. + +Corrected pre-existing Bun test assumptions (Bun.serve spy instead of global +replacement, directory ordering, homedir caching). Added prompt cycle detection +in tool-call summary serialization plus shared-reference regression test; the +Bun timeout is fixed, not increased. Live probe uses a native-signal jsdom +environment instead of Node-only utility APIs. No production wire/feature +semantics changed, no subagents/deployment/merge/push. Full final verification +is in the Bun-native follow-up of notes/text-file-embeds-implementation.md. @@ -1,7 +1,7 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json", "assist": { "actions": { "source": { "organizeImports": "on" } } }, - "linter": { "enabled": true, "rules": { "recommended": true } }, + "linter": { "enabled": true, "rules": { "preset": "recommended" } }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always" } }, "css": { "parser": { "tailwindDirectives": true } }, @@ -5,13 +5,17 @@ "": { "name": "dispatch-web", "dependencies": { - "@dispatch/transport-contract": "file:../dispatch-backend/packages/transport-contract", - "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract", - "@dispatch/wire": "file:../dispatch-backend/packages/wire", + "@dispatch/layout-contract": "file:../backend/packages/layout-contract", + "@dispatch/tile-layout-contract": "file:../backend/packages/tile-layout-contract", + "@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", + "gsap": "^3.15.0", "highlight.js": "^11.11.1", "marked": "^18.0.4", "marked-highlight": "^2.2.4", + "phosphor-svelte": "^3.1.0", }, "devDependencies": { "@biomejs/biome": "^2.4.16", @@ -21,6 +25,7 @@ "@testing-library/svelte": "^5.2.0", "@testing-library/user-event": "^14.6.1", "@tsconfig/svelte": "^5.0.0", + "@types/node": "^26.6.5", "daisyui": "^5.5.20", "fake-indexeddb": "^6.0.0", "jsdom": "^25.0.0", @@ -36,8 +41,8 @@ }, }, "overrides": { - "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract", - "@dispatch/wire": "file:../dispatch-backend/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=="], @@ -50,23 +55,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=="], @@ -78,11 +83,15 @@ "@csstools/css-tokenizer": ["@csstools/[email protected]", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], - "@dispatch/transport-contract": ["@dispatch/transport-contract@file:../dispatch-backend/packages/transport-contract", { "dependencies": { "@dispatch/ui-contract": "workspace:*", "@dispatch/wire": "workspace:*" } }], + "@dispatch/layout-contract": ["@dispatch/layout-contract@file:../backend/packages/layout-contract", {}], - "@dispatch/ui-contract": ["@dispatch/ui-contract@file:../dispatch-backend/packages/ui-contract", {}], + "@dispatch/tile-layout-contract": ["@dispatch/tile-layout-contract@file:../backend/packages/tile-layout-contract", {}], - "@dispatch/wire": ["@dispatch/wire@file:../dispatch-backend/packages/wire", {}], + "@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:../backend/packages/ui-contract", {}], + + "@dispatch/wire": ["@dispatch/wire@file:../backend/packages/wire", {}], "@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], @@ -146,101 +155,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=="], @@ -254,6 +263,8 @@ "@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], + "@types/trusted-types": ["@types/[email protected]", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@vitest/expect": ["@vitest/[email protected]", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ=="], @@ -270,7 +281,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=="], @@ -278,7 +289,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=="], @@ -304,7 +315,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=="], @@ -326,11 +337,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=="], @@ -348,17 +359,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=="], @@ -372,6 +383,8 @@ "graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gsap": ["[email protected]", "", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="], + "has-symbols": ["[email protected]", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["[email protected]", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -452,9 +465,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=="], @@ -462,13 +475,15 @@ "pathval": ["[email protected]", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "phosphor-svelte": ["[email protected]", "", { "dependencies": { "estree-walker": "^3.0.3", "magic-string": "^0.30.13" }, "peerDependencies": { "svelte": "^5.0.0 || ^5.0.0-next.96", "vite": ">=5" }, "optionalPeers": ["vite"] }, "sha512-M+CeN2s7YAFHWVxctLj41SyXsjWxoIoNi/rZ15XIYh9XEpbgJ2mE2hn54tA30l6WSagaBg2bA/OazlZJVDUkpQ=="], + "picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "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-zxcTTCedNGJM4R8sj/Cq/F0W/c4iE0afWBcBwMTRtw4WHYP9TWkYjdiH3npPRUYsXQCPR0hTU9yjovOu+E6EQA=="], + "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=="], @@ -482,7 +497,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=="], @@ -504,13 +519,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=="], @@ -536,6 +551,8 @@ "typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "vite-node": ["[email protected]", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], @@ -564,19 +581,19 @@ "zimmerframe": ["[email protected]", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], - "@dispatch/transport-contract/@dispatch/ui-contract": ["@dispatch/ui-contract@file:../dispatch-backend/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:../dispatch-backend/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=="], @@ -587,7 +604,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/archived-workspace-create-warning-plan.md b/notes/archived-workspace-create-warning-plan.md new file mode 100644 index 0000000..537473b --- /dev/null +++ b/notes/archived-workspace-create-warning-plan.md @@ -0,0 +1,85 @@ +# Plan — Warn when creating a workspace whose slug belongs to an archived workspace + +Status: requested by user, ready to implement +Branch: `feature/archived-workspace-create-warning` +Scope: **frontend only**. No backend change (the backend already exposes everything needed). + +--- + +## 1. User requirement + +> "I want the frontend to warn me if I try to create a workspace that already +> exists and is archived (it should say you can't)." + +So: when the user submits the **New workspace** form with a slug that matches an +existing **archived** workspace, the frontend must **not** create/ensure it and +must show a message saying it **can't** be created because a workspace with that +name already exists and is archived. + +## 2. Current behavior (why it's wrong today) + +- `HomeHeader.svelte` (`src/features/workspaces/ui/HomeHeader.svelte`) — the + New-workspace popover. `submit()` validates the slug with `isValidSlug`, then + calls `onCreate(slug)` and shows `result.error` in the existing `slugError` + slot when `result.ok === false`. +- `WorkspacesHome.svelte` → `createWorkspace(slug)` calls `store.ensure(slug)`, + then `store.refresh()`, `tiles.placeFirstEmpty(slug, …)`, and enters rename + mode (`renamingId = slug`). +- `store.ensure` → `http.ensure` → backend `PUT /workspaces/:id`, which is + **idempotent**: for an existing archived workspace it just returns it + (`archived: true`) — no error. The active list (`GET /workspaces`, archived + excluded by default) doesn't contain it, yet the home grid places a tile for + it and enters rename mode. Silent and confusing. + +Backend facts (no change needed): +- `GET /workspaces/:id` → the workspace incl. `archived: boolean`, or 404. + Frontend adapter already has `http.get(id)` (`src/features/workspaces/adapter/http.ts`) + — note it currently returns `null` for **both** 404 and network/other errors. +- `GET /workspaces?archived=true` → archived-only list (the store's + `refreshArchived()` / `archivedList`, used by the Archived view). + +## 3. Required behavior + +1. On submit, **before** calling `ensure`, determine whether a workspace with + that slug exists and is archived. Use an authoritative check against the + backend (e.g. `GET /workspaces/:id`) — do **not** rely on `archivedList` + alone, since it's only loaded when the Archived view is opened. +2. If it exists and is archived: + - do **not** call `ensure`, do not refresh/place a tile, do not enter rename; + - keep the popover open and show the error in the existing `slugError` slot, + clearly saying it can't be created because an archived workspace with that + name already exists. Suggested wording: + `A workspace named "<slug>" already exists and is archived — you can't create it.` + (It's fine to append a short hint that it can be restored from Archived + workspaces, but don't add new UI controls for that.) +3. If it doesn't exist, or exists and is **not** archived → today's behavior, + unchanged. +4. If the existence check itself fails (network/server error), don't silently + swallow it in a way that creates an archived workspace's tile: either surface + an error, or proceed exactly as today — pick one, keep it simple, and justify + it in the report. (Distinguishing 404 from "request failed" may require a + small adapter/store change; that's in scope.) +5. While the check is in flight, the existing `submitting` state should cover it + (no double submits). + +Follow the frontend's architecture rules in `AGENTS.md` (where decision logic +lives, store/adapter boundaries, Svelte 5 runes conventions, styling). + +## 4. Out of scope +- Creating a slug that matches an existing **active** workspace (unchanged). +- Any backend change (e.g. making `PUT /workspaces/:id` reject archived ids). +- New UI for unarchiving from the popover. + +## 5. Tests +- Unit/store test: creating an archived slug returns `ok: false` with the + "can't create" message and does **not** call `ensure`. +- Non-archived existing slug and brand-new slug → `ensure` is called as before. +- Check-failure path per your choice in 3.4. +- Component test (`HomeHeader.test.ts` or `WorkspacesHome` level, whichever + matches existing patterns): the message renders in the popover and the + popover stays open. + +## 6. Verification gate +`bun install`, `bun run typecheck`, `bun run test`, `bun run check`, +`bun run build` — all green. Commit to +`feature/archived-workspace-create-warning` only. Do not merge or push. diff --git a/notes/assumptions-log.md b/notes/assumptions-log.md index 026cde2..3291e28 100644 --- a/notes/assumptions-log.md +++ b/notes/assumptions-log.md @@ -2,6 +2,38 @@ > Recorded while working autonomously (user away). Raise these when the user returns. +## 2026-10-24 — Close-tab cascade completion + +- Retained the original visible-group + snapshot semantics below, including one close + request per id; no backend lineage cascade and no close-all retry workaround. +- User explicitly authorized replacing the companion backend symlink with an isolated + feature worktree to fix the reproducible active-turn Close → idle finalization race. + Backend commit `fd07259` fixes this and adds regression coverage. Verification writes + for the fixed backend use fresh private runtime paths, never the user's instance. +- Native modal lifecycle (top layer/focus containment/safe initial focus) replaces the + original CSS-only overlay; stale target dialogs dismiss on workspace/remote removal. +- Final verification and cross-repo handoff: `backend-handoff.md` close-tab milestone. + +## 2026-10-16 — Close-tab cascade confirm dialog (subagent groups) + +1. **"Subagents" of a tab = its VISIBLE subagent group (open tabs only).** The confirm dialog + triggers when the closed tab has descendants in the tab grid's grouping + (`tabGroupParentId` forest, transitive). Subagent conversations that were never opened as + tabs are NOT closed — the FE has no knowledge of them (no lineage API consumed here). The + backend's `POST /conversations/:id/close` does not cascade either, so this matches the + visible mental model. + +2. **The cascade issues one `POST /close` per conversation** (via the existing `onClose` → + `store.closeTab` port, unchanged). The backend close is idempotent, so a descendant closed + by another client mid-dialog is a harmless no-op. No new cross-unit contract was needed: + the decision logic is pure (`cascadeCloseIds` in the tabs unit) and the dialog lives inside + `TabList`. + +3. **Dialog offers three outs:** "Yes, close all N" (cascade), "No, keep them open" (close + only the target tab — the pre-change behavior), and Cancel/Escape/backdrop (close nothing). + The user asked for a yes/no prompt; Cancel was added as the safe dismissal path. The + cascade-order snapshot (ids) is taken when the dialog opens. + ## 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` @@ -13,7 +45,7 @@ 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. + removed). The user may want to `sudo chown -R "$(id -un):$(id -gn)" 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`. diff --git a/notes/instant-stop-2-plan.md b/notes/instant-stop-2-plan.md new file mode 100644 index 0000000..21b1db9 --- /dev/null +++ b/notes/instant-stop-2-plan.md @@ -0,0 +1,86 @@ +# Markdown Streaming Performance & UX — Implementation Plan + +**Status:** Ready for implementation — RE-SCOPED (see Scope) +**Branch:** `feature/markdown-streaming-render` (renamed from `feature/instant-stop-frontend-render`; commit to HEAD) +**Spec:** `notes/instant-stop-2-spec-ref.md` (read-only reference copy in this worktree; original at `backend/notes/instant-stop-2-frontend-render-backlog.md`) + +## Scope (IMPORTANT — this plan was re-scoped after the original dispatch) + +This feature implements ONLY the **markdown rendering performance/UX fix** — the checkpoint caching design below. The spec's other items are explicitly DEFERRED and must NOT be implemented here: + +- NO stop barrier / composer `stopping` state / stop-failure reconciliation (spec items A, B, C, G) +- NO delta coalescing / scroll frame-bounding (spec items D, F) +- No chat-store, Composer, ChatView, app-store, ws-adapter, or smart-scroll changes. + +The markdown fix stands alone: `streaming-markdown.ts` + `Markdown.svelte` + `markdown.ts`, plus tests. + +## Problem + +Every streamed delta re-renders the ENTIRE accumulating message through the full pipeline (closeOpenDelimiters regexes over the whole text → marked parse → hljs highlight of every fenced block → copy-button rewrite → DOMPurify sanitize → `{@html}` full subtree replacement). Cost per token grows with message length — ~O(n²) over the message lifetime — so long responses and growing code fences jank the UI, and whole-subtree DOM replacement churns layout and scroll. + +## Design — checkpoint caching of settled markdown blocks + +### New pure module: `src/features/markdown/logic/streaming-markdown.ts` +- `createStreamingMarkdown()`; `advance(state, textSoFar) → { prefixHtml, tailHtml }`; reset when the text shrinks or is not an append-extension of the cached source (e.g. authoritative replace). +- Internals: a block-boundary line-scanner, a per-block HTML cache, and a settled-source offset. + +### Safe-boundary rule (the core invariant) +A checkpoint may be placed ONLY at a blank-line boundary that is NOT inside: +- an open fenced code block (``` or ~~~), +- a list (any depth — blank lines inside a list do not end it), +- a blockquote, +- an unclosed raw-HTML construct (`<!-- -->`, unbalanced tag). + +When uncertain → do NOT checkpoint; extend the tail. The failure mode is degraded caching, NEVER wrong output. + +- Fallback: if new content matches a link-reference-definition pattern (`^\[.*\]:`), invalidate the cache and do one full re-render (a definition can rewrite earlier `[label]` shortrefs; rare in assistant output). +- Why blank-line + closed-context suffices: setext headings (`foo` + `---`) and GFM table delimiter rows require the triggering line to IMMEDIATELY follow the paragraph line — a blank line between makes retroactive block formation impossible. Lists/blockquotes/fences are tracked as open context by the scanner. + +### Rendering levels +- **Level 1 (mandatory):** the tail renders through TODAY'S exact pipeline (`closeOpenDelimiters` on the tail only → marked → `addCopyButtons` → DOMPurify). Output contract: `prefixHtml + tailHtml` ≡ `renderMarkdown(textSoFar)` at every step, modulo the newline marked emits between top-level blocks. +- **Level 2 (approved by the user):** hljs is SKIPPED for the open tail fence (plain escaped `<pre><code>`); a code block is highlighted EXACTLY ONCE, when it closes (settled into the cache with its highlighted HTML). Code renders uncolored while its fence is open; colors pop in when the fence closes — accepted UX. NO line-incremental highlighting — unsound (hljs state spans lines; an unterminated string/block comment recolors earlier lines). +- **Completion backstop:** when `streaming` flips false (done / turn-sealed), do ONE full authoritative render via today's `renderMarkdown(text)` and use that. Final/persisted output is always byte-identical to today's behavior; any incremental-path divergence is transient (streaming-only) by construction. + +### Component changes: `Markdown.svelte` +- Two sibling containers inside the existing `.markdown-body` wrapper: `<div>{@html prefixHtml}</div><div>{@html tailHtml}</div>`. + VERIFIED in the installed runtime: Svelte 5 skips the DOM write when the html string is unchanged (`svelte/src/internal/client/dom/blocks/html.js:75` — `if (value === (value = get_value() ?? '')) return;`), so the settled prefix's DOM subtree is never re-parsed / re-created / re-laid-out. +- The wrappers must not break block margin collapsing: no padding/border/min-height on them. +- The delegated copy-button listener stays on the outer container (unchanged). +- `streaming === false` → the single-container full-render path (today's behavior, unchanged). +- Thinking chunks (plain pre-wrap) and user text chunks (plain `<p>`) are unaffected. + +## Tests +- **Corpus equivalence property test:** a corpus of tricky markdown (setext after blank line; GFM table formation; link-reference definitions; nested lists with internal blank lines; blockquotes; ~~~ fences; inline emphasis spanning delta boundaries; raw HTML; a huge growing fence) replayed token-by-token: at EVERY prefix length, the concatenation (whitespace-normalized) equals `renderMarkdown(prefix)` where the boundary rule held, and the final render equals today's output exactly. +- hljs spy: exactly one highlight call per closed block; zero while a fence is open. +- Cache stability: `prefixHtml` string identity stable across appends; shrink/replace resets. +- All existing markdown tests remain green; sanitization guarantees unchanged for final HTML. + +## Performance validation + +Instrumented fixture (counts, not wall-clock): while streaming thousands of deltas into a long message with growing code fences, full-pipeline invocations and hljs calls remain proportional to the number of BLOCKS, not to message length × deltas. The subsystem is isolated — no full-app fixture needed. + +## Files +- New: `src/features/markdown/logic/streaming-markdown.ts` (+ test). +- Modified: `src/features/markdown/ui/Markdown.svelte`; `src/features/markdown/logic/markdown.ts` only if the tail pipeline needs a shared export (+ tests). +- NOT modified: chat store, Composer, ChatView, app store, ws adapter, smart-scroll, backend. + Note: the checkpoint fix itself removes most scroll/layout amplification, because settled content stops resizing (the ResizeObserver stops firing for the prefix) — no scroll-controller changes are needed for this feature. + +## Verification gates + +``` +bun run typecheck && bun run test && bun run check && bun run build +``` + +## Commit plan + +1. `docs(markdown): re-scoped implementation plan — streaming performance` (this file, replacing the earlier three-phase version) +2. `feat(markdown): checkpoint streaming render — settled-block cache, highlight on close` +(plus fixups as needed) + +## Out of scope (deferred, separate future work) + +- Stop barrier / stopping composer state / stop-failure reconciliation (spec items A, B, C, G). +- Delta coalescing + scroll frame-bounding (spec items D, F). +- Problem 1 (merged) / Problem 3 (backend queue semantics). +- Transcript virtualization; line-incremental highlighting (unsound). +- Backend WS event coalescing. diff --git a/notes/pinned-tiles-plan.md b/notes/pinned-tiles-plan.md new file mode 100644 index 0000000..6d50e55 --- /dev/null +++ b/notes/pinned-tiles-plan.md @@ -0,0 +1,129 @@ +# Pinned tiles + displacement reflow — plan + +Feature: home tiles can be dropped onto **specific cells** of the grid; empty cells are +real estate that persists. Dragging shows a live **FLIP** reflow preview; Escape/cancel +returns everything to the committed layout. + +## Interaction semantics (user-specified, literal) + +The grid's cells form a fixed stream (wide: column-major over 4 rows; tall: row-major +over 2 columns). Only **filled** cells flow. Insertion at a cell pushes the contiguous +run of filled cells **forward** (in stream order) until the first empty cell absorbs it: + +- `X _ X` + place Y at slot 1 → `Y X X` (X shifts into the gap; the second X never moves) +- `_ X Z _` + place Y at slot 2 → `_ Y X Z` (X and Z each shift one; leading gap kept) + +Rules that follow (and are deliberately uniform): + +1. **Gaps never collapse.** Removing a tile opens a gap at its anchor; nothing slides + backward to fill it. So an internal move = remove (gap opens) + insert (run pushes + forward): `X Y Z`, drag X to slot 3 → `_ Y X Z`. The gap at slot 1 is preserved — + consistent with the two examples above. +2. **Preview = the exact post-drop state, recomputed from the COMMITTED layout on every + hover-cell change** ("as if the user directly moved from the original to there" — no + accumulation of intermediate previews). The animation, however, runs from wherever + tiles currently are on screen (mid-flight included) to the new targets — re-entrant + FLIP. +3. **Escape / pointercancel** discards the preview: the dragged tile flies back to its + committed anchor and everything FLIPs home. Nothing persists. +4. During a drag: a **faint dot** marks the center of every unit cell (incl. one extra + pitch of slack past the content end), and the **dragged tile element itself follows + the pointer** (lifted: scale + shadow, no pointer events). A dashed ghost footprint + marks the landing anchor. + +## Data model (v2) + +```ts +TileLayoutEntry { id, colSpan, rowSpan, order, col?, row? } // col/row: pinned anchor +TileLayoutData { version: 2, tiles } // sanitize migrates v1→v2 +``` + +- Anchors are persisted in a **universal 4-row frame** (wide's frame). Tall normalizes: + render `{ c, r }` ↔ universal `{ col: 1, row: 2(r−1)+c }`. Wide is identity. + A pin outside tall's 2 columns reads as **unpinned** in tall (render-time clamp — same + philosophy as spans). Round-trips can be lossy in that ragged case; accepted. +- `col`/`row` absent = **unpinned** → flows into the first free cells at render time. +- `order` remains: arrival order for unpinned flow + first-wins tie-breaks. Sort-by-recent + now also **clears pins** (an explicit arrangement reset). +- v1 documents load as all-unpinned (render identical to today) and write back as v2 on + the next mutation. No version sniffing outside `sanitizeLayout`. + +## Pure ops (`logic/tile-layout.ts`) + +- `pack(entries, geometry)` — v2: place in-range pinned tiles first (universal→render + anchor; out-of-range/overlapping/span-unfit → deferred to unpinned), then flow unpinned + by first-fit. One pass, no solver; first-pinned-wins, strictly one-ply (a displaced pin + never bumps another pin). +- `displacePlace(placements, draggedId, cell, geometry)` — the render-frame core: + remove the dragged tile (gap opens), force-anchor it at `cell`, then walk the others in + stream order: keep if free, else re-anchor at the next fitting slot **forward** of the + conflict (never backward). Used for BOTH the live preview (on packed placements) and, + via `placeAtCell`, the commit. +- `placeAtCell(entries, id, cell, geometry)` — commit path: pack → displacePlace → + pin back to entries (universal-frame anchors; persisted spans kept raw, anchors come + from the render placements). +- `pinAtFirstEmpty(entries, id, geometry)` — the create flow: pin the tile at the first + truly-empty slot (holes now count). +- `firstEmptySlot` — unchanged contract, now hole-aware by construction. +- `sortByRecentActivity` — renumbers order AND clears pins. + +## Store (`tile-layout-store.svelte.ts`) + +- `move(id, toIndex)` → replaced by `placeAtCell(id, cell, geometry)`. +- Structural equality (`sameEntries`) gains `col`/`row`. + +## Drag (`logic/drag-controller.ts`) + +- `targetIndex: number` → `targetCell: { col, row } | null` (pure rename; semantics of + the state machine — tap/lift/drop/cancel, touch scroll-wins — unchanged). + +## Component (`HomeTileGrid.svelte`) + +- **Hit math** replaces `elementFromPoint`: cell = `floor((pointer − gridRect.origin) / + pitch) + 1` (pitch = unit + gap), clamped to ≥ 1 and to the fixed cross-axis. The gap + between cells maps to the preceding cell. The pointer always resolves to a cell during + a drag (clamped), so empty cells are drop targets. +- **Dots overlay**: while dragging, the grid gets a `radial-gradient` background + (2px dot, `base-content` at low opacity) with `background-size = pitch × pitch` and + `background-position = unit/2` so dots sit at cell centers; `padding` extends one pitch + past the content end so the append-slot has a dot. Pure CSS, no extra DOM. +- **Dragged tile follows the pointer**: the wrapper keeps its committed grid slot in the + layout; a `translate(pointer − grabOffset) scale(1.05)` transform (via gsap) lifts it + out visually; `pointer-events: none` + pointer capture keep the events flowing. +- **Ghost**: dashed outline (dragged tile's tone) at the preview anchor, footprint-sized. +- **FLIP**: GSAP `Flip` plugin. Capture in `$effect.pre` (state of all tile elements + except the dragged/settling one), mutate (preview placements / committed placements), + `Flip.from(state, …)` in `$effect` after the DOM update. Re-entrancy comes free: + `Flip.getState` records in-flight transforms, so each hover change animates from the + current visual position — while the preview itself is always recomputed from COMMITTED + (semantics vs. motion stay separate, per the user's requirement). +- **Drop**: commit `placeAtCell`, then settle the tile from its pointer transform to + identity (gsap, ~150ms) — it lands exactly where the ghost was. +- **Escape**: window `keydown` in the CAPTURE phase while dragging (beats the page-level + Escape→deselect handler, `stopPropagation`): dispatch `cancel` → preview discards → + tiles FLIP home → dragged tile gsap-animates from the pointer back to its anchor. + +## Out of scope / non-goals + +- No backward displacement (runs only push forward); no multi-tile drag; no touch + auto-scroll while dragging (existing scroll-wins semantics stay). +- The resize picker is unchanged: resizing a pinned tile may push it to unpinned flow if + the footprint no longer fits its anchor (render-time rule — persisted spans untouched). + +## Files + +1. `src/features/workspaces/logic/tile-layout.ts` (+ `tile-layout.test.ts`) — the bulk +2. `src/features/workspaces/tile-layout-store.svelte.ts` (+ test) +3. `src/features/workspaces/logic/drag-controller.ts` (+ test) — mechanical targetCell rename +4. `src/features/workspaces/ui/HomeTileGrid.svelte` — hit math, dots, follow-pointer, ghost, Flip +5. `src/features/workspaces/ui/WorkspacesHome.svelte` (+ test) — wire placeAtCell; sort label +6. `src/features/workspaces/ui/HomeHeader.svelte` — sort label copy + +## Verification + +- New pure tests: v2 sanitize/migration, pinned pack (incl. wide↔tall mapping, overlap + first-wins, out-of-range → flow), displacement (both user examples + internal moves + + span cases), pin round-trip, hole-aware firstEmptySlot, store equality. +- Component tests: Escape cancels a drag without persisting; drop commits `placeAtCell`. +- `bun run typecheck && bun run test && bun run build`; biome clean on touched files. +- Headless screenshots (wide + tall) of a pinned layout with gaps. diff --git a/notes/search-backend-selection-plan.md b/notes/search-backend-selection-plan.md new file mode 100644 index 0000000..b7c9623 --- /dev/null +++ b/notes/search-backend-selection-plan.md @@ -0,0 +1,112 @@ +# search-backend-selection — Frontend Plan (research + planning, NOT implemented) + +Goal: a "Search backend" dropdown under Settings that lets the user pick the active +search backend. GLOBAL setting persisted on the backend. Decided: exactly two +backend entries — "Firecrawl (self-hosted)" and "Umans (Exa)" — plus "None" as the +off state; save-on-change. Mirrors the backend plan in +`worktrees/search-backend-selection/backend/notes/`. + +## 1. Current state (verified in this worktree) + +- `src/app/views/SettingsPane.svelte` is currently FE-local only: a single + `ChatLimitField` (chat limit, persisted browser-side). It takes `ports` and calls + `store.setChatLimit` — so the pane already has a store seam for persistence. +- Server-persisted per-workspace settings today follow the heartbeat pattern: own + feature dir (`src/features/heartbeat/`) with `logic/view-model.ts` + `ui/*.svelte`, + store methods `heartbeatConfig()` / `setHeartbeatConfig()` (store.svelte.ts ~2303), + own view pane (`HeartbeatPane.svelte`), registered via `register-views.ts`. +- `ChatLimitField.svelte` is the canonical settings-field pattern: `$state` + value/saving/error/justSaved, `$effect` re-seed while untouched, dirty-guarded + save button, Enter-to-save, spinner while saving. +- Dropdown precedent: `features/chat/ui/ModelSelector.svelte`, + `ReasoningEffortSelector.svelte`, `HeartbeatView.svelte` all use native `<select>` + with DaisyUI classes. +- Each FE feature exposes a `manifest` (name/description) listed in + `ExtensionsPane.svelte`'s frontend-modules table — a new feature dir adds its + manifest there. + +## 2. Proposed UI + +New feature `src/features/search-settings/`: + +``` +features/search-settings/ +├── index.ts # public exports + manifest +├── logic/view-model.ts # pure: options list, dirty check, validation +├── logic/view-model.test.ts +└── ui/SearchBackendField.svelte + └── SearchBackendField.test.ts +``` + +**`SearchBackendField.svelte`** — modeled on `ChatLimitField`: +- Native `<select class="select select-bordered select-sm">` bound to local `$state`. +- Options: "Firecrawl (self-hosted)" and "Umans (Exa)" (disabled with a "not + configured" suffix when `!configured` — i.e. no `UMANS_API_KEY`), **plus** a + `"none"` option labeled e.g. "None (search disabled)". +- **Save on change** (decided): a select commits immediately — no separate Set + button. Saving/saved/error feedback around the change. +- States: `saving` (spinner), `saved` ("Saved: <label>."), `error` (inline + `text-error`, revert select to last persisted value on failure). +- Loading state: skeleton/disabled select while `GET` is in flight. +- Help text below: one line explaining the selection affects the `web_search` + tool's *search* mode only — scrape/crawl still route to the self-hosted + Firecrawl backend (and `map` is being removed from the tool entirely). + +**Placement**: inside `SettingsPane.svelte`, under the Chat limit section (user +asked for "a dropdown under settings"). The config is GLOBAL, so no +workspace-switch re-seeding is needed — load once on first mount (or on settings +view open) and cache in the store; the field still re-seeds from the persisted +value after a successful save (ChatLimitField's `$effect` pattern). + +## 3. Store / API wiring + +`store.svelte.ts` (port contract + implementation): + +```ts +searchBackendConfig(): Promise<SearchBackendConfigResult>; +setSearchBackend(backend: string): Promise<SearchBackendConfigResult>; +// state: searchBackendConfig | null (loaded), loading flag, last error +``` + +Calls the backend contract (see backend plan §2.4 — GLOBAL routes): + +``` +GET /search/config → { backend, available: [{id,label,configured}] } +PUT /search/config { backend } → { backend } | 400 | 503 +``` +(No capabilities field — the selection affects search mode only; scrape/crawl +always route to Firecrawl. `map` is removed from the tool entirely, per the +backend plan.) + +- Wire types live in `@dispatch/transport-contract` / `wire` (backend plan) — FE + types mirror them via the existing wire type imports. +- Load when the Settings pane mounts (lazy) or on workspace switch; cache like + heartbeatConfig does. +- 503 (extension not loaded) → render the field disabled with "search not + available" hint — graceful degradation, matching heartbeat's degrade philosophy. + +## 4. Registering the module + +- `features/search-settings/index.ts` exports `manifest` (name "Search Settings", + description) → add to the `ExtensionsPane.svelte` frontend-modules list so the + table doesn't drift from what's composed. + +## 5. Tests / verification + +- `view-model.test.ts`: options construction (available ∪ none, configured flag → + disabled option), dirty check, revert-on-error value computation. +- `SearchBackendField.test.ts`: render options from config, save-on-change fires + `save`, error state reverts selection, loading state disables. +- Store tests: `searchBackendConfig`/`setSearchBackend` wiring against a fake port, + workspace-switch re-seed. +- Full `bun run typecheck` / `bun run test` / `bun run check` per AGENTS.md. + +## 6. Decisions (shared with backend plan §4) + +1. Dropdown entries: exactly "Firecrawl (self-hosted)" + "Umans (Exa)", plus + "None (search disabled)". Umans sub-provider fixed to `exa`. +2. GLOBAL setting persisted to the backend (`GET/PUT /search/config`) — no + workspace-scoped behavior. +3. Save-on-change with saved/error feedback (no Set button). +4. Relay output decided: hits only — the tool returns parsed `SearchHit[]`; + the relay model's prose answer is discarded. No open questions remain. diff --git a/notes/text-file-embeds-implementation.md b/notes/text-file-embeds-implementation.md new file mode 100644 index 0000000..2f23a41 --- /dev/null +++ b/notes/text-file-embeds-implementation.md @@ -0,0 +1,294 @@ +# Text-file embeds — implementation report + +## Delivered behavior and syntax + +Save the document completely, then emit a short summary and a **standalone paragraph**: + +```markdown +I saved the implementation plan; the key change is the new validation boundary. + +[Implementation plan](text-file:notes/plan.md) +``` + +Absolute path and filename encoding examples: + +```markdown +[Research](text-file:/tmp/research.md) + +[Research with spaces](<text-file:notes/research%20report.md>) + +[Literal percent](text-file:notes/100%25.md) +``` + +The prefix is exactly `text-file:` (not image syntax). Destinations are POSIX +absolute paths or paths relative to the conversation's authoritative effective +working directory. Angle-bracket destinations can also contain literal spaces. +Markdown escapes are interpreted by marked, then percent escapes are decoded +**once**. `%2520` denotes a literal `%20` filename; `%23` and `%3F` denote filename +`#` and `?`. The HTTP adapter uses URLSearchParams to transport that literal +filesystem path; the backend does not perform another destination decode. + +Only a complete, single-line, top-level inline-link paragraph opts in. Ordinary +links, images, inline/fenced/indented code, reference-style links, titled links, +list/blockquote links, and links surrounded by prose retain ordinary Markdown +behavior and never trigger text-file reads. While streaming, an embed waits for +a blank-line paragraph boundary or completion of the message, avoiding speculative +fetches for incomplete links. Examples inside code never fetch. Unsupported or +malformed destinations remain inert ordinary Markdown, not browser/server URL +fetches. Raw HTML cannot forge a file-fetching block. + +## Presentation + +The document appears inside the existing **inset-well**, matching the recessed +panel style of tool-call/thinking blocks. It includes its label/path and +Refresh/Retry controls. Markdown extensions `.md`, `.markdown`, `.mdown`, `.mkd`, +`.mkdn` render through the existing marked/highlight.js/DOMPurify pipeline. +Other valid text/code files render safely as literal whitespace-preserving code, +without guessing a language or interpreting their HTML/Markdown. + +The default preview renders approximately the **first six and last six source +lines**, with an omitted-source-line count. Files of twelve lines or fewer are +not duplicated. Expand document shows the full rendered document; Collapse +document returns to the preview. The control exposes `aria-expanded`. +Top-level code-fence context is restored around preview fragments. Previews are +source-line excerpts, not a six-visual-line layout clamp: wrapping, headings, +tables and multiline constructs can change their visual height/formatting. Some +constructs crossing excerpt boundaries (lists, tables, reference definitions, +raw HTML) are necessarily approximate; expansion renders the authoritative full +Markdown. + +Both preview and full content are sanitized. Existing raw-HTML/link policy and +remote-image click consent are preserved. Omitted images are not mounted until +expansion. Relative local images resolve against the returned document's directory +through the existing local-image route. Embedded documents receive no text-file +client, so nested embed references are inert and cannot recursively fetch. + +Loading, empty-file, transport/validation failure and retry states are visible; +failures do not break surrounding chat. File text is display data, never a +conversation/history mutation. + +## File lifecycle and resource policy + +- **Live view, not snapshot.** Only the short reference remains in stored chat. + No document body or hash/snapshot is persisted with the message. +- A mounted chat fetches current disk bytes on first reference. Repeated exact + decoded paths share an in-flight/result entry while subscribed. There is no + file watcher or polling. Already displayed content does not update itself. +- Refresh (or Retry after failure) invalidates **all live files in that mounted + chat**, refetches authoritative context, and rereads their current contents. + This prevents mixing old and refreshed routing contexts. Expansion alone does + not fetch. File deletion produces a not-found state on the next read; recreation + can recover through Retry. +- When the last subscriber leaves, its entry is aborted/released. Returning to + chat/reloading/remounting reads current disk state, which can differ from the + original message. No historical immutability is implied. +- Known cwd/computer setting changes and conversation/workspace switches replace + the context-bound client, abort old requests and discard its cache. Late + responses cannot publish into a new client/path. External context edits not + signaled to the UI are detected by backend guards on subsequent reads or by + Refresh; existing displayed text is not watched for external changes. +- Limit **256 KiB (262144 bytes)** per file, strict UTF-8, allowing tab/LF/CR but + rejecting NUL and other C0/C1 binary controls. Exactly the limit is accepted; + oversize files are rejected, never silently truncated. +- At most **8 embeds per message**, **32 active distinct paths per mounted chat** + (at most 8 MiB source text), and **4 concurrent file loads**. The per-message + limit is explained inline; active-cache overflow has a visible error. +- Backend opens a single nonblocking/no-follow descriptor, checks regular-file + status and reads at most limit+1 bytes, including a final growth check. FIFOs, + devices, directories and final-component symlinks are rejected. Ancestor + directory symlinks and ordinary `..`/absolute paths are allowed: this is **not a + cwd sandbox**. Reads have a byte/work bound but no special filesystem timeout + or guarantee of a coherent snapshot while another process modifies a file. + +Unsupported: file URLs, HTTP/network destinations, tilde expansion, Windows paths, +backslashes, control characters and remote-computer embeds. Remote targets are +explicitly rejected, never read from an identically named local path. + +## Backend seam and trust boundary + +Existing `transport-http` is extended; no new extension/tier or kernel feature. +Additive `transport-contract` interfaces are `TextFileQuery`, `TextFileResponse`, +`TextFileContextResponse`; package version remains 0.26.0 with local `file:` deps, +and the frontend's tracked public contract mirror is updated. + +```text +GET /conversations/:id/text-file-context + -> { conversationId, cwd: string|null, computerId: string|null } + +GET /conversations/:id/text-file?path=...&expectedCwd=...&expectedComputerId=... + -> { path, text, format: "markdown"|"text", bytes, maxBytes } +``` + +Context preflight performs no LSP or file work. Both routes resolve persisted +conversation settings and workspace defaults server-side, not browser state or +server process cwd. Relative conversation cwd is resolved against an absolute +workspace default; an unresolvable configured relative cwd returns 422. With no +cwd configured, context is null, absolute local file references work, and relative +file references return 400. The frontend always supplies both preflight-derived +guards. Empty guard values assert unset cwd/local computer; absent guards skip +comparison. Guards never override server routing. The route rechecks context +after I/O before returning successful text. + +All endpoint responses use `Cache-Control: no-store`, including middleware errors. +Relevant statuses: 400 invalid/non-text/nonregular, 403 unreadable or rejected +Origin, 404 unknown conversation/missing file, 409 stale context, 413 oversized, +422 unsupported remote/unresolvable configured context, 500 read failure. Existing +Host rejection remains 421. + +**Authentication limitation, explicitly audited:** this application currently uses +Host/Origin checks plus loopback/Tailscale deployment restrictions, not bearer or +per-user session authentication. The new routes retain exactly that existing trust +boundary; they do not invent an authentication guarantee. A trusted client with +access to this API can read supported local files accessible to the backend UID, +including absolute paths outside cwd. Do not expose this transport publicly or to +untrusted users. No arbitrary HTTP fetch is added. + +## Initial AI context, custom prompts and persistence + +Mandatory application guidance is appended **after template variable resolution** +in `SystemPromptService.construct`. The unchanged default template and exact +user-authored custom-template bytes are preserved. An empty custom template now +constructs guidance-only context. Guidance includes actual syntax/examples, +save-before-reference, short summaries, supported paths/limits, local-only/live +semantics, preview/expand behavior and the distinction between user display and +reading a file for the model's own understanding. + +New conversations and normal prompt reconstruction (compaction, missing resolved +prompt, cwd/computer metadata invalidation) receive it, including installations +with existing custom templates. Resolved prompts remain persisted per conversation. +Existing matching resolved prompts, including legacy empty prompts, are returned +**byte-for-byte unchanged** until ordinary reconstruction; changing the global +template alone does not retrofit existing conversations. There is no migration or +read-time mutation. The pure suffix-idempotent `appendTextFileGuidance` helper is +also used for missing/stale read-only cache-warming assembly, yielding the same +provider system prompt as the next real turn without warm persistence writes. +Generic `resolveText` remains generic and does not append application guidance. + +The session-orchestrator tests capture actual provider requests using real prompt +service, conversation store, assembly and kernel turn loop: default/custom first +turns, legacy continued/warm prompts, missing/cwd/computer invalidation, warm/real +parity, compaction and later reuse. History retains the reference, not the file +body. This is verified delivery, not merely a default-string assertion. + +Explicit caller-owned per-turn `systemPrompt` overrides (e.g. heartbeat) retain +their existing **AS-IS bypass** contract; they do not receive appended guidance. +The initial instructions in already-running/persisted conversations are not +retroactively changed, and this task did not redeploy/restart the shared service. + +## Initial verification (superseded by Bun-native follow-up below) + +`bun install` completed inside both feature worktrees before build/testing; frontend +was refreshed after backend declarations were built. All work is on +`feature/text-file-embeds`, with no merge or push. After the user's stop request, +all delegated generations were stopped and the lead completed fixes, tests, +verification, documentation and commits directly; no further subagents. + +### Frontend + +- `bun run typecheck`: **0 errors / 0 warnings**. +- `bun run check`: clean, 308 files (no warnings/infos). +- `NODE_OPTIONS=--no-experimental-webstorage bun run test`: **2586 passed, + 2 opt-in live tests skipped**, twice, 130 passed files / one skipped file. +- Markdown-specific tests: **236 passed** (parser, streaming, sanitized UI, + image consent, head/tail/expand, code/plain text, request effects and limits). +- `bun run build`: succeeds; existing Svelte initial-value capture diagnostics + and large-chunk advisory remain. +- `TEXT_FILE_LIVE_PROBE=1 bunx vitest run scripts/text-file-live.test.ts`: + **2 passed** against a freshly spawned ephemeral Bun HTTP app using a real + conversation store and temp disk files. The actual Svelte component and client + render a saved plan from only summary+reference, expand the omitted middle, + refresh updated content, remount/reload current bytes, show deletion, recover on + Retry and reject authoritative remote context. The fixture process/files are + cleaned up. No deployed service is used or restarted. +- Plain `bun run test` on host **Node v26.11.1** reproduces 26 pre-existing + localStorage-related failures in untouched `tile-layout-store` and + `WorkspacesHome` suites. A temporary `git archive HEAD` baseline with the same + dependencies independently reproduced exactly those 26 failures. Disabling + Node's experimental Web Storage lets jsdom supply browser storage and makes + the whole suite pass; no unrelated application/test code was modified for this. + +### Backend + +- `bun run typecheck`: succeeds. +- `bun run test`: **4277 passed / 10 skipped**, 221 passed files / one skipped. +- `bun run test:bun`: **190 passed / 0 failed**, including two real ephemeral + loopback text-file HTTP tests in the standard Bun test target. +- `bun run check`: clean, 754 files (no warnings/infos). +- `bun build packages/host-bin/src/main.ts --compile --outfile dist/dispatch-server --minify` + and equivalent CLI compile: both succeed, worktree-local ignored outputs only. +- Standard `bin/build --no-frontend` cannot complete here: its existing sibling + `claude` symlink points at the separate main Claude checkout, where bundling + fails resolving `@dispatch/kernel`. The wrapper may have refreshed ignored compiler outputs there before failing; + no dependency install or tracked edits were made there; the standalone feature backend/CLI compile above is the verified + build. External Claude bundling is **not** claimed verified. +- Backend tests cover pure path/query/encoding policy, effective context, + strict UTF-8/binary/empty/exact-limit/oversize files, directories/devices/FIFO/ + symlink refusal, unknown conversation, stale guards, remote inheritance with + an existing local path, live changes/deletion and Host/Origin rejection. +- `git diff --check` clean in both repositories. No probe server/collector leaks. + +### Remaining manual validation + +Automated UI tests use jsdom, not a graphical browser. After deploying both +commits, visually confirm block styling/responsiveness on desktop/mobile, expand +and collapse a longer Markdown plan, and click a remote-image consent button. +No screenshots, graphical-browser layout check, AI-provider-generated live reply, +remote file support, watchers, snapshots or editor/attachment redesign are claimed. + +## Bun-native verification follow-up — 2026-10-24 + +This is the **authoritative final verification**, superseding the initial +Node-backed Vitest results above. `bun run` previously respected Vitest's +`#!/usr/bin/env node` launcher. Both repositories now explicitly execute +`bun --bun node_modules/vitest/vitest.mjs` in `test` and `test:watch`, so ordinary +`bun run test` uses Bun itself, including its workers. No `NODE_OPTIONS` is needed; +all following runs explicitly unset it. Runtime: **Bun 1.4.3**. + +Bun-native runs exposed existing test assumptions which have been corrected, not +skipped and not hidden with larger timeouts: + +- HTTP/WS listener tests now spy on **Bun.serve only** and restore it afterward, + instead of redefining the non-configurable global `Bun` object. The fallback + global stub remains only for non-Bun supplemental runners. +- The image migration test sorts a directory listing before comparing its + contents; native filesystem enumeration order is not a contract. +- The local-image home-expansion test uses a path relative to the actual OS home + into its temporary fixture; it no longer assumes mutating HOME changes Bun's + cached homedir result. No real home-directory file is created. +- The frontend cyclic tool-input fallback was genuinely slow under Bun's pretty + JSON serializer. A local ancestor-stack replacer detects cycles before native + recursion, preserving the existing fallback text. Added a regression case for + nested cycles and **shared non-cyclic references** so valid JSON isn't rejected. + The original timeout-prone test now completes promptly; no timeout was raised. +- The opt-in live UI/network test no longer depends on Node-only + `transferableAbortController`. Its scoped jsdom environment retains the + runtime's matching native AbortController/AbortSignal pair, so actual Bun fetch + and cancellation are exercised. The production adapter remains unchanged. + +Final commands/results: + +| Worktree | Command | Result | +| --- | --- | --- | +| frontend | `bun run test` | **2587 passed / 2 opt-in skipped**, repeated green (130 passed files / one skipped) | +| frontend | `bun run test:text-file-live` | **2 passed**, real Svelte + ephemeral Bun HTTP + temp disk files | +| frontend | `bun --bun run typecheck` | **0 errors / 0 warnings** | +| frontend | `bun --bun run check` | **309 files clean**, no warnings/infos | +| frontend | `bun --bun run build` | **passed**, existing initial-value/large-chunk advisories remain | +| backend | `bun run test` | **4278 passed / 9 skipped**, twice (221 passed files / one skipped) | +| backend | `bun run test:bun` | **190 passed / 0 failed** | +| backend | `bun --bun run typecheck` | **passed** | +| backend | `bun --bun run check` | **754 files clean**, no warnings/infos | +| backend | `bun build ... --compile` (server and CLI) | **both passed**, ignored worktree-local binaries | + +The nine backend skips are the existing SSH integration suite; an additional +Bun-only test previously skipped under Node now runs, hence 4278 rather than +4277 passing. The frontend count increases by one for the new shared-reference +regression test. The two frontend live tests are intentionally opt-in but were +run separately and passed. No tests were excluded to obtain green results. + +Dependencies were refreshed using `bun install` inside both feature worktrees. +The external-Claude standard-build wrapper limitation described above remains; +no external/main checkout edits, installs, service restarts, merge or push were +performed in this verification follow-up. No subagents were used. Native probe +processes and temporary fixture files were cleaned up. Browser visual validation +remains manual as documented above. diff --git a/notes/text-file-embeds-plan.md b/notes/text-file-embeds-plan.md new file mode 100644 index 0000000..e56b3ed --- /dev/null +++ b/notes/text-file-embeds-plan.md @@ -0,0 +1,93 @@ +# Text-file embeds in chat Markdown + +## User request (verbatim) + +> I would like to add a new feature to the frontend: embedding of text files into the chat markdown. This is to save on the ai having to output verbatim content, when it can instead just link to it to show it in the chat. For example when making an new plan, it can just embed the plan. Or giving research, it can just embed it to display in the chat. This feature will also require a initial context update to explain to do this to the ai. Plan the feature and then summon a sol agent to implement it + +## Goal + +An assistant can save a plan, research report, or other text document to disk and reference it concisely in its reply. The chat displays that document inline without requiring the model to output its contents again. The reference remains the stored chat text; reading the embed for display must not inject its body back into model history. + +## Observed integration points + +- Frontend: `src/features/markdown/ui/Markdown.svelte`, `logic/markdown.ts`, and `logic/streaming-markdown.ts`. Rendering currently uses marked, highlight.js and DOMPurify. Its rendering core is synchronous/pure; streaming caches settled blocks separately from the changing tail. +- Chat wiring: `src/features/chat/ui/ChatView.svelte` supplies the Markdown component with the backend base URL and conversation cwd for local images. Audit the conversation computer context as well before resolving file references. +- Backend: `packages/transport-http/src/routes/images.ts` has a `/local-image` route; it serves local raster files, not text files. Inspect existing file/remote-execution contracts before introducing a text-serving seam. +- AI guidance: `packages/system-prompt/src/service.ts` defines `DEFAULT_TEMPLATE`, including existing image-embedding instructions. Stored custom templates take precedence over that fallback, and resolved prompts are persisted per conversation. Updating only the fallback may therefore not teach agents using an existing custom template. Trace the actual initial-context assembly and choose the appropriate non-destructive delivery point. + +These are discovery pointers, not a mandate to copy the image route's filesystem policy or to restructure the renderer. + +## Implementation plan + +### 1. Define the reference contract + +Implementation contract: a standalone paragraph link `[Plan](text-file:notes/plan.md)` opts into a text-file embed. An absolute path is also supported, e.g. `[Research](text-file:/tmp/research.md)`. For spaces use an angle-bracket destination, e.g. `[Research](<text-file:notes/research%20report.md>)`; percent escapes decode once after Markdown escapes, and literal percent signs use `%25`. Destinations are POSIX filesystem paths only, never `file:` URLs, HTTP URLs, tilde expansion, backslashes, or arbitrary network targets. Remote-computer text embeds are explicitly unsupported in this version (never fall back to reading the same path locally). A short labeled file reference is enough for an assistant to emit it. Ordinary Markdown links, images, inline code, and fenced code must retain their existing behavior. Syntax shown inside code must not fetch files. + +The syntax and its supported path forms must be identical across parser, tests, and AI instructions. Support absolute paths and paths relative to the conversation cwd. Decide explicitly how file URLs, spaces/encoding, and remote-computer paths work. Reject unsupported destinations rather than accidentally reading the same path from a different computer. Do not silently add arbitrary remote HTTP fetching. + +### 2. Provide bounded, authenticated file reads + +Reuse suitable existing contracts if available; otherwise expose the smallest transport operation required by the frontend. Keep access within the existing authenticated/authorized transport boundary and validate all request inputs. Resolve against the correct conversation/computer context, not the browser URL or server process cwd. + +Return text as data, never executable HTML. Bound file size and read work; handle non-regular files, binary/invalid text, unavailable paths, and remote errors gracefully. Ensure unsupported paths/protocols cannot become browser fetches or server-side URL fetches. Use a concrete, tested text-size policy and communicate any rejection or truncation in the UI. If extending contracts, follow package boundaries and existing route/composition patterns. + +### 3. Display an inline document + +Additional user requirement (verbatim): + +> the inline text should render the markdown, but it should be inside of a block like how tool calls are inside of an expandable block. It should show ~the first and last 6 lines of text from the markdown, and have an expand button to show more. + +This supersedes the generic document-container presentation. Render Markdown documents through the existing sanitized Markdown pipeline inside an expandable, tool-call-like block. The default preview displays rendered Markdown from approximately the first six and last six source lines, with a clear omission indicator between them; short documents must not duplicate overlapping lines. Provide an accessible Expand/Collapse control to show the full rendered document. Determine concrete styling from existing tool-call UI patterns. Both preview fragments and expanded content must be sanitized, preserve image-consent policy, and never recursively fetch nested embeds. Display other supported text/code files safely, preserving whitespace and using existing highlighting where suitable. Include the reference label/path so users can tell embedded content from the assistant's own prose. + +Keep fetching in an effectful UI/adapter layer rather than making pure Markdown rendering perform I/O. Provide visible loading, empty-file, and failure states, with a practical retry path. A failure must not break the surrounding chat. + +Integrate with streaming without repeatedly fetching on every token or replacing settled document state unnecessarily. Clean up stale requests on unmount/context changes and prevent late responses from rendering under a different path/conversation/computer. Repeated references should not cause uncontrolled duplicate work. + +Embedded content is untrusted: sanitize it, preserve existing remote-image consent, and do not recursively fetch embeds found within an embedded document. Preserve the existing security policy for raw HTML and links. Keep rendering bounded for large files. + +### 4. Specify persistence semantics + +Document whether an embed displays the current on-disk file or a captured snapshot. Prefer the simplest coherent implementation using existing infrastructure; do not imply historical immutability if the file is fetched live. State what happens when a file changes or is deleted and how refresh/retry behaves. Snapshots, file watchers, editors, and broad attachment-system redesign are not required by this request. + +### 5. Teach the AI in its initial context + +Explain the actual implemented syntax with a plan/research example, supported path rules, and relevant limitations. Tell the assistant to finish saving the file before emitting its reference, to use embeds instead of repeating long saved content when useful, and to retain a short explanatory summary in its reply. Make clear that embedding displays the file to the user; it is not a substitute for reading a file the assistant itself needs to understand. + +Verify guidance reaches the intended initial-context path for existing installations/custom templates without overwriting user-authored prompts. Respect existing prompt persistence semantics; explicitly report which conversations receive the new instructions and when. Add coverage for the delivery mechanism, not just a string assertion on an unused fallback. + +### 6. Verify and commit + +Read each repo's `AGENTS.md` and applicable scoped rules. Run `bun install` in both worktrees before verification. Implement within the isolated feature worktrees only, coordinating owners if delegating package work. + +Cover at least: +- Parsing and path resolution, escaped/encoded paths, non-embed links/images/code, incomplete streaming syntax. +- Markdown and plain-text display, XSS attempts, nested embeds, existing image-consent behavior. +- Loading, missing/empty/oversized/binary files, retry, context switches, stale responses, repeated references. +- Backend validation/access behavior and correct computer routing or explicit unsupported-computer rejection. +- Initial-context guidance and custom-template compatibility. +- End-to-end scenario: save a plan file, emit only its embed plus a short summary, and see its formatted contents in chat; reload and error behavior match documented semantics. + +Run frontend typecheck, tests, formatting/lint checks and production build. Run applicable backend typecheck, tests (including Bun-specific transport coverage as appropriate), formatting/lint checks and build verification according to its architecture guidance. Report pre-existing failures distinctly; do not silently claim passing checks. + +Commit verified work in both repositories on `feature/text-file-embeds`. Include final syntax, lifecycle semantics, verification results, and any limitations in the implementation report. Do not merge or push. + +## Acceptance criteria + +1. A short file reference displays a saved plan/research document inline in assistant chat without repeating its body in the model reply, inside a tool-call-like expandable block with rendered first/last approximately six-source-line previews and Expand/Collapse. +2. Existing ordinary Markdown behavior remains intact, including streamed messages. +3. File I/O and document rendering are bounded and remain within the existing transport trust and sanitization policies. The audited existing trust boundary is Host/Origin plus trusted-network deployment, not per-user/session authentication. +4. The AI receives accurate initial-context instructions through a verified path, without destroying custom prompts. +5. Both repositories contain committed, verified work on the feature branch; `dev` remains untouched. + +## Handoff + +This plan defines user-visible goals, integration observations, and acceptance criteria. The Sol implementation agent owns technical design, including final syntax and the minimal appropriate contract/architecture. Do not treat illustrative approaches as additional user requirements. Resolve details by inspecting the codebase and record the chosen behavior in the report. + +## Implementation outcome + +Implemented and verified; authoritative final syntax, lifecycle, context delivery, +limits and verification caveats are recorded in +`notes/text-file-embeds-implementation.md`. Existing persisted prompts are not migrated; +remote computer embeds are explicitly unsupported. The user requested no subagents +during implementation; all delegated generations were stopped and remaining work +was completed directly. No merge, push or shared deployment. diff --git a/notes/tool-call-display-plan.md b/notes/tool-call-display-plan.md new file mode 100644 index 0000000..000cbb3 --- /dev/null +++ b/notes/tool-call-display-plan.md @@ -0,0 +1,254 @@ +# Tool-call display cleanup — plan + +Branch: `feature/tool-call-cards` (frontend-only; no backend change required). + +## ✅ DECISIONS (user, final — these OVERRIDE anything below that disagrees) +1. **Every tool call is its own bubble.** No batch grouping anymore — parallel calls in one step + render as consecutive independent bubbles, each one a single card (call + result). +2. **Add a status icon** to the header: spinner while pending, ✓ (success tint) on ok, ✗ (error + tint) on `isError`. Keep the wrench as the tool icon; the status icon is added alongside it. +3. **Default collapsed** (all cards, including errors). +4. **Output preview = 12 lines: the first 6 and the last 6**, with a `⋯ N more lines` toggle + between them that expands to the full output (and collapses back). Outputs with ≤ 12 lines + render in full with no toggle. +5. Per-tool summaries for Dispatch's built-in tools + a generic fallback (§2.4) — proceed with + the recommendation. + +## Goal (from the user) +1. A tool call and its response render as **one dropdown** — not a call card plus a separate + result card. +2. The collapsed header shows a **concise summary of what was called AND what came back** + (e.g. `run_shell bun run test → 128 lines`). +3. Inspiration: oh-my-pi's tool cards. + +--- + +## 1. Current state (research) + +### Data +- Wire (`.dispatch/wire.reference.md`): `ToolCallChunk { toolCallId, toolName, input: unknown, stepId? }` + and `ToolResultChunk { toolCallId, toolName, content: string, isError, stepId? }`. Results are + separate chunks (role `tool`), paired to calls by `toolCallId`. The kernel guarantees exactly one + result per call (synthesized on interrupt). +- Results are **plain strings** — no structured `details` (exit code, line counts, diff, …). Any + result summary must be derived from `content` + `isError` + the call's `input`. + +### Rendering (`src/features/chat/ui/ChatView.svelte`, `src/core/chunks/groups.ts`) +- `groupRenderedChunks` makes a `tool-batch` group **only when a step has ≥2 calls**. Those batch + entries already pair call+result in one `LazyCollapse` (title = wrench + toolName + spinner/error + badge; body = `JSON.stringify(input)` then raw `content`). +- **Single-call steps (the common case) are NOT paired**: the call and the result each become a + `single` group and render as two separate collapses with the same title (wrench + toolName). This + is the "two dropdowns" the user sees. +- Neither path shows *what* was called or *what* came back in the collapsed header — only the tool + name. +- Keys: single tool chunks are keyed `c<seq>` / `p<index>`, so a provisional single tool card + changes key at seal → its open state is lost (same bug class the thinking-ordinal keys fixed). +- `LazyCollapse.svelte` (daisyUI checkbox collapse, lazily-mounted body) is the shared primitive — + keep it. +- Consumers of `RenderGroup` beyond ChatView: `src/core/metrics/place.ts` (`groupStepId`, + `groupTurnId` — step/turn metrics placement) and tests (`groups.test.ts`, `ui.test.ts`, + `ChatView.test.ts`, `place.test.ts`, `placement-scenarios.test.ts`). +- Chat-limit trimming (`trim.ts`) unloads by chunk seq, so a call can be unloaded while its result + stays loaded → an **orphan result** is possible and must still render. + +### Dispatch tool result shapes (backend, read-only) +| tool | input | result `content` | +|---|---|---| +| `run_shell` | `command`, `timeout?` | raw output; `isError` on non-zero exit / abort; `…\n\n[Command timed out]`; `[Output truncated: …]` | +| `read_file` | `path`, `offset?`, `limit?` | numbered lines / dir listing / `(empty file: …)` | +| `edit_file` | `path`, `oldString`, `newString`, `replaceAll?` | `Replaced N occurrence(s) in "…".` (+ appended LSP diagnostics) | +| `write_file` | `path`, `content`, `overwrite?` | `Created/Overwrote "…" (N bytes).` | +| `web_search` | `query` / `url`, `mode?` | markdown text | +| `lsp` | `operation`, `path`, `line?`, `character?` | text | +| `todo_write` | `todos[]` | text | +| `load_skill` | `name` | skill text | +| `consult_vision` | `question`, `imageIds?`/`path?` | conv id + answer | +| agent tools (`summon_agent`, `send_agent_message`, `read_agent`, `list_agents`, …) | `task`/`agent`/`title`, `conversationId`, `message` | text | +| MCP tools | arbitrary | arbitrary → generic fallback | + +Note: the exit code of `run_shell` is not in the result — only `isError`. + +### oh-my-pi (`../../oh-my-pi/packages/collab-web/src/tool-render/`) +- **One card per call, result folded in** (`ToolView.tsx`): the transcript pairs `toolResult` + messages to calls by id and passes `result` into the call's card; standalone results are never + rendered. +- **Header (one line, always visible)**: status glyph (spinner running / green ok / red err / + hollow pending) · tool name · per-tool **`Summary`** (truncated with ellipsis) · chevron. +- **Body (on expand)**: per-tool **`Body`** — args shown in a tool-appropriate way (e.g. `$ cmd` + prompt line for bash, path for read, colored diff for edit) then output in an `Output` block that + shows the first ~10–14 lines with a `⋯ N more lines` expander. Errors tinted red. +- **Registry** (`registry.ts`): `toolName → { Summary, Body }` with a **generic fallback** + (`argsDigest` = compact one-line JSON of args; body = args JSON + result text). +- Summaries mostly describe the *call* (command / path / pattern / query) and append result facts as + small badges when known (`+12 −3`, `failed`, `N matches`). + +--- + +## 2. Design + +### 2.1 One card per tool call (call + result) +Every tool call renders as exactly one collapse containing both call and response. A tool result is +never rendered on its own, except as an orphan (its call is unloaded) — then it renders as the same +card with the call side unknown. + +**Decision: no batch grouping.** Calls a model emitted together in one step render as consecutive +independent bubbles (one card each), in stream order. One code path for every tool call. + +### 2.2 Collapsed header = call summary → result summary +``` +[status] tool_name <call summary, truncated…> → <result summary> [chevron] +``` +- **status**: spinner while the result is pending; ✓ (success tint) on ok; ✗ (error tint) on + `isError`. Added alongside the existing wrench (decision 2). +- **call summary**: the most meaningful argument, single-line, whitespace-collapsed, truncated + (CSS `truncate` + a char cap). +- **result summary**: a short fact about the response (`128 lines`, `no output`, `1 replacement`, + `3.2 KB`, `failed`, `timed out`, first line of an error…). Omitted while pending. + +Examples: +``` +✓ run_shell bun run test → 42 lines +✗ run_shell bun run typecheck → failed · 12 lines +✓ read_file src/core/chunks/groups.ts:1-80 → 80 lines +✓ edit_file src/features/chat/ui/ChatView.svelte +3 −1 → 1 replacement +✓ write_file notes/plan.md → created · 4.1 KB +✓ web_search "svelte 5 runes snippets" → 37 lines +✓ lsp references src/app/store.svelte.ts:12 → 9 lines +⟳ summon_agent "tool-call-display: research" (running) +✓ mcp__foo {"a":1,"b":"x"} → first line of output… +``` + +### 2.3 Expanded body +1. **Call**: a tool-appropriate view of the input — `$ <command>` for shell, path line for + file tools, old/new for edit (as a simple −/+ block), otherwise a key/value list of scalar args with + pretty JSON for nested values (fallback = current `JSON.stringify(input, null, 2)`). +2. **Response**: the `content` in a `<pre>`, error-tinted when `isError`. If it has > 12 lines, + show the **first 6 lines, a `⋯ N more lines` toggle, then the last 6 lines** (N = total − 12); + the toggle expands to the full output and collapses back. ≤ 12 lines → full output, no toggle. + (Replaces today's `max-h-96` scroll boxes; still lazily mounted by `LazyCollapse`.) +3. Pending → "running…" placeholder instead of the response. + +### 2.4 Where the per-tool knowledge lives +A pure, data-driven **summarizer table** keyed by `toolName` with a **generic fallback**, mirroring +oh-my-pi's registry. It only produces strings/view-models (no components), so ChatView stays one +generic template. Unknown/MCP tools → fallback: +- call summary: first present of `command | path | file_path | query | url | pattern | name | + question | title | task | message | conversationId`, else compact one-line JSON of the input; +- result summary: `isError` ? `failed · <first line>` : `N lines` / `no output` / first line if + single-line and short. + +This is chat transcript rendering, not the surface interpreter, so the "never switch on a surface +id" rule doesn't apply; the table is optional per-tool polish with a guaranteed generic path +(GLOSSARY currently says tools are "rendered generically … by toolName" — this stays true for any +tool without an entry). + +--- + +## 3. Implementation steps (dependency-ordered) + +### Step 1 — Grouping: pair every call with its result (`src/core/chunks/groups.ts`) +- Replace the `single`-tool path AND the `tool-batch` group (decision 1: no batching) with ONE + group kind per tool call: + ```ts + export interface ToolEntry { + readonly toolCallId: string; + readonly toolName: string; // from the call, or from an orphan result + readonly call: ToolCallChunk | null; // null = orphan result (call unloaded by chat limit) + readonly result: ToolResultChunk | null; // null = pending + } + export type RenderGroup = + | { kind: "single"; chunk: RenderedChunk } // non-tool chunks only + | { kind: "tool"; entry: ToolEntry; provisional: boolean; stepId?: string; turnId?: string }; + ``` + (Name check vs GLOSSARY before coining; `ToolBatchEntry` / `tool-batch` go away — remove them + from `core/chunks/index.ts` and `features/chat/index.ts` exports.) +- Each `tool-call` chunk emits one `tool` group at its position; its `tool-result` (matched by + `toolCallId`, regardless of `stepId` — legacy rows without `stepId` pair too) is absorbed and + never emitted on its own. `provisional` = call OR result provisional; `stepId`/`turnId` from the + call (fall back to the result). +- Orphan results (no call in the rendered window) → a `tool` group at the result's position with + `call: null`. +- Keep the existing duplicate-`toolCallId` dedupe (first call occurrence wins; never emit two + groups with the same `toolCallId` — duplicate keys abort Svelte's keyed each). +- Stable render key `t<toolCallId>` — survives the provisional→committed seal (fixes the + lost-open-state bug for single tool cards). +- Update `src/core/metrics/place.ts` `groupStepId` / `groupTurnId` for the new shape (step-metrics + rows must still land after the LAST tool card of their step). +- Tests: rewrite `groups.test.ts` (every call → its own paired group; parallel calls → consecutive + groups; legacy no-stepId pairing; orphan result; pending result; dedupe), keep + `place.test.ts` / `placement-scenarios.test.ts` green (update fixtures for the new shape). + +### Step 2 — Pure summary view-model (new `src/features/chat/logic/tool-summary.ts`) +```ts +export interface ToolCardView { + readonly name: string; + readonly status: "pending" | "ok" | "error"; + readonly callSummary: string; // one line, truncated + readonly callBadges: readonly string[]; // e.g. "+3 −1", ":1-80" + readonly resultSummary: string | null; // null while pending + readonly callDetail: // how to render the input when expanded + | { kind: "command"; text: string } + | { kind: "edit"; path: string; oldText: string; newText: string } + | { kind: "fields"; fields: readonly { key: string; value: string; multiline: boolean }[] }; + readonly output: ToolOutputView | null; // null while pending +} +/** Decision 4: > 12 lines → head (first 6) + hiddenCount + tail (last 6); else full. */ +export type ToolOutputView = + | { kind: "full"; lines: readonly string[]; isError: boolean } + | { kind: "elided"; head: readonly string[]; hiddenCount: number; tail: readonly string[]; + all: readonly string[]; isError: boolean }; +export function viewToolEntry(entry: ToolEntry): ToolCardView; +``` +- Input normalization: `input` is `unknown` — accept objects; if a string, try `JSON.parse`; never + throw. +- Per-tool table for: `run_shell`, `read_file`, `edit_file`, `write_file`, `web_search`, `lsp`, + `todo_write`, `load_skill`, `consult_vision`, `youtube_transcript`, agent tools. Generic fallback + for everything else. +- Result facts derived from `content`: line count, `no output` / `(empty file…)`, `[Command timed + out]` → `timed out`, `[Output truncated…]` → `truncated`, edit `Replaced N occurrence(s)` → + `N replacement(s)` (+ `diagnostics` badge when diagnostics are appended), write `(N bytes)` → + `created/overwrote · size`. +- Exhaustive vitest coverage (pure, no mocks): each tool + fallback, malformed input, error, + pending, orphan, and the output elision boundaries (12 lines → full; 13 → 6 + 1 hidden + 6; + trailing newline handling). + +### Step 3 — Component (`src/features/chat/ui/ToolCard.svelte`, thin) +- Props: `entry: ToolEntry`. Calls `viewToolEntry`, renders `LazyCollapse` (collapsed by default) + with the header (status icon · wrench · name · `truncate` call summary · badges · `→ result + summary`) and the lazily-mounted body (call detail + output: first 6 lines, `⋯ N more lines` + toggle, last 6 lines; toggle expands to full output and back). +- Header layout must stay one line: `min-w-0 truncate` on the summary, `shrink-0` on name/status/ + result summary. `LazyCollapse` may need a `min-w-0` on its title flex row. +- Accessible label: `Toggle tool call <name>`. + +### Step 4 — Wire into `ChatView.svelte` +- Delete the `tool-call` / `tool-result` branches of `chunkRow` (they can no longer reach it). +- Delete the batched-tool branch. A `tool` group renders ONE bubble (same + `chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0` shim) containing one + `<ToolCard {entry}/>`. +- Key update in `keyedRows`: `t<toolCallId>` per Step 1. +- Update `ui.test.ts` / `ChatView.test.ts`: one bubble + one card per call (parallel calls → + separate bubbles); header contains call + result summary; no standalone result card; pending + spinner; ✓/✗ status icon; error tint; orphan result card; collapsed by default. + +### Step 5 — Verify + live check +`bun install`, `bun run typecheck`, `bun run test` (×2), `bun run check`, `bun run build`. Then a +human visual pass via `bin/up` from `worktrees/tool-call-cards/` (stop the main `bin/up` first — +same ports): a turn with a single shell call, parallel calls (separate bubbles), an error, an edit, +a long output (6 + toggle + 6), a streaming (pending) call, and a reload mid-turn (seal keeps open +state). + +No wire/contract change → no `backend-handoff.md` ask. Update `GLOSSARY.md` only if a new term is +coined. + +--- + +## 4. Optional backend follow-up (not required) +Result summaries are heuristics over strings. If richer headers are wanted later (real exit code, +match counts, diff stats), the backend could add an optional structured `details` object to +`ToolResultChunk` (as oh-my-pi does). That would be a wire change via `backend-handoff.md` — out of +scope for this feature. + +## 5. Open questions +All resolved — see **DECISIONS** at the top. diff --git a/package.json b/package.json index ea6c751..f2012ba 100644 --- a/package.json +++ b/package.json @@ -8,23 +8,28 @@ "build": "vite build", "preview": "vite preview", "typecheck": "svelte-check --tsconfig ./tsconfig.json", - "test": "vitest run --passWithNoTests", - "test:watch": "vitest", + "test": "bun --bun node_modules/vitest/vitest.mjs run --passWithNoTests", + "test:watch": "bun --bun node_modules/vitest/vitest.mjs", "check": "biome check .", - "check:fix": "biome check --write ." + "check:fix": "biome check --write .", + "test:text-file-live": "TEXT_FILE_LIVE_PROBE=1 bun --bun node_modules/vitest/vitest.mjs run scripts/text-file-live.test.ts --environment ./scripts/text-file-environment.ts" }, "dependencies": { - "@dispatch/transport-contract": "file:../dispatch-backend/packages/transport-contract", - "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract", - "@dispatch/wire": "file:../dispatch-backend/packages/wire", + "@dispatch/layout-contract": "file:../backend/packages/layout-contract", + "@dispatch/tile-layout-contract": "file:../backend/packages/tile-layout-contract", + "@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", + "gsap": "^3.15.0", "highlight.js": "^11.11.1", "marked": "^18.0.4", - "marked-highlight": "^2.2.4" + "marked-highlight": "^2.2.4", + "phosphor-svelte": "^3.1.0" }, "overrides": { - "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract", - "@dispatch/wire": "file:../dispatch-backend/packages/wire" + "@dispatch/ui-contract": "file:../backend/packages/ui-contract", + "@dispatch/wire": "file:../backend/packages/wire" }, "devDependencies": { "@biomejs/biome": "^2.4.16", @@ -34,6 +39,7 @@ "@testing-library/svelte": "^5.2.0", "@testing-library/user-event": "^14.6.1", "@tsconfig/svelte": "^5.0.0", + "@types/node": "^26.6.5", "daisyui": "^5.5.20", "fake-indexeddb": "^6.0.0", "jsdom": "^25.0.0", diff --git a/scripts/bench-pure.ts b/scripts/bench-pure.ts new file mode 100644 index 0000000..0bff5a1 --- /dev/null +++ b/scripts/bench-pure.ts @@ -0,0 +1,71 @@ +/** + * Bench the PURE pipeline a tab runs when opening a conversation mid-turn: + * history fetch → applyHistory → fold every replayed event (one render-pass + * worth of derivations per event, like the UI does) . + * bun scripts/bench-pure.ts <conversationId> [scale] + * `scale` synthesizes a longer turn by repeating the replay's step events. + */ +import { + applyHistory, + foldEvent, + groupRenderedChunks, + initialState, + initialWindowSize, + selectChunks, + windowTranscript, +} from "../src/core/chunks/index.ts"; +import { interleaveTurnMetrics } from "../src/core/metrics/index.ts"; +import { renderMarkdown } from "../src/features/markdown/logic/markdown.ts"; + +const conv = process.argv[2] ?? ""; +const HTTP = process.env.PROBE_HTTP ?? "http://localhost:24991"; +const replay = JSON.parse(await Bun.file(`/tmp/replay-${conv}.json`).text()) as { type: string }[]; +const hist = (await (await fetch(`${HTTP}/conversations/${conv}?sinceSeq=0`)).json()) as { + chunks: { seq: number; turnId?: string; chunk: { type: string } }[]; +}; +const turnId = (replay.find((e) => e.type === "turn-start") as { turnId: string }).turnId; +const committedThisTurn = hist.chunks.filter((c) => c.turnId === turnId).length; +console.log( + `history chunks=${hist.chunks.length} (of which current turn=${committedThisTurn}), replay events=${replay.length}`, +); + +let state = windowTranscript( + applyHistory(initialState(), hist.chunks as never), + initialWindowSize(256), +); +let derive = 0; +let md = 0; +const t0 = performance.now(); +for (const ev of replay) { + state = foldEvent(state, ev as never); + // What each per-event render recomputes in ChatView: + const d0 = performance.now(); + const chunks = selectChunks(state); + interleaveTurnMetrics(groupRenderedChunks(chunks), []); + derive += performance.now() - d0; + const last = chunks[chunks.length - 1]; + if (last?.streaming && last.chunk.type === "text") { + const m0 = performance.now(); + renderMarkdown(last.chunk.text, { streaming: true }); + md += performance.now() - m0; + } +} +const total = performance.now() - t0; +const rendered = selectChunks(state); +const dupTool = new Map<string, number>(); +for (const r of rendered) { + if (r.chunk.type === "tool-call" || r.chunk.type === "tool-result") { + const k = `${r.chunk.type}:${(r.chunk as { toolCallId: string }).toolCallId}`; + dupTool.set(k, (dupTool.get(k) ?? 0) + 1); + } +} +const dups = [...dupTool.values()].filter((n) => n > 1).length; +console.log({ + renderedChunks: rendered.length, + committed: state.committed.length, + provisional: state.provisional.length, + duplicatedToolChunks: dups, + totalMs: Math.round(total), + deriveMs: Math.round(derive), + markdownFullRerenderMs: Math.round(md), +}); diff --git a/scripts/live-probe-provider-retry.ts b/scripts/live-probe-provider-retry.ts index 952163c..d7b8c6b 100644 --- a/scripts/live-probe-provider-retry.ts +++ b/scripts/live-probe-provider-retry.ts @@ -19,7 +19,7 @@ * 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 + * PROBE_MODEL=opencode-1/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"; @@ -33,7 +33,7 @@ import { } 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 MODEL = process.env.PROBE_MODEL ?? "opencode-1/deepseek-v4-flash"; const PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: ok"; type ChatMsg = ChatDeltaMessage | ChatErrorMessage; diff --git a/scripts/live-probe.ts b/scripts/live-probe.ts index 6121eac..ad00179 100644 --- a/scripts/live-probe.ts +++ b/scripts/live-probe.ts @@ -4,7 +4,7 @@ * `bun run test`. Run with the backend up: * * bun scripts/live-probe.ts # default model - * PROBE_MODEL=opencode/glm-5 bun scripts/live-probe.ts + * PROBE_MODEL=opencode-1/glm-5 bun scripts/live-probe.ts * PROBE_TOOL_PROMPT="..." bun scripts/live-probe.ts # override the tool turn * * Drives the FE's REAL network-facing modules (the thin live integration test the @@ -55,7 +55,7 @@ import { createConversationCache } from "../src/features/conversation-cache/inde const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205"; const HTTP_BASE = process.env.PROBE_HTTP ?? "http://localhost:24203"; -const MODEL = process.env.PROBE_MODEL ?? "opencode/deepseek-v4-flash"; +const MODEL = process.env.PROBE_MODEL ?? "opencode-1/deepseek-v4-flash"; const TEXT_PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: hello from dispatch"; const TOOL_PROMPT = process.env.PROBE_TOOL_PROMPT ?? diff --git a/scripts/network-policy.test.ts b/scripts/network-policy.test.ts new file mode 100644 index 0000000..4c3ed12 --- /dev/null +++ b/scripts/network-policy.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { + buildAllowedHosts, + checkDevRequest, + checkHost, + isAllowedLocalAddress, + normalizeHost, + normalizeHostHeader, + tailscaleSelf, +} from "./network-policy"; + +// Synthetic status: reserved .example DNS and documentation-only IPv4/IPv6 addresses. +const tailscaleStatusJson = `{"Self":{"HostName":"laptop", + "DNSName":"dev-node.tailnet.example.", + "TailscaleIPs":["192.0.2.10","2001:db8::10"]}}`; +const allowedHosts = buildAllowedHosts({ tailscaleStatusJson, env: {} }); +const allowedAddresses = new Set(["127.0.0.1", "::1", ...tailscaleSelf(tailscaleStatusJson).ips]); +const policy = { allowedHosts, allowedAddresses }; +const request = (localAddress = "::1", host: string | undefined = "localhost", origin?: string) => + checkDevRequest({ localAddress, host, origin }, policy); + +describe("backend-compatible hosts", () => { + it.each([ + ["LOCALHOST.:34204", "localhost"], + ["127.0.0.1:34204", "127.0.0.1"], + ["[0:0:0:0:0:0:0:1]:34204", "::1"], + ["DEV-NODE", "dev-node"], + ["DEV-NODE.tailnet.example.:34204", "dev-node.tailnet.example"], + ["192.0.2.10:34204", "192.0.2.10"], + ["[2001:db8::10]:34204", "2001:db8::10"], + ])("normalizes and allows %s", (raw, expected) => { + expect(normalizeHost(raw)).toBe(expected); + expect(checkHost(raw, allowedHosts)).toBe("ok"); + }); + + it.each( + ( + "evil.com|dev-node.evil.com|192.0.2.10.nip.io|laptop|localhost.evil.com|" + + "sub.localhost|127.0.0.2|100.1.2.3|172.17.0.1|[email protected]|localhost/path|" + + "localhost:bad|localhost:65536|[::1]suffix|localhost\n|localhost..|%6cocalhost|" + ).split("|"), + )("rejects %j even where Vite would allow it", (host) => { + expect(checkHost(host, allowedHosts)).toMatchObject({ status: 421 }); + }); + + const missingStatuses = [undefined, "invalid", "null", "[]", "{}", '{"Self":null}']; + it.each(missingStatuses)("fails closed for status %j, retains env overrides", (json) => { + const input = { + ...(json !== undefined ? { tailscaleStatusJson: json } : {}), + env: { + DISPATCH_ALLOWED_HOSTS: " EXTRA.example.:8080, https://evil.com", + DISPATCH_TAILSCALE_NAME: "DEV-NODE", + }, + }; + const expected = "localhost,127.0.0.1,::1,extra.example,dev-node".split(","); + expect([...buildAllowedHosts(input)]).toEqual(expected); + expect(tailscaleSelf(json)).toEqual({ ips: [] }); + }); + + it("keeps auto-detection when env hosts are supplied, without trusting OS hostname", () => { + const hosts = buildAllowedHosts({ + tailscaleStatusJson, + env: { DISPATCH_ALLOWED_HOSTS: "extra.example" }, + }); + expect(hosts.has("dev-node.tailnet.example")).toBe(true); + expect(hosts.has("extra.example")).toBe(true); + expect(hosts.has("laptop")).toBe(false); + expect(tailscaleSelf(tailscaleStatusJson).hostName).toBe("laptop"); + expect(tailscaleSelf('{"Self":{"TailscaleIPs":[null,"evil.com","192.0.2.10"]}}').ips).toEqual([ + "192.0.2.10", + ]); + }); + + it.each([ + ["LOCALHOST.:34204", "localhost:34204"], + ["[0:0:0:0:0:0:0:1]:34204", "[::1]:34204"], + ["[::1]", "[::1]"], + ["DEV-NODE.", "dev-node"], + ])("preserves valid authorities for Vite: %s", (raw, expected) => { + expect(normalizeHostHeader(raw)).toBe(expected); + }); +}); + +describe("destination interfaces (not remote peer addresses)", () => { + it.each( + ( + "127.0.0.1|::1|0:0:0:0:0:0:0:1|::ffff:127.0.0.1|::ffff:7f00:1|" + + "192.0.2.10|::ffff:192.0.2.10|2001:db8::10" + ).split("|"), + )("allows local address %s", (address) => { + expect(isAllowedLocalAddress(address, allowedAddresses)).toBe(true); + }); + + it.each([ + undefined, + ..."|172.17.0.1|::ffff:172.17.0.1|192.168.1.2|100.1.2.3|0.0.0.0|::|127.0.0.2".split("|"), + ])("rejects non-selected interface %j", (address) => { + expect(isAllowedLocalAddress(address, allowedAddresses)).toBe(false); + // undefined must not use the helper's default loopback address. + expect( + checkDevRequest({ localAddress: address, host: "localhost", origin: undefined }, policy), + ).toMatchObject({ status: 403 }); + }); + + it("does not treat an env host extra as an allowed interface", () => { + const hosts = buildAllowedHosts({ env: { DISPATCH_ALLOWED_HOSTS: "172.17.0.1" } }); + expect( + checkDevRequest( + { localAddress: "172.17.0.1", host: "172.17.0.1", origin: undefined }, + { allowedHosts: hosts, allowedAddresses }, + ), + ).toMatchObject({ status: 403 }); + }); +}); + +describe("shared HTTP / HMR request decision", () => { + it.each([ + undefined, + "http://DEV-NODE.:34204", + "https://[2001:db8::10]:443", + ])("allows trusted or absent Origin %j", (origin) => { + expect(request("::ffff:192.0.2.10", "dev-node", origin)).toBe("ok"); + }); + const badOrigins = + "null|file://localhost|https://evil.com|http://localhost/|http://user@localhost"; + it.each(badOrigins.split("|"))("rejects Origin %j", (origin) => { + expect(request("::1", "localhost", origin)).toMatchObject({ status: 403 }); + }); + it("requires Host and checks it before Origin", () => { + for (const host of [undefined, "evil.com"]) { + expect( + checkDevRequest({ localAddress: "127.0.0.1", host, origin: "null" }, policy), + ).toMatchObject({ status: 421 }); + } + }); +}); diff --git a/scripts/network-policy.ts b/scripts/network-policy.ts new file mode 100644 index 0000000..3bb122c --- /dev/null +++ b/scripts/network-policy.ts @@ -0,0 +1,165 @@ +/** Host/Origin semantics mirrored from backend packages/network-policy (review step 2.4). + * Keep in sync there; no I/O or ambient environment reads in this module. + */ +export interface NetworkPolicyEnv { + readonly DISPATCH_ALLOWED_HOSTS?: string; + readonly DISPATCH_TAILSCALE_NAME?: string; +} + +export type PolicyDecision<Status extends number> = "ok" | { status: Status; reason: string }; +type HeaderValue = string | null | undefined; + +function ipAddress(raw: string): string { + if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(raw)) { + return raw.split(".").every((part) => Number(part) <= 255 && String(Number(part)) === part) + ? raw + : ""; + } + if (!raw.includes(":") || /[^\da-f:.]/i.test(raw)) return ""; + try { + return new URL(`http://[${raw}]`).hostname.slice(1, -1); + } catch { + return ""; + } +} + +/** Invalid authorities normalize to "" (fail closed). Bare IPv6 is also accepted. */ +export function normalizeHost(raw: string): string { + const host = raw.toLowerCase(); + if (host.includes(":") && !host.startsWith("[") && host.split(":").length > 2) { + return ipAddress(host); + } + const match = /^(?:\[([\da-f:.]+)\]|([^\s:/\\@?#[\],%]+))(?::(\d+))?$/.exec(host); + if (!match || match[0] !== host || (match[3] !== undefined && Number(match[3]) > 65535)) { + return ""; + } + if (match[1]) return match[1].includes(":") ? ipAddress(match[1]) : ""; + const name = (match[2] ?? "").replace(/\.$/, ""); + return name.length <= 253 && + name.split(".").every((label) => /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/.test(label)) + ? name + : ""; +} + +/** OS HostName is deliberately not trusted; the shell may add it after verifying DNS. */ +export function buildAllowedHosts(input: { + tailscaleStatusJson?: string; + env: NetworkPolicyEnv; +}): ReadonlySet<string> { + const allowed = new Set(["localhost", "127.0.0.1", "::1"]); + const add = (raw: string) => { + const normalized = normalizeHost(raw); + if (normalized) allowed.add(normalized); + }; + try { + const status: unknown = JSON.parse(input.tailscaleStatusJson ?? "null"); + if (status && typeof status === "object" && "Self" in status) { + const self = status.Self; + if (self && typeof self === "object") { + if ("DNSName" in self && typeof self.DNSName === "string") { + const name = normalizeHost(self.DNSName); + if (name) { + add(name); + add(name.split(".")[0] ?? ""); + } + } + if ("TailscaleIPs" in self && Array.isArray(self.TailscaleIPs)) { + for (const ip of self.TailscaleIPs) { + if (typeof ip === "string" && ipAddress(ip)) add(ip); + } + } + } + } + } catch { + // Missing/malformed status must leave the loopback and explicit env policy intact. + } + for (const host of (input.env.DISPATCH_ALLOWED_HOSTS ?? "").split(",")) add(host.trim()); + if (input.env.DISPATCH_TAILSCALE_NAME) add(input.env.DISPATCH_TAILSCALE_NAME.trim()); + return allowed; +} + +export function checkHost( + hostHeader: HeaderValue, + allowed: ReadonlySet<string>, +): PolicyDecision<421> { + const host = hostHeader == null ? "" : normalizeHost(hostHeader); + return host && allowed.has(host) ? "ok" : { status: 421, reason: "Host is not allowed" }; +} + +export function checkOrigin( + originHeader: HeaderValue, + allowed: ReadonlySet<string>, +): PolicyDecision<403> { + if (originHeader == null) return "ok"; + const match = /^https?:\/\/([^/\\?#\s]+)$/i.exec(originHeader); + if (match?.[1] && match[0] === originHeader && checkHost(match[1], allowed) === "ok") { + try { + new URL(originHeader); + return "ok"; + } catch { + // Reject malformed URL authorities even if they resemble an allowed hostname. + } + } + return { status: 403, reason: "Origin is not allowed" }; +} + +/** Canonical authority for Vite's narrower parser; preserve ports and IPv6 brackets. */ +export function normalizeHostHeader(raw: string): string { + const host = normalizeHost(raw); + const port = /^(?:\[[^\]]+\]|[^:]+)(:\d+)$/.exec(raw)?.[1] ?? ""; + return `${host.includes(":") ? `[${host}]` : host}${port}`; +} + +/** Only the destination interface matters, NOT the remote peer's IP. */ +export function isAllowedLocalAddress( + address: string | undefined, + allowedAddresses: ReadonlySet<string>, +): boolean { + if (!address) return false; + // Dual-stack Node listeners report IPv4 destinations as ::ffff:a.b.c.d (or hex). + let normalized = normalizeHost(address); + if (normalized.startsWith("::ffff:")) { + const words = normalized.slice(7).split(":"); + if (words.length === 2) { + const value = + Number.parseInt(words[0] ?? "", 16) * 65536 + Number.parseInt(words[1] ?? "", 16); + normalized = [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255].join("."); + } + } + return allowedAddresses.has(normalized); +} + +/** Extract only actual IPs; neither env host extras nor a whole Tailscale subnet is trusted. */ +export function tailscaleSelf(json: string | undefined): { ips: string[]; hostName?: string } { + try { + const self = JSON.parse(json ?? "null")?.Self; + const ips = Array.isArray(self?.TailscaleIPs) + ? self.TailscaleIPs.filter( + (ip: unknown): ip is string => typeof ip === "string" && !!ipAddress(ip), + ) + : []; + return { + ips: ips.map((ip: string) => ipAddress(ip)), + ...(typeof self?.HostName === "string" ? { hostName: self.HostName } : {}), + }; + } catch { + return { ips: [] }; + } +} + +export interface DevNetworkPolicy { + readonly allowedHosts: ReadonlySet<string>; + readonly allowedAddresses: ReadonlySet<string>; +} + +/** Shared HTTP and HMR guard. Host precedes Origin, matching the backend. */ +export function checkDevRequest( + input: { localAddress: string | undefined; host: string | undefined; origin: string | undefined }, + policy: DevNetworkPolicy, +): PolicyDecision<403 | 421> { + if (!isAllowedLocalAddress(input.localAddress, policy.allowedAddresses)) { + return { status: 403, reason: "Local interface is not allowed" }; + } + const host = checkHost(input.host, policy.allowedHosts); + return host === "ok" ? checkOrigin(input.origin, policy.allowedHosts) : host; +} diff --git a/scripts/probe-replay.ts b/scripts/probe-replay.ts new file mode 100644 index 0000000..8a7faf9 --- /dev/null +++ b/scripts/probe-replay.ts @@ -0,0 +1,36 @@ +/** + * Probe: subscribe to a conversation over WS, capture the late-join replay, + * and report event counts / bytes / time-to-receive. Saves to /tmp/replay-<id>.json. + * bun scripts/probe-replay.ts <conversationId> [wsUrl] + */ +const conv = process.argv[2]; +const url = process.argv[3] ?? "ws://localhost:24990"; +if (!conv) throw new Error("conversation id required"); + +const ws = new WebSocket(url); +const events: unknown[] = []; +let bytes = 0; +let t0 = 0; +let lastAt = 0; +ws.onopen = () => { + t0 = performance.now(); + ws.send(JSON.stringify({ type: "chat.subscribe", conversationId: conv })); +}; +ws.onmessage = (ev) => { + const s = String(ev.data); + const m = JSON.parse(s); + if (m.type !== "chat.delta") return; + bytes += s.length; + lastAt = performance.now(); + events.push(m.event); +}; +setTimeout( + async () => { + ws.close(); + const counts: Record<string, number> = {}; + for (const e of events as { type: string }[]) counts[e.type] = (counts[e.type] ?? 0) + 1; + console.log({ events: events.length, bytes, recvMs: Math.round(lastAt - t0), counts }); + await Bun.write(`/tmp/replay-${conv}.json`, JSON.stringify(events)); + }, + Number(process.env.WAIT_MS ?? 3000), +); diff --git a/scripts/probe-tab.ts b/scripts/probe-tab.ts new file mode 100644 index 0000000..16ad423 --- /dev/null +++ b/scripts/probe-tab.ts @@ -0,0 +1,113 @@ +/** + * Focused probe: load a selected conversation through the FE's REAL pure modules + * and see whether the tab's render path would throw. Not part of the test suite. + * + * bun scripts/probe-tab.ts <conversationId> + */ +import "fake-indexeddb/auto"; +import type { + ConversationHistoryResponse, + ConversationMetricsResponse, +} from "@dispatch/transport-contract"; +import { + applyHistory, + groupRenderedChunks, + initialState, + selectChunks, + selectMessages, +} from "../src/core/chunks/index.ts"; +import { + applyDurableMetrics, + initialMetricsState, + selectOrderedTurnMetrics, +} from "../src/core/metrics/index.ts"; + +const HTTP_BASE = process.env.PROBE_HTTP ?? "http://localhost:24991"; +const CONV = process.argv[2]; +if (!CONV) { + console.error("Usage: bun scripts/probe-tab.ts <conversationId>"); + process.exit(1); +} + +async function historySync(id: string, sinceSeq: number): Promise<ConversationHistoryResponse> { + const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?sinceSeq=${sinceSeq}`; + const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } }); + if (!res.ok) throw new Error(`history fetch ${res.status} for ${url}`); + return (await res.json()) as ConversationHistoryResponse; +} + +async function metricsSync(id: string): Promise<ConversationMetricsResponse> { + const res = await fetch(`${HTTP_BASE}/conversations/${encodeURIComponent(id)}/metrics`, { + headers: { Origin: "http://localhost:24204" }, + }); + if (!res.ok) throw new Error(`metrics fetch ${res.status}`); + return (await res.json()) as ConversationMetricsResponse; +} + +let failures = 0; +const record = (name: string, ok: boolean, detail?: string) => { + if (!ok) failures += 1; + console.log(` ${ok ? "OK" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); +}; + +console.log(`Probing conversation ${CONV} via ${HTTP_BASE}`); + +// 1. applyHistory (the tab's cold-load path) +try { + const hist = await historySync(CONV, 0); + const chunks = hist.chunks ?? []; + console.log(` history: ${chunks.length} chunks, latestSeq=${hist.latestSeq}`); + let state = initialState(); + try { + state = applyHistory(state, chunks as never); + record( + `applyHistory(${chunks.length} chunks)`, + true, + `state=${JSON.stringify(state).length} bytes`, + ); + } catch (e) { + record("applyHistory", false, String(e)); + } + + // 2. selectMessages / selectChunks — what the transcript renders + try { + const messages = selectMessages(state); + record("selectMessages", true, `${messages.length} messages`); + } catch (e) { + record("selectMessages", false, String(e)); + } + + // 3. groupRenderedChunks — the render grouping + try { + const grouped = groupRenderedChunks(selectChunks(state)); + record("groupRenderedChunks", true, `${grouped.length} groups`); + } catch (e) { + record("groupRenderedChunks", false, String(e)); + } +} catch (e) { + record("history load", false, String(e)); +} + +// 4. metrics path +try { + const m = await metricsSync(CONV); + let ms = initialMetricsState(); + const turns = (m as { turns?: unknown[] }).turns ?? []; + try { + ms = applyDurableMetrics(ms, turns as never); + } catch (e) { + failures += 1; + console.log(` FAIL applyDurableMetrics — ${String(e)}`); + } + const ordered = selectOrderedTurnMetrics(ms); + record( + "metrics fold+apply", + true, + `${ordered.length} ordered turn metrics (${turns.length} turns)`, + ); +} catch (e) { + record("metrics load", false, String(e)); +} + +console.log(failures === 0 ? "\nALL OK — tab render path is clean." : `\n${failures} FAILURE(S).`); +process.exit(failures === 0 ? 0 : 1); diff --git a/scripts/text-file-environment.ts b/scripts/text-file-environment.ts new file mode 100644 index 0000000..6507be2 --- /dev/null +++ b/scripts/text-file-environment.ts @@ -0,0 +1,19 @@ +import type { Environment } from "vitest/environments"; +import { builtinEnvironments } from "vitest/environments"; + +/** Real runtime fetch and AbortSignal must belong to the same implementation. + * jsdom supplies DOM APIs but replaces these globals with an incompatible realm. + * Keep the runtime's pair for this opt-in real-network component probe only. + */ +export default { + name: "text-file-jsdom", + transformMode: "web", + async setup(global, options) { + const controller = global.AbortController; + const signal = global.AbortSignal; + const environment = await builtinEnvironments.jsdom.setup(global, options); + global.AbortController = controller; + global.AbortSignal = signal; + return environment; + }, +} satisfies Environment; diff --git a/scripts/text-file-live.test.ts b/scripts/text-file-live.test.ts new file mode 100644 index 0000000..3305616 --- /dev/null +++ b/scripts/text-file-live.test.ts @@ -0,0 +1,119 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { rm, writeFile } from "node:fs/promises"; +import { resolve as resolvePath } from "node:path"; +import { createInterface } from "node:readline"; +import { fireEvent, render, screen } from "@testing-library/svelte"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createTextFileClient, Markdown } from "../src/features/markdown"; + +interface Fixture { + url: string; + path: string; + cwd: string; + conversationId: string; + remoteConversationId: string; +} + +// Opt-in real cross-repo UI/network probe. Owns an ephemeral loopback server and +// temporary files only; never connects to or restarts the deployed application. +// bun run test:text-file-live +// The backend must be installed and typechecked first (workspace public dists). +describe.skipIf(process.env.TEXT_FILE_LIVE_PROBE !== "1")("live saved-file chat display", () => { + let child: ChildProcess; + let fixture: Fixture; + beforeAll(async () => { + fixture = await new Promise<Fixture>((resolve, reject) => { + child = spawn("bun", ["packages/transport-http/src/text-file-probe.ts"], { + cwd: resolvePath(process.cwd(), "../backend"), + stdio: ["ignore", "pipe", "pipe"], + }); + const timer = setTimeout( + () => reject(new Error("Text-file fixture startup timed out")), + 15000, + ); + let stderr = ""; + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + child.once("error", reject); + child.once("exit", (code) => { + clearTimeout(timer); + reject(new Error(`Text-file fixture exited (${code}): ${stderr}`)); + }); + if (!child.stdout) { + reject(new Error("Missing fixture stdout")); + return; + } + const lines = createInterface({ input: child.stdout }); + lines.once("line", (line) => { + clearTimeout(timer); + try { + resolve(JSON.parse(line) as Fixture); + } catch (error) { + reject(error); + } + }); + }); + }, 20000); + afterAll(async () => { + if (child && child.exitCode === null) { + await new Promise<void>((resolve) => { + const timer = setTimeout(() => child.kill("SIGKILL"), 3000); + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + child.kill("SIGTERM"); + }); + } + }); + + it("renders only the saved reference, previews/expands Markdown, and rereads changed/deleted files on refresh and remount", async () => { + const saved = Array.from({ length: 20 }, (_, i) => `## Saved step ${i + 1}`).join("\n"); + await writeFile(fixture.path, saved); + const reply = "I saved the implementation plan.\n\n[Plan](text-file:plan%20100%25.md)"; + const client = createTextFileClient(fixture.url, fixture.conversationId, fetch); + let view = render(Markdown, { text: reply, textFiles: client }); + try { + await screen.findByRole("heading", { name: "Saved step 1" }); + expect(screen.getByRole("heading", { name: "Saved step 20" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Saved step 10" })).not.toBeInTheDocument(); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + expect(screen.getByRole("heading", { name: "Saved step 10" })).toBeInTheDocument(); + expect(reply).not.toContain(saved); + await writeFile(fixture.path, "# Updated on disk\n\n**Fresh content**"); + await fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await screen.findByRole("heading", { name: "Updated on disk" }); + expect(view.container.querySelector("strong")?.textContent).toBe("Fresh content"); + view.unmount(); + view = render(Markdown, { text: reply, textFiles: client }); + await screen.findByRole("heading", { name: "Updated on disk" }); + await rm(fixture.path); + await fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Text file not found"); + await writeFile(fixture.path, "# Recreated plan"); + await fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByRole("heading", { name: "Recreated plan" }); + } finally { + view.unmount(); + client.dispose(); + } + }); + + it("does not read the same local path for an authoritative remote conversation", async () => { + const client = createTextFileClient(fixture.url, fixture.remoteConversationId, fetch); + const view = render(Markdown, { + text: `[Remote](text-file:${encodeURI(fixture.path)})`, + textFiles: client, + }); + try { + expect(await screen.findByRole("alert")).toHaveTextContent( + "not available for remote computers", + ); + expect(screen.queryByRole("heading", { name: "Recreated plan" })).not.toBeInTheDocument(); + } finally { + view.unmount(); + client.dispose(); + } + }); +}); diff --git a/scripts/vite-network-guard.test.ts b/scripts/vite-network-guard.test.ts new file mode 100644 index 0000000..8bef397 --- /dev/null +++ b/scripts/vite-network-guard.test.ts @@ -0,0 +1,117 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Connect, Plugin, PreviewServer, ViteDevServer } from "vite"; +import { describe, expect, it, vi } from "vitest"; +import { applyViteNetworkPolicy, devNetworkGuard } from "./vite-network-guard"; + +const policy = { + allowedHosts: new Set([ + "localhost", + "127.0.0.1", + "::1", + "dev-node.tailnet.example", + "192.0.2.10", + ]), + allowedAddresses: new Set(["127.0.0.1", "::1", "192.0.2.10"]), +}; + +function install(mode: "dev" | "preview") { + let middleware: Connect.NextHandleFunction | undefined; + const http = new EventEmitter(); + const upgrade = vi.fn(); + http.on("upgrade", upgrade); + const server = { + middlewares: { + use: (handler: Connect.NextHandleFunction) => { + middleware = handler; + }, + }, + httpServer: http, + }; + const plugin = devNetworkGuard(policy); + const hook = mode === "dev" ? plugin.configureServer : plugin.configurePreviewServer; + if (typeof hook !== "function") throw new Error(`Missing ${mode} guard hook`); + // Hooks only need the injected middleware stack and HTTP event emitter. + hook.call({} as never, server as unknown as ViteDevServer & PreviewServer); + if (!middleware) throw new Error(`Missing ${mode} middleware`); + return { middleware, http, upgrade }; +} + +function request(localAddress: string | undefined, host?: string, origin?: string) { + return { socket: { localAddress }, headers: { host, origin } } as IncomingMessage; +} + +const cases = [ + ["loopback", "127.0.0.1", "localhost", undefined, 200], + ["IPv6 loopback", "::1", "[::1]:34204", undefined, 200], + ["mapped address / canonical Host", "::ffff:127.0.0.1", "LOCALHOST.:34204", undefined, 200], + [ + "tailnet", + "192.0.2.10", + "dev-node.tailnet.example", + "https://dev-node.tailnet.example:34204", + 200, + ], + ["unselected interface with trusted Host", "172.17.0.1", "localhost", undefined, 403], + ["missing interface", undefined, "localhost", undefined, 403], + ["arbitrary IP Host", "127.0.0.1", "127.0.0.2", undefined, 421], + ["rebinding Host", "127.0.0.1", "evil.example", undefined, 421], + ["missing Host", "127.0.0.1", undefined, undefined, 421], + ["disallowed Origin", "127.0.0.1", "localhost", "https://evil.example", 403], + ["opaque Origin", "127.0.0.1", "localhost", "null", 403], + ["Host before Origin", "127.0.0.1", "evil.example", "null", 421], +] as const; + +describe.each(["dev", "preview"] as const)("%s HTTP guard", (mode) => { + it.each(cases)("%s", (_label, local, host, origin, status) => { + const { middleware } = install(mode); + const req = request(local, host, origin); + const writeHead = vi.fn(); + const end = vi.fn(); + const next = vi.fn(); + middleware(req, { writeHead, end } as unknown as ServerResponse, next); + if (status === 200) { + expect(next).toHaveBeenCalledExactlyOnceWith(); + expect(writeHead).not.toHaveBeenCalled(); + if (host === "LOCALHOST.:34204") expect(req.headers.host).toBe("localhost:34204"); + } else { + expect(writeHead).toHaveBeenCalledWith(status, { "Content-Type": "text/plain" }); + expect(end).toHaveBeenCalledOnce(); + expect(next).not.toHaveBeenCalled(); // In particular, cannot reach CORS/static serving. + } + }); +}); + +describe("dev upgrades remain protected", () => { + it.each(cases)("%s", (_label, local, host, origin, status) => { + const { http, upgrade } = install("dev"); + const end = vi.fn(); + http.emit("upgrade", request(local, host, origin), { end }, Buffer.alloc(0)); + if (status === 200) { + expect(upgrade).toHaveBeenCalledOnce(); + expect(end).not.toHaveBeenCalled(); + } else { + expect(upgrade).not.toHaveBeenCalled(); + expect(end).toHaveBeenCalledWith(expect.stringContaining(`HTTP/1.1 ${status}`)); + } + }); +}); + +it("sets explicit allowlists and guarded CORS for preview as well as dev", () => { + const config = applyViteNetworkPolicy( + { + server: { host: true, port: 34204 }, + preview: { host: true, port: 34204, cors: true, allowedHosts: true }, + }, + policy, + ); + for (const serving of [config.server, config.preview]) { + expect(serving?.allowedHosts).toEqual([...policy.allowedHosts]); + expect(serving?.cors).toEqual({ origin: true }); + expect(serving?.host).toBe(true); + expect(serving?.port).toBe(34204); + } + const plugin = config.plugins?.[0] as Plugin; + expect(plugin.configureServer).toBeTypeOf("function"); + expect(plugin.configurePreviewServer).toBeTypeOf("function"); +}); diff --git a/scripts/vite-network-guard.ts b/scripts/vite-network-guard.ts new file mode 100644 index 0000000..7fe9eca --- /dev/null +++ b/scripts/vite-network-guard.ts @@ -0,0 +1,74 @@ +import type { IncomingMessage } from "node:http"; +import type { Plugin, UserConfig } from "vite"; +import { checkDevRequest, type DevNetworkPolicy, normalizeHostHeader } from "./network-policy"; + +/** Explicitly protect BOTH serving modes; preview must not inherit permissive CORS alone. */ +export function applyViteNetworkPolicy(config: UserConfig, network: DevNetworkPolicy): UserConfig { + return { + ...config, + plugins: [devNetworkGuard(network), ...(config.plugins ?? [])], + server: { + ...config.server, + allowedHosts: [...network.allowedHosts], + // Safe reflection only after our pre-middleware validates the Origin. + cors: { origin: true }, + }, + preview: { + ...config.preview, + allowedHosts: [...network.allowedHosts], + cors: { origin: true }, + }, + }; +} + +export function devNetworkGuard(policy: DevNetworkPolicy): Plugin { + const check = (req: IncomingMessage) => + checkDevRequest( + { + localAddress: req.socket.localAddress, + host: req.headers.host, + origin: req.headers.origin, + }, + policy, + ); + // Installed before Vite's CORS, host check and static-file middleware in + // either mode. No post-hook: even preflights and error responses are guarded. + const middleware: import("vite").Connect.NextHandleFunction = (req, res, next) => { + const decision = check(req); + if (decision !== "ok") { + res.writeHead(decision.status, { "Content-Type": "text/plain" }); + res.end(decision.reason); + return; + } + req.headers.host = normalizeHostHeader(req.headers.host ?? ""); + next(); + }; + return { + name: "dispatch-dev-network-guard", + enforce: "pre", + configurePreviewServer(server) { + server.middlewares.use(middleware); + }, + configureServer(server) { + server.middlewares.use(middleware); + // Vite registers HMR before configureServer. Wrap (rather than prepend to) + // its listeners: EventEmitter otherwise continues into HMR after a denial. + const http = server.httpServer; + if (http) { + const upgrades = http.listeners("upgrade"); + http.removeAllListeners("upgrade"); + http.on("upgrade", (req, socket, head) => { + const decision = check(req); + if (decision !== "ok") { + socket.end( + `HTTP/1.1 ${decision.status} ${decision.status === 421 ? "Misdirected Request" : "Forbidden"}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, + ); + return; + } + req.headers.host = normalizeHostHeader(req.headers.host ?? ""); + for (const upgrade of upgrades) upgrade.call(http, req, socket, head); + }); + } + }, + }; +} diff --git a/src/App.svelte b/src/App.svelte index 27c9271..32eaf70 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import { App, createAppStore } from "./app"; import { createHistoryAdapter } from "./adapters/history"; + import type { WorkspaceLayoutPort } from "./features/plugins"; import { createWorkspaceHttp, createWorkspaceStore, @@ -65,6 +66,23 @@ function navigate(path: string): void { history.navigate(path); } + + // plan 04 §4.4: the workspace plugins modal's "Reset layout to default" + // affordance. The port gates on an ACTIVE layout provider (read through the + // store's reactive getters, so enable/disable mid-session flips the button) + // and resets via the layout store (`DELETE /workspaces/:id/layout` → the + // server re-seeds → the WS broadcast re-renders every client). + const layoutPort: WorkspaceLayoutPort = { + get providerActive() { + return store.layout.providerState === "present"; + }, + reset: (workspaceId) => store.layout.resetToDefault(workspaceId), + // The plugins modal's per-workspace toggles can change WHICH layout + // plugin serves the workspace (the per-workspace activation rule) — the + // store re-probes only the active workspace (non-active toggles are + // picked up by their own probe on switch). + refresh: (workspaceId) => store.layout.onWorkspacePluginsChanged(workspaceId), + }; </script> {#if route.kind === "home"} @@ -73,6 +91,9 @@ onNavigate={navigate} computers={store.computers} hasActive={(id) => store.workspaceHasActiveConversations(id)} + plugins={store.plugins} + layout={layoutPort} + tiles={store.tileLayout} /> {:else} <App {store} onNavigate={navigate} /> diff --git a/src/adapters/idb/index.test.ts b/src/adapters/idb/index.test.ts index c05c605..aa56f15 100644 --- a/src/adapters/idb/index.test.ts +++ b/src/adapters/idb/index.test.ts @@ -30,6 +30,16 @@ describe("createIdbChunkStore", () => { expect(loaded[0]?.chunk).toEqual(textChunk("a")); }); + it("round-trips a chunk's turnId (and upserts it onto an older row)", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + await store.append("conv1", [makeChunk(1, "a"), makeChunk(2, "b")]); + expect((await store.load("conv1")).map((c) => c.turnId)).toEqual([undefined, undefined]); + await store.append("conv1", [{ ...makeChunk(1, "a"), turnId: "t1" }]); + const loaded = await store.load("conv1"); + expect(loaded.map((c) => c.turnId)).toEqual(["t1", undefined]); + expect("turnId" in (loaded[1] ?? {})).toBe(false); + }); + 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")]; @@ -118,3 +128,18 @@ describe("createIdbChunkStore", () => { expect(loaded2[0]?.chunk).toEqual(textChunk("b1")); }); }); + +it("persists epoch across adapter recreation and append, including empty histories", async () => { + const indexedDB = new IDBFactory(); + const store = createIdbChunkStore({ indexedDB }); + await store.append("c", [makeChunk(1, "legacy")]); + expect(await store.historyEpoch("c")).toBeUndefined(); + await store.reset("c", 2); + const reopened = createIdbChunkStore({ indexedDB }); + expect(await reopened.load("c")).toEqual([]); + expect(await reopened.historyEpoch("c")).toBe(2); + await reopened.append("c", [makeChunk(101, "new")]); + expect(await reopened.historyEpoch("c")).toBe(2); + await reopened.delete("c"); + expect(await reopened.historyEpoch("c")).toBeUndefined(); +}); diff --git a/src/adapters/idb/index.ts b/src/adapters/idb/index.ts index 96b2cbc..8b1848a 100644 --- a/src/adapters/idb/index.ts +++ b/src/adapters/idb/index.ts @@ -14,11 +14,13 @@ interface ChunkRecord { seq: number; role: StoredChunk["role"]; chunk: StoredChunk["chunk"]; + turnId?: string; } interface MetaRecord { conversationId: string; lastAccess: number; + historyEpoch?: number; } export interface CreateIdbChunkStoreOptions { @@ -70,7 +72,11 @@ function keyRangeFor(conversationId: string): IDBKeyRange { } function chunksToStoredChunks(records: ChunkRecord[]): StoredChunk[] { - return records.map((r) => ({ seq: r.seq, role: r.role, chunk: r.chunk })); + return records.map((r) => + typeof r.turnId === "string" + ? { seq: r.seq, role: r.role, chunk: r.chunk, turnId: r.turnId } + : { seq: r.seq, role: r.role, chunk: r.chunk }, + ); } export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): ConversationChunkStore { @@ -87,6 +93,26 @@ export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): Conversa } return { + async historyEpoch(conversationId) { + const db = await getDb(); + const tx = db.transaction(META_STORE, "readonly"); + const meta = await requestToPromise<MetaRecord | undefined>( + tx.objectStore(META_STORE).get(conversationId), + ); + await txComplete(tx); + return meta?.historyEpoch; + }, + async reset(conversationId, historyEpoch) { + const db = await getDb(); + const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite"); + tx.objectStore(CHUNKS_STORE).delete(keyRangeFor(conversationId)); + tx.objectStore(META_STORE).put({ + conversationId, + historyEpoch, + lastAccess: Date.now(), + } satisfies MetaRecord); + await txComplete(tx); + }, async load(conversationId: string): Promise<readonly StoredChunk[]> { const db = await getDb(); const tx = db.transaction(CHUNKS_STORE, "readonly"); @@ -113,10 +139,13 @@ export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): Conversa seq: c.seq, role: c.role, chunk: c.chunk, + ...(c.turnId !== undefined ? { turnId: c.turnId } : {}), } satisfies ChunkRecord); } + const meta = await requestToPromise<MetaRecord | undefined>(metaStore.get(conversationId)); metaStore.put({ + ...meta, conversationId, lastAccess: Date.now(), } satisfies MetaRecord); diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts index 9d821e2..e4e4ba4 100644 --- a/src/adapters/ws/index.test.ts +++ b/src/adapters/ws/index.test.ts @@ -6,7 +6,7 @@ interface FakeSocket extends WebSocketLike { sent: string[]; resolveOpen(): void; invokeMessage(data: string): void; - invokeClose(): void; + invokeClose(code?: number): void; } function fakeSocket(): FakeSocket { @@ -44,8 +44,8 @@ function fakeSocket(): FakeSocket { invokeMessage(data: string) { onmessage?.({ data }); }, - invokeClose() { - onclose?.({ code: 1000, reason: "" }); + invokeClose(code = 1000) { + onclose?.({ code, reason: "" }); }, sent, }; @@ -405,3 +405,134 @@ describe("createSurfaceSocket", () => { expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "queued" }); }); }); + +describe("reconnect backoff after open", () => { + it.each([1013, 1006, 1011])("grows and caps delays across repeated early %s closes", (code) => { + vi.useFakeTimers(); + const random = vi.spyOn(Math, "random").mockReturnValue(0.5); + const sockets: FakeSocket[] = []; + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => { + const ws = fakeSocket(); + sockets.push(ws); + return ws; + }, + }); + try { + for (const delay of [500, 1000, 2000, 4000, 8000, 16000, 30000, 30000]) { + const count = sockets.length; + sockets.at(-1)?.resolveOpen(); + vi.advanceTimersByTime(100); + sockets.at(-1)?.invokeClose(code); + vi.advanceTimersByTime(delay - 1); + expect(sockets).toHaveLength(count); + vi.advanceTimersByTime(1); + expect(sockets).toHaveLength(count + 1); + } + } finally { + handle.close(); + random.mockRestore(); + vi.useRealTimers(); + } + }); + + it.each([ + "healthy", + "normal", + "going away", + ])("resets accumulated backoff after a %s connection", (mode) => { + vi.useFakeTimers(); + const random = vi.spyOn(Math, "random").mockReturnValue(0.5); + const sockets: FakeSocket[] = []; + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => { + const ws = fakeSocket(); + sockets.push(ws); + return ws; + }, + }); + try { + sockets[0]?.resolveOpen(); + sockets[0]?.invokeClose(1013); + vi.advanceTimersByTime(500); + sockets[1]?.resolveOpen(); + if (mode === "healthy") vi.advanceTimersByTime(30000); + sockets[1]?.invokeClose(mode === "normal" ? 1000 : mode === "going away" ? 1001 : 1013); + vi.advanceTimersByTime(499); + expect(sockets).toHaveLength(2); + vi.advanceTimersByTime(1); + expect(sockets).toHaveLength(3); + handle.close(); + vi.advanceTimersByTime(60000); + expect(sockets).toHaveLength(3); + } finally { + handle.close(); + random.mockRestore(); + vi.useRealTimers(); + } + }); +}); + +it("routes chat.queued only through the typed chat callback, never surface/error handling", () => { + const ws = fakeSocket(); + const onChat = vi.fn(); + const onMessage = vi.fn(); + const handle = createSurfaceSocket({ + url: "ws://test", + socketFactory: () => ws, + onChat, + onMessage, + }); + const ack = { + type: "chat.queued", + conversationId: "c1", + reason: "compaction", + messageId: "q1", + requestId: "r1", + }; + ws.invokeMessage(JSON.stringify(ack)); + expect(onChat).toHaveBeenCalledExactlyOnceWith(ack); + expect(onMessage).not.toHaveBeenCalled(); + handle.close(); +}); + +it("routes resync notices to chat without reconnect/resend or surface errors", () => { + vi.useFakeTimers(); + const ws = fakeSocket(); + const onChat = vi.fn(); + const onMessage = vi.fn(); + const onReopen = vi.fn(); + const factory = vi.fn(() => ws); + const handle = createSurfaceSocket({ + url: "ws://test", + socketFactory: factory, + onChat, + onMessage, + onReopen, + }); + try { + ws.resolveOpen(); + const notice = { + type: "chat.resync-required", + conversationId: "c1", + turnId: "t1", + reason: "replay-limit", + }; + ws.invokeMessage(JSON.stringify(notice)); + ws.invokeMessage(JSON.stringify(notice)); + vi.advanceTimersByTime(1000); + expect(onChat).toHaveBeenCalledTimes(2); + expect(onChat).toHaveBeenCalledWith(notice); + expect(onMessage).not.toHaveBeenCalled(); + expect(onReopen).not.toHaveBeenCalled(); + expect(factory).toHaveBeenCalledTimes(1); + expect(ws.sent).toEqual([]); + } finally { + handle.close(); + vi.useRealTimers(); + } +}); diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts index 1309db0..e1d7d22 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -1,9 +1,17 @@ import type { + ChatAcceptedMessage, ChatDeltaMessage, ChatErrorMessage, + ChatQueuedMessage, + ChatResyncRequiredMessage, + ChatSubscriptionRejectedMessage, ConversationCompactedMessage, ConversationOpenMessage, ConversationStatusChangedMessage, + ConversationTabGroupChangedMessage, + ExtensionsChangedMessage, + LayoutChangedMessage, + TileLayoutChangedMessage, WsClientMessage, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; @@ -20,13 +28,40 @@ export interface WebSocketLike { export interface SurfaceSocketOptions { url: string; onMessage: (msg: SurfaceServerMessage) => void; - onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void; + onChat?: ( + msg: + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage, + ) => 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; + /** Broadcast when a conversation's (visual) tab group parent changes. */ + onConversationTabGroupChanged?: (msg: ConversationTabGroupChangedMessage) => void; + /** + * Broadcast after every plugin-manager mutation (plan 05 §11): every + * connected frontend refetches its extension list (the server re-pushes the + * workspace-filtered surface catalog itself). + */ + onExtensionsChanged?: (msg: ExtensionsChangedMessage) => void; + /** + * Broadcast after every successful layout write (plan 02 §2.5): the FULL + * tree for one workspace. The consumer filters by its active workspace. + */ + onLayout?: (msg: LayoutChangedMessage) => void; + /** + * Broadcast after every successful home tile-layout write: the FULL + * document for one device orientation (wide/tall). The consumer filters by + * its active geometry mode. + */ + onTileLayout?: (msg: TileLayoutChangedMessage) => void; onReopen?: () => void; socketFactory?: (url: string) => WebSocketLike; } @@ -44,7 +79,12 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa let disposed = false; let reconnectAttempt = 0; let reconnectTimer: ReturnType<typeof setTimeout> | null = null; + let healthyTimer: ReturnType<typeof setTimeout> | null = null; let isOpen = false; + function clearHealthyTimer(): void { + if (healthyTimer !== null) clearTimeout(healthyTimer); + healthyTimer = null; + } const queue: string[] = []; function connect(isReconnect: boolean): void { @@ -54,7 +94,12 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa socket.onopen = () => { if (disposed) return; isOpen = true; - reconnectAttempt = 0; + // Opening is not proof of recovery: replay may immediately overflow the + // server's slow-consumer bound. Only a sustained open resets the streak. + healthyTimer = setTimeout(() => { + healthyTimer = null; + reconnectAttempt = 0; + }, 30_000); for (const raw of queue.splice(0)) { socket?.send(raw); } @@ -67,22 +112,47 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa 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); + if ( + msg.type === "chat.accepted" || + msg.type === "chat.subscription-rejected" || + msg.type === "chat.delta" || + msg.type === "chat.error" || + msg.type === "chat.queued" || + msg.type === "chat.resync-required" + ) { + opts.onChat?.( + msg as + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage, + ); } 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.tabGroupChanged") { + opts.onConversationTabGroupChanged?.(msg as ConversationTabGroupChangedMessage); } else if (msg.type === "conversation.compacted") { opts.onConversationCompacted?.(msg as ConversationCompactedMessage); + } else if (msg.type === "extensions-changed") { + opts.onExtensionsChanged?.(msg as ExtensionsChangedMessage); + } else if (msg.type === "layout") { + opts.onLayout?.(msg as LayoutChangedMessage); + } else if (msg.type === "tile-layout") { + opts.onTileLayout?.(msg as TileLayoutChangedMessage); } else { opts.onMessage(msg as SurfaceServerMessage); } } }; - socket.onclose = () => { + socket.onclose = (ev) => { + clearHealthyTimer(); if (disposed) return; + if (isOpen && (ev.code === 1000 || ev.code === 1001)) reconnectAttempt = 0; isOpen = false; scheduleReconnect(); }; @@ -112,6 +182,7 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa }, close(): void { disposed = true; + clearHealthyTimer(); if (reconnectTimer !== null) { clearTimeout(reconnectTimer); reconnectTimer = null; diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index dd2b773..4bc2aad 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import { nextBackoffMs, parseServerMessage, serialize } from "./logic"; +describe("serialize — workspace.set (plan 06 §6.4)", () => { + it("serializes a workspace.set client message", () => { + const msg = { type: "workspace.set" as const, workspaceId: "ws-1" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); +}); + describe("serialize", () => { it("serializes a subscribe message", () => { const msg = { type: "subscribe" as const, surfaceId: "s1" }; @@ -232,6 +239,38 @@ describe("parseServerMessage", () => { }); }); + it("parses a conversation.open message with a persisted model", () => { + const data = JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + model: "openrouter/stealth/ox-alpha", + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + model: "openrouter/stealth/ox-alpha", + }); + }); + + it("omits the model when conversation.open carries a non-string/empty model", () => { + const result = parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + model: 42, + }), + ); + 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" })), @@ -398,3 +437,204 @@ describe("nextBackoffMs", () => { } }); }); + +describe("parseServerMessage — extensions-changed (plan 05)", () => { + it("parses the broadcast with a pluginId", () => { + const result = parseServerMessage( + JSON.stringify({ type: "extensions-changed", action: "unload", pluginId: "mcp" }), + ); + expect(result).toEqual({ type: "extensions-changed", action: "unload", pluginId: "mcp" }); + }); + + it("parses a scan broadcast (no pluginId)", () => { + const result = parseServerMessage( + JSON.stringify({ type: "extensions-changed", action: "scan" }), + ); + expect(result).toEqual({ type: "extensions-changed", action: "scan" }); + }); + + it("rejects an unknown action", () => { + expect( + parseServerMessage(JSON.stringify({ type: "extensions-changed", action: "explode" })), + ).toBeNull(); + }); + + it("rejects a missing action", () => { + expect(parseServerMessage(JSON.stringify({ type: "extensions-changed" }))).toBeNull(); + }); +}); + +describe("parseServerMessage — layout (plan 02)", () => { + const TREE = { version: 1, root: { kind: "leaf", view: "chat" } }; + + it("parses the broadcast (workspaceId + full tree)", () => { + const result = parseServerMessage( + JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: TREE }), + ); + expect(result).toEqual({ type: "layout", workspaceId: "ws-a", tree: TREE }); + }); + + it("rejects a missing/invalid workspaceId", () => { + expect(parseServerMessage(JSON.stringify({ type: "layout", tree: TREE }))).toBeNull(); + expect( + parseServerMessage(JSON.stringify({ type: "layout", workspaceId: 7, tree: TREE })), + ).toBeNull(); + }); + + it("rejects a missing/invalid tree (wrong version, missing root, non-object)", () => { + expect(parseServerMessage(JSON.stringify({ type: "layout", workspaceId: "ws-a" }))).toBeNull(); + expect( + parseServerMessage( + JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: { version: 2, root: {} } }), + ), + ).toBeNull(); + expect( + parseServerMessage( + JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: { version: 1 } }), + ), + ).toBeNull(); + expect( + parseServerMessage(JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: 7 })), + ).toBeNull(); + }); + + it("an unknown type still parses to null (the layout addition doesn't loosen the guard)", () => { + expect(parseServerMessage(JSON.stringify({ type: "layout.unknown" }))).toBeNull(); + }); +}); + +describe("parseServerMessage — subagent tab groups", () => { + it("forwards the effective tabGroupParentId on conversation.open", () => { + const result = parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "child", + workspaceId: "w1", + tabGroupParentId: "parent", + }), + ); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "child", + workspaceId: "w1", + tabGroupParentId: "parent", + }); + }); + + it("omits a non-string/empty tabGroupParentId on conversation.open", () => { + for (const tabGroupParentId of ["", 42, null]) { + const result = parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + tabGroupParentId, + }), + ); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); + } + }); + + it("parses conversation.tabGroupChanged with a parent id and with null (ungrouped)", () => { + for (const tabGroupParentId of ["parent", null]) { + const msg = { + type: "conversation.tabGroupChanged", + conversationId: "c1", + workspaceId: "w1", + tabGroupParentId, + }; + expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg); + } + }); + + it("rejects a malformed conversation.tabGroupChanged", () => { + const base = { type: "conversation.tabGroupChanged", conversationId: "c1", workspaceId: "w1" }; + expect(parseServerMessage(JSON.stringify(base))).toBeNull(); // missing (undefined) parent + expect(parseServerMessage(JSON.stringify({ ...base, tabGroupParentId: "" }))).toBeNull(); + expect(parseServerMessage(JSON.stringify({ ...base, tabGroupParentId: 1 }))).toBeNull(); + const { workspaceId: _w, ...noWorkspace } = base; + expect( + parseServerMessage(JSON.stringify({ ...noWorkspace, tabGroupParentId: "p" })), + ).toBeNull(); + }); +}); + +it("preserves a compaction epoch and rejects absent or invalid epochs", () => { + const msg = { + type: "conversation.compacted", + conversationId: "c", + newConversationId: "archive", + messagesSummarized: 2, + messagesKept: 1, + historyEpoch: 7, + }; + expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg); + for (const historyEpoch of [undefined, -1, 1.5, "7"]) { + expect(parseServerMessage(JSON.stringify({ ...msg, historyEpoch }))).toBeNull(); + } +}); + +describe("chat.queued admission acknowledgement", () => { + const ack = { + type: "chat.queued", + conversationId: "c1", + reason: "compaction", + messageId: "q1", + }; + + it.each([undefined, "request-1"])("preserves optional requestId %s", (requestId) => { + const msg = { ...ack, ...(requestId !== undefined ? { requestId } : {}) }; + expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg); + }); + + it.each([ + { conversationId: undefined }, + { conversationId: 12 }, + { messageId: undefined }, + { messageId: null }, + { reason: undefined }, + { reason: "other" }, + { requestId: null }, + { requestId: 12 }, + ])("rejects malformed acknowledgement %j", (bad) => { + expect(parseServerMessage(JSON.stringify({ ...ack, ...bad }))).toBeNull(); + }); + + it("serializes a correlated send", () => { + const msg = { type: "chat.send" as const, message: "hello", requestId: "r1" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); +}); + +describe("chat.resync-required", () => { + it.each(["replay-limit", "payload-limit"])("parses %s with optional turn identity", (reason) => { + const msg = { type: "chat.resync-required", conversationId: "c1", reason }; + expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg); + expect(parseServerMessage(JSON.stringify({ ...msg, turnId: "t1" }))).toEqual({ + ...msg, + turnId: "t1", + }); + }); + it.each([ + {}, + { conversationId: null }, + { conversationId: 1 }, + { reason: "unknown" }, + { reason: null }, + { turnId: null }, + { turnId: 42 }, + ])("rejects malformed notice %j", (bad) => { + const msg = { + type: "chat.resync-required", + conversationId: "c1", + reason: "replay-limit", + ...bad, + }; + if (Object.keys(bad).length === 0) delete (msg as { conversationId?: unknown }).conversationId; + expect(parseServerMessage(JSON.stringify(msg))).toBeNull(); + }); +}); diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index 03ef763..d167669 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -1,9 +1,15 @@ import type { ChatDeltaMessage, ChatErrorMessage, + ChatQueuedMessage, + ChatResyncRequiredMessage, ConversationCompactedMessage, ConversationOpenMessage, ConversationStatusChangedMessage, + ConversationTabGroupChangedMessage, + ExtensionsChangedMessage, + LayoutChangedMessage, + TileLayoutChangedMessage, WsClientMessage, WsServerMessage, } from "@dispatch/transport-contract"; @@ -19,11 +25,19 @@ const VALID_SERVER_TYPES = new Set([ "surface", "update", "error", + "chat.accepted", + "chat.subscription-rejected", "chat.delta", "chat.error", + "chat.queued", + "chat.resync-required", "conversation.open", "conversation.statusChanged", "conversation.compacted", + "conversation.tabGroupChanged", + "extensions-changed", + "layout", + "tile-layout", ]); /** Serialize a client message to a JSON string for the wire. */ @@ -97,12 +111,56 @@ export function parseServerMessage(data: string): WsServerMessage | null { : { type: "error", message: parsed.message }; return msg; } + case "chat.accepted": { + if (typeof parsed.conversationId !== "string" || typeof parsed.requestId !== "string") + return null; + return { + type: "chat.accepted", + conversationId: parsed.conversationId, + requestId: parsed.requestId, + }; + } + case "chat.subscription-rejected": { + if (typeof parsed.conversationId !== "string" || parsed.reason !== "subscription-limit") + return null; + return { + type: "chat.subscription-rejected", + conversationId: parsed.conversationId, + reason: parsed.reason, + }; + } 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.resync-required": { + if (typeof parsed.conversationId !== "string") return null; + if (parsed.turnId !== undefined && typeof parsed.turnId !== "string") return null; + if (parsed.reason !== "replay-limit" && parsed.reason !== "payload-limit") return null; + const msg: ChatResyncRequiredMessage = { + type: "chat.resync-required", + conversationId: parsed.conversationId, + reason: parsed.reason, + ...(parsed.turnId !== undefined ? { turnId: parsed.turnId } : {}), + }; + return msg; + } + case "chat.queued": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.messageId !== "string") return null; + if (parsed.reason !== "compaction") return null; + if (parsed.requestId !== undefined && typeof parsed.requestId !== "string") return null; + const msg: ChatQueuedMessage = { + type: "chat.queued", + conversationId: parsed.conversationId, + messageId: parsed.messageId, + reason: "compaction", + ...(parsed.requestId !== undefined ? { requestId: parsed.requestId } : {}), + }; + return msg; + } case "chat.error": { if (typeof parsed.message !== "string") return null; const conversationId = parsed.conversationId; @@ -111,15 +169,43 @@ export function parseServerMessage(data: string): WsServerMessage | null { conversationId !== undefined ? { type: "chat.error", conversationId, message: parsed.message } : { type: "chat.error", message: parsed.message }; - return msg; + if (parsed.requestId !== undefined && typeof parsed.requestId !== "string") return null; + return { + ...msg, + ...(typeof parsed.requestId === "string" ? { requestId: parsed.requestId } : {}), + }; } case "conversation.open": { if (typeof parsed.conversationId !== "string") return null; if (typeof parsed.workspaceId !== "string") return null; + // Forward the persisted per-conversation model when the backend includes + // it, so a client can stamp the opened tab with the conversation's ACTUAL + // model (fix-toolcall-duplicate-keys plan Part 4). + const model = parsed.model; + // The EFFECTIVE tab group parent, so a summoned subagent's tab lands in + // its summoner's group immediately. + const tabGroupParentId = parsed.tabGroupParentId; const msg: ConversationOpenMessage = { type: "conversation.open", conversationId: parsed.conversationId, workspaceId: parsed.workspaceId, + ...(typeof model === "string" && model.length > 0 ? { model } : {}), + ...(typeof tabGroupParentId === "string" && tabGroupParentId.length > 0 + ? { tabGroupParentId } + : {}), + }; + return msg; + } + case "conversation.tabGroupChanged": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.workspaceId !== "string") return null; + const parent = parsed.tabGroupParentId; + if (parent !== null && (typeof parent !== "string" || parent.length === 0)) return null; + const msg: ConversationTabGroupChangedMessage = { + type: "conversation.tabGroupChanged", + conversationId: parsed.conversationId, + workspaceId: parsed.workspaceId, + tabGroupParentId: parent, }; return msg; } @@ -148,7 +234,10 @@ export function parseServerMessage(data: string): WsServerMessage | null { if (typeof parsed.newConversationId !== "string") return null; if (typeof parsed.messagesSummarized !== "number") return null; if (typeof parsed.messagesKept !== "number") return null; + if (!Number.isSafeInteger(parsed.historyEpoch) || (parsed.historyEpoch as number) < 0) + return null; const msg: ConversationCompactedMessage = { + historyEpoch: parsed.historyEpoch as number, type: "conversation.compacted", conversationId: parsed.conversationId, newConversationId: parsed.newConversationId, @@ -157,6 +246,63 @@ export function parseServerMessage(data: string): WsServerMessage | null { }; return msg; } + case "extensions-changed": { + // Plugin-manager broadcast (plan 05): the payload is intentionally + // minimal — clients refetch GET /plugins + the (server re-pushed) + // surface catalog rather than diffing here. + const action = parsed.action; + if (action !== "load" && action !== "unload" && action !== "reload") { + if (action !== "scan" && action !== "forget") return null; + } + const pluginId = parsed.pluginId; + const msg: ExtensionsChangedMessage = { + type: "extensions-changed", + action, + ...(pluginId !== undefined && typeof pluginId === "string" ? { pluginId } : {}), + }; + return msg; + } + case "layout": { + // Layout-provider broadcast (plan 02 §2.5): the FULL tree for ONE + // workspace (decision 7 — no patch protocol). Light shape checks here + // (the tree's structural validation is the backend's write-time job); + // the store filters by its active workspace. + if (typeof parsed.workspaceId !== "string") return null; + const tree = parsed.tree; + if ( + tree === null || + typeof tree !== "object" || + Array.isArray(tree) || + (tree as { version?: unknown }).version !== 1 || + typeof (tree as { root?: unknown }).root !== "object" || + (tree as { root?: unknown }).root === null + ) { + return null; + } + const msg: LayoutChangedMessage = { + type: "layout", + workspaceId: parsed.workspaceId, + tree: tree as LayoutChangedMessage["tree"], + }; + return msg; + } + case "tile-layout": { + // Home tile-layout broadcast (the synced home grid): the FULL document + // for ONE device orientation. Light shape checks here (the document's + // structural validation is the backend's write-time job); the store + // filters by its active geometry mode. + if (parsed.mode !== "wide" && parsed.mode !== "tall") return null; + const data = parsed.data; + if (data === null || !isRecord(data) || data.version !== 2 || !Array.isArray(data.tiles)) { + return null; + } + const msg: TileLayoutChangedMessage = { + type: "tile-layout", + mode: parsed.mode, + data: data as unknown as TileLayoutChangedMessage["data"], + }; + return msg; + } default: return null; } diff --git a/src/app.css b/src/app.css index f5b269c..3c7b17c 100644 --- a/src/app.css +++ b/src/app.css @@ -12,6 +12,237 @@ themes: monokai --default; } +/* Inset "well" — the Artix wiki's code blocks (darkstrap `pre`): a panel that + reads as pressed INTO the page — the page colour darkened, a dark inner + shadow under the top edge, a faint light catch-line under the bottom edge. + Shared by the chat's thinking/tool blocks (LazyCollapse) and the sidebar + views (TilePanels). darkstrap: background rgba(0,0,0,.3); box-shadow + rgba(255,255,255,.1) 0 1px 0, rgba(0,0,0,.8) 0 1px 7px 0 inset; radius 4px. + Ported onto the active daisyUI theme so it always matches, and toned down a + step: the fill is the theme's page colour (base-100 — both the chat and the + sidebar sit on it) darkened 18% (darkstrap: 30%), computed OPAQUE and exposed + as --inset-well-bg so sticky children can paint the same colour; the inner + shadow is 0.6 (darkstrap: 0.8); the catch-line is the theme's text colour at + 10% instead of plain white. In @layer components so utilities can override. */ +@layer components { + .inset-well { + --inset-well-bg: color-mix(in srgb, var(--color-base-100) 82%, black); + background-color: var(--inset-well-bg); + border: 0; + border-radius: var(--radius-box); + box-shadow: + 0 1px 0 color-mix(in srgb, var(--color-base-content) 10%, transparent), + inset 0 1px 7px 0 rgb(0 0 0 / 0.6); + } +} + +/* App navbar — the Artix wiki's navbar (darkstrap `.navbar-inner` on a + fixed-top bar): a top-to-bottom gradient (#444 → #222), a soft drop shadow + under the bar (0 1px 10px), and engraved text (1px black text-shadow). + Shared by the workspace top bar (App) and the home page header. Ported onto + the active daisyUI theme so it always matches. darkstrap's bar is LIGHTER + than its near-black page (that's what makes the shadow read), so the + gradient is shifted to keep that relationship on our lighter page: it runs + from the page colour (base-100) lifted 20% toward the text colour down to + base-100 lifted 3%. No bottom border (darkstrap has a 1px #030303 one — + removed by user request; the gradient + shadow carry the edge). The shadow + is 0.5 (darkstrap: 0.1 — invisible on our lighter page). Positioned + + z-indexed so the shadow paints over the content below. */ +@layer components { + .app-navbar { + position: relative; + z-index: 10; + flex-shrink: 0; + background-image: linear-gradient( + to bottom, + color-mix(in srgb, var(--color-base-100) 80%, var(--color-base-content)), + color-mix(in srgb, var(--color-base-100) 97%, var(--color-base-content)) + ); + box-shadow: 0 1px 10px rgb(0 0 0 / 0.5); + } + /* Engraved navbar text — titles only; on tiny faint labels it muddies. */ + .app-navbar-engraved { + text-shadow: 0 1px 0 black; + } +} + +/* Navbar buttons — the navbar's (`.app-navbar`) raised button, in the + same depth language as the rest of the UI: at rest they're RAISED (a + gradient a step lighter than the bar, a light highlight inside the top edge, + a thin dark edge + small drop below, light primary-blue text), and when + PRESSED they flip to the inset well (dark inner shadow) — pushed into the + bar. `.navbar-btn-primary` is the main action: the same button, tinted with + the theme's primary colour. `.navbar-btn-icon` is the square icon-only form. + Self-contained (not DaisyUI .btn) so nothing fights the gradients; colours + all come from the theme. */ +@layer components { + .navbar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2rem; + padding-inline: 0.75rem; + border-radius: var(--radius-field); + font-size: 0.875rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; + cursor: pointer; + user-select: none; + /* The theme's primary, lifted toward white: crisp on the grey gradient + (plain primary read dim, and an engraved text-shadow muddied it at this + size — the shadow stays on the big titles only). */ + color: color-mix(in srgb, var(--color-primary) 75%, white); + border: 1px solid rgb(0 0 0 / 0.55); + background-image: linear-gradient( + to bottom, + color-mix(in srgb, var(--color-base-100) 72%, var(--color-base-content)), + color-mix(in srgb, var(--color-base-100) 90%, var(--color-base-content)) + ); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.12), + 0 1px 2px rgb(0 0 0 / 0.45); + transition: + filter 120ms ease-out, + box-shadow 120ms ease-out; + } + .navbar-btn:hover { + filter: brightness(1.12); + } + .navbar-btn:active, + .navbar-btn[aria-expanded="true"] { + /* Pressed → the inset well: pushed into the bar. */ + filter: none; + background-image: none; + background-color: color-mix(in srgb, var(--color-base-100) 82%, black); + box-shadow: + inset 0 1px 5px rgb(0 0 0 / 0.7), + 0 1px 0 color-mix(in srgb, var(--color-base-content) 10%, transparent); + } + .navbar-btn:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } + .navbar-btn-icon { + width: 2rem; + padding-inline: 0; + } + /* Secondary navbar actions: visually just text — no background, border or + shadow. The light primary-blue colour (same as the buttons' text) and the + spacing mark them as clickable; hover brightens + underlines, press dims. */ + .navbar-link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2rem; + min-width: 2rem; /* icon-only (compact) links keep a comfortable tap target */ + padding-inline: 0.375rem; + border-radius: var(--radius-field); + font-size: 0.875rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; + cursor: pointer; + user-select: none; + background: none; + border: 0; + color: color-mix(in srgb, var(--color-primary) 75%, white); + text-underline-offset: 4px; + transition: color 120ms ease-out; + } + .navbar-link:hover { + color: color-mix(in srgb, var(--color-primary) 45%, white); + text-decoration: underline; + } + .navbar-link:active { + color: var(--color-primary); + } + .navbar-link:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } + .navbar-btn-primary { + color: var(--color-primary-content); + background-image: linear-gradient( + to bottom, + color-mix(in srgb, var(--color-primary) 82%, white), + color-mix(in srgb, var(--color-primary) 88%, black) + ); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.35), + 0 1px 2px rgb(0 0 0 / 0.45); + } + .navbar-btn-primary:active { + background-color: color-mix(in srgb, var(--color-primary) 80%, black); + } +} + +/* Raised button — the exact mirror of `.inset-well`, for a neutral surface + that pops OUT where the wells sink in (the sidebar's "+ Add view" under the + view wells). The well = page colour darkened 18%, dark shadow INSIDE under + the top edge (inset 0 1px 7px), light catch-line OUTSIDE below (0 1px 0). + Mirrored: page colour LIFTED toward the text colour, light highlight INSIDE + the top edge (inset 0 1px 0), the dark shadow OUTSIDE below (0 1px 7px), + plus a faint top-lit gradient. Pressed → it becomes the well (pushed in). */ +@layer components { + .raised-btn { + display: flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: var(--radius-box); + cursor: pointer; + color: color-mix(in srgb, var(--color-primary) 75%, white); + background-color: color-mix(in srgb, var(--color-base-100) 90%, var(--color-base-content)); + background-image: linear-gradient(to bottom, rgb(255 255 255 / 0.06), rgb(0 0 0 / 0.1)); + box-shadow: + inset 0 1px 0 color-mix(in srgb, var(--color-base-content) 18%, transparent), + 0 1px 7px 0 rgb(0 0 0 / 0.6); + transition: + filter 120ms ease-out, + box-shadow 120ms ease-out; + } + .raised-btn:hover { + filter: brightness(1.12); + } + .raised-btn:active { + filter: none; + background-image: none; + background-color: color-mix(in srgb, var(--color-base-100) 82%, black); + box-shadow: + 0 1px 0 color-mix(in srgb, var(--color-base-content) 10%, transparent), + inset 0 1px 7px 0 rgb(0 0 0 / 0.6); + } + .raised-btn:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } +} + +/* Raised — the INVERSE of `.inset-well`, for things that pop OUT of the page + (the home page's workspace tiles). Where the well has a dark shadow INSIDE + under its top edge and a light catch-line OUTSIDE below it, a raised surface + has a light highlight INSIDE along its top edge, a soft shadow OUTSIDE below + it, and a faint top-lit gradient over its own colour (lighter top → darker + bottom — convex, where the well is concave). Built on Tailwind's shadow + variables (not a bare box-shadow) so it COMPOSES with the ring / shadow + utilities: the inner highlight rides --tw-inset-shadow (kept by + `hover:shadow-xl`, rings, drag), the drop rides --tw-shadow (which + `hover:shadow-xl` / `shadow-2xl` swap for a bigger lift). Works on any + background colour (the tiles' tone colours), so it always matches the theme. */ +@layer components { + .raised { + --tw-inset-shadow: inset 0 1px 0 rgb(255 255 255 / 0.45), inset 0 -1px 0 rgb(0 0 0 / 0.25); + --tw-shadow: 0 2px 9px 0 rgb(0 0 0 / 0.7); + box-shadow: + var(--tw-inset-shadow), var(--tw-inset-ring-shadow, 0 0 #0000), + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + background-image: linear-gradient(to bottom, rgb(255 255 255 / 0.14), rgb(0 0 0 / 0.14)); + } +} + /* Rendered-Markdown (assistant messages) typography — scoped to .markdown-body so it never leaks into the rest of the app. */ .markdown-body { @@ -127,3 +358,28 @@ body, body { overflow: hidden; } + +/* App-shell height. NOT `h-screen` (100vh): on iOS/iPadOS WebKit (Safari, + Vivaldi, …) 100vh is the viewport with the browser toolbars RETRACTED, so + while they're showing the bottom of the app sits underneath them (the + sidebar's last item / the composer looked flush against the bottom edge). + 100dvh tracks the visible viewport as the toolbars show/hide; 100vh stays as + the fallback for engines without dvh. */ +.h-app { + height: 100vh; +} +@supports (height: 100dvh) { + .h-app { + height: 100dvh; + } +} + +/* Transcript rows: skip layout + paint for rows scrolled out of view. A long + turn has hundreds of rows; without this every one is laid out on first + paint (and on every resize). `contain-intrinsic-size: auto <h>` makes the + browser remember each row's last rendered height, so scrolling back up + doesn't jump; the 64px is only the estimate for never-rendered rows. */ +.chat-row { + content-visibility: auto; + contain-intrinsic-size: auto 64px; +} diff --git a/src/app/App.svelte b/src/app/App.svelte index 7e5ac7d..d557149 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,847 +1,222 @@ -<script lang="ts"> - import type { ImageInput, ReasoningEffort } from "@dispatch/transport-contract"; - import type { InvokeMessage } from "@dispatch/ui-contract"; - import { tick } from "svelte"; - import Table from "../components/Table.svelte"; - import { - CacheWarmingView, - manifest as cacheWarmingManifest, - type WarmFeedback, - } from "../features/cache-warming"; - import { - ChatView, - CompactionView, - Composer, - manifest as chatManifest, - ModelSelector, - ReasoningEffortSelector, - type CompactNowResult, - type ComposerStatus, - type ReasoningEffortSaveResult, - type SaveCompactPercentResult, - } 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, - } from "../features/settings"; - import { - createSmartScrollController, - manifest as smartScrollManifest, - ScrollToBottom, - } from "../features/smart-scroll"; - import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host"; - 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, TabList } from "../features/tabs"; - import { manifest as viewsManifest, ViewSidebar } from "../features/views"; - import { - CwdField, - type CwdSaveResult, - LspStatusView, - type LspStatusResult, - 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, 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 - // and keep it out of the generic Extensions surface list — SurfaceView itself - // stays fully generic (it never switches on a surface id). - const CACHE_WARMING_ID = "cache-warming"; - // The message-queue extension's per-conversation surface (steering). Pulled - // out of the generic Extensions list and rendered as a compact panel above the - // composer — pending steering messages are tied to the chat, not the sidebar. - const MESSAGE_QUEUE_ID = "message-queue"; - // The `todo` extension's per-conversation task list surface (model-maintained). - const TODO_ID = "todo"; - - // 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: 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), - }); - const sidebarPanels = sidebarStore.load() ?? DEFAULT_VIEWS; - - function handleSidebarChange(kinds: readonly (string | null)[]): void { - sidebarStore.save(kinds.filter((k): k is string => k !== null)); - } - - // Frontend module list for the "Loaded Modules" view, AGGREGATED from each - // feature's public `manifest` export so it can't drift from what's actually - // composed. (The backend's "Loaded Extensions" surface is a SEPARATE, - // backend-owned list.) FE features are internal units of this single repo, so - // there is no per-module version — they all share dispatch-web's version. - const MODULE_COLUMNS = ["Module", "Description"] as const; - const loadedModules: readonly (readonly [string, string])[] = [ - chatManifest, - tabsManifest, - surfaceHostManifest, - viewsManifest, - conversationCacheManifest, - markdownManifest, - cacheWarmingManifest, - 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, - // unless the reader has scrolled up (then show a "scroll to bottom" button). - // One controller owns the chat scroll region; effects below feed it the edges. - const smartScroll = createSmartScrollController(); - let transcriptEl = $state<HTMLElement | undefined>(); - let transcriptContentEl = $state<HTMLElement | undefined>(); - - // Chat-limit unload gate: old chunks may be unloaded only while the reader is - // stuck to the bottom. While stuck, a trim removes content far ABOVE the - // viewport and the controller re-pins to the bottom — no visible jump; while - // reading history, trimming is deferred instead of yanking the page (the old - // Dispatch bug). In an $effect so a swapped store prop would be re-wired. - $effect(() => { - store.attachUnloadGate(() => smartScroll.isAtBottom()); - }); - - // "Show earlier messages": page older history back in, preserving the reader's - // viewport position — prepended content grows scrollHeight, so shift scrollTop - // by the growth (the manual analogue of CSS scroll anchoring, which not every - // engine applies here). - async function handleShowEarlier(): Promise<void> { - const el = transcriptEl; - const prevHeight = el?.scrollHeight ?? 0; - const prevTop = el?.scrollTop ?? 0; - await store.activeChat.showEarlier(); - await tick(); - if (el) { - const delta = el.scrollHeight - prevHeight; - if (delta > 0) el.scrollTop = prevTop + delta; - } - } - - // Attach/detach the controller to the live scroll element + content (disposed on - // unmount). The content element is observed (ResizeObserver) so the view follows - // height changes that aren't a transcript append. - $effect(() => { - if (!transcriptEl) return; - return smartScroll.attach(transcriptEl, transcriptContentEl); - }); - - // New transcript content streamed in (or messages loaded) → follow the bottom - // while stuck. Reads `chunks.length` so the effect re-runs on every append. - $effect(() => { - void store.activeChat.chunks.length; - smartScroll.contentChanged(); - }); - - // The message-queue surface spec + whether it currently has pending messages - // (steering). Rendered as a compact panel above the composer only when non-empty. - const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID)); - const hasQueuedMessages = $derived.by(() => { - const spec = messageQueueSpec; - if (spec === null) return false; - const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID); - if (field === undefined || field.kind !== "custom") return false; - const data = parseMessageQueuePayload(field.payload); - return data !== null && data.messages.length > 0; - }); - - // The todo surface spec + its parsed task list (model-maintained, read-only). - const todoSpec = $derived(store.surface(TODO_ID)); - const todoData = $derived.by(() => { - const spec = todoSpec; - if (spec === null) return null; - const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === TODO_ID); - if (field === undefined || field.kind !== "custom") return null; - 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; - smartScroll.reset(); - }); - - // Right sidebar: persisted open/closed state. Defaults to open on wide - // screens (first visit), then remembers the user's toggle thereafter. - const WIDE_BREAKPOINT = 1024; // Tailwind `lg` - const sidebarOpenStore = createLocalStore<boolean>("dispatch.sidebar.open", { - storage: untrack(() => store.storage), - }); - 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); - }); - - function handleInvoke(msg: InvokeMessage) { - store.invoke(msg.surfaceId, msg.actionId, msg.payload); - } - - function handleSend(text: string, images?: readonly ImageInput[]): void { - store.send(text, images); - } - - function handleQueue(text: string) { - store.queueMessage(text); - } - - function handleStop() { - store.stopGeneration(); - } - - function handleSelectModel(model: string) { - store.selectModel(model); - } - - // Adapt the store's WarmResult to the cache-warming feature's WarmNow port. - async function warmNow(): Promise<WarmFeedback | null> { - const result = await store.warmNow(); - if (result === null) return null; - return result.ok - ? { - ok: true, - cachePct: result.response.cachePct, - expectedCacheRate: result.response.expectedCacheRate, - } - : { 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); - if (result === null) return null; - return result.ok - ? { ok: true, reasoningEffort: result.reasoningEffort } - : { ok: false, error: result.error }; - } - - // Adapt the store's compact result to the compaction view's port. - async function compactNow(): Promise<CompactNowResult | null> { - const result = await store.compactNow(); - if (result === null) return null; - return result.ok - ? { - ok: true, - messagesSummarized: result.response.messagesSummarized, - messagesKept: result.response.messagesKept, - } - : { ok: false, error: result.error }; - } - - async function saveCompactPercent( - percent: number, - ): Promise<SaveCompactPercentResult | null> { - const result = await store.setCompactPercent(percent); - if (result === null) return null; - return result.ok - ? { ok: true, percent: result.percent } - : { ok: false, error: result.error }; - } +<script module lang="ts"> + import { registerBuiltinViews } from "./register-views"; - // 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 - // anchoring), exactly like `handleShowEarlier`. - async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> { - const el = transcriptEl; - const prevHeight = el?.scrollHeight ?? 0; - const prevTop = el?.scrollTop ?? 0; - const result = await store.setChatLimit(value); - await tick(); - if (el) { - const delta = el.scrollHeight - prevHeight; - if (delta > 0) el.scrollTop = prevTop + delta; - } - return result.ok - ? { ok: true, chatLimit: result.chatLimit } - : { ok: false, error: result.error }; - } - - // 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; - return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error }; - } - - async function loadLspStatus(): Promise<LspStatusResult | null> { - const result = await store.lspStatus(); - 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 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); + // Register every built-in view ONCE per app load, BEFORE any tile renders. + // The composition root stays the only place store ports are wired: the + // wrappers receive them through `ports` (see `view-ports.ts`). + registerBuiltinViews(); </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. --> - <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]"> - <!-- 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)" - > - build: {__APP_VERSION__} - </span> - <button - class="btn btn-square btn-ghost btn-sm mx-1 shrink-0" - aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"} - aria-expanded={sidebarOpen} - onclick={() => (sidebarOpen = !sidebarOpen)} - > - <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" - > - {#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> - - {#if store.lastError} - <div role="alert" class="alert alert-error mx-4 mt-2"> - <strong>Error:</strong> - {store.lastError.message} - </div> - {/if} - - {#if store.activeChat.error} - <div role="alert" class="alert alert-warning mx-4 mt-2"> - <strong>Chat error:</strong> - {store.activeChat.error} - </div> - {/if} - - <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} - <ChatView - chunks={store.activeChat.chunks} - turnMetrics={store.activeChat.turnMetrics} - hasEarlier={store.activeChat.hasEarlier} - onShowEarlier={handleShowEarlier} - thinkingKeyBase={store.activeChat.thinkingKeyBase} - providerRetry={store.activeChat.providerRetry} - apiBaseUrl={store.httpBase} - /> - {/key} - </div> - </div> - {#if store.activeChat.chunks.length === 0} - <div - class="pointer-events-none absolute inset-0 flex items-center justify-center" - aria-hidden="true" - > - <span class="select-none text-4xl font-bold opacity-10">Dispatch</span> - </div> - {/if} - <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} /> - </div> - - {#if hasQueuedMessages && messageQueueSpec !== null} - <!-- Pending steering messages (the message-queue surface). Rendered via - 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} /> - </div> - {/if} - - <Composer - onSend={handleSend} - onQueue={handleQueue} - onStop={handleStop} - contextSize={store.activeChat.currentContextSize} - contextWindow={store.modelInfo[store.activeModel]?.contextWindow} - status={composerStatus} - /> - </div> - - <!-- Full-height right sidebar. On wide screens (`lg:relative`) it is in-flow, so - opening it shrinks the whole left column (push). Below `lg` it overlays - (`max-lg:absolute`, full height) with a backdrop. --> - <aside - class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative" - class:w-80={sidebarOpen} - class:w-0={!sidebarOpen} - > - <div - 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} /> - </div> - </aside> +<script lang="ts"> + import type { LayoutTree } from "@dispatch/layout-contract"; + import { untrack } from "svelte"; + import { createCollapseStore, firstCollapsibleNodeId, TileRoot } from "../features/layout"; + import { RunModal, type HeartbeatRunView } from "../features/heartbeat"; + import { SystemPromptBuilder } from "../features/system-prompt"; + import type { AppStore } from "./store.svelte"; + import type { AppViewPorts, ChatViewportBridge } from "./view-ports"; + import ErrorModal from "./ErrorModal.svelte"; + + let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props(); + + // Modal chrome — app shell state, OUTSIDE the tree (layout-00 decision 2). + 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); + + // --- The layout tree (plan 02): the ACTIVE workspace's tile tree, served + // by the backend's layout provider plugin when one is active (probed on + // boot, on workspace switch, and on extensions-changed; `layout` WS pushes + // re-render live) and the built-in DEFAULT_LAYOUT otherwise (no-provider + // mode: edits are ephemeral, logged once, no error UI). The store owns the + // whole lifecycle; the shell just renders `tree` and forwards edits. + // `dispatch.sidebar.views` is RETIRED (D-L4): its only reader is the store's + // one-way §2.7 migration. + const tree = $derived(store.layout.tree); + + function handleTreeChange(next: LayoutTree): void { + store.layout.onTreeChange(next); + } + + // --- Sidebar collapse: renderer chrome state (per-node-id open/closed), + // persisted with the SAME key/semantics as today (`dispatch.sidebar.open`, a + // bare boolean, default open on wide screens). The toggle drives the tree's + // single collapsible node ("side" on the default tree; generically the first + // collapsible split child). + const collapse = createCollapseStore({ + storage: untrack(() => store.storage), + viewportWidth: typeof window !== "undefined" ? window.innerWidth : undefined, + }); + const collapseState = $derived($collapse); + const collapseKey = $derived(firstCollapsibleNodeId(tree)); + const sidebarOpen = $derived( + collapseKey !== null ? (collapseState.openByNode[collapseKey] ?? collapseState.defaultOpen) : false, + ); + + function toggleSidebar(): void { + if (collapseKey !== null) collapse.toggle(collapseKey); + } + + // --- The port bag every view wrapper receives. This is the ONE place the + // store's ports are wired (AGENTS.md: composition root). `store` is read + // through a getter so a swapped store prop stays reactive through it. + const chatViewport: ChatViewportBridge = { el: null }; + const viewPorts: AppViewPorts = { + get store() { + return store; + }, + chatViewport, + openSystemPromptBuilder: () => (systemPromptModalOpen = true), + openHeartbeatRun: (run) => (heartbeatRun = run), + }; + + // 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; + }); + + function handleInvokeHome(): void { + onNavigate("/"); + } +</script> - <!-- Backdrop: only on narrow screens (overlay mode), click to close. --> - {#if sidebarOpen} - <!-- svelte-ignore a11y_no_static_element_interactions --> - <div - class="fixed inset-0 z-20 bg-black/30 lg:hidden" - role="button" - tabindex="0" - aria-label="Close sidebar" - onclick={() => (sidebarOpen = false)} - onkeydown={(e) => { - if (e.key === "Escape" || e.key === "Enter") sidebarOpen = false; - }} - ></div> - {/if} +<main class="h-app relative flex flex-col overflow-hidden"> + <!-- 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. CHROME — stays outside the tree (layout-00 decision 2). + `.app-navbar` (app.css): the Artix wiki's darkstrap navbar look. --> + <div class="app-navbar flex items-center justify-between gap-2 px-2 pt-[calc(0.5rem+5px)] pb-2"> + <span + class="app-navbar-engraved min-w-0 flex-1 shrink truncate pl-2 text-sm font-semibold text-primary" + data-testid="top-bar-title" + title={topBarTitle} + aria-label="Active conversation title" + > + {topBarTitle} + </span> + <span + class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-primary/70" + title="Build version (git short hash)" + > + build: {__APP_VERSION__} + </span> + <a + href="/" + class="btn btn-ghost btn-sm shrink-0 px-2 text-primary" + aria-label="Back to dashboard" + title="Back to dashboard" + onclick={(e) => { + e.preventDefault(); + handleInvokeHome(); + }} + > + <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> + {#if collapseKey !== null} + <button + class="btn btn-square btn-ghost btn-sm mx-1 shrink-0 text-primary" + aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"} + aria-expanded={sidebarOpen} + onclick={toggleSidebar} + > + <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" + > + {#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> + {/if} + </div> + + {#if store.lastError} + <div role="alert" class="alert alert-error mx-4 mt-2"> + <strong>Error:</strong> + {store.lastError.message} + </div> + {/if} + + {#if store.activeChat.error} + <div role="alert" class="alert alert-warning mx-4 mt-2"> + <strong>Chat error:</strong> + {store.activeChat.error} + </div> + {/if} + + <!-- THE TREE: the tile renderer owns the content region. Tile edits are + applied here and persisted to dispatch.sidebar.views (above). --> + <div class="min-h-0 min-w-0 flex-1"> + <TileRoot {tree} {collapse} ports={viewPorts} onTreeChange={handleTreeChange} /> + </div> </main> {#if store.fatalError} - <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} /> + <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} /> {/if} {#if systemPromptModalOpen} - <SystemPromptBuilder - loadPrompt={loadSystemPromptPrompt} - savePrompt={saveSystemPromptPrompt} - loadVariables={loadSystemPromptVariablesPrompt} - onClose={() => (systemPromptModalOpen = false)} - /> + <SystemPromptBuilder + loadPrompt={() => store.loadSystemPrompt()} + savePrompt={(template) => store.setSystemPrompt(template)} + loadVariables={() => store.loadSystemPromptVariables()} + 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} + <!-- 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={(conversationId) => store.watchConversation(conversationId)} + closeChat={(conversationId) => store.unwatchConversation(conversationId)} + stopRun={(runId) => store.stopHeartbeatRun(runId)} + onClose={() => (heartbeatRun = null)} + apiBaseUrl={store.httpBase} + /> + {/key} {/if} - -{#snippet viewContent(kind: string)} - {#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} - 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} /> - <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"} - <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. --> - {#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> - <Table columns={MODULE_COLUMNS} rows={loadedModules} /> - </section> - <section class="mt-4 flex flex-col gap-3"> - <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3> - {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)} - <SurfaceView {spec} onInvoke={handleInvoke} /> - {/each} - </section> - {:else if kind === "cache-warming"} - <!-- Re-mount per conversation (like ChatView) so the view's local warming - history / manual-warm feedback can't bleed across tabs. --> - {#key store.activeConversationId} - <CacheWarmingView - spec={store.surface(CACHE_WARMING_ID)} - canWarm={store.activeConversationId !== null} - onInvoke={handleInvoke} - {warmNow} - /> - {/key} - {:else if kind === "tasks"} - <!-- 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} - <TodoList payload={todoData} /> - {/key} - {:else if kind === "compaction"} - <!-- 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} - canCompact={store.activeConversationId !== null} - {compactNow} - 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 dcdcb9b..ebc8004 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -1,9 +1,12 @@ +import type { LayoutTree } from "@dispatch/layout-contract"; +import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; -import { render, screen } from "@testing-library/svelte"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { WebSocketLike } from "../adapters/ws"; +import { overlaySidePanels } from "../features/layout"; import App from "./App.svelte"; import { createAppStore } from "./store.svelte"; @@ -57,11 +60,17 @@ function fakeSocket(): FakeSocket { 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"] }), { + if (/\/models(\?.*)?$/.test(url)) { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { status: 200, }); } + if (url.endsWith("/layout")) { + // No layout provider in these tests: the plan-02 no-provider signal. + return new Response(JSON.stringify({ error: "No layout provider is active" }), { + status: 404, + }); + } if (url.includes("/conversations?status=")) { return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); } @@ -73,7 +82,60 @@ function fakeFetchImpl(): typeof fetch { status: 200, }); } - return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + if (url.endsWith("/settings/vision")) { + return new Response(JSON.stringify({ imageLimit: 10, compactionModel: null }), { + status: 200, + }); + } + if (/\/plugins\/[^/]+\/frontend/.test(url)) { + // plan 03 frontend module (no fixtures declare one) — the plain 404 + // the route returns for unknown/unloaded/frontend-less plugins. + return new Response(JSON.stringify({ error: "No frontend module for this plugin" }), { + status: 404, + }); + } + if (/\/plugins(\?.*)?$/.test(url)) { + // Valid EMPTY list (plan 03): the plugin host's boot + // `extensions-changed`/workspace syncs read this — a shapeless + // fallback body would be cast to PluginListResponse and crash the + // diff (`list.loaded` not iterable). + return new Response( + JSON.stringify({ loaded: [], disabledWithReason: [], discovered: [], missing: [] }), + { status: 200 }, + ); + } + return new Response(JSON.stringify({ chunks: [], historyEpoch: 0, latestSeq: 0 }), { + status: 200, + }); + }; +} + +/** + * A fetch whose layout provider is ACTIVE and serves the given sidebar + * panels (plan 02): `GET /workspaces/:id/layout` returns DEFAULT_LAYOUT with + * the views overlaid; `PUT` echoes the tree (200). Tests that need specific + * sidebar views visible drive the layout through THIS edge — the pre-seeded + * `dispatch.sidebar.views` localStorage is only read by the one-way §2.7 + * migration now. + */ +function fakeFetchWithLayout(views: readonly string[]): typeof fetch { + const inner = fakeFetchImpl(); + return 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("/layout")) { + const method = init?.method ?? "GET"; + if (method === "PUT" && typeof init?.body === "string") { + const body = JSON.parse(init.body) as { tree: LayoutTree }; + return new Response(JSON.stringify({ tree: body.tree }), { status: 200 }); + } + if (method === "GET") { + const tree = overlaySidePanels(DEFAULT_LAYOUT, views); + return new Response(JSON.stringify({ tree }), { status: 200 }); + } + const tree = overlaySidePanels(DEFAULT_LAYOUT, views); + return new Response(JSON.stringify({ tree }), { status: 200 }); + } + return inner(input, init); }; } @@ -118,6 +180,119 @@ function activeConversationId(store: ReturnType<typeof createAppStore>): string } describe("App component interaction tests", () => { + it.each([ + "all", + "parent", + "cancel", + ] as const)("group close %s flows through the real store, persistence and HTTP close port", async (choice) => { + const ws = fakeSocket(); + const storage = createFakeStorage(); + const closed: string[] = []; + const base = fakeFetchWithLayout(["tabs"]); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes("/conversations?status=")) { + return new Response( + JSON.stringify({ + conversations: [ + { id: "p", workspaceId: "default" }, + { id: "c1", workspaceId: "default", tabGroupParentId: "p" }, + { id: "c2", workspaceId: "default", tabGroupParentId: "p" }, + { id: "g", workspaceId: "default", tabGroupParentId: "c1" }, + { id: "solo", workspaceId: "default" }, + { id: "other-workspace", workspaceId: "other", tabGroupParentId: "p" }, + ].map((conversation) => ({ + ...conversation, + title: conversation.id, + status: "idle", + lastActivityAt: 0, + })), + }), + { status: 200 }, + ); + } + if (url.endsWith("/close")) { + expect(init?.method).toBe("POST"); + const id = url.split("/").at(-2) as string; + closed.push(id); + return new Response(JSON.stringify({ conversationId: id, abortedTurn: false }), { + status: 200, + }); + } + return base(input, init); + }, + localStorage: storage, + }); + try { + ws.resolveOpen(); + render(App, { props: { store, onNavigate: vi.fn() } }); + await screen.findByRole("tablist"); + for (const [id, parent] of [ + ["p", null], + ["c1", "p"], + ["c2", "p"], + ["g", "c1"], + ["solo", null], + ] as const) { + ws.feedServerMessage({ + type: "conversation.open", + conversationId: id, + workspaceId: "default", + ...(parent === null ? {} : { tabGroupParentId: parent }), + }); + } + ws.feedServerMessage({ + type: "conversation.open", + conversationId: "other-workspace", + workspaceId: "other", + tabGroupParentId: "p", + }); + await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(5)); + store.selectTab("p"); + const parent = await waitFor(() => { + const tab = document.querySelector<HTMLElement>('[role="tab"][data-conversation-id="p"]'); + expect(tab).not.toBeNull(); + return tab; + }); + if (parent === null) throw new Error("Parent tab missing"); + await fireEvent.click(within(parent).getByRole("button", { name: "Close tab" })); + const dialog = screen.getByRole("dialog", { name: "Close subagents too?" }); + expect(dialog).toHaveTextContent("3 subagent tabs"); + expect(closed).toEqual([]); + await fireEvent.click( + within(dialog).getByRole("button", { + name: + choice === "all" + ? /Yes, close all 4/ + : choice === "parent" + ? /No, keep them open/ + : "Cancel", + }), + ); + const expected = choice === "all" ? ["p", "c1", "c2", "g"] : choice === "parent" ? ["p"] : []; + expect(closed).toEqual(expected); + expect(store.tabs.map((tab) => tab.conversationId)).toEqual( + ["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)), + ); + expect(screen.queryByRole("dialog")).toBeNull(); + if (choice !== "cancel") { + const persisted = JSON.parse(storage.getItem("dispatch.tabs") as string) as { + tabs: { conversationId: string }[]; + }; + expect(persisted.tabs.map((tab) => tab.conversationId)).toEqual([ + ...["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)), + "other-workspace", + ]); + expect(store.activeConversationId).not.toBe("p"); + } + } finally { + store.dispose(); + } + }); + it("renders the model selector and composer in draft mode", () => { const ws = fakeSocket(); const store = createAppStore({ @@ -231,7 +406,7 @@ describe("App component interaction tests", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl: fakeFetchWithLayout(["extensions"]), localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); @@ -295,7 +470,7 @@ describe("App component interaction tests", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl: fakeFetchWithLayout(["extensions"]), localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); @@ -372,12 +547,133 @@ describe("App component interaction tests", () => { store.dispose(); }); + it.each([ + "look at this", + "", + ])("queues staged images through the full UI with text %j", async (text) => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + chatFlushScheduler: null, + }); + ws.resolveOpen(); + const { container } = render(App, { props: { store, onNavigate: vi.fn() } }); + const user = userEvent.setup(); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "start"); + await user.click(screen.getByRole("button", { name: "Send" })); + const conversationId = activeConversationId(store); + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId, turnId: "t1" }, + }); + if (text) await user.type(textarea, text); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { + value: [new File(["GIF"], "shot.gif", { type: "image/gif" })], + }); + input.dispatchEvent(new Event("change", { bubbles: true })); + await screen.findByRole("button", { name: "Remove image" }); + ws.sent.length = 0; + + await user.click(screen.getByRole("button", { name: "Queue" })); + + expect(sentMessages(ws).filter((m: { type: string }) => m.type.startsWith("chat."))).toEqual([ + { + type: "chat.queue", + requestId: expect.any(String), + conversationId, + workspaceId: "default", + text, + images: [{ url: "data:image/gif;base64,R0lG", mimeType: "image/gif" }], + }, + ]); + expect(textarea).toHaveValue(""); + expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument(); + store.dispose(); + }); + + it("uses only the focused conversation's queue snapshot for the Flush label and POSTs stop", async () => { + const ws = fakeSocket(); + const fetchImpl = vi.fn(fakeFetchImpl()); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + chatFlushScheduler: null, + }); + ws.resolveOpen(); + render(App, { props: { store, onNavigate: vi.fn() } }); + const user = userEvent.setup(); + await user.type(screen.getByRole("textbox", { name: "Message input" }), "start"); + await user.click(screen.getByRole("button", { name: "Send" })); + const firstId = activeConversationId(store); + const startTurn = (conversationId: string) => + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId, turnId: `turn-${conversationId}` }, + }); + const pushQueue = (conversationId: string, payload: unknown) => + ws.feedSurfaceMessage({ + type: "surface", + conversationId, + spec: { + id: "message-queue", + region: "inline", + title: "Message queue", + fields: [{ kind: "custom", rendererId: "message-queue", payload }], + }, + }); + startTurn(firstId); + await screen.findByRole("button", { name: "Stop generation" }); + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "message-queue", region: "inline", title: "Message queue", scope: "conversation" }, + ], + }); + const pending = { messages: [{ id: "m1", text: "next", queuedAt: 1 }] }; + pushQueue(firstId, pending); + const flush = await screen.findByRole("button", { name: "Flush" }); + ws.sent.length = 0; + fetchImpl.mockClear(); + await user.click(flush); + expect(fetchImpl.mock.calls.filter(([, init]) => init?.method === "POST")).toEqual([ + [expect.stringContaining(`/conversations/${firstId}/stop`), { method: "POST" }], + ]); + expect(sentMessages(ws)).toEqual([]); // No new send, queue, or cancel operation. + + store.newDraft(); + await user.type(screen.getByRole("textbox", { name: "Message input" }), "second"); + await user.click(screen.getByRole("button", { name: "Send" })); + const secondId = activeConversationId(store); + expect(secondId).not.toBe(firstId); + startTurn(secondId); + await screen.findByRole("button", { name: "Stop generation" }); + pushQueue(firstId, pending); // Late old-scope update must not relabel the new conversation. + await vi.waitFor(() => + expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument(), + ); + pushQueue(secondId, pending); + await screen.findByRole("button", { name: "Flush" }); + pushQueue(secondId, { messages: [] }); + await screen.findByRole("button", { name: "Stop generation" }); + pushQueue(secondId, { messages: "malformed" }); + await vi.waitFor(() => + expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument(), + ); + 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(), + chatFlushScheduler: null, }); ws.resolveOpen(); @@ -415,7 +711,7 @@ describe("App component interaction tests", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl: fakeFetchWithLayout(["extensions"]), localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); @@ -454,19 +750,20 @@ describe("App component interaction tests", () => { store.dispose(); }); - it("the Extensions view lists frontend modules aggregated from feature manifests", () => { + it("the Extensions view lists frontend modules aggregated from feature manifests", async () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl: fakeFetchWithLayout(["extensions"]), 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(); + // The Extensions view arrives with the provider's tree (an async GET — + // plan 02), so await the modules table rendering. + expect(await screen.findByRole("columnheader", { name: "Module" })).toBeInTheDocument(); for (const name of [ "chat", "tabs", @@ -486,8 +783,8 @@ describe("App component interaction tests", () => { // 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"] }), { + if (/\/models(\?.*)?$/.test(url)) { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { status: 200, }); } @@ -502,7 +799,9 @@ describe("App component interaction tests", () => { status: 200, }); } - return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + return new Response(JSON.stringify({ chunks: [], historyEpoch: 0, latestSeq: 0 }), { + status: 200, + }); }; const ws = fakeSocket(); @@ -583,4 +882,22 @@ describe("App component interaction tests", () => { store.dispose(); }); + + it("the Vision view renders the vision settings UI (image limit + compaction model)", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchWithLayout(["vision"]), + localStorage: createFakeStorageWithViews(["vision"]), + }); + ws.resolveOpen(); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + // The vision settings load (`GET /settings/vision`) seeds the inputs. + expect(await screen.findByLabelText(/Image limit/)).toBeInTheDocument(); + expect(await screen.findByLabelText(/Compaction model/)).toBeInTheDocument(); + + store.dispose(); + }); }); diff --git a/src/app/chat-registry.svelte.ts b/src/app/chat-registry.svelte.ts new file mode 100644 index 0000000..63f58ad --- /dev/null +++ b/src/app/chat-registry.svelte.ts @@ -0,0 +1,297 @@ +import { untrack } from "svelte"; +import { createLocalStore } from "../adapters/local-storage"; +import type { SurfaceSocketHandle } from "../adapters/ws"; +import { normalizeChatLimit } from "../core/chunks"; +import type { ChatStore, HistorySync, MetricsSync } from "../features/chat"; +import { createChatStore, createPendingInputStorage } from "../features/chat"; +import type { ConversationCache } from "../features/conversation-cache"; +import type { TabsStore } from "../features/tabs"; +import type { ChatLimitResult, CreateAppStoreOptions } from "./store-types"; +import { liveConversationIds } from "./subscription-budget"; +import { randomId } from "./uuid"; + +/** App-owned tab/watch stores and idempotent transcript loading; no singleton state. */ +export function createChatRegistry(deps: { + storage: Storage | undefined; + tabsStore: TabsStore; + createCache: () => ConversationCache; + historySync: HistorySync; + metricsSync: MetricsSync; + defaultModel: string; + activeModel: () => string; + activeWorkspaceId: () => string; + send: SurfaceSocketHandle["send"]; + reportError: (context: string, err: unknown) => void; + chatFlushScheduler: CreateAppStoreOptions["chatFlushScheduler"]; +}) { + // 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: deps.storage, + }); + 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 = deps.createCache(); + + const chatStores = new Map<string, ChatStore>(); + // Includes in-flight loads: repeated activation must not start a second load. + const loadedConversationIds = new Set<string>(); + const loadingConversationIds = new Set<string>(); + + // 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>(); + const watchedIds = new Set<string>(); + const subscribedIds = new Set<string>(); + + function syncChatSubscriptions(): void { + const desired = liveConversationIds(deps.tabsStore.activeConversationId, watchedIds); + for (const id of subscribedIds) { + if (!desired.has(id)) { + deps.send({ type: "chat.unsubscribe", conversationId: id }); + subscribedIds.delete(id); + } + } + for (const id of desired) { + if (subscribedIds.has(id)) continue; + subscribedIds.add(id); + deps.send({ type: "chat.subscribe", conversationId: id }); + if (loadedConversationIds.has(id) && !loadingConversationIds.has(id)) { + (chatStores.get(id) ?? watchStores.get(id))?.resync(); + } + } + } + + /** + * Coalesce live chat events to at most one fold + render per animation + * frame (a late-join replay of a long turn arrives as a burst of WS frames; + * folding each separately re-derives the whole transcript per frame). A + * timeout backs up rAF, which browsers pause in hidden tabs — a backgrounded + * tab still applies its events (just less often). + */ + function scheduleChatFlush(flush: () => void): void { + let ran = false; + const run = (): void => { + if (ran) return; + ran = true; + flush(); + }; + if (typeof requestAnimationFrame === "function") requestAnimationFrame(run); + setTimeout(run, 250); + } + + const chatFlushScheduler = + deps.chatFlushScheduler === undefined ? scheduleChatFlush : deps.chatFlushScheduler; + + function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore { + return createChatStore({ + ...(deps.storage === undefined + ? {} + : { + pendingInputStorage: createPendingInputStorage(deps.storage, conversationId), + }), + conversationId, + createRequestId: randomId, + model, + workspaceId, + transport: { + send(msg) { + deps.send(msg); + }, + }, + historySync: deps.historySync, + metricsSync: deps.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()), + ...(chatFlushScheduler !== null ? { scheduleFlush: chatFlushScheduler } : {}), + onError: (context, err) => { + deps.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, + deps.defaultModel, + untrack(deps.activeWorkspaceId), + ); + let draftConversationId: string = initialDraftId; + + let activeChat = $state<ChatStore>(draftStore as ChatStore); + function getActiveChat(): ChatStore { + const activeId = deps.tabsStore.activeConversationId; + if (activeId === null) { + return draftStore; + } + return chatStores.get(activeId) ?? draftStore; + } + + function ensureLoaded(conversationId: string): void { + if (loadedConversationIds.has(conversationId)) return; + const store = chatStores.get(conversationId) ?? watchStores.get(conversationId); + if (store === undefined) return; + loadedConversationIds.add(conversationId); + loadingConversationIds.add(conversationId); + void store.load().finally(() => loadingConversationIds.delete(conversationId)); + } + + function refreshActiveChat(): void { + syncChatSubscriptions(); + const activeId = deps.tabsStore.activeConversationId; + if (activeId !== null) ensureLoaded(activeId); + activeChat = getActiveChat(); + } + + /** Discovery creates metadata; only the focused chat and explicit viewers watch live. */ + function subscribeChat(conversationId: string): void { + void conversationId; + syncChatSubscriptions(); + } + + /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */ + function unsubscribeChat(conversationId: string): void { + if (subscribedIds.delete(conversationId)) { + deps.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 { + watchedIds.add(conversationId); + // An open tab already has a live store + subscription — reuse it. + const tabStore = chatStores.get(conversationId); + if (tabStore !== undefined) { + syncChatSubscriptions(); + ensureLoaded(conversationId); + return tabStore; + } + const existing = watchStores.get(conversationId); + if (existing !== undefined) return existing; + const store = createChatFor(conversationId, deps.activeModel(), deps.activeWorkspaceId()); + watchStores.set(conversationId, store); + subscribeChat(conversationId); + ensureLoaded(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 { + watchedIds.delete(conversationId); + syncChatSubscriptions(); + // 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); + loadedConversationIds.delete(conversationId); + unsubscribeChat(conversationId); + } + + return { + cache, + chatStores, + watchStores, + loadedConversationIds, + createChatFor, + ensureLoaded, + getActiveChat, + refreshActiveChat, + subscribeChat, + unsubscribeChat, + reconnect(): void { + subscribedIds.clear(); + syncChatSubscriptions(); + }, + watchConversation, + unwatchConversation, + get draftStore() { + return draftStore; + }, + set draftStore(value: ChatStore) { + draftStore = value; + }, + get draftConversationId() { + return draftConversationId; + }, + set draftConversationId(value: string) { + draftConversationId = value; + }, + get activeChat() { + return activeChat; + }, + get chatLimit() { + return chatLimit; + }, + 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 }; + }, + attachUnloadGate(gate: () => boolean): void { + unloadGate = gate; + }, + dispose(): void { + for (const store of chatStores.values()) { + store.dispose(); + } + chatStores.clear(); + for (const store of watchStores.values()) { + store.dispose(); + } + watchStores.clear(); + loadedConversationIds.clear(); + draftStore.dispose(); + }, + }; +} +export type ChatRegistry = ReturnType<typeof createChatRegistry>; diff --git a/src/app/conversation-settings.svelte.ts b/src/app/conversation-settings.svelte.ts new file mode 100644 index 0000000..b853a8f --- /dev/null +++ b/src/app/conversation-settings.svelte.ts @@ -0,0 +1,440 @@ +import type { + CompactPercentResponse, + ConversationComputerResponse, + CwdResponse, + LspStatusResponse, + McpStatusResponse, + ReasoningEffort, + ReasoningEffortResponse, + SetCompactPercentRequest, + SetConversationComputerRequest, + SetCwdRequest, + SetReasoningEffortRequest, + SetVisionSettingsRequest, +} from "@dispatch/transport-contract"; +import { untrack } from "svelte"; +import type { SetThinkingRequest, ThinkingResponse } from "../features/chat/reasoning-effort"; +import { + normalizeVisionSettings, + type VisionSettings, + type VisionSettingsPatch, +} from "../features/vision"; + +import type { + CompactPercentResult, + ComputerResult, + CwdResult, + LspResult, + McpResult, + ReasoningEffortResult, + ThinkingResult, + VisionSettingsResult, +} from "./store-types"; + +/** Conversation settings keep their original reactive cells and response guards. */ +export function createConversationSettings(deps: { + httpBase: string; + fetchImpl: typeof fetch; + workspaceConversationId: () => string; + activeWorkspaceId: () => string; + reportError: (context: string, err: unknown) => void; +}) { + const { httpBase, fetchImpl } = deps; + // 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); + let textFileContextRevision = $state(0); + + /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */ + async function refreshCwd(): Promise<void> { + textFileContextRevision++; + const id = deps.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 (deps.workspaceConversationId() === id) cwd = data.cwd ?? null; + } catch (err) { + deps.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> { + textFileContextRevision++; + const id = deps.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 (deps.workspaceConversationId() === id) computerId = data.computerId ?? null; + } catch (err) { + deps.reportError("Failed to load computer", 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 = deps.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 (deps.workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null; + } catch (err) { + deps.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 = deps.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; + // Guard against the SPA fallback: when DISPATCH_WEB_DIR is set (production), + // unknown API routes get index.html with HTTP 200 + text/html instead of a + // 404. Treat any non-JSON 200 as "endpoint not shipped" → leave null (ON). + const contentType = res.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) return; + const data = (await res.json()) as ThinkingResponse; + // Guard a slow response losing a race with a conversation switch. + if (deps.workspaceConversationId() === id) thinking = data.thinking ?? null; + } catch (err) { + deps.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) { + deps.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 = deps.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 (deps.workspaceConversationId() === id) compactPercent = data.threshold; + } catch (err) { + deps.reportError("Failed to load compact percent", err); + } + } + return { + refreshCwd, + refreshComputer, + refreshReasoningEffort, + refreshThinking, + refreshCompactPercent, + refreshVisionSettings, + get textFileContextRevision() { + return textFileContextRevision; + }, + get cwd() { + return cwd; + }, + get computerId() { + return computerId; + }, + get reasoningEffort() { + return reasoningEffort; + }, + get thinking() { + return thinking; + }, + get compactPercent() { + return compactPercent; + }, + get visionSettings() { + return visionSettings; + }, + async setCwd(value: string): Promise<CwdResult | null> { + const id = deps.workspaceConversationId(); + const body: SetCwdRequest = { + cwd: value, + workspaceId: untrack(deps.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 (deps.workspaceConversationId() === id) { + cwd = next; + textFileContextRevision++; + } + 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 = deps.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 (deps.workspaceConversationId() === id) { + computerId = next; + textFileContextRevision++; + } + return { ok: true, computerId: next }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set computer request failed", + }; + } + }, + + async setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null> { + const id = deps.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 (deps.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 = deps.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})`, + }; + } + // Guard against the SPA fallback (200 + text/html when the endpoint + // is not shipped). Optimistically apply locally — the backend can't + // persist it, but the UI reflects the user's intent for this session. + const contentType = res.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + if (deps.workspaceConversationId() === id) thinking = enabled; + return { ok: true, thinking: enabled }; + } + const data = (await res.json()) as ThinkingResponse; + const next = data.thinking ?? enabled; + if (deps.workspaceConversationId() === id) thinking = next; + return { ok: true, thinking: next }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set thinking request failed", + }; + } + }, + async setCompactPercent(percent: number): Promise<CompactPercentResult | null> { + const id = deps.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 (deps.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 lspStatus(): Promise<LspResult | null> { + const id = deps.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 = deps.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", + }; + } + }, + }; +} diff --git a/src/app/history-sync.ts b/src/app/history-sync.ts new file mode 100644 index 0000000..ed5d889 --- /dev/null +++ b/src/app/history-sync.ts @@ -0,0 +1,38 @@ +import type { + ConversationHistoryResponse, + ConversationMetricsResponse, +} from "@dispatch/transport-contract"; +import type { HistorySync, MetricsSync } from "../features/chat"; + +export 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}`); + } + const data = (await res.json()) as ConversationHistoryResponse; + if ( + !data || + !Array.isArray(data.chunks) || + !Number.isSafeInteger(data.historyEpoch) || + data.historyEpoch < 0 + ) { + throw new Error("Invalid history response"); + } + return data; + }; +} + +export 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; + }; +} diff --git a/src/app/http-actions.ts b/src/app/http-actions.ts new file mode 100644 index 0000000..55407b4 --- /dev/null +++ b/src/app/http-actions.ts @@ -0,0 +1,638 @@ +import type { + CompactResponse, + ComputerStatusResponse, + SetSystemPromptTemplateRequest, + SystemPromptTemplateResponse, + SystemPromptVariablesResponse, + TestComputerResponse, + WarmRequest, + WarmResponse, +} from "@dispatch/transport-contract"; +import { untrack } from "svelte"; +import type { + AccountUsageProviderId, + AccountUsageResult, + AccountUsageSettingsResult, + UpdateAccountUsageSettingsRequest, +} from "../features/account-usage"; +import { normalizeAccountUsage, normalizeAccountUsageReport } from "../features/account-usage"; +import type { + ConcurrencyCooldownResult, + ConcurrencyDeleteResult, + ConcurrencyLimitResult, + ConcurrencyLimitsResult, + ConcurrencyStatusResult, +} from "../features/concurrency"; +import { + normalizeConcurrencyCooldown, + normalizeConcurrencyLimit, + normalizeConcurrencyLimits, + normalizeConcurrencyStatus, +} from "../features/concurrency"; +import type { + HeartbeatConfig, + HeartbeatConfigPatch, + HeartbeatConfigResult, + HeartbeatNextRunResult, + HeartbeatRun, + HeartbeatRunsResult, + HeartbeatStopResult, +} from "../features/heartbeat"; +import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat"; +import { + normalizeSearchBackendConfig, + type SearchBackendConfigResult, +} from "../features/search-settings"; + +import type { + CompactResult, + ComputerStatusResult, + SystemPromptLoadResult, + SystemPromptSaveResult, + SystemPromptVariablesResult, + TestComputerResult, + WarmResult, +} from "./store-types"; + +/** Stateless app HTTP actions; callers inject the current focus/workspace. */ +export function createHttpActions(deps: { + httpBase: string; + fetchImpl: typeof fetch; + activeConversationId: () => string | null; + activeModel: () => string; + activeWorkspaceId: () => string; + reportError: (context: string, err: unknown) => void; +}) { + const { httpBase, fetchImpl } = deps; + return { + async warmNow(): Promise<WarmResult | null> { + const conversationId = deps.activeConversationId(); + if (conversationId === null) return null; + const body: WarmRequest = { conversationId, model: deps.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 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", + }; + } + }, + stopGeneration(): void { + const conversationId = deps.activeConversationId(); + if (conversationId === null) return; + void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, { + method: "POST", + }).catch((err) => { + deps.reportError("Failed to stop generation", err); + }); + }, + + async compactNow(keepLastN?: number): Promise<CompactResult | null> { + const conversationId = deps.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 heartbeatConfig(): Promise<HeartbeatConfigResult> { + // Workspace-scoped (NOT per-conversation): use the active workspace id. + const wsId = untrack(deps.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(deps.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(deps.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(deps.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(deps.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", + }; + } + }, + 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 accountUsage(opts?: { readonly refresh?: boolean }): Promise<AccountUsageResult> { + try { + const res = await fetchImpl(`${httpBase}/usage${opts?.refresh ? "?refresh=1" : ""}`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Account usage failed (HTTP ${res.status})`, + ...(res.status === 503 ? { unavailable: true } : {}), + }; + } + return { ok: true, usage: normalizeAccountUsage(await res.json(), Date.now()) }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Account usage request failed", + }; + } + }, + + async setAccountUsageSettings( + provider: AccountUsageProviderId, + settings: UpdateAccountUsageSettingsRequest, + ): Promise<AccountUsageSettingsResult> { + try { + const res = await fetchImpl(`${httpBase}/usage/${encodeURIComponent(provider)}/settings`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(settings), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Save usage settings failed (HTTP ${res.status})`, + }; + } + const report = normalizeAccountUsageReport(await res.json()); + return report + ? { ok: true, report } + : { ok: false, error: "Malformed usage settings response" }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Save usage settings 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 searchBackendConfig(): Promise<SearchBackendConfigResult> { + try { + const res = await fetchImpl(`${httpBase}/search/config`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Search config failed (HTTP ${res.status})`, + // The 503 degrade = the backend's search-config service not loaded + // (the tool-web-search extension absent) — a distinct UI state. + ...(res.status === 503 ? { unavailable: true } : {}), + }; + } + // Normalize the untyped JSON at this network seam (pure helper) so a + // malformed/partial response can never crash the renderer. + const config = normalizeSearchBackendConfig(await res.json()); + return { ok: true, config }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Search config request failed", + }; + } + }, + + async setSearchBackend(backend: string): Promise<SearchBackendConfigResult> { + try { + const res = await fetchImpl(`${httpBase}/search/config`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ backend }), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set search backend failed (HTTP ${res.status})`, + ...(res.status === 503 ? { unavailable: true } : {}), + }; + } + const config = normalizeSearchBackendConfig(await res.json()); + return { ok: true, config }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set search backend 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", + }; + } + }, + }; +} diff --git a/src/app/open-conversations.svelte.ts b/src/app/open-conversations.svelte.ts new file mode 100644 index 0000000..9c8c25b --- /dev/null +++ b/src/app/open-conversations.svelte.ts @@ -0,0 +1,288 @@ +import type { + ConversationListResponse, + SetTabGroupRequest, + SetTitleRequest, + TabGroupResponse, +} from "@dispatch/transport-contract"; +import type { ConversationStatus } from "@dispatch/wire"; +import type { ConversationCache } from "../features/conversation-cache"; +import type { TabsStore } from "../features/tabs"; + +import type { ChatRegistry } from "./chat-registry.svelte"; +import type { createConversationSettings } from "./conversation-settings.svelte"; + +/** Cross-device tab discovery/reconciliation and backend lifecycle status. */ +export function createOpenConversations(deps: { + httpBase: string; + fetchImpl: typeof fetch; + tabsStore: TabsStore; + registry: ChatRegistry; + cache: ConversationCache; + activeModel: () => string; + settings: ReturnType<typeof createConversationSettings>; + syncSubscriptions: () => void; + reportError: (context: string, err: unknown) => void; +}) { + const { httpBase, fetchImpl, tabsStore, registry, cache } = deps; + /** + * 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) => { + deps.reportError("Failed to close conversation", err); + }); + } + + /** + * 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, 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, + model?: string, + tabGroupParentId?: string, + ): void { + if (registry.chatStores.has(conversationId)) return; + // Stamp the tab with the conversation's PERSISTED model when the open + // broadcast carries it (falling back to the current global selection) — + // never let the globally-active model overwrite a conversation's real one + // (fix-toolcall-duplicate-keys plan Part 4). + const tabModel = model ?? deps.activeModel(); + const store = + registry.watchStores.get(conversationId) ?? + registry.createChatFor(conversationId, tabModel, workspaceId); + registry.watchStores.delete(conversationId); + store.setModel(tabModel); + registry.chatStores.set(conversationId, store); + registry.subscribeChat(conversationId); + tabsStore.openTab({ + conversationId, + model: tabModel, + title: "Conversation", + workspaceId, + ...(tabGroupParentId !== undefined ? { tabGroupParentId } : {}), + }); + } + + /** + * 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 { + registry.unsubscribeChat(conversationId); + const store = registry.chatStores.get(conversationId); + if (store !== undefined) { + store.dispose(); + registry.chatStores.delete(conversationId); + } + registry.loadedConversationIds.delete(conversationId); + void cache.delete(conversationId); + tabsStore.closeTab(conversationId); + conversationStatuses.delete(conversationId); + registry.refreshActiveChat(); + deps.syncSubscriptions(); + void deps.settings.refreshCwd(); + void deps.settings.refreshComputer(); + void deps.settings.refreshReasoningEffort(); + void deps.settings.refreshThinking(); + void deps.settings.refreshCompactPercent(); + } + + // 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. + */ + let pendingFetch: Promise<void> | undefined; + function fetchOpenConversations(): Promise<void> { + pendingFetch ??= loadOpenConversations().finally(() => { + pendingFetch = undefined; + }); + return pendingFetch; + } + + async function loadOpenConversations(): Promise<void> { + try { + const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`); + if (!res.ok) return; + const data = (await res.json()) as ConversationListResponse; + + if (!data || !Array.isArray(data.conversations)) { + deps.reportError( + "Failed to load open conversations", + new Error("Invalid conversations response"), + ); + return; + } + // 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(registry.chatStores.keys()); + for (const conv of data.conversations) { + if (!existingIds.has(conv.id)) { + // Stamp the tab with the conversation's PERSISTED model (when the + // list carries it) instead of the global active model — the fix for + // tabs showing/overwriting the wrong model + // (fix-toolcall-duplicate-keys plan Part 4). + const model = conv.model ?? deps.activeModel(); + const store = + registry.watchStores.get(conv.id) ?? + registry.createChatFor(conv.id, model, conv.workspaceId); + registry.watchStores.delete(conv.id); + store.setModel(model); + registry.chatStores.set(conv.id, store); + // Seed the cache timer's turn gap (only an idle conversation is waiting on the user). + if (conv.status === "idle") store.noteLastActivity(conv.lastActivityAt); + registry.subscribeChat(conv.id); + tabsStore.openTab({ + conversationId: conv.id, + model, + title: conv.title, + workspaceId: conv.workspaceId, + ...(conv.tabGroupParentId !== undefined + ? { tabGroupParentId: conv.tabGroupParentId } + : {}), + }); + } else { + // Already open — reconcile title + workspace from the authoritative + // backend list. A raced `conversation.open` (summon) may have stamped + // the tab with the wrong workspace ("default"); the backend's + // authoritative conversation record is the source of truth, so a + // differing tab workspace is corrected here. + tabsStore.setTitle(conv.id, conv.title); + // Seed the cache timer's turn gap from the backend (a tab restored + // from localStorage at boot has no live `done` to go by). Only an + // idle conversation is waiting on the user. + if (conv.status === "idle") + registry.chatStores.get(conv.id)?.noteLastActivity(conv.lastActivityAt); + const tab = tabsStore.tabs.find((t) => t.conversationId === conv.id); + if (tab?.workspaceId !== conv.workspaceId) { + tabsStore.setWorkspaceId(conv.id, conv.workspaceId); + } + // The backend's EFFECTIVE tab group parent is authoritative too. + if (tab?.tabGroupParentId !== conv.tabGroupParentId) { + tabsStore.setTabGroupParent(conv.id, conv.tabGroupParentId ?? null); + } + } + } + + // 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) && + !registry.chatStores.get(tab.conversationId)?.pendingInputs.length + ) { + removeTabLocally(tab.conversationId); + } + } + registry.refreshActiveChat(); + } catch (err) { + deps.reportError( + `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`, + err, + ); + } + } + + return { + closeConversation, + openConversation, + removeTabLocally, + fetchOpenConversations, + 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) => { + deps.reportError("Failed to rename conversation", err); + }); + }, + + setTabGroup(conversationId: string, parentId: string | null): void { + const previous = + tabsStore.tabs.find((t) => t.conversationId === conversationId)?.tabGroupParentId ?? null; + tabsStore.setTabGroupParent(conversationId, parentId); + void (async () => { + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(conversationId)}/tab-group`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parentId } satisfies SetTabGroupRequest), + }, + ); + if (!res.ok) { + const err = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(err?.error ?? `HTTP ${res.status}`); + } + const body = (await res.json()) as TabGroupResponse; + tabsStore.setTabGroupParent(conversationId, body.tabGroupParentId); + } catch (err) { + // Revert the optimistic move, then surface the failure. + tabsStore.setTabGroupParent(conversationId, previous); + deps.reportError("Failed to update the tab's subagent group", err); + } + })(); + }, + + get conversationStatuses() { + return conversationStatuses; + }, + set conversationStatuses(value: Map<string, ConversationStatus>) { + conversationStatuses = value; + }, + conversationStatus(conversationId: string): ConversationStatus | undefined { + return conversationStatuses.get(conversationId); + }, + workspaceHasActiveConversations(workspaceId: string): boolean { + return activeWorkspaces.has(workspaceId); + }, + }; +} +export type OpenConversations = ReturnType<typeof createOpenConversations>; diff --git a/src/app/plugin-host.svelte.ts b/src/app/plugin-host.svelte.ts new file mode 100644 index 0000000..8054005 --- /dev/null +++ b/src/app/plugin-host.svelte.ts @@ -0,0 +1,151 @@ +/** + * The frontend-plugin HOST wiring (plan 03 §3.2) — the composition-root glue + * between the ABI driver (`core/plugin-abi`), the view registry, the + * surfaces ports, and the plugins HTTP edge. + * + * The sync triggers (boot, `extensions-changed`, `workspace.set` → the app + * store calls `sync()` on exactly those events) each fetch the plugin list + + * the ACTIVE workspace's enablement rows, derive the desired candidate set + * (pure), and run one driver pass. The driver's effects are injected HERE at + * the edge: + * - `importModule` — the dynamic `import()` (the only module-system touch); + * - `buildHostApi` — the per-plugin `FrontendHostAPI` with FORCED pluginId + * attribution into the layout registry, app-store-backed surfaces ports, + * and a plugin-prefixed logger; + * - `unregisterViewsByPlugin` — the registry's unload sweep. + * + * After every pass the per-plugin FE states are reported to the plugins + * store (the manager UI renders "incompatible frontend plugin" and + * activation failures). + */ +import { + createFrontendDriver, + FRONTEND_ABI_VERSION, + type FrontendDriver, + type FrontendHostAPI, + type FrontendPluginState, + frontendCandidates, + usablePluginIds, +} from "../core/plugin-abi"; +import { + type LayoutAbiPort, + registerPluginView, + unregisterViewsByPlugin, + type ViewComponent, +} from "../features/layout"; +import type { PluginsHttp } from "../features/plugins"; + +/** The surfaces ports the ABI hands plugin views — backed by the app store. */ +export interface FrontendSurfacesPorts { + readonly subscribe: (surfaceId: string, conversationId?: string) => void; + readonly unsubscribe: (surfaceId: string, conversationId?: string) => void; + readonly invoke: ( + surfaceId: string, + actionId: string, + payload?: unknown, + conversationId?: string, + ) => void; + readonly getSpec: (surfaceId: string) => unknown; +} + +/** The shape of the plugins store the FE states are reported to. */ +export interface FrontendStateReporter { + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void; +} + +export interface PluginHostOptions { + readonly http: PluginsHttp; + /** The backend HTTP base (import-URL origin). */ + readonly httpBase: string; + readonly reporter: FrontendStateReporter; + /** The ACTIVE workspace provider (read per sync — workspace.set trigger). */ + readonly activeWorkspaceId: () => string; + readonly surfaces: FrontendSurfacesPorts; + /** + * The layout port (plan 04 §4.5) — the ABI's `api.layout` (applyOps to the + * ACTIVE workspace's provider + getTree, null in no-provider mode). Built + * by the composition root over the layout store + ops HTTP edge. + */ + readonly layout: LayoutAbiPort; +} + +export interface PluginHost { + /** + * Run one sync pass (all three triggers share it — the active workspace + * is read per call, which is what makes `workspace.set` a distinct + * trigger). Never throws: a failed list fetch keeps the current set. + */ + sync(): Promise<void>; +} + +export function createPluginHost(opts: PluginHostOptions): PluginHost { + const driver: FrontendDriver = createFrontendDriver({ + importModule: (url) => import(/* @vite-ignore */ url), + buildHostApi: (pluginId) => buildHostApi(pluginId), + unregisterViewsByPlugin, + logger: console, + }); + + function buildHostApi(pluginId: string): FrontendHostAPI { + return { + abiVersion: FRONTEND_ABI_VERSION, + registerView: (reg) => + registerPluginView( + { + id: reg.id, + label: reg.label, + // ONE controlled cast at the ABI seam — the registry is a + // heterogeneous component bag (register-views.ts precedent); + // the plugin's own build already vetted its component. + component: reg.component as ViewComponent, + ...(reg.panelEligible !== undefined ? { panelEligible: reg.panelEligible } : {}), + }, + pluginId, + ), + surfaces: { + subscribe: (surfaceId, conversationId) => + opts.surfaces.subscribe(surfaceId, conversationId), + unsubscribe: (surfaceId, conversationId) => + opts.surfaces.unsubscribe(surfaceId, conversationId), + invoke: (surfaceId, actionId, payload, conversationId) => + opts.surfaces.invoke(surfaceId, actionId, payload, conversationId), + getSpec: (surfaceId) => opts.surfaces.getSpec(surfaceId) as never, + }, + logger: { + info: (msg) => console.info(`[frontend-plugin:${pluginId}] ${msg}`), + warn: (msg) => console.warn(`[frontend-plugin:${pluginId}] ${msg}`), + }, + // plan 04 §4.5: the layout port — the composition-root-built adapter + // over the layout store + the ops HTTP edge (the plugin never sees + // either; only this typed port). + layout: opts.layout, + }; + } + + return { + async sync(): Promise<void> { + // Both reads are fail-soft (null on network failure) — a down backend + // keeps the CURRENT imported set untouched (no unload on a flaky + // fetch, and the manager UI's last-known states persist). + const workspaceId = opts.activeWorkspaceId(); + const [list, workspace] = await Promise.all([ + opts.http.list(), + opts.http.workspacePlugins(workspaceId), + ]); + if (list === null) return; + // Shape guard at the outermost edge (asymmetric testing: the shell + // guards, the pure diff stays trusting): a malformed-but-200 + // `/plugins` response (wrong shape, contract drift, a test fake that + // doesn't model the route) must fail SOFT exactly like `null` — keep + // the current imported set, never throw from the fire-and-forget sync. + if (!Array.isArray(list.loaded)) return; + const candidates = frontendCandidates(list, { + workspaceId, + usableIds: usablePluginIds(workspace?.plugins ?? []), + httpBase: opts.httpBase, + }); + await driver.sync(candidates); + opts.reporter.setFrontendStates(driver.states()); + }, + }; +} diff --git a/src/app/register-views.ts b/src/app/register-views.ts new file mode 100644 index 0000000..05afd89 --- /dev/null +++ b/src/app/register-views.ts @@ -0,0 +1,81 @@ +/** + * Built-in view registration — the composition root's view vocabulary. + * + * One thin wrapper component per existing view (src/app/views/*.svelte), each + * closing over exactly the props/ports App.svelte's `viewContent` snippet used + * to pass — INCLUDING each view's `{#key}` remount semantics (moved INSIDE the + * wrappers). Registry ids match the old `viewKinds` ids exactly: no + * user-facing renaming, no persistence-key changes. + * + * `chat` / `composer` / `message-queue` are CONTENT views (decision 5): they + * register with `panelEligible: false` so they never appear in the panel + * dropdowns. All 15 sidebar views keep the eligible default. + * + * Imported ONCE by App.svelte's module script — before any tile renders. + */ + +import type { Component } from "svelte"; +import { registerView, type ViewComponent, type ViewRegistration } from "../features/layout"; +import type { AppViewPorts } from "./view-ports"; +import AccountUsagePane from "./views/AccountUsagePane.svelte"; +import CacheWarmingPane from "./views/CacheWarmingPane.svelte"; +import ChatPane from "./views/ChatPane.svelte"; +import CompactionPane from "./views/CompactionPane.svelte"; +import ComposerPane from "./views/ComposerPane.svelte"; +import ConcurrencyPane from "./views/ConcurrencyPane.svelte"; +import ExtensionsPane from "./views/ExtensionsPane.svelte"; +import HeartbeatPane from "./views/HeartbeatPane.svelte"; +import LspPane from "./views/LspPane.svelte"; +import McpPane from "./views/McpPane.svelte"; +import MessageQueuePane from "./views/MessageQueuePane.svelte"; +import ModelPane from "./views/ModelPane.svelte"; +import NamedAgentsPane from "./views/NamedAgentsPane.svelte"; +import SettingsPane from "./views/SettingsPane.svelte"; +import SystemPromptPane from "./views/SystemPromptPane.svelte"; +import TabsPane from "./views/TabsPane.svelte"; +import TasksPane from "./views/TasksPane.svelte"; +import VisionPane from "./views/VisionPane.svelte"; + +/** + * Every wrapper takes `{ ports: AppViewPorts }` and TileLeaf supplies it — but + * the registry is a HETEROGENEOUS bag of components (generic `Component`), so + * the composition root bridges each wrapper's concrete props type to the + * registry's opaque one. ONE controlled cast at this seam (the renderer always + * passes the port bag; the wrappers re-narrow it) instead of `any` in the + * contract. + */ +type WrapperComponent = Component<{ ports: AppViewPorts }>; +function asView(component: WrapperComponent): ViewComponent { + return component as unknown as ViewComponent; +} + +export function registerBuiltinViews(): void { + const views: readonly ViewRegistration[] = [ + // --- sidebar views (panel-eligible by default — the 14 old viewKinds + account-usage) + { id: "tabs", label: "Tabs", component: asView(TabsPane) }, + { id: "model", label: "Model", component: asView(ModelPane) }, + { id: "named-agents", label: "Named Agents", component: asView(NamedAgentsPane) }, + { id: "lsp", label: "Language Servers", component: asView(LspPane) }, + { id: "mcp", label: "MCP Servers", component: asView(McpPane) }, + { id: "extensions", label: "Extensions", component: asView(ExtensionsPane) }, + { id: "cache-warming", label: "Cache Warming", component: asView(CacheWarmingPane) }, + { id: "tasks", label: "Tasks", component: asView(TasksPane) }, + { id: "compaction", label: "Compaction", component: asView(CompactionPane) }, + { id: "vision", label: "Vision", component: asView(VisionPane) }, + { id: "heartbeat", label: "Heartbeat", component: asView(HeartbeatPane) }, + { id: "concurrency", label: "Concurrency", component: asView(ConcurrencyPane) }, + { id: "account-usage", label: "Usage", component: asView(AccountUsagePane) }, + { id: "system-prompt", label: "System Prompt", component: asView(SystemPromptPane) }, + { id: "settings", label: "Settings", component: asView(SettingsPane) }, + // --- content views (never in the panel dropdowns — decision 5) + { id: "chat", label: "Chat", component: asView(ChatPane), panelEligible: false }, + { id: "composer", label: "Composer", component: asView(ComposerPane), panelEligible: false }, + { + id: "message-queue", + label: "Message Queue", + component: asView(MessageQueuePane), + panelEligible: false, + }, + ]; + for (const view of views) registerView(view); +} diff --git a/src/app/resolve-http-url.test.ts b/src/app/resolve-http-url.test.ts index 0da2591..c556812 100644 --- a/src/app/resolve-http-url.test.ts +++ b/src/app/resolve-http-url.test.ts @@ -19,13 +19,13 @@ describe("resolveHttpUrl", () => { }); 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"); + const result = resolveHttpUrl({}, { protocol: "http:", hostname: "192.0.2.10" }); + expect(result).toBe("http://192.0.2.10:24203"); }); it("derives https://<hostname>:24203 from https location", () => { - const result = resolveHttpUrl({}, { protocol: "https:", hostname: "arch-razer" }); - expect(result).toBe("https://arch-razer:24203"); + const result = resolveHttpUrl({}, { protocol: "https:", hostname: "dev-node" }); + expect(result).toBe("https://dev-node:24203"); }); it("uses VITE_HTTP_PORT when set", () => { diff --git a/src/app/resolve-ws-url.test.ts b/src/app/resolve-ws-url.test.ts index b5ba6c3..be1ab12 100644 --- a/src/app/resolve-ws-url.test.ts +++ b/src/app/resolve-ws-url.test.ts @@ -19,13 +19,13 @@ describe("resolveWsUrl", () => { }); 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"); + const result = resolveWsUrl({}, { protocol: "http:", hostname: "192.0.2.10" }); + expect(result).toBe("ws://192.0.2.10:24205"); }); it("derives wss://<hostname>:24205 from https location", () => { - const result = resolveWsUrl({}, { protocol: "https:", hostname: "arch-razer" }); - expect(result).toBe("wss://arch-razer:24205"); + const result = resolveWsUrl({}, { protocol: "https:", hostname: "dev-node" }); + expect(result).toBe("wss://dev-node:24205"); }); it("uses VITE_WS_PORT when set", () => { diff --git a/src/app/settings-refreshers.svelte.ts b/src/app/settings-refreshers.svelte.ts new file mode 100644 index 0000000..fb68895 --- /dev/null +++ b/src/app/settings-refreshers.svelte.ts @@ -0,0 +1,257 @@ +import type { + ComputerListResponse, + ModelMetadata, + ModelsResponse, +} from "@dispatch/transport-contract"; +import type { ComputerEntry } from "@dispatch/wire"; +import type { + NamedAgent, + NamedAgentDeleteResult, + NamedAgentInput, + NamedAgentSaveResult, + NamedAgentsResult, +} from "../features/named-agents"; +import { normalizeNamedAgentRecord, normalizeNamedAgents } from "../features/named-agents"; + +/** Replace-or-append a Named Agent into a list (immutably), keyed by id. */ +function upsertNamedAgentList( + agents: readonly NamedAgent[], + agent: NamedAgent, +): readonly NamedAgent[] { + const idx = agents.findIndex((a) => a.id === agent.id); + if (idx === -1) return [...agents, agent]; + return agents.map((a) => (a.id === agent.id ? agent : a)); +} + +/** Workspace catalogs, global settings discovery and Named Agent records. */ +export function createSettingsRefreshers(deps: { + httpBase: string; + fetchImpl: typeof fetch; + activeWorkspaceId: () => string; + reportError: (context: string, err: unknown) => void; +}) { + const { httpBase, fetchImpl } = deps; + 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[]>([]); + // The persisted Named Agents (global user config). Seeded from the backend on + // boot + kept in sync by the CRUD methods; drives both the sidebar editor and + // the synthetic model-selector provider. + let namedAgents = $state<readonly NamedAgent[]>([]); + /** + * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace + * (`GET /workspaces/:id/plugins/providers-status`, plan 06 §6.5). `null` = + * unknown (not fetched yet / endpoint missing) — the first-boot guardrail + * shows only on a confident `0` (§7). + */ + let enabledProviderCount = $state<number | null>(null); + + /** Replace-or-append a Named Agent into the reactive list (immutably). */ + function upsertNamedAgent(agent: NamedAgent): void { + namedAgents = upsertNamedAgentList(namedAgents, agent); + } + + /** + * Refetch the model catalog for the ACTIVE workspace + * (`GET /models?workspaceId=`, plan 06 §6.5 — workspace-aware; a backend + * without the param returns the global catalog, which stays correct). + * Re-run on boot, on every workspace switch, and on `extensions-changed` + * (a provider toggled in another client updates the picker here too). + */ + async function refreshModels(): Promise<void> { + try { + const res = await fetchImpl( + `${httpBase}/models?workspaceId=${encodeURIComponent(deps.activeWorkspaceId())}`, + ); + if (!res.ok) return; + const data = (await res.json()) as ModelsResponse; + // Defensive: a malformed/SPA-fallback response must never poison the + // picker with a non-array (the old boot chain had the same exposure). + if (!Array.isArray(data.models)) return; + models = data.models; + modelInfo = data.modelInfo ?? {}; + } catch (err) { + deps.reportError("Failed to load model list", err); + } + } + + /** + * Refetch the active workspace's provider status (the §6.5 first-boot + * guardrail signal). Guarded against a slow response losing a race with a + * workspace switch (only the CURRENT workspace's result lands). Non-JSON + * 200 (SPA fallback — endpoint not shipped) leaves it null (no prompt). + */ + async function refreshProviderStatus(): Promise<void> { + const workspaceId = deps.activeWorkspaceId(); + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`, + ); + if (!res.ok) { + if (workspaceId === deps.activeWorkspaceId()) enabledProviderCount = null; + return; + } + const contentType = res.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) return; + const data = (await res.json()) as { enabledProviderCount?: number }; + if (workspaceId === deps.activeWorkspaceId()) { + enabledProviderCount = + typeof data.enabledProviderCount === "number" ? data.enabledProviderCount : null; + } + } catch { + // Non-fatal: the guardrail just stays hidden (null) when the endpoint + // is unreachable. + if (workspaceId === deps.activeWorkspaceId()) enabledProviderCount = null; + } + } + + function loadGlobalCatalogs(): void { + // 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) => { + deps.reportError("Failed to load computer list", err); + }); + + // Fetch the persisted Named Agents (global user config). Seeded on boot so the + // sidebar editor + the synthetic model-selector provider have data immediately. + // Non-fatal: a failure leaves an empty list (the user can add agents). + void fetchImpl(`${httpBase}/named-agents`) + .then((res) => { + if (!res.ok) return { namedAgents: [] } as { namedAgents: NamedAgent[] }; + return res.json() as Promise<{ namedAgents: NamedAgent[] }>; + }) + .then((data) => { + namedAgents = normalizeNamedAgents(data); + }) + .catch((err) => { + deps.reportError("Failed to load named agents", err); + }); + } + + return { + refreshModels, + refreshProviderStatus, + loadGlobalCatalogs, + get models() { + return models; + }, + get modelInfo() { + return modelInfo; + }, + get computers() { + return computers; + }, + get namedAgents() { + return namedAgents; + }, + get enabledProviderCount() { + return enabledProviderCount; + }, + async refreshNamedAgents(): Promise<NamedAgentsResult> { + try { + const res = await fetchImpl(`${httpBase}/named-agents`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Named agents failed (HTTP ${res.status})`, + }; + } + const agents = normalizeNamedAgents(await res.json()); + namedAgents = agents; + return { ok: true, agents }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Named agents request failed", + }; + } + }, + + async createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult> { + try { + const res = await fetchImpl(`${httpBase}/named-agents`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Create named agent failed (HTTP ${res.status})`, + }; + } + const agent = normalizeNamedAgentRecord(await res.json()); + if (agent === null) return { ok: false, error: "Malformed named agent response" }; + upsertNamedAgent(agent); + return { ok: true, agent }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Create named agent request failed", + }; + } + }, + + async updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult> { + try { + const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Update named agent failed (HTTP ${res.status})`, + }; + } + const agent = normalizeNamedAgentRecord(await res.json()); + if (agent === null) return { ok: false, error: "Malformed named agent response" }; + upsertNamedAgent(agent); + return { ok: true, agent }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Update named agent request failed", + }; + } + }, + + async deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult> { + try { + const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Delete named agent failed (HTTP ${res.status})`, + }; + } + namedAgents = namedAgents.filter((a) => a.id !== id); + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Delete named agent request failed", + }; + } + }, + }; +} diff --git a/src/app/store-types.ts b/src/app/store-types.ts new file mode 100644 index 0000000..6835166 --- /dev/null +++ b/src/app/store-types.ts @@ -0,0 +1,614 @@ +import type { + CompactResponse, + ComputerStatusResponse, + LspStatusResponse, + McpStatusResponse, + ModelMetadata, + ReasoningEffort, + SystemPromptVariable, + TestComputerResponse, + WarmResponse, +} from "@dispatch/transport-contract"; +import type { SurfaceSpec } from "@dispatch/ui-contract"; +import type { ComputerEntry, ConversationStatus, ImageInput } from "@dispatch/wire"; +import type { WebSocketLike } from "../adapters/ws"; +import type { ProtocolState } from "../core/protocol"; +import type { + AccountUsageProviderId, + AccountUsageResult, + AccountUsageSettingsResult, + UpdateAccountUsageSettingsRequest, +} from "../features/account-usage"; +import type { ChatStore } from "../features/chat"; +import type { + ConcurrencyCooldownResult, + ConcurrencyDeleteResult, + ConcurrencyLimitResult, + ConcurrencyLimitsResult, + ConcurrencyStatusResult, +} from "../features/concurrency"; +import type { + HeartbeatConfigPatch, + HeartbeatConfigResult, + HeartbeatNextRunResult, + HeartbeatRunsResult, + HeartbeatStopResult, +} from "../features/heartbeat"; +import type { LayoutStore } from "../features/layout"; +import type { TextFileClient } from "../features/markdown"; +import type { + NamedAgent, + NamedAgentDeleteResult, + NamedAgentInput, + NamedAgentSaveResult, + NamedAgentSelectionSnapshot, + NamedAgentsResult, +} from "../features/named-agents"; +import type { PluginsStore } from "../features/plugins"; +import type { SearchBackendConfigResult } from "../features/search-settings"; +import type { Tab } from "../features/tabs"; +import type { VisionSettings, VisionSettingsPatch } from "../features/vision"; +import type { TileLayoutStore } from "../features/workspaces"; + +/** 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 }; + +/** Outcome of `PUT /conversations/:id/cwd`. */ +export type CwdResult = + | { 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 }; + +/** 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 }; + +/** + * 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 }; + +/** Outcome of `PUT /conversations/:id/compact-percent`. */ +export type CompactPercentResult = + | { 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 }; + +export interface AppStore { + 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 by id, or null if absent. With conversationId, require a + * snapshot received for that conversation (not a retained prior-scope spec). + */ + surface(surfaceId: string, conversationId?: 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, images?: readonly ImageInput[]): void; + /** + * Cancel (remove) a single queued steering message by id so it never runs + * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: 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; + /** Select a raw model for the active conversation. Pass `snapshot` to record a Named Agent selection (plan §7). */ + selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): 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; + /** + * Move a tab into a subagent group (the tab group parent's id) or out of + * any group (null). Purely presentational (NOT lineage): applied + * optimistically, persisted via `PUT /conversations/:id/tab-group` (synced + * to other clients by `conversation.tabGroupChanged`), reverted on failure. + */ + setTabGroup(conversationId: string, parentId: string | null): void; + invoke(surfaceId: string, actionId: string, payload?: unknown): void; + /** + * Explicitly (re-)subscribe one surface, scoped to `conversationId` (or the + * focused conversation when absent) — the frontend-plugin ABI's + * `surfaces.subscribe` path (plan 03). The app auto-subscribes every + * catalog entry; this is the re-assertion port (same wire semantics). + */ + subscribeSurface(surfaceId: string, conversationId?: string): void; + /** Drop one surface's subscription (the ABI's `surfaces.unsubscribe`). */ + unsubscribeSurface(surfaceId: string, conversationId?: string): 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; + /** Invalidates live-file routing after known settings changes. */ + readonly textFileContextRevision: number; + createTextFileClient(conversationId: string): TextFileClient; + /** 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>; + /** + * Load Claude + Codex subscription usage (`GET /usage`). Served from the + * backend's cache (5 min TTL); `refresh: true` sends `?refresh=1`, which the + * server still rate-limits (min interval + failure cooldown). A 503 (the + * `account-usage` extension isn't loaded) returns `unavailable: true`. + */ + accountUsage(opts?: { readonly refresh?: boolean }): Promise<AccountUsageResult>; + /** + * Update a provider's usage settings (`PUT /usage/:provider/settings`; today + * only `hyper`: `dailyAllowance` + `resetAnchor` epoch-ms or null). Returns + * the provider's refreshed report. + */ + setAccountUsageSettings( + provider: AccountUsageProviderId, + settings: UpdateAccountUsageSettingsRequest, + ): Promise<AccountUsageSettingsResult>; + /** + * Load the GLOBAL search-backend selection + * (`GET /search/config`): which engine backs the `web_search` tool's search + * mode. Not workspace-scoped — the selection is global. `unavailable: true` + * on the failure variant marks the 503 degrade (the backend's + * search-config service not loaded) so the UI can disable the field. + */ + searchBackendConfig(): Promise<SearchBackendConfigResult>; + /** + * Persist a new search-backend selection + * (`PUT /search/config`, body `{ backend }`). `400` (unknown/unconfigured + * backend) and `503` (service not loaded) both surface as `ok: false`. + */ + setSearchBackend(backend: string): Promise<SearchBackendConfigResult>; + /** + * 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; + // ── Named Agents (global config; plan §9) ────────────────────────────────── + /** + * The persisted Named Agents (from `GET /named-agents`). Global user config + * (not per-workspace); seeded on boot. Drives the sidebar editor AND the + * synthetic "Named Agents" provider in the model selector (plan §6). + */ + readonly namedAgents: readonly NamedAgent[]; + /** + * The active conversation's Named Agent selection snapshot (plan §7), or null + * when it was selected via a raw model. Survives the Named Agent being renamed + * / removed (the snapshot retains the friendly label + captured raw model). + */ + readonly namedAgentSnapshot: NamedAgentSelectionSnapshot | null; + /** Refetch the Named Agents (`GET /named-agents`). */ + refreshNamedAgents(): Promise<NamedAgentsResult>; + // ── Plugins (plan 07) ────────────────────────────────────────────────────── + /** + * The plugin manager + per-workspace enablement store (one instance per + * app store). The workspaces page's manager/modal read + mutate through + * it; the app store feeds its `onExtensionsChanged` hook. + */ + readonly plugins: PluginsStore; + /** + * The ACTIVE workspace's tile tree (plan 02): served by the backend's + * layout provider when one is active, the local default otherwise. The + * shell reads `tree` for the renderer and feeds edits back through + * `onTreeChange` (debounced PUT + rollback); the store owns the probes, + * the WS-push filter, and the no-provider degrade. + */ + readonly layout: LayoutStore; + /** + * The home grid's synced tile layout (ONE shared document per device + * orientation — `wide` = landscape/desktop, `tall` = portrait/mobile), + * served by the backend's home-tile-layout plugin. The home page locks the + * active orientation to the resolved grid geometry; this store owns the + * probes, the WS-push filter, the debounced PUT + rollback, and the + * offline degrade. + */ + readonly tileLayout: TileLayoutStore; + /** + * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace + * (`GET /workspaces/:id/plugins/providers-status`). `null` = unknown — the + * first-boot guardrail renders only on a confident `0` (§7). + */ + readonly enabledProviderCount: number | null; + /** + * Create a Named Agent (`POST /named-agents`). On success the store's + * `namedAgents` list is updated (append/replace) so every consumer — the + * sidebar editor AND the model selector — reflects it without a reload. + */ + createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult>; + /** + * Update a Named Agent (`PUT /named-agents/:id`). On success the store list is + * updated in place. Returns the canonical saved record. + */ + updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult>; + /** + * Remove a Named Agent (`DELETE /named-agents/:id`). On success it leaves the + * store list immediately (removing it from the sidebar + model selector), and + * does NOT alter conversations that previously selected it (those keep their + * snapshot — plan §7). + */ + deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult>; + dispose(): void; +} + +export interface CreateAppStoreOptions { + 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; + /** + * How live chat events are batched before folding into a chat store. + * Default: once per animation frame (with a timeout fallback for hidden + * tabs). `null` = fold every event synchronously (tests that assert on the + * transcript immediately after feeding a frame). + */ + chatFlushScheduler?: ((flush: () => void) => void) | null; +} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 22b0a25..8fcc4cc 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -1,537 +1,81 @@ import type { - 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, + WorkspaceSetMessage, } from "@dispatch/transport-contract"; -import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; +import type { SurfaceSpec } from "@dispatch/ui-contract"; 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, -} from "../core/protocol"; -import type { ChatStore, HistorySync, MetricsSync } from "../features/chat"; -import { createChatStore } from "../features/chat"; -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 { createSurfaceSocket } from "../adapters/ws"; +import { getSurfaceSpec } from "../core/protocol"; +import type { ChatStore } from "../features/chat"; +import { migrateLegacyModelName } from "../features/chat"; 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 { LayoutStore } from "../features/layout"; +import { + createLayoutAbiPort, + createLayoutHttp, + createLayoutStore, + listPanelViews, +} from "../features/layout"; +import { createTextFileClient } from "../features/markdown"; +import type { NamedAgent, NamedAgentSelectionSnapshot } from "../features/named-agents"; +import type { PluginsStore } from "../features/plugins"; +import { createPluginsHttp, createPluginsStore } from "../features/plugins"; import type { Tab, TabsState } from "../features/tabs"; import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs"; +import type { VisionSettings } from "../features/vision"; import { - normalizeVisionSettings, - type VisionSettings, - type VisionSettingsPatch, -} from "../features/vision"; + createTileLayoutHttp, + createTileLayoutStore, + geometryFor, + type TileLayoutStore, +} from "../features/workspaces"; +import { createChatRegistry } from "./chat-registry.svelte"; +import { createConversationSettings } from "./conversation-settings.svelte"; +import { createHistorySync, createMetricsSync } from "./history-sync"; +import { createHttpActions } from "./http-actions"; +import { createOpenConversations } from "./open-conversations.svelte"; +import { createPluginHost } from "./plugin-host.svelte"; import { resolveHttpUrl } from "./resolve-http-url"; import { resolveWsUrl } from "./resolve-ws-url"; +import { createSettingsRefreshers } from "./settings-refreshers.svelte"; +import type { AppStore, CreateAppStoreOptions } from "./store-types"; +import { createSurfaceProtocol } from "./surface-protocol.svelte"; import { randomId } from "./uuid"; - -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 }; - -/** Outcome of `PUT /conversations/:id/cwd`. */ -export type CwdResult = - | { 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 }; - -/** 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 }; - -/** Outcome of `POST /conversations/:id/compact` (manual compaction). */ -export type CompactResult = - | { 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 }; - -/** 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 }; - -export interface AppStore { - 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; - 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>; - /** - * 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; - /** 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; - }; -} - -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; - }; -} +import { createWsHandlers } from "./ws-handlers"; + +const DEFAULT_MODEL = "opencode-1/deepseek-v4-flash"; + +export type { + AppStore, + ChatLimitResult, + CompactPercentResult, + CompactResult, + ComputerResult, + ComputerStatusResult, + CreateAppStoreOptions, + CwdResult, + LspResult, + McpResult, + ReasoningEffortResult, + SystemPromptLoadResult, + SystemPromptSaveResult, + SystemPromptVariablesResult, + TestComputerResult, + ThinkingResult, + VisionSettingsResult, + WarmResult, +} from "./store-types"; export function createAppStore(opts?: CreateAppStoreOptions): AppStore { - 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); + // The active conversation's Named Agent snapshot (null when selected via a raw + // model). Derived from the active tab; survives a Named Agent being edited. + let namedAgentSnapshot = $state<NamedAgentSelectionSnapshot | null>(null); let fatalError = $state<string | null>(null); // The workspace currently in view (its URL slug); "default" until routing @@ -562,127 +106,82 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB; const localStorageOpt = opts?.localStorage ?? globalThis.localStorage; - const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", { + // ── Plugins (plan 07) ────────────────────────────────────────────────────── + // The plugin-manager + per-workspace enablement state lives in its own + // feature store (one instance per app store, passed explicitly to the UI). + // The app store wires the server's `extensions-changed` broadcast into it + // (§11 multi-client coherence) and refreshes the workspace-scoped model + // picker + guardrail on the same event. + const pluginsHttp = createPluginsHttp(httpBase, fetchImpl); + const pluginsStore = createPluginsStore(pluginsHttp); + + // ── Layout (plan 02) ───────────────────────────────────────────────────────── + // The ACTIVE workspace's tile tree, owned by the backend's layout provider + // plugin when one is active (its well-known routes + the `layout` WS push) + // and rendered from the local default otherwise (no-provider mode: edits + // ephemeral, logged once, no error UI). Probes run on boot, on every + // workspace switch, and on `extensions-changed` (§2.6); the one-way + // localStorage migration (§2.7) arms on the boot probe. + const layoutHttp = createLayoutHttp(httpBase, fetchImpl); + const layoutStore = createLayoutStore({ + http: layoutHttp, + workspaceId: activeWorkspaceId, storage: localStorageOpt, + knownViews: listPanelViews().map((v) => v.id), }); - 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", { + // ── Home tile-layout (the synced Win8-Start grid) ───────────────────────── + // ONE shared document PER device orientation (`wide` = landscape/desktop, + // `tall` = portrait/mobile), persisted by the backend's home-tile-layout + // plugin and broadcast over WS (`tile-layout`). The home page locks the + // active orientation to the resolved grid geometry and renders + // `tileLayout.entries`; this store owns the probes, the WS-push filter, + // the debounced PUT + rollback, and the offline degrade. + const tileLayoutStore = createTileLayoutStore({ + http: createTileLayoutHttp(httpBase, fetchImpl), + initialMode: + typeof window === "undefined" + ? "wide" + : geometryFor(window.innerWidth, window.innerHeight).mode, 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 refreshers = createSettingsRefreshers({ + httpBase, + fetchImpl, + activeWorkspaceId: () => activeWorkspaceId, + reportError, + }); - 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); - } - } + const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", { + storage: localStorageOpt, + }); + const tabsStore: TabsStore = createTabsStore(storageAdapter); - // 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); + const registry = createChatRegistry({ + storage: localStorageOpt, + tabsStore, + createCache: () => + createConversationCache(createIdbChunkStore({ indexedDB: indexedDBFactory })), + historySync: createHistorySync(httpBase, fetchImpl), + metricsSync: createMetricsSync(httpBase, fetchImpl), + defaultModel: DEFAULT_MODEL, + activeModel: () => activeModel, + activeWorkspaceId: () => activeWorkspaceId, + send: (msg) => socket?.send(msg), + reportError, + chatFlushScheduler: opts?.chatFlushScheduler, + }); - /** - * 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); - } - } + const cache = registry.cache; + const settings = createConversationSettings({ + httpBase, + fetchImpl, + workspaceConversationId, + activeWorkspaceId: () => activeWorkspaceId, + reportError, + }); /** Refetch the workspace conversation's persisted model (works for a draft too). */ async function refreshModel(): Promise<void> { const id = workspaceConversationId(); @@ -691,14 +190,21 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { if (!res.ok) return; const data = (await res.json()) as ModelResponse; if (workspaceConversationId() !== id) return; + // The backend is authoritative for the persisted snapshot: restore how the + // conversation was selected (a Named Agent, or none). Falls back to the tab's + // local snapshot when the response omits it (older backend). + namedAgentSnapshot = data.namedAgent ?? namedAgentSnapshot; 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); + tabsStore.setSnapshot(activeId, namedAgentSnapshot); + registry.chatStores.get(activeId)?.setModel(data.model); + registry.chatStores.get(activeId)?.setNamedAgent(namedAgentSnapshot); } else { - draftStore.setModel(data.model); + registry.draftStore.setModel(data.model); + registry.draftStore.setNamedAgent(namedAgentSnapshot); } } } catch (err) { @@ -706,174 +212,6 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } } - // 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 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; @@ -886,97 +224,35 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { * promotion (first send), so a cwd set on a draft carries into the real turn. */ function workspaceConversationId(): string { - return tabsStore.activeConversationId ?? draftConversationId; + return tabsStore.activeConversationId ?? registry.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); - } - } - } - } + const surfaces = createSurfaceProtocol({ + focusedConversationId, + send: (msg) => 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 refreshCompactPercent(); - } + const conversations = createOpenConversations({ + httpBase, + fetchImpl, + tabsStore, + registry, + cache, + activeModel: () => activeModel, + settings, + syncSubscriptions: surfaces.syncSubscriptions, + reportError, + }); + const httpActions = createHttpActions({ + httpBase, + fetchImpl, + activeConversationId: () => tabsStore.activeConversationId, + activeModel: () => activeModel, + activeWorkspaceId: () => activeWorkspaceId, + reportError, + }); /** * Surface a swallowed error to the user via the full-screen error modal @@ -992,229 +268,133 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { 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; + // ── Frontend plugins (plan 03) ───────────────────────────────────────────── + // The ABI driver's composition-root wiring: one host instance per app + // store. `sync()` runs on boot, on `extensions-changed`, and on every + // workspace switch (the three load-lifecycle triggers — the active + // workspace is read per call, which makes workspace.set meaningful). + // The surfaces ports delegate to the SAME protocol/socket machinery the + // app store's own surface methods use (closure functions below, shared + // with the returned object — no second protocol state). + const pluginHost = createPluginHost({ + http: pluginsHttp, + httpBase, + reporter: pluginsStore, + activeWorkspaceId: () => activeWorkspaceId, + surfaces: { + subscribe: (surfaceId, conversationId) => + surfaces.subscribeSurfaceOp(surfaceId, conversationId), + unsubscribe: (surfaceId, conversationId) => + surfaces.unsubscribeSurfaceOp(surfaceId, conversationId), + invoke: (surfaceId, actionId, payload, conversationId) => + surfaces.invokeSurfaceOp(surfaceId, actionId, payload, conversationId), + getSpec: (surfaceId) => surfaces.surfaceSpecOp(surfaceId), + }, + // plan 04 §4.5: the plugins' `api.layout` port — applyOps posts to the + // ACTIVE workspace's provider (the ops route) and feeds the returned + // tree back through the store (instant local echo; the WS broadcast is + // every client's authoritative copy); getTree exposes the active tree + // only while a provider is PRESENT (null = no-provider mode). + layout: createLayoutAbiPort({ + store: layoutStore, + postOps: (workspaceId, ops) => layoutHttp.postOps(workspaceId, ops), + }), }); - /** - * 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 = { + const socketOpts = createWsHandlers({ 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(); - } - }, - }; + send: (msg) => socket?.send(msg), + registry, + conversations, + surfaces, + cache, + tabsStore, + activeWorkspaceId: () => activeWorkspaceId, + workspaceSetMsg: (id) => workspaceSetMsg(id), + pluginsStore, + pluginHost, + layoutStore, + tileLayoutStore, + refreshers, + reportError, + }); 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); - }); + // Tell the backend THIS connection's active workspace (plan 06 §6.4). The + // socket queues the send until the connection opens; on receipt the server + // re-pushes the workspace-filtered surface catalog. Also refreshed on + // reconnect (onReopen — a new connection is anonymous again). + const workspaceSetMsg = (workspaceId: string): WorkspaceSetMessage => ({ + type: "workspace.set", + workspaceId, + }); + socket.send(workspaceSetMsg(activeWorkspaceId)); + + // Fetch the model catalog (workspace-scoped — plan 06 §6.5). + void refreshers.refreshModels(); + // Fetch the active workspace's provider status (§6.5 first-boot guardrail). + void refreshers.refreshProviderStatus(); + // Probe the ACTIVE layout provider (plan 02 §2.6 boot trigger): the tree is + // served by the backend's layout plugin when one is active. `boot: true` + // arms the one-way §2.7 localStorage migration on the first success. + void layoutStore.probe(activeWorkspaceId, { boot: true }); + // Frontend plugins (plan 03): the load lifecycle's boot trigger — every + // loaded ∧ enabled ∧ frontend-declaring ∧ handshake-passing plugin is + // imported + activated (after the first list fetch lands). + void pluginHost.sync(); + // One-time legacy model-name migration on boot (independent of the fetch; + // preserved from the pre-refreshModels boot chain). + if (migrateLegacyModelName(activeModel) !== activeModel) { + activeModel = migrateLegacyModelName(activeModel); + } + + refreshers.loadGlobalCatalogs(); // 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(); + // One-time persisted-state migration: rewrite a legacy `opencode/<model>` + // tab model to the numbered `opencode-1/<model>` namespace, and persist the + // migrated value back so a re-load is a no-op (idempotent). + const model = migrateLegacyModelName(tab.model); + if (model !== tab.model) { + tabsStore.setModel(tab.conversationId, model); + } + const store = registry.createChatFor(tab.conversationId, model, tab.workspaceId); + registry.chatStores.set(tab.conversationId, store); // 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); + registry.subscribeChat(tab.conversationId); } if (persistedState.activeConversationId !== null) { const activeTab = persistedState.tabs.find( (t) => t.conversationId === persistedState.activeConversationId, ); if (activeTab !== undefined) { - activeModel = activeTab.model; + activeModel = migrateLegacyModelName(activeTab.model); + namedAgentSnapshot = activeTab.snapshot ?? null; } } } - refreshActiveChat(); - void refreshCwd(); - void refreshComputer(); + registry.refreshActiveChat(); + void settings.refreshCwd(); + void settings.refreshComputer(); void refreshModel(); - void refreshReasoningEffort(); - void refreshCompactPercent(); - void refreshVisionSettings(); + void settings.refreshReasoningEffort(); + void settings.refreshThinking(); + void settings.refreshCompactPercent(); + void settings.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(); + void conversations.fetchOpenConversations(); return { get tabs(): readonly Tab[] { @@ -1231,162 +411,209 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { }, setActiveWorkspace(workspaceId: string): void { activeWorkspaceId = workspaceId; + // Tell the backend the switch (plan 06 §6.4): workspace switching is + // instant + client-side, so the server must be told explicitly — it + // re-pushes the workspace-filtered surface catalog on receipt. The send + // is idempotent + queued until the socket opens. + socket?.send(workspaceSetMsg(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; + registry.draftStore = registry.createChatFor(nextDraftId, activeModel, workspaceId); + registry.draftConversationId = nextDraftId; tabsStore.newDraft(); - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshComputer(); + namedAgentSnapshot = null; + registry.refreshActiveChat(); + surfaces.syncSubscriptions(); + void settings.refreshCwd(); + void settings.refreshComputer(); void refreshModel(); - void refreshReasoningEffort(); - void refreshCompactPercent(); + void settings.refreshReasoningEffort(); + void settings.refreshThinking(); + void settings.refreshCompactPercent(); + // The model picker + provider guardrail are WORKSPACE-SCOPED now + // (plan 06 §6.5): refetch both for the new workspace. + void refreshers.refreshModels(); + void refreshers.refreshProviderStatus(); + // The tile tree is WORKSPACE-SCOPED too (plan 02): reset to the local + // default for the new workspace and probe its provider (§2.6 — every + // workspace switch is a probe, which is also the missed-push catch-up). + layoutStore.onWorkspaceChanged(workspaceId); + // Frontend plugins (plan 03): the load lifecycle's workspace.set + // trigger — the enablement set is WORKSPACE-SCOPED, so a switch may + // load and unload frontend plugins. + void pluginHost.sync(); }, get activeChat(): ChatStore { - return activeChat; + return registry.activeChat; }, get models(): readonly string[] { - return models; + return refreshers.models; }, get modelInfo(): Readonly<Record<string, ModelMetadata>> { - return modelInfo; + return refreshers.modelInfo; }, get activeModel(): string { return activeModel; }, get catalog() { - return protocol.catalog; + return surfaces.protocol.catalog; }, get surfaces(): readonly SurfaceSpec[] { const out: SurfaceSpec[] = []; - for (const entry of protocol.catalog) { - const spec = getSurfaceSpec(protocol, entry.id); + for (const entry of surfaces.protocol.catalog) { + const spec = getSurfaceSpec(surfaces.protocol, entry.id); if (spec) out.push(spec); } return out; }, get lastError() { - return protocol.lastError; + return surfaces.protocol.lastError; }, get storage() { return localStorageOpt; }, + get textFileContextRevision(): number { + return settings.textFileContextRevision; + }, + createTextFileClient(conversationId) { + return createTextFileClient(httpBase, conversationId, fetchImpl); + }, get cwd(): string | null { - return cwd; + return settings.cwd; }, get computerId(): string | null { - return computerId; + return settings.computerId; }, get computers(): readonly ComputerEntry[] { - return computers; + return refreshers.computers; }, get reasoningEffort(): ReasoningEffort | null { - return reasoningEffort; + return settings.reasoningEffort; + }, + get thinking(): boolean | null { + return settings.thinking; }, get compactPercent(): number | null { - return compactPercent; + return settings.compactPercent; }, get visionSettings(): VisionSettings | null { - return visionSettings; + return settings.visionSettings; }, async refreshVisionSettings(): Promise<void> { - await refreshVisionSettings(); + await settings.refreshVisionSettings(); }, get chatLimit(): number { - return chatLimit; + return registry.chatLimit; }, conversationStatus(conversationId: string): ConversationStatus | undefined { - return conversationStatuses.get(conversationId); + return conversations.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); + return conversations.workspaceHasActiveConversations(workspaceId); }, get currentConversationId(): string { return workspaceConversationId(); }, - surface(surfaceId: string): SurfaceSpec | null { - return getSurfaceSpec(protocol, surfaceId); + surface(surfaceId: string, conversationId?: string): SurfaceSpec | null { + return surfaces.surfaceSpecOp(surfaceId, conversationId); }, send(text: string, images?: readonly ImageInput[]): void { if (tabsStore.activeConversationId === null) { - // Draft: promote to tab on first send - const conversationId = draftConversationId; + // Draft: promote to tab on first send. Carry a Named Agent selection's + // snapshot so the promoted conversation retains how it was selected (§7). + const conversationId = registry.draftConversationId; const model = activeModel; + const snapshot = namedAgentSnapshot ?? undefined; tabsStore.createTab({ conversationId, model, title: deriveTitle(text), workspaceId: activeWorkspaceId, + ...(snapshot ? { snapshot } : {}), }); - chatStores.set(conversationId, draftStore); - void draftStore.load(); + registry.chatStores.set(conversationId, registry.draftStore); // Prepare next draft const nextDraftId = randomId(); - draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId); - draftConversationId = nextDraftId; + registry.draftStore = registry.createChatFor(nextDraftId, activeModel, activeWorkspaceId); + registry.draftConversationId = nextDraftId; - refreshActiveChat(); + registry.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 refreshCompactPercent(); + surfaces.syncSubscriptions(); + void settings.refreshCwd(); + void settings.refreshComputer(); + void settings.refreshReasoningEffort(); + void settings.refreshThinking(); + void settings.refreshCompactPercent(); // Now send on the promoted store - chatStores.get(conversationId)?.send(text, images); + registry.chatStores.get(conversationId)?.send(text, images); } else { - activeChat.send(text, images); + registry.activeChat.send(text, images); } }, - queueMessage(text: string): void { + queueMessage(text: string, images?: readonly ImageInput[]): 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); + registry.activeChat.queueMessage(text, images); }, - selectModel(model: string): void { + 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. + registry.activeChat.cancelQueuedMessage(messageId); + }, + + selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void { activeModel = model; + namedAgentSnapshot = snapshot ?? null; const activeId = tabsStore.activeConversationId; if (activeId !== null) { tabsStore.setModel(activeId, model); - chatStores.get(activeId)?.setModel(model); + tabsStore.setSnapshot(activeId, snapshot ?? null); + registry.chatStores.get(activeId)?.setModel(model); + registry.chatStores.get(activeId)?.setNamedAgent(snapshot ?? null); + const body: SetModelRequest = { model, ...(snapshot ? { namedAgent: snapshot } : {}) }; void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(activeId)}/model`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model } satisfies SetModelRequest), + body: JSON.stringify(body), }).catch((err) => { reportError("Failed to persist model", err); }); } else { - draftStore.setModel(model); + registry.draftStore.setModel(model); + registry.draftStore.setNamedAgent(snapshot ?? null); } }, newDraft(): void { tabsStore.newDraft(); const nextDraftId = randomId(); - draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId); - draftConversationId = nextDraftId; - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshComputer(); + registry.draftStore = registry.createChatFor(nextDraftId, activeModel, activeWorkspaceId); + registry.draftConversationId = nextDraftId; + namedAgentSnapshot = null; + registry.refreshActiveChat(); + surfaces.syncSubscriptions(); + void settings.refreshCwd(); + void settings.refreshComputer(); void refreshModel(); - void refreshReasoningEffort(); - void refreshCompactPercent(); + void settings.refreshReasoningEffort(); + void settings.refreshThinking(); + void settings.refreshCompactPercent(); }, selectTab(conversationId: string): void { @@ -1394,747 +621,150 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId); if (tab !== undefined) { activeModel = tab.model; + namedAgentSnapshot = tab.snapshot ?? null; + } else { + namedAgentSnapshot = null; } - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshComputer(); + registry.refreshActiveChat(); + surfaces.syncSubscriptions(); + void settings.refreshCwd(); + void settings.refreshComputer(); void refreshModel(); - void refreshReasoningEffort(); - void refreshCompactPercent(); + void settings.refreshReasoningEffort(); + void settings.refreshThinking(); + void settings.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); + conversations.closeConversation(conversationId); + conversations.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); - }); - }, + renameTab: conversations.renameTab, + setTabGroup: conversations.setTabGroup, 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); - } + surfaces.invokeSurfaceOp(surfaceId, actionId, payload); }, - 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" }; - } + subscribeSurface(surfaceId: string, conversationId?: string): void { + surfaces.subscribeSurfaceOp(surfaceId, conversationId); }, - 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" }; - } + unsubscribeSurface(surfaceId: string, conversationId?: string): void { + surfaces.unsubscribeSurfaceOp(surfaceId, conversationId); }, - 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", - }; - } - }, + warmNow: httpActions.warmNow, - 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", - }; - } - }, + setCwd: settings.setCwd, - 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", - }; - } - }, + setComputer: settings.setComputer, - 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", - }; - } - }, + computerStatus: httpActions.computerStatus, - 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); - }); - }, + testComputer: httpActions.testComputer, - 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", - }; - } - }, + setReasoningEffort: settings.setReasoningEffort, - 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", - }; - } - }, + setThinking: settings.setThinking, - 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", - }; - } - }, + stopGeneration: httpActions.stopGeneration, - 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 }; - }, + compactNow: httpActions.compactNow, - 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", - }; - } - }, + setCompactPercent: settings.setCompactPercent, - 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", - }; - } - }, + setVisionSettings: settings.setVisionSettings, - 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", - }; - } - }, + setChatLimit: registry.setChatLimit, - 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", - }; - } - }, + lspStatus: settings.lspStatus, - 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", - }; - } - }, + mcpStatus: settings.mcpStatus, - 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", - }; - } - }, + heartbeatConfig: httpActions.heartbeatConfig, - 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", - }; - } - }, + setHeartbeatConfig: httpActions.setHeartbeatConfig, + + heartbeatRuns: httpActions.heartbeatRuns, + + stopHeartbeatRun: httpActions.stopHeartbeatRun, + + heartbeatNextRun: httpActions.heartbeatNextRun, watchConversation(conversationId: string): ChatStore { - return watchConversation(conversationId); + return registry.watchConversation(conversationId); }, unwatchConversation(conversationId: string): void { - unwatchConversation(conversationId); + registry.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", - }; - } - }, + concurrencyLimits: httpActions.concurrencyLimits, - 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", - }; - } - }, + getConcurrencyLimit: httpActions.getConcurrencyLimit, - 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", - }; - } - }, + setConcurrencyLimit: httpActions.setConcurrencyLimit, - 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", - }; - } - }, + deleteConcurrencyLimit: httpActions.deleteConcurrencyLimit, - 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", - }; - } - }, + accountUsage: httpActions.accountUsage, - 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", - }; - } - }, + setAccountUsageSettings: httpActions.setAccountUsageSettings, - 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", - }; - } - }, + concurrencyStatus: httpActions.concurrencyStatus, - 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", - }; - } - }, + searchBackendConfig: httpActions.searchBackendConfig, - 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", - }; - } - }, + setSearchBackend: httpActions.setSearchBackend, - 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", - }; - } + getConcurrencyCooldown: httpActions.getConcurrencyCooldown, + + setConcurrencyCooldown: httpActions.setConcurrencyCooldown, + + loadSystemPrompt: httpActions.loadSystemPrompt, + + setSystemPrompt: httpActions.setSystemPrompt, + + loadSystemPromptVariables: httpActions.loadSystemPromptVariables, + + attachUnloadGate: registry.attachUnloadGate, + + // ── Named Agents (global config; plan §9) ──────────────────────────────── + + get namedAgents(): readonly NamedAgent[] { + return refreshers.namedAgents; + }, + get namedAgentSnapshot(): NamedAgentSelectionSnapshot | null { + return namedAgentSnapshot; }, - attachUnloadGate(gate: () => boolean): void { - unloadGate = gate; + // ── Plugins (plan 07) ──────────────────────────────────────────────────── + get plugins(): PluginsStore { + return pluginsStore; + }, + get layout(): LayoutStore { + return layoutStore; }, + get tileLayout(): TileLayoutStore { + return tileLayoutStore; + }, + get enabledProviderCount(): number | null { + return refreshers.enabledProviderCount; + }, + + refreshNamedAgents: refreshers.refreshNamedAgents, + + createNamedAgent: refreshers.createNamedAgent, + + updateNamedAgent: refreshers.updateNamedAgent, + + deleteNamedAgent: refreshers.deleteNamedAgent, get fatalError(): string | null { return fatalError; @@ -2144,15 +774,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { }, dispose(): void { - for (const store of chatStores.values()) { - store.dispose(); - } - chatStores.clear(); - for (const store of watchStores.values()) { - store.dispose(); - } - watchStores.clear(); - draftStore.dispose(); + registry.dispose(); socket?.close(); socket = null; }, diff --git a/src/app/store.test.ts b/src/app/store.test.ts index a945ea7..90c11b3 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -1,9 +1,29 @@ import type { ConversationHistoryResponse, WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; -import { describe, expect, it, vi } from "vitest"; +import type { NamedAgent } from "@dispatch/wire"; +import { IDBFactory } from "fake-indexeddb"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { WebSocketLike } from "../adapters/ws"; import { createAppStore } from "./store.svelte"; +// Never let an omitted injection reach a real backend. Track rejected requests +// too: the app deliberately catches network errors, which must not hide a bad fake. +const unexpectedFetch = vi.fn<typeof fetch>(async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + throw new Error(`Unexpected fetch: ${method} ${url}`); +}); + +beforeEach(() => { + unexpectedFetch.mockClear(); + vi.stubGlobal("fetch", unexpectedFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + expect(unexpectedFetch).not.toHaveBeenCalled(); +}); + interface FakeSocket extends WebSocketLike { sent: string[]; resolveOpen(): void; @@ -104,17 +124,124 @@ interface FakeFetchOptions { models?: readonly string[]; history?: Record<string, ConversationHistoryResponse>; model?: string | null; + /** Persisted Named Agents (GET /named-agents + CRUD in-memory). */ + namedAgents?: readonly NamedAgent[]; } function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch { - const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"]; + const models = opts?.models ?? ["opencode-1/deepseek-v4-flash", "openai/gpt-4o"]; const history = opts?.history ?? {}; - return async (input: string | URL | Request): Promise<Response> => { + // In-memory Named Agent store for the CRUD routes (the fake fetch mimics the + // backend's /named-agents surface). + let namedAgents: NamedAgent[] = opts?.namedAgents ? [...opts.namedAgents] : []; + return 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 }), { status: 200 }); + const method = + init?.method ?? (typeof input !== "string" && !(input instanceof URL) ? input.method : "GET"); + const bodyText = + typeof init?.body === "string" + ? init.body + : typeof input !== "string" && + !(input instanceof URL) && + typeof input.body !== "undefined" && + "body" in input + ? await input.clone().text() + : null; + const { pathname, searchParams } = new URL(url); + if (pathname === "/models" && method === "GET") { + return Response.json({ models }); + } + if (method === "GET") { + if (pathname === "/conversations" && searchParams.get("status") === "active,idle") { + // Most tests drive tabs via WS/local actions. Disable boot reconciliation + // so an in-flight empty list cannot remove a tab created by the test. + // Discovery tests override this route with their authoritative lists. + return Response.json({ error: "Discovery unavailable in this fixture" }, { status: 503 }); + } + if (pathname === "/plugins") return Response.json({ plugins: [] }); + if (pathname === "/computers") return Response.json({ computers: [] }); + if (/^\/workspaces\/[^/]+\/plugins$/.test(pathname)) { + return Response.json({ workspaceId: pathname.split("/")[2], plugins: [] }); + } + if (/^\/workspaces\/[^/]+\/plugins\/providers-status$/.test(pathname)) { + return Response.json({ enabledProviderCount: 1 }); + } + // Explicitly simulate absent layout providers; retain local cached layouts. + if ( + /^\/workspaces\/[^/]+\/layout$/.test(pathname) || + /^\/home\/tile-layout\/(wide|tall)$/.test(pathname) + ) { + return Response.json({ error: "No layout provider" }, { status: 404 }); + } + if (pathname === "/settings/vision") { + return Response.json({ imageLimit: 10, compactionModel: null }); + } + const setting = + /^\/conversations\/[^/]+\/(cwd|computer|reasoning-effort|thinking|compact-percent)$/.exec( + pathname, + )?.[1]; + if (setting !== undefined) { + const key = { + cwd: "cwd", + computer: "computerId", + "reasoning-effort": "reasoningEffort", + thinking: "thinking", + "compact-percent": "compactPercent", + }[setting]; + if (key !== undefined) return Response.json({ [key]: null }); + } + if (/^\/conversations\/[^/]+\/metrics$/.test(pathname)) { + return Response.json({ turns: [] }); + } + if (/^\/conversations\/[^/]+$/.test(pathname) && searchParams.has("sinceSeq")) { + return Response.json( + history[url] ?? + ({ chunks: [], historyEpoch: 0, latestSeq: 0 } satisfies ConversationHistoryResponse), + ); + } + } + if (/^\/conversations\/[^/]+\/close$/.test(pathname) && method === "POST") { + return Response.json({ conversationId: pathname.split("/")[2], abortedTurn: false }); + } + if (pathname === "/named-agents" && method === "GET") { + return new Response(JSON.stringify({ namedAgents }), { status: 200 }); + } + if (pathname === "/named-agents" && method === "POST") { + const payload = + bodyText === null + ? null + : (JSON.parse(bodyText) as { name?: string; model?: string } | null); + const agent: NamedAgent = { + id: `na-${namedAgents.length + 1}`, + name: payload?.name ?? "", + model: payload?.model ?? "", + }; + namedAgents = [...namedAgents, agent]; + return new Response(JSON.stringify({ namedAgent: agent }), { status: 201 }); } - if (url.endsWith("/model")) { + if (/^\/named-agents\/[^/]+$/.test(pathname) && method === "PUT") { + const id = url.split("/named-agents/")[1] ?? ""; + const payload = + bodyText === null + ? null + : (JSON.parse(bodyText) as { name?: string; model?: string } | null); + const updated: NamedAgent = { + id, + name: payload?.name ?? "", + model: payload?.model ?? "", + }; + namedAgents = namedAgents.map((a) => (a.id === id ? updated : a)); + return new Response(JSON.stringify({ namedAgent: updated }), { status: 200 }); + } + if (/^\/named-agents\/[^/]+$/.test(pathname) && method === "DELETE") { + const id = url.split("/named-agents/")[1] ?? ""; + namedAgents = namedAgents.filter((a) => a.id !== id); + return new Response(JSON.stringify({ id }), { status: 200 }); + } + if ( + /^\/conversations\/[^/]+\/model$/.test(pathname) && + (method === "GET" || method === "PUT") + ) { return new Response( JSON.stringify({ conversationId: "ignored", @@ -123,9 +250,7 @@ function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch { { status: 200 }, ); } - const body = - history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse); - return new Response(JSON.stringify(body), { status: 200 }); + return unexpectedFetch(input, init); }; } @@ -145,8 +270,8 @@ function createFakeStorage(): Storage { getItem(key: string): string | null { return map.get(key) ?? null; }, - key(_index: number): string | null { - return null; + key(index: number): string | null { + return [...map.keys()][index] ?? null; }, removeItem(key: string) { map.delete(key); @@ -163,6 +288,22 @@ function activeConversationId(store: ReturnType<typeof createAppStore>): string return id as string; } +describe("fakeFetchImpl", () => { + it.each([ + "http://localhost:24203/unexpected", + new URL("http://localhost:24203/conversations/c/unknown-setting"), + new Request("http://localhost:24203/models", { method: "DELETE" }), + ])("rejects an unexpected URL or method: %s", async (input) => { + try { + await expect(fakeFetchImpl()(input)).rejects.toThrow("Unexpected fetch:"); + expect(unexpectedFetch).toHaveBeenCalledOnce(); + } finally { + // This test deliberately exercises the guard; all store tests require zero calls. + unexpectedFetch.mockClear(); + } + }); +}); + describe("createAppStore", () => { it("starts with empty catalog and no surfaces", () => { const ws = fakeSocket(); @@ -181,6 +322,29 @@ describe("createAppStore", () => { store.dispose(); }); + it("probes only the portrait tile layout at boot and injects its cached document", () => { + const width = vi.spyOn(window, "innerWidth", "get").mockReturnValue(390); + const height = vi.spyOn(window, "innerHeight", "get").mockReturnValue(844); + const storage = createFakeStorage(); + const cached = { version: 2, tiles: [{ id: "a", order: 0, colSpan: 2, rowSpan: 2 }] }; + storage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached)); + const fetchImpl = vi.fn(fakeFetchImpl()); + const store = createAppStore({ socketFactory: fakeSocket, fetchImpl, localStorage: storage }); + try { + expect(store.tileLayout.mode).toBe("tall"); + expect(store.tileLayout.entries).toEqual(cached.tiles); + expect( + fetchImpl.mock.calls + .map(([url]) => String(url)) + .filter((url) => url.includes("/home/tile-layout/")), + ).toEqual([expect.stringMatching(/\/home\/tile-layout\/tall$/)]); + } finally { + store.dispose(); + width.mockRestore(); + height.mockRestore(); + } + }); + it("updates catalog when catalog message arrives", () => { const ws = fakeSocket(); const store = createAppStore({ @@ -457,6 +621,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), localStorage: createFakeStorage(), + chatFlushScheduler: null, }); ws.resolveOpen(); @@ -518,17 +683,25 @@ describe("createAppStore", () => { { seq: 1, role: "user", chunk: { type: "text", text: "hi" } }, { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } }, ], + historyEpoch: 0, latestSeq: 2, }; - const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => { + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async ( + input: string | URL | Request, + init?: RequestInit, + ): 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"] }), { + if (/\/models(\?.*)?$/.test(url)) { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { status: 200, }); } - return new Response(JSON.stringify(historyResponse), { status: 200 }); + if (/\/conversations\/[^/?]+\?sinceSeq=/.test(url)) { + return Response.json(historyResponse); + } + return base(input, init); }; const ws = fakeSocket(); @@ -573,19 +746,19 @@ describe("createAppStore", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl({ - models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"], + models: ["opencode-1/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", - ]); + await vi.waitFor(() => { + expect(store.models).toEqual([ + "opencode-1/deepseek-v4-flash", + "openai/gpt-4o", + "anthropic/claude-3", + ]); + }); store.dispose(); }); @@ -599,11 +772,68 @@ describe("createAppStore", () => { }); ws.resolveOpen(); - expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); store.dispose(); }); + it("does not silently switch the active model when the selected one is absent from the catalog", async () => { + // The catalog only has umans; the active (default/selected) OpenCode model is + // NOT available. The FE must NOT auto-fall back to the first catalog model — + // it keeps the selection and lets the model selector surface it as unavailable. + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ models: ["umans/umans-glm-5.2"] }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + await vi.waitFor(() => expect(store.models).toEqual(["umans/umans-glm-5.2"])); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); + + store.dispose(); + }); + + it("migrates a legacy persisted opencode/<model> tab model to opencode-1/<model>", async () => { + const ws = fakeSocket(); + // Seed localStorage with a persisted tab whose model is the OBSOLETE + // `opencode/deepseek-v4-flash` namespace. + const storage = createFakeStorage(); + storage.setItem( + "dispatch.tabs", + JSON.stringify({ + tabs: [ + { + conversationId: "c-legacy", + title: "Legacy", + model: "opencode/deepseek-v4-flash", + workspaceId: "default", + }, + ], + activeConversationId: null, + }), + ); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + // After restore, the restored tab's model + the persisted store are migrated. + const restored = store.tabs.find((t) => t.conversationId === "c-legacy"); + expect(restored?.model).toBe("opencode-1/deepseek-v4-flash"); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); + // Issue 7: the migrated tab state is ACTUALLY written back to the storage + // adapter immediately (not merely kept in memory) — a re-load is now a no-op. + const persisted = JSON.parse(storage.getItem("dispatch.tabs") ?? "") as { + tabs: Array<{ conversationId: string; model: string }>; + }; + const persistedModel = persisted.tabs.find((t) => t.conversationId === "c-legacy")?.model; + expect(persistedModel).toBe("opencode-1/deepseek-v4-flash"); + expect(persisted.tabs.some((t) => t.model.startsWith("opencode/"))).toBe(false); + store.dispose(); + }); + it("draft: sending the first message creates a tab titled from the message", () => { const ws = fakeSocket(); const store = createAppStore({ @@ -679,7 +909,7 @@ describe("createAppStore", () => { 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"); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); // Wait for the persisted model fetch to resolve. await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o")); @@ -688,12 +918,148 @@ describe("createAppStore", () => { store.dispose(); }); + it("fetchOpenConversations stamps tabs with the conversation's persisted model (not the global active model)", async () => { + const ws = fakeSocket(); + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async ( + input: string | URL | Request, + init?: RequestInit, + ): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (/\/models(\?.*)?$/.test(url)) { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { + status: 200, + }); + } + // The authoritative open-conversation list — one conversation whose + // persisted model differs from the global activeModel. + if (url.includes("/conversations?status=")) { + return new Response( + JSON.stringify({ + conversations: [ + { + id: "conv-model", + createdAt: 1000, + lastActivityAt: 2000, + title: "Model conv", + status: "idle", + workspaceId: "default", + model: "openrouter/stealth/ox-alpha", + }, + ], + }), + { status: 200 }, + ); + } + return base(input, init); + }; + + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // global activeModel is the default until anything changes it + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); + + // fetchOpenConversations (boot reconcile) opens the conversation's tab — + // stamped with the PERSISTED model from the list, not activeModel. + await vi.waitFor(() => { + const tab = store.tabs.find((t) => t.conversationId === "conv-model"); + expect(tab?.model).toBe("openrouter/stealth/ox-alpha"); + }); + + store.dispose(); + }); + + it("fetchOpenConversations seeds the chat store's cache-timer turn gap from an idle conversation's lastActivityAt", async () => { + const ws = fakeSocket(); + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async ( + input: string | URL | Request, + init?: RequestInit, + ): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes("/conversations?status=")) { + return new Response( + JSON.stringify({ + conversations: [ + { + id: "conv-seed", + createdAt: 1000, + lastActivityAt: 7777, + title: "Seed conv", + status: "idle", + workspaceId: "default", + model: "claude/claude-opus-4-6", + }, + ], + }), + { status: 200 }, + ); + } + if (url.endsWith("/metrics")) { + return new Response(JSON.stringify({ turns: [] }), { status: 200 }); + } + return base(input, init); + }; + + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await vi.waitFor(() => { + expect(store.tabs.some((t) => t.conversationId === "conv-seed")).toBe(true); + }); + store.selectTab("conv-seed"); + expect(store.activeChat.cacheTimer.running).toEqual({ + kind: "turn", + turnId: null, + startedAt: 7777, + }); + + store.dispose(); + }); + + it("conversation.open WS message carrying a model stamps the tab with it", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // The user stays on their current (draft) tab; the backend broadcasts + // `conversation.open` with the conversation's persisted model. + ws.feedServerMessage({ + type: "conversation.open", + conversationId: "conv-ws", + workspaceId: "default", + model: "openrouter/stealth/ox-alpha", + }); + + await vi.waitFor(() => { + const tab = store.tabs.find((t) => t.conversationId === "conv-ws"); + expect(tab?.model).toBe("openrouter/stealth/ox-alpha"); + expect(tab?.workspaceId).toBe("default"); + }); + + store.dispose(); + }); + it("chat.delta routes to the matching tab only", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), localStorage: createFakeStorage(), + chatFlushScheduler: null, }); ws.resolveOpen(); @@ -891,6 +1257,135 @@ describe("createAppStore", () => { 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, + headers: { "content-type": "application/json" }, + }); + } + 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("treats a SPA-fallback response (200 + text/html) as 'never set' (null ⇒ ON)", async () => { + // In production (DISPATCH_WEB_DIR set), unknown API routes get the SPA + // fallback: index.html with HTTP 200 + text/html. This must NOT crash + // res.json() — it should leave `thinking` null (ON, the default). + 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("<!DOCTYPE html><html><body>SPA fallback</body></html>", { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // give the (HTML) 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, + headers: { "content-type": "application/json" }, + }); + } + if (url.endsWith("/thinking")) { + return new Response(JSON.stringify({ conversationId: "x", thinking: null }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + 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({ @@ -1112,17 +1607,21 @@ describe("createAppStore", () => { store.dispose(); }); - it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => { + it("re-subscribes and resyncs a loaded conversation on reconnect", async () => { const fetchedUrls: string[] = []; - const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => { + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async ( + input: string | URL | Request, + init?: RequestInit, + ): 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"] }), { + if (/\/models(\?.*)?$/.test(url)) { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { status: 200, }); } - return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + return base(input, init); }; const ws = reconnectableSocket(); @@ -1205,9 +1704,6 @@ describe("createAppStore", () => { status: 200, }); } - if (url.includes("/heartbeat")) { - return new Response(JSON.stringify(config), { status: 200 }); - } return base(input, init); }; } @@ -1223,6 +1719,7 @@ describe("createAppStore", () => { if (!result.ok) throw new Error("unreachable"); expect(result.config).toEqual({ enabled: true, + inactiveOnly: true, systemPrompt: "sys", taskPrompt: "task", intervalMinutes: 15, @@ -1423,7 +1920,7 @@ describe("createAppStore", () => { const limits = opts?.limits ?? { limits: [ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ], }; const limit = opts?.limit ?? { providerId: "umans", limit: 4 }; @@ -1431,7 +1928,7 @@ describe("createAppStore", () => { providers: [ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, { - providerId: "openai-compat", + providerId: "opencode-1", limit: 5, inFlight: 5, queued: 3, @@ -1491,7 +1988,7 @@ describe("createAppStore", () => { if (!result.ok) throw new Error("unreachable"); expect(result.limits).toEqual([ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ]); store.dispose(); }); @@ -1663,7 +2160,7 @@ describe("createAppStore", () => { autoReduced: false, }); expect(result.providers[1]).toMatchObject({ - providerId: "openai-compat", + providerId: "opencode-1", paused: true, pausedUntil: 1_719_408_000_000, cooldownMs: 350, @@ -1969,6 +2466,7 @@ describe("createAppStore — vision settings (global)", () => { fetchImpl: typeof fetch; puts: { imageLimit?: number; compactionModel?: string | null }[]; } { + const base = fakeFetchImpl(); let current = initial; const puts: { imageLimit?: number; compactionModel?: string | null }[] = []; return { @@ -1976,7 +2474,7 @@ describe("createAppStore — vision settings (global)", () => { 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")) { + if (/\/models(\?.*)?$/.test(url)) { return new Response( JSON.stringify({ models: ["kimi/k2", "umans/glm-5.2"], @@ -1997,8 +2495,7 @@ describe("createAppStore — vision settings (global)", () => { } 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 }); + return base(input, init); }, }; } @@ -2061,6 +2558,7 @@ describe("createAppStore — vision settings (global)", () => { }); it("surfaces a PUT error", 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("/settings/vision")) { @@ -2071,7 +2569,7 @@ describe("createAppStore — vision settings (global)", () => { status: 200, }); } - return new Response(JSON.stringify({ models: [] }), { status: 200 }); + return base(input, init); }; const store = createAppStore({ socketFactory: () => fakeSocket(), @@ -2087,3 +2585,616 @@ describe("createAppStore — vision settings (global)", () => { store.dispose(); }); }); + +describe("createAppStore — Named Agents", () => { + it("seeds namedAgents from GET /named-agents on boot", async () => { + const ws = fakeSocket(); + const agents: readonly NamedAgent[] = [ + { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + ]; + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ namedAgents: agents }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await vi.waitFor(() => expect(store.namedAgents).toEqual(agents)); + store.dispose(); + }); + + it("createNamedAgent appends to the store list", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.createNamedAgent({ name: "Research", model: "umans/glm" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.agent.id).toBe("na-1"); + expect(store.namedAgents).toHaveLength(1); + expect(store.namedAgents[0]?.name).toBe("Research"); + } + store.dispose(); + }); + + it("updateNamedAgent updates the record in place", async () => { + const ws = fakeSocket(); + const agents: readonly NamedAgent[] = [ + { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + ]; + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ namedAgents: agents }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.updateNamedAgent("na-1", { + name: "Coding", + model: "opencode-1/new", + }); + expect(result.ok).toBe(true); + expect(store.namedAgents[0]?.name).toBe("Coding"); + expect(store.namedAgents[0]?.model).toBe("opencode-1/new"); + store.dispose(); + }); + + it("deleteNamedAgent removes the record immediately", async () => { + const ws = fakeSocket(); + const agents: readonly NamedAgent[] = [ + { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + { id: "na-2", name: "Research Agent", model: "umans/glm" }, + ]; + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ namedAgents: agents }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + await vi.waitFor(() => expect(store.namedAgents).toHaveLength(agents.length)); + + const result = await store.deleteNamedAgent("na-1"); + expect(result.ok).toBe(true); + expect(store.namedAgents.map((a) => a.id)).toEqual(["na-2"]); + store.dispose(); + }); + + it("selectModel records a Named Agent snapshot on the active conversation", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.selectModel("opencode-1/claude-sonnet-4.5", { + namedAgentId: "na-1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }); + + expect(store.activeModel).toBe("opencode-1/claude-sonnet-4.5"); + expect(store.namedAgentSnapshot?.namedAgentName).toBe("Coding Agent"); + store.dispose(); + }); + + it("selectModel without a snapshot clears a prior Named Agent selection", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.selectModel("opencode-1/claude-sonnet-4.5", { + namedAgentId: "na-1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }); + store.selectModel("openai/gpt-4o"); + + expect(store.activeModel).toBe("openai/gpt-4o"); + expect(store.namedAgentSnapshot).toBeNull(); + store.dispose(); + }); +}); + +describe("conversation load-on-demand", () => { + async function setup( + ws: WebSocketLike, + count = 3, + activeConversationId: string | null = "lazy-0", + workspaceCount = 1, + ) { + const storage = createFakeStorage(); + const conversations = Array.from({ length: count }, (_, i) => ({ + id: `lazy-${i}`, + title: `Conversation ${i}`, + model: "openai/gpt-4o", + workspaceId: i % workspaceCount === 0 ? "default" : `workspace-${i % workspaceCount}`, + status: i === 1 ? "active" : "idle", + lastActivityAt: 1234, + })); + storage.setItem( + "dispatch.tabs", + JSON.stringify({ + tabs: conversations.slice(0, Math.ceil(count / 2)).map((c) => ({ + conversationId: c.id, + title: c.title, + model: c.model, + workspaceId: c.workspaceId, + })), + activeConversationId, + }), + ); + const base = fakeFetchImpl(); + const fetchImpl = vi.fn<typeof fetch>(async (input, init) => { + const url = String(input); + if (url.includes("/conversations?status=")) { + return Response.json({ conversations }); + } + if (/\/conversations\/[^/?]+\?sinceSeq=/.test(url)) { + return Response.json({ + chunks: [ + { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hi" } }, + { seq: 2, role: "assistant", turnId: "t1", chunk: { type: "text", text: "hello" } }, + ], + historyEpoch: 0, + latestSeq: 2, + }); + } + if (url.endsWith("/metrics")) return Response.json({ turns: [] }); + return base(input, init); + }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: storage, + indexedDB: new IDBFactory(), + chatFlushScheduler: null, + }); + const requests = () => + fetchImpl.mock.calls + .map(([url]) => new URL(String(url)).pathname + new URL(String(url)).search) + .filter((url) => /\/conversations\/[^/?]+(?:\?sinceSeq=|\/metrics$)/.test(url)); + await vi.waitFor(() => expect(store.tabs).toHaveLength(Math.ceil(count / workspaceCount))); + if (activeConversationId !== null) { + await vi.waitFor(() => + expect(requests()).toContain(`/conversations/${activeConversationId}/metrics`), + ); + } + return { store, requests }; + } + + // These exercise 300 real Svelte-backed tabs and count requests, not elapsed + // time. Allow contention without shrinking the regression's large-tab fixture. + it("boots 300 persisted/discovered tabs with only active history + metrics", async () => { + const ws = fakeSocket(); + const { store, requests } = await setup(ws, 300); + try { + ws.resolveOpen(); + expect(requests()).toEqual([ + "/conversations/lazy-0?sinceSeq=0&limit=192", + "/conversations/lazy-0/metrics", + ]); + expect( + parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"), + ).toHaveLength(1); + expect(store.tabs[299]?.title).toBe("Conversation 299"); + expect(store.conversationStatus("lazy-1")).toBe("active"); + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: "lazy-1", + workspaceId: "default", + status: "queued", + }); + expect(store.conversationStatus("lazy-1")).toBe("queued"); + expect(requests()).toHaveLength(2); + } finally { + store.dispose(); + } + }, 20_000); + + it("discovers 339 conversations across 24 workspaces without subscribing to all of them", async () => { + const ws = fakeSocket(); + const { store } = await setup(ws, 339, "lazy-0", 24); + try { + ws.resolveOpen(); + const subscriptions = parseSent(ws).filter( + (message) => (message as { type: string }).type === "chat.subscribe", + ); + expect(subscriptions).toEqual([{ type: "chat.subscribe", conversationId: "lazy-0" }]); + store.setActiveWorkspace("workspace-1"); + store.selectTab("lazy-1"); + expect(parseSent(ws)).toContainEqual({ type: "chat.unsubscribe", conversationId: "lazy-0" }); + expect(parseSent(ws)).toContainEqual({ type: "chat.subscribe", conversationId: "lazy-1" }); + } finally { + store.dispose(); + } + }, 20_000); + + it("does not load any history while the active slot is a draft", async () => { + const { store, requests } = await setup(fakeSocket(), 300, null); + try { + expect(requests()).toEqual([]); + store.selectTab("lazy-0"); + await vi.waitFor(() => expect(requests()).toContain("/conversations/lazy-0/metrics")); + expect(requests()).toHaveLength(2); + } finally { + store.dispose(); + } + }, 20_000); + + it("loads a tab once while pending and catches up a previously loaded tab on reactivation", async () => { + const { store, requests } = await setup(fakeSocket()); + try { + store.selectTab("lazy-2"); + store.selectTab("lazy-0"); + store.selectTab("lazy-2"); + await vi.waitFor(() => expect(requests()).toHaveLength(6)); + expect(requests().filter((u) => u.includes("lazy-2"))).toHaveLength(2); + expect(store.activeChat.cacheTimer.running?.startedAt).toBe(1234); + } finally { + store.dispose(); + } + }); + + it("re-subscribes and resyncs only focused tabs and watches on reconnect", async () => { + const ws = reconnectableSocket(); + const { store, requests } = await setup(ws); + try { + ws.open(); + store.watchConversation("lazy-watch"); + await vi.waitFor(() => expect(requests()).toHaveLength(4)); + ws.sent.length = 0; + const before = requests().length; + ws.closeRemote(); + await new Promise((r) => setTimeout(r, 800)); + ws.open(); + await vi.waitFor(() => expect(requests()).toHaveLength(before + 4)); + expect( + requests() + .slice(before) + .every((u) => /lazy-(0|watch)[/?]/.test(u)), + ).toBe(true); + expect( + parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"), + ).toHaveLength(2); + } finally { + store.dispose(); + } + }); + + it("loads watches, including unloaded tabs, and loads disposed watches again", async () => { + const { store, requests } = await setup(fakeSocket()); + try { + const watch = store.watchConversation("lazy-1"); + store.watchConversation("lazy-1"); + await vi.waitFor(() => expect(requests()).toHaveLength(4)); + expect(watch.chunks).toHaveLength(2); + store.selectTab("lazy-1"); + expect(requests()).toHaveLength(4); + store.watchConversation("lazy-watch"); + await vi.waitFor(() => expect(requests()).toHaveLength(6)); + store.unwatchConversation("lazy-watch"); + const reopened = store.watchConversation("lazy-watch"); + await vi.waitFor(() => expect(requests()).toHaveLength(8)); + expect(reopened.chunks).toHaveLength(2); + } finally { + store.dispose(); + } + }); + + it("merges pre-load live deltas with history without duplicate bubbles", async () => { + const ws = fakeSocket(); + const { store, requests } = await setup(ws); + try { + ws.resolveOpen(); + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId: "lazy-1", turnId: "t1" }, + }); + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "user-message", conversationId: "lazy-1", turnId: "t1", text: "hi" }, + }); + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "text-delta", conversationId: "lazy-1", turnId: "t1", delta: "hello" }, + }); + expect(requests()).toHaveLength(2); + store.selectTab("lazy-1"); + expect(store.activeChat.chunks.map((c) => c.chunk)).toEqual([ + { type: "text", text: "hi" }, + { type: "text", text: "hello" }, + ]); + await vi.waitFor(() => expect(requests()).toHaveLength(4)); + expect(store.activeChat.chunks).toHaveLength(2); + expect(store.activeChat.chunks.every((c) => !c.provisional)).toBe(true); + expect(store.activeChat.generating).toBe(true); + } finally { + store.dispose(); + } + }); + + it("compaction reloads loaded stores but does not load unseen conversations", async () => { + const ws = fakeSocket(); + const { store, requests } = await setup(ws); + try { + ws.resolveOpen(); + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId: "lazy-1", turnId: "old" }, + }); + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "text-delta", conversationId: "lazy-1", turnId: "old", delta: "stale" }, + }); + for (const id of ["lazy-1", "unknown", "lazy-0"]) { + ws.feedServerMessage({ + type: "conversation.compacted", + conversationId: id, + newConversationId: `${id}-archive`, + messagesSummarized: 10, + historyEpoch: 0, + messagesKept: 2, + }); + } + await vi.waitFor(() => expect(requests()).toHaveLength(4)); + expect(requests().every((u) => u.includes("lazy-0"))).toBe(true); + store.selectTab("lazy-1"); + await vi.waitFor(() => expect(requests()).toHaveLength(6)); + expect(store.activeChat.chunks).toHaveLength(2); + } finally { + store.dispose(); + } + }); + + it("keeps a watched store loaded when it becomes a tab, and reloads a closed tab", async () => { + const ws = fakeSocket(); + const { store, requests } = await setup(ws); + try { + ws.resolveOpen(); + const watch = store.watchConversation("lazy-watch"); + await vi.waitFor(() => expect(requests()).toHaveLength(4)); + ws.feedServerMessage({ + type: "conversation.open", + conversationId: "lazy-watch", + workspaceId: "default", + }); + store.selectTab("lazy-watch"); + expect(store.activeChat.chunks).toEqual(watch.chunks); + store.unwatchConversation("lazy-watch"); + expect(requests()).toHaveLength(4); + store.newDraft(); // Closing a background tab must not activate/load its neighbor. + store.closeTab("lazy-watch"); + ws.feedServerMessage({ + type: "conversation.open", + conversationId: "lazy-watch", + workspaceId: "default", + }); + expect(requests()).toHaveLength(4); + store.selectTab("lazy-watch"); + await vi.waitFor(() => expect(requests()).toHaveLength(6)); + } finally { + store.dispose(); + } + }); +}); + +it.each([ + null, + {}, + { conversations: {} }, +])("reports malformed conversation lists: %j", async (body) => { + const ws = fakeSocket(); + const base = fakeFetchImpl(); + const store = createAppStore({ + socketFactory: () => ws, + indexedDB: new IDBFactory(), + localStorage: createFakeStorage(), + fetchImpl: async (input, init) => + String(input).includes("/conversations?status=") ? Response.json(body) : base(input, init), + }); + try { + ws.resolveOpen(); + await vi.waitFor(() => expect(store.fatalError).toContain("Invalid conversations response")); + } finally { + store.dispose(); + } +}); + +describe("compaction queued acknowledgement routing", () => { + it.each([ + "surface-first", + "ack-first", + "drained-before-ack", + ])("uses only authoritative queue snapshots: %s", (order) => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + chatFlushScheduler: null, + }); + ws.resolveOpen(); + store.send("queued text", [{ url: "data:image/png;base64,AAAA" }]); + const cid = activeConversationId(store); + const send = parseSent(ws).find((m) => (m as { type: string }).type === "chat.send") as { + requestId: string; + }; + expect(send.requestId).toEqual(expect.any(String)); + const sourceChat = store.activeChat; + const watch = store.watchConversation("other-conversation"); + watch.send("unrelated local echo"); + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [{ id: "message-queue", region: "inline", title: "Queue", scope: "conversation" }], + }); + const spec = (messages: { id: string; text: string; timestamp: number }[]) => ({ + id: "message-queue", + region: "inline", + title: "Queue", + fields: [{ kind: "custom" as const, rendererId: "message-queue", payload: { messages } }], + }); + const queued = spec([{ id: "q1", text: "queued text", timestamp: 1 }]); + const drained = spec([]); + const ack = () => + ws.feedServerMessage({ + type: "chat.queued", + conversationId: cid, + reason: "compaction", + messageId: "q1", + requestId: send.requestId, + }); + if (order === "ack-first") { + ack(); + expect(store.surface("message-queue", cid)).toBeNull(); + } + ws.feedSurfaceMessage({ type: "surface", conversationId: cid, spec: queued }); + expect(store.surface("message-queue", cid)).toEqual(queued); + if (order === "drained-before-ack") { + ws.feedSurfaceMessage({ + type: "update", + update: { surfaceId: "message-queue", conversationId: cid, spec: drained }, + }); + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId: cid, turnId: "t1" }, + }); + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "user-message", conversationId: cid, turnId: "t1", text: "queued text" }, + }); + } + ack(); + ack(); + expect(store.surface("message-queue", cid)).toEqual( + order === "drained-before-ack" ? drained : queued, + ); + expect(sourceChat.chunks.map((c) => c.chunk)).toEqual( + order === "drained-before-ack" ? [{ type: "text", text: "queued text" }] : [], + ); + expect(sourceChat.error).toBeNull(); + expect(watch.chunks.map((c) => c.chunk)).toEqual([ + { type: "text", text: "unrelated local echo" }, + ]); + expect(parseSent(ws).filter((m) => (m as { type: string }).type === "chat.send")).toHaveLength( + 2, + ); + expect(parseSent(ws).filter((m) => (m as { type: string }).type === "chat.queue")).toHaveLength( + 0, + ); + store.dispose(); + }); +}); + +it("routes omitted replay only to the matching watch/tab without WS resend or an error banner", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + indexedDB: new IDBFactory(), + localStorage: createFakeStorage(), + chatFlushScheduler: null, + }); + try { + ws.resolveOpen(); + const tab = store.activeChat; + const watch = store.watchConversation("omitted-watch"); + const sentBefore = ws.sent.length; + ws.feedServerMessage({ + type: "chat.resync-required", + conversationId: "unknown", + turnId: "unknown-turn", + reason: "replay-limit", + }); + expect(watch.recovering).toBe(false); + expect(tab.recovering).toBe(false); + ws.feedServerMessage({ + type: "chat.resync-required", + conversationId: "omitted-watch", + turnId: "t1", + reason: "replay-limit", + }); + expect(watch.recovering).toBe(true); + expect(watch.generating).toBe(true); + expect(tab.recovering).toBe(false); + expect(watch.error).toBeNull(); + expect(tab.error).toBeNull(); + expect(store.fatalError).toBeNull(); + expect(ws.sent).toHaveLength(sentBefore); + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "turn-ended", + conversationId: "omitted-watch", + turnId: "t1", + reason: "aborted", + }, + }); + await vi.waitFor(() => expect(watch.pendingSync).toBe(false)); + expect(watch.recovering).toBe(false); + expect(watch.generating).toBe(false); + expect(ws.sent).toHaveLength(sentBefore); + } finally { + store.dispose(); + } +}); + +describe("unconfirmed first-message recovery", () => { + it("retains a local-only tab and input after reload and authoritative discovery, without resending", async () => { + const storage = createFakeStorage(); + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + if (String(input).endsWith("/conversations?status=active,idle")) + return Response.json({ conversations: [] }); + return base(input, init); + }; + const ws = fakeSocket(); + const first = createAppStore({ + localStorage: storage, + indexedDB: new IDBFactory(), + fetchImpl, + socketFactory: () => ws, + }); + ws.resolveOpen(); + first.send("must survive", [{ url: "data:image/png;base64,AAAA" }]); + const id = first.activeConversationId; + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(first.activeChat.pendingInputs).toHaveLength(1); + first.dispose(); + const nextSocket = fakeSocket(); + const next = createAppStore({ + localStorage: storage, + indexedDB: new IDBFactory(), + fetchImpl, + socketFactory: () => nextSocket, + }); + try { + nextSocket.resolveOpen(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next.tabs.some((tab) => tab.conversationId === id)).toBe(true); + expect(next.activeChat.pendingInputs).toMatchObject([ + { request: { message: "must survive" } }, + ]); + const requestId = next.activeChat.pendingInputs[0]?.request.requestId ?? ""; + nextSocket.feedServerMessage({ type: "chat.accepted", conversationId: id ?? "", requestId }); + expect(next.activeChat.pendingInputs).toEqual([]); + expect( + parseSent(nextSocket).some((message) => + ["chat.send", "chat.queue"].includes((message as { type: string }).type), + ), + ).toBe(false); + } finally { + next.dispose(); + } + }); +}); diff --git a/src/app/subscription-budget.test.ts b/src/app/subscription-budget.test.ts new file mode 100644 index 0000000..03cdaa1 --- /dev/null +++ b/src/app/subscription-budget.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { + liveConversationIds, + MAX_CHAT_SUBSCRIPTIONS, + MAX_SURFACE_SUBSCRIPTIONS, +} from "./subscription-budget"; + +describe("live subscription budget", () => { + it("reserves the focused conversation even when hundreds of viewers exist", () => { + const viewers = Array.from({ length: 339 }, (_, i) => `viewer-${i}`); + const selected = liveConversationIds("focused", viewers); + expect(selected.size).toBe(MAX_CHAT_SUBSCRIPTIONS); + expect(selected.has("focused")).toBe(true); + expect(selected.size + MAX_SURFACE_SUBSCRIPTIONS).toBeLessThanOrEqual(256); + }); + it("deduplicates a modal viewing the focused chat and permits drafts", () => { + expect([...liveConversationIds("same", ["same", "other"])]).toEqual(["same", "other"]); + expect([...liveConversationIds(null, [])]).toEqual([]); + }); +}); diff --git a/src/app/subscription-budget.ts b/src/app/subscription-budget.ts new file mode 100644 index 0000000..fc0ccb2 --- /dev/null +++ b/src/app/subscription-budget.ts @@ -0,0 +1,15 @@ +/** Split the server's 256 shared slots so catalog growth cannot evict chat watchers. */ +export const MAX_CHAT_SUBSCRIPTIONS = 128; +export const MAX_SURFACE_SUBSCRIPTIONS = 128; + +export function liveConversationIds( + activeId: string | null, + watchedIds: Iterable<string>, +): Set<string> { + return new Set( + [...new Set([...(activeId === null ? [] : [activeId]), ...watchedIds])].slice( + 0, + MAX_CHAT_SUBSCRIPTIONS, + ), + ); +} diff --git a/src/app/surface-protocol.svelte.ts b/src/app/surface-protocol.svelte.ts new file mode 100644 index 0000000..c79c8b6 --- /dev/null +++ b/src/app/surface-protocol.svelte.ts @@ -0,0 +1,146 @@ +import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; +import type { SurfaceSocketHandle } from "../adapters/ws"; +import { + applyServerMessage, + getSurfaceSpec, + type ProtocolState, + initialState as protocolInitialState, + invoke as protocolInvoke, + subscribe as protocolSubscribe, + unsubscribe as protocolUnsubscribe, +} from "../core/protocol"; +import { MAX_SURFACE_SUBSCRIPTIONS } from "./subscription-budget"; + +/** Shared app/plugin surface operations over the single app protocol state. */ +export function createSurfaceProtocol(deps: { + focusedConversationId: () => string | undefined; + send: SurfaceSocketHandle["send"]; +}) { + let protocol = $state<ProtocolState>(protocolInitialState()); + // Rendering may retain the prior spec during re-scope. Consumers making + // conversation-specific decisions can opt into a snapshot with a known scope. + let receivedScopes = $state<ReadonlyMap<string, string | undefined>>(new Map()); + function handleServerMessage(msg: SurfaceServerMessage): void { + const next = applyServerMessage(protocol, msg); + if (next !== protocol && (msg.type === "surface" || msg.type === "update")) { + const snapshot = msg.type === "surface" ? msg : msg.update; + const scopes = new Map(receivedScopes); + scopes.set(snapshot.spec.id, snapshot.conversationId); + receivedScopes = scopes; + } + protocol = next; + // 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 = deps.focusedConversationId(); + const catalogIds = new Set( + protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS).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) { + deps.send(msg); + } + } + } + for (const entry of protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS)) { + // 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) { + deps.send(msg); + } + } + receivedScopes = new Map([...receivedScopes].filter(([id]) => protocol.subscriptions.has(id))); + } + + /** + * The surface OP implementations — closure functions shared by the app + * store's public methods AND the frontend-plugin ABI's surfaces ports (the + * plugin host binds exactly these; there is no second protocol state). + */ + function invokeSurfaceOp( + surfaceId: string, + actionId: string, + payload?: unknown, + conversationId?: string, + ): void { + const result = protocolInvoke( + protocol, + surfaceId, + actionId, + payload, + conversationId ?? deps.focusedConversationId(), + ); + protocol = result.state; + for (const msg of result.outgoing) { + deps.send(msg); + } + } + + function subscribeSurfaceOp(surfaceId: string, conversationId?: string): void { + if ( + !protocol.subscriptions.has(surfaceId) && + protocol.subscriptions.size >= MAX_SURFACE_SUBSCRIPTIONS + ) + return; + const result = protocolSubscribe( + protocol, + surfaceId, + conversationId ?? deps.focusedConversationId(), + ); + protocol = result.state; + for (const msg of result.outgoing) { + deps.send(msg); + } + } + + function unsubscribeSurfaceOp(surfaceId: string, conversationId?: string): void { + // protocolUnsubscribe keys on the surface alone (it drops the pair it + // was subscribed under); the optional id documents the intent. + void conversationId; + const result = protocolUnsubscribe(protocol, surfaceId); + protocol = result.state; + const scopes = new Map(receivedScopes); + scopes.delete(surfaceId); + receivedScopes = scopes; + for (const msg of result.outgoing) { + deps.send(msg); + } + } + + function surfaceSpecOp(surfaceId: string, conversationId?: string): SurfaceSpec | null { + if (conversationId !== undefined && receivedScopes.get(surfaceId) !== conversationId) + return null; + return getSurfaceSpec(protocol, surfaceId); + } + + return { + handleServerMessage, + syncSubscriptions, + invokeSurfaceOp, + subscribeSurfaceOp, + unsubscribeSurfaceOp, + surfaceSpecOp, + get protocol() { + return protocol; + }, + }; +} diff --git a/src/app/surface-protocol.test.ts b/src/app/surface-protocol.test.ts new file mode 100644 index 0000000..405e561 --- /dev/null +++ b/src/app/surface-protocol.test.ts @@ -0,0 +1,96 @@ +import type { SurfaceSpec } from "@dispatch/ui-contract"; +import { describe, expect, it, vi } from "vitest"; +import { createSurfaceProtocol } from "./surface-protocol.svelte"; + +const spec: SurfaceSpec = { id: "s1", region: "inline", title: "Snapshot", fields: [] }; + +function fixture() { + let focusedId = "c1"; + const surfaces = createSurfaceProtocol({ + focusedConversationId: () => focusedId, + send: vi.fn(), + }); + surfaces.handleServerMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "inline", title: "Snapshot", scope: "conversation" }], + }); + return { + surfaces, + focus(id: string) { + focusedId = id; + surfaces.syncSubscriptions(); + }, + }; +} + +describe("conversation-scoped surface lookup", () => { + it("keeps retained rendering but withholds another conversation's snapshot from decisions", () => { + const { surfaces, focus } = fixture(); + expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull(); + surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec }); + expect(surfaces.surfaceSpecOp("s1", "c1")).toEqual(spec); + focus("c2"); + expect(surfaces.surfaceSpecOp("s1")).toEqual(spec); + expect(surfaces.surfaceSpecOp("s1", "c2")).toBeNull(); + surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec }); + expect(surfaces.surfaceSpecOp("s1", "c2")).toBeNull(); + const updated = { ...spec, title: "Current" }; + surfaces.handleServerMessage({ + type: "update", + update: { surfaceId: "s1", conversationId: "c2", spec: updated }, + }); + expect(surfaces.surfaceSpecOp("s1", "c2")).toEqual(updated); + expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull(); + }); + + it("does not treat an unscoped snapshot as known conversation-specific data", () => { + const { surfaces } = fixture(); + surfaces.handleServerMessage({ type: "surface", spec }); + expect(surfaces.surfaceSpecOp("s1")).toEqual(spec); + expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull(); + }); + + it("drops snapshot scope on explicit unsubscribe and catalog removal", () => { + const { surfaces } = fixture(); + surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec }); + surfaces.unsubscribeSurfaceOp("s1"); + expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull(); + surfaces.subscribeSurfaceOp("s1"); + expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull(); + surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec }); + expect(surfaces.surfaceSpecOp("s1", "c1")).toEqual(spec); + surfaces.handleServerMessage({ type: "catalog", catalog: [] }); + expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull(); + }); +}); + +describe("surface subscription budget", () => { + it("bounds a large catalog and releases old slots before subscribing a replacement catalog", () => { + const live = new Set<string>(); + let peak = 0; + const surfaces = createSurfaceProtocol({ + focusedConversationId: () => "chat", + send(message) { + if (message.type === "subscribe") live.add(message.surfaceId); + if (message.type === "unsubscribe") live.delete(message.surfaceId); + peak = Math.max(peak, live.size); + }, + }); + for (const prefix of ["old", "new"]) { + surfaces.handleServerMessage({ + type: "catalog", + catalog: Array.from({ length: 339 }, (_, i) => ({ + id: `${prefix}-${i}`, + region: "inline", + title: "Surface", + scope: "global" as const, + })), + }); + expect(live.size).toBe(128); + expect([...live].every((id) => id.startsWith(prefix))).toBe(true); + } + surfaces.subscribeSurfaceOp("overflow"); + expect(live.has("overflow")).toBe(false); + expect(peak).toBe(128); + }); +}); diff --git a/src/app/view-ports.ts b/src/app/view-ports.ts new file mode 100644 index 0000000..577fe11 --- /dev/null +++ b/src/app/view-ports.ts @@ -0,0 +1,54 @@ +/** + * The port bag the composition root hands to every registered view wrapper. + * + * This is the ONLY shape of app state the wrappers may touch: the app store + * plus the two chrome ports that live outside the tree (the system-prompt + * builder modal and the heartbeat run modal). Keeping it here is what makes + * `register-views.ts` possible — the wrappers take `{ ports }` and close over + * exactly what `viewContent` used to pass in App.svelte. + */ + +import { tick } from "svelte"; +import type { HeartbeatRunView } from "../features/heartbeat"; +import type { AppStore } from "./store.svelte"; + +/** + * The chat transcript's live scroll element, shared with views that must + * preserve the reader's viewport across a prepend (today: the settings view's + * chat-limit save — the same mechanism as "show earlier"). The chat pane writes + * it on mount via an effect; nothing else may. + */ +export interface ChatViewportBridge { + el: HTMLElement | null; +} + +export interface AppViewPorts { + readonly store: AppStore; + readonly chatViewport: ChatViewportBridge; + /** Chrome: open the system-prompt builder modal (app shell state, not a view). */ + readonly openSystemPromptBuilder: () => void; + /** Chrome: open the heartbeat run modal for a run (app shell state, not a view). */ + readonly openHeartbeatRun: (run: HeartbeatRunView) => void; +} + +/** + * Run an async action that PREPENDS transcript content while preserving the + * reader's viewport position: prepended content grows scrollHeight, so shift + * scrollTop by the growth (the manual analogue of CSS scroll anchoring, which + * not every engine applies here). Used by "show earlier" AND the chat-limit + * save (which refills older history on a raise). + */ +export async function preserveViewportAcross<T>( + el: HTMLElement | null | undefined, + run: () => Promise<T>, +): Promise<T> { + const prevHeight = el?.scrollHeight ?? 0; + const prevTop = el?.scrollTop ?? 0; + const result = await run(); + await tick(); + if (el) { + const delta = el.scrollHeight - prevHeight; + if (delta > 0) el.scrollTop = prevTop + delta; + } + return result; +} diff --git a/src/app/views/AccountUsagePane.svelte b/src/app/views/AccountUsagePane.svelte new file mode 100644 index 0000000..09afb5f --- /dev/null +++ b/src/app/views/AccountUsagePane.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + /** + * The `account-usage` view — Claude + Codex + Hyper usage bars with + * on-track pace markers. GLOBAL (not workspace/conversation scoped), so it + * stays mounted across tab switches (no {#key}). + */ + import { + AccountUsageView, + type LoadAccountUsage, + type SaveAccountUsageSettings, + } from "../../features/account-usage"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + const loadAccountUsage: LoadAccountUsage = (opts) => store.accountUsage(opts); + const saveAccountUsageSettings: SaveAccountUsageSettings = (provider, settings) => + store.setAccountUsageSettings(provider, settings); +</script> + +<AccountUsageView load={loadAccountUsage} saveSettings={saveAccountUsageSettings} /> diff --git a/src/app/views/CacheWarmingPane.svelte b/src/app/views/CacheWarmingPane.svelte new file mode 100644 index 0000000..d1d9b43 --- /dev/null +++ b/src/app/views/CacheWarmingPane.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + /** + * The `cache-warming` view — the backend's conversation-scoped cache-warming + * surface with the WarmNow adapter. Re-mounts per conversation (like + * ChatView) so the view's local warming history / manual-warm feedback + * can't bleed across tabs (the {#key} moved inside the wrapper). + */ + import { CacheWarmingView, type WarmFeedback } from "../../features/cache-warming"; + import type { AppViewPorts } from "../view-ports"; + + const CACHE_WARMING_ID = "cache-warming"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Adapt the store's WarmResult to the cache-warming feature's WarmNow port. + async function warmNow(): Promise<WarmFeedback | null> { + const result = await store.warmNow(); + if (result === null) return null; + return result.ok + ? { + ok: true, + cachePct: result.response.cachePct, + expectedCacheRate: result.response.expectedCacheRate, + } + : { ok: false, error: result.error }; + } +</script> + +{#key store.activeConversationId} + <CacheWarmingView + spec={store.surface(CACHE_WARMING_ID)} + canWarm={store.activeConversationId !== null} + onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)} + {warmNow} + /> +{/key} diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte new file mode 100644 index 0000000..b5e05bb --- /dev/null +++ b/src/app/views/ChatPane.svelte @@ -0,0 +1,117 @@ +<script lang="ts"> + /** + * The `chat` view — the transcript region that moved out of App.svelte with + * ALL of its coupling: the smart-scroll controller (created here, reset on + * conversation switch like today's reset effect), the unload-gate attach + * effect, the follow-bottom effect, "show earlier" with viewport + * preservation, the ScrollToBottom affordance, and the empty-state + * watermark. Keyed on the active conversation exactly like today's ChatView. + */ + import { ChatView, PendingInputs, isClaudeSubscriptionModel } from "../../features/chat"; + import { createSmartScrollController, ScrollToBottom } from "../../features/smart-scroll"; + import { preserveViewportAcross, type AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + const textFiles = $derived.by(() => { + void store.activeWorkspaceId; + void store.textFileContextRevision; + const id = store.activeConversationId; + return id ? store.createTextFileClient(id) : undefined; + }); + $effect(() => { + const client = textFiles; + return () => client?.dispose(); + }); + + // Cache timer: Claude Code subscription models only (5-minute prompt-cache expiry). + const cacheTimer = $derived( + isClaudeSubscriptionModel(store.activeModel) ? store.activeChat.cacheTimer : null, + ); + + // One controller owns the chat scroll region; the effects below feed it the + // edges (identical to the App.svelte wiring this moved out of). + const smartScroll = createSmartScrollController(); + let transcriptEl = $state<HTMLElement | undefined>(); + let transcriptContentEl = $state<HTMLElement | undefined>(); + + // The settings view's chat-limit save preserves the reader's viewport over + // the history refill — share the live scroll element through the bridge. + $effect(() => { + ports.chatViewport.el = transcriptEl ?? null; + }); + + // Chat-limit unload gate: old chunks may be unloaded only while the reader is + // stuck to the bottom. While stuck, a trim removes content far ABOVE the + // viewport and the controller re-pins to the bottom — no visible jump; while + // reading history, trimming is deferred instead of yanking the page (the old + // Dispatch bug). In an $effect so a swapped store prop would be re-wired. + $effect(() => { + store.attachUnloadGate(() => smartScroll.isAtBottom()); + }); + + // "Show earlier messages": page older history back in, preserving the reader's + // viewport position. + async function handleShowEarlier(): Promise<void> { + await preserveViewportAcross(transcriptEl, () => store.activeChat.showEarlier()); + } + + // Attach/detach the controller to the live scroll element + content (disposed + // on unmount). The content element is observed (ResizeObserver) so the view + // follows height changes that aren't a transcript append. + $effect(() => { + if (!transcriptEl) return; + return smartScroll.attach(transcriptEl, transcriptContentEl); + }); + + // New transcript content streamed in (or messages loaded) → follow the bottom + // while stuck. Reads `chunks.length` so the effect re-runs on every append. + $effect(() => { + void store.activeChat.chunks.length; + smartScroll.contentChanged(); + }); + + // Conversation/tab switch → snap to the bottom of the new transcript. + $effect(() => { + void store.activeConversationId; + smartScroll.reset(); + }); +</script> + +<div class="relative h-full"> + <!-- The right gutter lives INSIDE the scroller (on the content), not outside + it: overlay scrollbars (iOS/iPadOS WebKit — Safari, Vivaldi, …; macOS) + take no layout space and paint over the scroller's right edge, so with + the gutter outside they drew on top of the tool/thinking cards. Now the + scroller spans to the pane edge and its scrollbar sits in the empty + gutter, clear of the content (classic scrollbars likewise). --> + <div bind:this={transcriptEl} class="h-full overflow-y-auto"> + <div bind:this={transcriptContentEl} class="pr-4"> + {#key store.activeConversationId} + <ChatView + chunks={store.activeChat.chunks} + recovering={store.activeChat.recovering} + turnMetrics={store.activeChat.turnMetrics} + hasEarlier={store.activeChat.hasEarlier} + onShowEarlier={handleShowEarlier} + thinkingKeyBase={store.activeChat.thinkingKeyBase} + providerRetry={store.activeChat.providerRetry} + apiBaseUrl={store.httpBase} + cwd={store.cwd} + {textFiles} + {cacheTimer} + /> + {/key} + <PendingInputs inputs={store.activeChat.pendingInputs} onDiscard={store.activeChat.discardPendingInput} /> + </div> + </div> + {#if store.activeChat.chunks.length === 0 && store.activeChat.pendingInputs.length === 0} + <div + class="pointer-events-none absolute inset-0 flex items-center justify-center" + aria-hidden="true" + > + <span class="select-none text-4xl font-bold opacity-10">Dispatch</span> + </div> + {/if} + <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} /> +</div> diff --git a/src/app/views/CompactionPane.svelte b/src/app/views/CompactionPane.svelte new file mode 100644 index 0000000..ff31f0b --- /dev/null +++ b/src/app/views/CompactionPane.svelte @@ -0,0 +1,48 @@ +<script lang="ts"> + /** + * The `compaction` view — message compaction with the store adapters. + * Per-conversation (keyed so the percent + feedback can't bleed across tabs; + * the {#key} moved inside the wrapper). + */ + import { + CompactionView, + type CompactNowResult, + type SaveCompactPercentResult, + } from "../../features/chat"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Adapt the store's compact result to the compaction view's port. + async function compactNow(): Promise<CompactNowResult | null> { + const result = await store.compactNow(); + if (result === null) return null; + return result.ok + ? { + ok: true, + messagesSummarized: result.response.messagesSummarized, + messagesKept: result.response.messagesKept, + } + : { ok: false, error: result.error }; + } + + async function saveCompactPercent( + percent: number, + ): Promise<SaveCompactPercentResult | null> { + const result = await store.setCompactPercent(percent); + if (result === null) return null; + return result.ok + ? { ok: true, percent: result.percent } + : { ok: false, error: result.error }; + } +</script> + +{#key store.currentConversationId} + <CompactionView + percent={store.compactPercent} + canCompact={store.activeConversationId !== null} + {compactNow} + savePercent={saveCompactPercent} + /> +{/key} diff --git a/src/app/views/ComposerPane.svelte b/src/app/views/ComposerPane.svelte new file mode 100644 index 0000000..00dbc9f --- /dev/null +++ b/src/app/views/ComposerPane.svelte @@ -0,0 +1,43 @@ +<script lang="ts"> + /** + * The `composer` view — the send box with the status derivation that used to + * live in App.svelte (error > queued > running > idle) and the + * send/queue/stop ports. Not conversation-scoped (no {#key}) — the composer + * is a persistent column fixture, exactly like today. + */ + import { Composer, type ComposerStatus } from "../../features/chat"; + import { MESSAGE_QUEUE_RENDERER_ID, messageQueueCount } from "../../features/surface-host"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + const queuedMessageCount = $derived( + store.activeConversationId === null + ? undefined + : messageQueueCount(store.surface(MESSAGE_QUEUE_RENDERER_ID, store.activeConversationId)), + ); + + // 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"; + }); +</script> + +<Composer + onSend={(text, images) => store.send(text, images)} + onQueue={(text, images) => store.queueMessage(text, images)} + onStop={() => store.stopGeneration()} + contextSize={store.activeChat.currentContextSize} + contextWindow={store.modelInfo[store.activeModel]?.contextWindow} + {queuedMessageCount} + status={composerStatus} +/> diff --git a/src/app/views/ConcurrencyPane.svelte b/src/app/views/ConcurrencyPane.svelte new file mode 100644 index 0000000..d04307f --- /dev/null +++ b/src/app/views/ConcurrencyPane.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + /** + * The `concurrency` view — per-provider concurrency limits + live status. + * GLOBAL (not workspace- or conversation-scoped), so the panel stays mounted + * across tab switches (no {#key}, like today). + */ + import { + ConcurrencyView, + type DeleteConcurrencyLimit, + type LoadConcurrencyLimits, + type LoadConcurrencyStatus, + type SaveConcurrencyCooldown, + type SaveConcurrencyLimit, + } from "../../features/concurrency"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Thin passthroughs (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> + +<ConcurrencyView + models={store.models} + loadLimits={loadConcurrencyLimits} + saveLimit={saveConcurrencyLimit} + deleteLimit={deleteConcurrencyLimit} + loadStatus={loadConcurrencyStatus} + saveCooldown={saveConcurrencyCooldown} +/> diff --git a/src/app/views/ExtensionsPane.svelte b/src/app/views/ExtensionsPane.svelte new file mode 100644 index 0000000..d5baa39 --- /dev/null +++ b/src/app/views/ExtensionsPane.svelte @@ -0,0 +1,82 @@ +<script lang="ts"> + /** + * The `extensions` view — the frontend-modules table (aggregated from each + * feature's public `manifest` so it can't drift from what's actually + * composed) plus the generic surface list. The backend's "Loaded Extensions" + * surface is a SEPARATE, backend-owned list. Unkeyed (like today). + */ + import Table from "../../components/Table.svelte"; + import { manifest as accountUsageManifest } from "../../features/account-usage"; + import { manifest as cacheWarmingManifest } from "../../features/cache-warming"; + import { manifest as chatManifest } from "../../features/chat"; + import { manifest as concurrencyManifest } from "../../features/concurrency"; + import { manifest as conversationCacheManifest } from "../../features/conversation-cache"; + import { manifest as cwdLspManifest } from "../../features/cwd-lsp"; + import { manifest as computerManifest } from "../../features/computer"; + import { manifest as heartbeatManifest } from "../../features/heartbeat"; + import { manifest as layoutManifest } from "../../features/layout"; + import { manifest as markdownManifest } from "../../features/markdown"; + import { manifest as mcpManifest } from "../../features/mcp"; + import { manifest as namedAgentsManifest } from "../../features/named-agents"; + import { manifest as searchSettingsManifest } from "../../features/search-settings"; + import { manifest as settingsManifest } from "../../features/settings"; + import { manifest as smartScrollManifest } from "../../features/smart-scroll"; + import { SurfaceView } from "../../features/surface-host"; + import { manifest as surfaceHostManifest } from "../../features/surface-host"; + import { manifest as systemPromptManifest } from "../../features/system-prompt"; + import { manifest as tabsManifest } from "../../features/tabs"; + import { manifest as viewsManifest } from "../../features/views"; + import { manifest as visionManifest } from "../../features/vision"; + import type { AppViewPorts } from "../view-ports"; + + // Surfaces with dedicated renderers elsewhere stay out of the generic list: + // cache-warming (its own view), message-queue (its own view), todo (the + // Tasks view). SurfaceView itself stays fully generic (it never switches on + // a surface id) — the FILTER is this view's data selection. + const CACHE_WARMING_ID = "cache-warming"; + const MESSAGE_QUEUE_ID = "message-queue"; + const TODO_ID = "todo"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + const MODULE_COLUMNS = ["Module", "Description"] as const; + // FE features are internal units of this single repo, so there is no + // per-module version — they all share dispatch-web's version. + const loadedModules: readonly (readonly [string, string])[] = [ + chatManifest, + tabsManifest, + surfaceHostManifest, + viewsManifest, + layoutManifest, + conversationCacheManifest, + markdownManifest, + cacheWarmingManifest, + cwdLspManifest, + mcpManifest, + computerManifest, + smartScrollManifest, + settingsManifest, + systemPromptManifest, + heartbeatManifest, + concurrencyManifest, + accountUsageManifest, + visionManifest, + namedAgentsManifest, + searchSettingsManifest, + ].map((m) => [m.name, m.description] as const); +</script> + +<section> + <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3> + <Table columns={MODULE_COLUMNS} rows={loadedModules} /> +</section> +<section class="mt-4 flex flex-col gap-3"> + <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3> + {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)} + <SurfaceView + {spec} + onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)} + /> + {/each} +</section> diff --git a/src/app/views/HeartbeatPane.svelte b/src/app/views/HeartbeatPane.svelte new file mode 100644 index 0000000..1bb2e86 --- /dev/null +++ b/src/app/views/HeartbeatPane.svelte @@ -0,0 +1,56 @@ +<script lang="ts"> + /** + * The `heartbeat` view — workspace-scoped autonomous-agent heartbeat (config + * + run history) with the store adapters. Opening a RUN raises the chrome + * port: the run modal is app shell state (outside the tree — layout-00 + * decision 2). Unkeyed; the config + runs are per-workspace (like today). + */ + import { + HeartbeatView, + type HeartbeatConfigResult, + type HeartbeatNextRunResult, + type HeartbeatRunView, + type HeartbeatRunsResult, + type HeartbeatStopResult, + } from "../../features/heartbeat"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Thin passthroughs (kept for structural consistency with cwd-lsp/mcp — + // 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(); + } +</script> + +<HeartbeatView + models={store.models} + loadConfig={loadHeartbeatConfig} + saveConfig={saveHeartbeatConfig} + loadRuns={loadHeartbeatRuns} + stopRun={stopHeartbeatRun} + loadVariables={() => store.loadSystemPromptVariables()} + loadDefaultPrompt={() => store.loadSystemPrompt()} + loadNextRun={loadHeartbeatNextRun} + onOpenRun={(run) => ports.openHeartbeatRun(run)} +/> diff --git a/src/app/views/LspPane.svelte b/src/app/views/LspPane.svelte new file mode 100644 index 0000000..5645ace --- /dev/null +++ b/src/app/views/LspPane.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + /** + * The `lsp` view — loaded language servers per conversation. Re-mounts per + * conversation (incl. draft) so the loaded server list is isolated (the + * {#key} moved inside the wrapper). + */ + import { LspStatusView, type LspStatusResult } from "../../features/cwd-lsp"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + async function loadLspStatus(): Promise<LspStatusResult | null> { + const result = await store.lspStatus(); + if (result === null) return null; + return result.ok + ? { ok: true, cwd: result.response.cwd, servers: result.response.servers } + : { ok: false, error: result.error }; + } +</script> + +{#key store.currentConversationId} + <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} /> +{/key} diff --git a/src/app/views/McpPane.svelte b/src/app/views/McpPane.svelte new file mode 100644 index 0000000..82e139d --- /dev/null +++ b/src/app/views/McpPane.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + /** + * The `mcp` view — loaded MCP servers per conversation. Re-mounts per + * conversation (incl. draft) so the loaded server list is isolated (the + * {#key} moved inside the wrapper). + */ + import { McpStatusView, type McpStatusResult } from "../../features/mcp"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + 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 }; + } +</script> + +{#key store.currentConversationId} + <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} /> +{/key} diff --git a/src/app/views/MessageQueuePane.svelte b/src/app/views/MessageQueuePane.svelte new file mode 100644 index 0000000..46b9a22 --- /dev/null +++ b/src/app/views/MessageQueuePane.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + /** + * The `message-queue` view — pending steering messages, rendered via the + * generic SurfaceView (dispatches on rendererId, never surface id). The + * non-empty conditional moved INSIDE the view (decision 4): the tree's leaf + * always exists; an idle queue renders NOTHING. + */ + import { SurfaceView } from "../../features/surface-host"; + import { parseMessageQueuePayload } from "../../features/surface-host/logic/message-queue"; + import type { AppViewPorts } from "../view-ports"; + + // The message-queue extension's per-conversation surface (steering). Pulled + // out of the generic Extensions list — pending steering messages are tied to + // the chat, not the sidebar. + const MESSAGE_QUEUE_ID = "message-queue"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // The surface spec + whether it currently has pending messages. + const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID)); + const hasQueuedMessages = $derived.by(() => { + const spec = messageQueueSpec; + if (spec === null) return false; + const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID); + if (field === undefined || field.kind !== "custom") return false; + const data = parseMessageQueuePayload(field.payload); + return data !== null && data.messages.length > 0; + }); +</script> + +{#if hasQueuedMessages && messageQueueSpec !== null} + <div class="px-4 pt-2"> + <SurfaceView + spec={messageQueueSpec} + onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)} + onCancelQueuedMessage={(messageId) => store.cancelQueuedMessage(messageId)} + /> + </div> +{/if} diff --git a/src/app/views/ModelPane.svelte b/src/app/views/ModelPane.svelte new file mode 100644 index 0000000..0344e47 --- /dev/null +++ b/src/app/views/ModelPane.svelte @@ -0,0 +1,131 @@ +<script lang="ts"> + /** + * The `model` view — the model selector + the per-conversation + * reasoning-effort / cwd / computer controls, with the store adapters that + * used to live in App.svelte. The controls re-mount per workspace + * conversation (the {#key} moved inside the wrapper) so inputs can't bleed + * across tabs — editable for a draft too. + */ + import { + ModelSelector, + ReasoningEffortSelector, + type ThinkingSelection, + type ThinkingSelectionSaveResult, + } from "../../features/chat"; + import { CwdField, type CwdSaveResult } from "../../features/cwd-lsp"; + import { + ComputerField, + type ComputerSaveResult, + type ComputerStatusResult, + type LoadComputerStatus, + type SaveComputer, + type TestComputer, + type TestComputerResult, + } from "../../features/computer"; + import type { NamedAgentSelectionSnapshot } from "../../features/named-agents"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + function handleSelectModel(model: string): void { + store.selectModel(model); + } + + // A Named Agent selected in the model selector: resolve it to its raw model + // AND record the snapshot on the active conversation (plan §6/§7). The store + // persists the raw model to `/conversations/:id/model` + retains the snapshot + // on the tab. + function handleSelectNamedAgent(snapshot: NamedAgentSelectionSnapshot): void { + store.selectModel(snapshot.model, snapshot); + } + + // 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, selection: result.reasoningEffort } + : { ok: false, error: result.error }; + } + + async function saveCwd(cwd: string): Promise<CwdSaveResult | null> { + const result = await store.setCwd(cwd); + if (result === null) return null; + return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error }; + } + + 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 }; + }; +</script> + +<div class="flex flex-col gap-3"> + <ModelSelector + models={store.models} + selected={store.activeModel} + onSelect={handleSelectModel} + modelInfo={store.modelInfo} + namedAgents={store.namedAgents} + namedAgentSnapshot={store.namedAgentSnapshot} + onSelectNamedAgent={handleSelectNamedAgent} + zeroProviders={store.enabledProviderCount === 0} + /> + {#key store.currentConversationId} + <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> diff --git a/src/app/views/NamedAgentsPane.svelte b/src/app/views/NamedAgentsPane.svelte new file mode 100644 index 0000000..413141a --- /dev/null +++ b/src/app/views/NamedAgentsPane.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + /** + * The `named-agents` view — a global, collapsible sidebar view of curated + * presets. The store owns the list (authoritative for the model selector + + * agent tooling); the view drives CRUD via injected ports (auto-save). + * Unkeyed — the list is global (no {#key}, like today). + */ + import { + NamedAgentsView, + type NamedAgentInput, + } from "../../features/named-agents"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Adapt the store's Named Agent CRUD to the named-agents feature's ports. + const namedAgentPorts = { + create: (input: NamedAgentInput) => store.createNamedAgent(input), + update: (id: string, input: NamedAgentInput) => store.updateNamedAgent(id, input), + remove: (id: string) => store.deleteNamedAgent(id), + }; +</script> + +<NamedAgentsView agents={store.namedAgents} models={store.models} ports={namedAgentPorts} /> diff --git a/src/app/views/SettingsPane.svelte b/src/app/views/SettingsPane.svelte new file mode 100644 index 0000000..f119103 --- /dev/null +++ b/src/app/views/SettingsPane.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + /** + * The `settings` view — FE-local settings (the chat limit). Not + * conversation-scoped (no {#key}: the chat limit is global). A chat-limit + * RAISE refills the active chat (prepends older history) — the reader's + * viewport is preserved over the prepend via the chat viewport bridge + * (exactly like today's saveChatLimit). + */ + import { tick } from "svelte"; + import { ChatLimitField, type ChatLimitSaveResult } from "../../features/settings"; + import { SearchBackendField } from "../../features/search-settings"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> { + const el = ports.chatViewport.el; + const prevHeight = el?.scrollHeight ?? 0; + const prevTop = el?.scrollTop ?? 0; + const result = await store.setChatLimit(value); + await tick(); + if (el) { + const delta = el.scrollHeight - prevHeight; + if (delta > 0) el.scrollTop = prevTop + delta; + } + return result.ok + ? { ok: true, chatLimit: result.chatLimit } + : { ok: false, error: result.error }; + } +</script> + +<div class="flex flex-col gap-3"> + <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} /> + <SearchBackendField + load={() => store.searchBackendConfig()} + save={(backend) => store.setSearchBackend(backend)} + /> +</div> diff --git a/src/app/views/SystemPromptPane.svelte b/src/app/views/SystemPromptPane.svelte new file mode 100644 index 0000000..0fb5a11 --- /dev/null +++ b/src/app/views/SystemPromptPane.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + /** + * The `system-prompt` view — a pointer to the global system-prompt template + * that opens the full-page modal editor (half template / half variable + * palette). The modal itself is CHROME (App shell state, outside the tree — + * layout-00 decision 2); this view only raises the port. Unkeyed (like + * today). + */ + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); +</script> + +<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={() => ports.openSystemPromptBuilder()}> + Open builder + </button> +</div> diff --git a/src/app/views/TabsPane.svelte b/src/app/views/TabsPane.svelte new file mode 100644 index 0000000..4547e06 --- /dev/null +++ b/src/app/views/TabsPane.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + /** + * The `tabs` view — the conversation tab list (moved out of the top bar into + * the sidebar). Re-mounts per workspace so the filtered tab set + scroll + * reset cleanly on a workspace switch (the {#key} moved inside the wrapper). + */ + import { TabList } from "../../features/tabs"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); +</script> + +{#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)} + onSetTabGroup={(id, parentId) => store.setTabGroup(id, parentId)} + /> +{/key} diff --git a/src/app/views/TasksPane.svelte b/src/app/views/TasksPane.svelte new file mode 100644 index 0000000..6a375b3 --- /dev/null +++ b/src/app/views/TasksPane.svelte @@ -0,0 +1,29 @@ +<script lang="ts"> + /** + * The `tasks` view — the todo extension's per-conversation task list + * (model-maintained, read-only). Re-mounts per conversation so the task list + * is isolated per conversation (the {#key} moved inside the wrapper). + * TodoList always reserves its fixed height (empty or full). + */ + import TodoList from "../../features/surface-host/ui/TodoList.svelte"; + import { parseTodoPayload } from "../../features/surface-host/logic/todo"; + import type { AppViewPorts } from "../view-ports"; + + // The `todo` extension's per-conversation task list surface. + const TODO_ID = "todo"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + const todoData = $derived.by(() => { + const spec = store.surface(TODO_ID); + if (spec === null) return null; + const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === TODO_ID); + if (field === undefined || field.kind !== "custom") return null; + return parseTodoPayload(field.payload); + }); +</script> + +{#key store.activeConversationId} + <TodoList payload={todoData} /> +{/key} diff --git a/src/app/views/VisionPane.svelte b/src/app/views/VisionPane.svelte new file mode 100644 index 0000000..5d53545 --- /dev/null +++ b/src/app/views/VisionPane.svelte @@ -0,0 +1,45 @@ +<script lang="ts"> + /** + * The `vision` view — global vision settings (image-compaction limit + + * model) with the store adapters. Not conversation-scoped (no {#key}), so + * the settings stay mounted across tab switches (like today). + */ + import { + VisionSettingsView, + type LoadVisionSettingsResult, + type SaveVisionSettingsResult, + type VisionSettingsPatch, + } from "../../features/vision"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // 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 }; + } +</script> + +<VisionSettingsView + models={store.models} + modelInfo={store.modelInfo} + load={loadVisionSettings} + save={saveVisionSettings} +/> diff --git a/src/app/ws-handlers.ts b/src/app/ws-handlers.ts new file mode 100644 index 0000000..e4fd916 --- /dev/null +++ b/src/app/ws-handlers.ts @@ -0,0 +1,216 @@ +import type { + ChatAcceptedMessage, + ChatDeltaMessage, + ChatErrorMessage, + ChatQueuedMessage, + ChatResyncRequiredMessage, + ChatSubscriptionRejectedMessage, + ConversationCompactedMessage, + ConversationOpenMessage, + ConversationStatusChangedMessage, + ConversationTabGroupChangedMessage, + ExtensionsChangedMessage, + LayoutChangedMessage, + TileLayoutChangedMessage, + WorkspaceSetMessage, +} from "@dispatch/transport-contract"; +import type { SubscribeMessage } from "@dispatch/ui-contract"; +import type { SurfaceSocketHandle, SurfaceSocketOptions } from "../adapters/ws"; +import type { ConversationCache } from "../features/conversation-cache"; +import type { LayoutStore } from "../features/layout"; +import type { PluginsStore } from "../features/plugins"; +import type { TabsStore } from "../features/tabs"; +import type { TileLayoutStore } from "../features/workspaces"; +import type { ChatRegistry } from "./chat-registry.svelte"; +import type { OpenConversations } from "./open-conversations.svelte"; +import type { createPluginHost } from "./plugin-host.svelte"; +import type { createSettingsRefreshers } from "./settings-refreshers.svelte"; +import type { createSurfaceProtocol } from "./surface-protocol.svelte"; + +/** Typed socket callbacks over explicitly injected app-owned instances. */ +export function createWsHandlers(deps: { + url: string; + send: SurfaceSocketHandle["send"]; + registry: ChatRegistry; + conversations: OpenConversations; + surfaces: ReturnType<typeof createSurfaceProtocol>; + cache: ConversationCache; + tabsStore: TabsStore; + activeWorkspaceId: () => string; + workspaceSetMsg: (workspaceId: string) => WorkspaceSetMessage; + pluginsStore: PluginsStore; + pluginHost: ReturnType<typeof createPluginHost>; + layoutStore: LayoutStore; + tileLayoutStore: TileLayoutStore; + refreshers: ReturnType<typeof createSettingsRefreshers>; + reportError: (context: string, err: unknown) => void; +}): SurfaceSocketOptions { + const { + registry, + conversations, + surfaces, + cache, + tabsStore, + pluginsStore, + pluginHost, + layoutStore, + tileLayoutStore, + } = deps; + function handleChatMessage( + msg: + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage, + ): void { + let targetId: string | undefined; + if (msg.type === "chat.delta") { + targetId = msg.event.conversationId; + } else { + targetId = msg.conversationId; + } + + if (targetId !== undefined) { + const store = registry.chatStores.get(targetId) ?? registry.watchStores.get(targetId); + if (store !== undefined) { + store.handleDelta(msg); + return; + } + } + + if ( + msg.type === "chat.accepted" || + msg.type === "chat.subscription-rejected" || + msg.type === "chat.queued" || + msg.type === "chat.resync-required" + ) + return; + + // fallback: try all stores (chat.error without conversationId) + for (const store of registry.chatStores.values()) { + store.handleDelta(msg); + } + for (const store of registry.watchStores.values()) { + store.handleDelta(msg); + } + } + + return { + url: deps.url, + onMessage: surfaces.handleServerMessage, + onChat: handleChatMessage, + onConversationOpen(msg: ConversationOpenMessage): void { + conversations.openConversation( + msg.conversationId, + msg.workspaceId, + msg.model, + msg.tabGroupParentId, + ); + }, + onConversationTabGroupChanged(msg: ConversationTabGroupChangedMessage): void { + // Another client (or this one, echoed) regrouped a tab — apply the + // authoritative EFFECTIVE value. Unknown tabs are a reducer no-op. + tabsStore.setTabGroupParent(msg.conversationId, msg.tabGroupParentId); + }, + onConversationStatusChanged(msg: ConversationStatusChangedMessage): void { + const { conversationId, status, workspaceId } = msg; + if (status === "closed") { + // Closed on another device (or the backend) — remove the tab locally. + if (registry.chatStores.has(conversationId)) { + conversations.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. + conversations.conversationStatuses = new Map(conversations.conversationStatuses).set( + conversationId, + status, + ); + // Reconcile the already-open tab's workspace from this authoritative + // lifecycle event (the payload workspace is the conversation's actual + // persisted workspace). A raced `conversation.open` may have stamped the + // wrong workspace ("default") — correct it here. + if ( + registry.chatStores.has(conversationId) && + (tabsStore.tabs.find((t) => t.conversationId === conversationId)?.workspaceId ?? + workspaceId) !== workspaceId + ) { + tabsStore.setWorkspaceId(conversationId, workspaceId); + } + // 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") && + !registry.chatStores.has(conversationId) + ) { + conversations.openConversation(conversationId, workspaceId); + } + }, + onConversationCompacted(msg: ConversationCompactedMessage): void { + const cid = msg.conversationId; + const store = registry.chatStores.get(cid) ?? registry.watchStores.get(cid); + if (store && registry.loadedConversationIds.has(cid)) { + store.resync(msg.historyEpoch); + } else { + store?.invalidateHistory(msg.historyEpoch); + // Unseen conversations stay lazy. Invalidation is queued before their + // next load; epoch-guarded commits reject any older in-flight response. + void cache + .reset(cid, msg.historyEpoch) + .catch((err) => deps.reportError("Failed to reset conversation cache", err)); + } + }, + 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 surfaces.protocol.subscriptions) { + const msg: SubscribeMessage = + sub.conversationId === undefined + ? { type: "subscribe", surfaceId } + : { type: "subscribe", surfaceId, conversationId: sub.conversationId }; + deps.send(msg); + } + // A reconnect is a NEW connection — the server forgot this client's + // workspace too. Re-declare it so the re-pushed catalog is filtered + // (plan 06 §6.4). + deps.send(deps.workspaceSetMsg(deps.activeWorkspaceId())); + registry.reconnect(); + void conversations.fetchOpenConversations(); + }, + onExtensionsChanged(_msg: ExtensionsChangedMessage) { + // §11 multi-client coherence: another client (or this one) mutated the + // plugin set. Refetch the plugin list (+ any open workspace modal view), + // the workspace-scoped model catalog, and the provider guardrail. The + // surface catalog is NOT refetched here — the server re-pushes it + // (workspace-filtered) on the same broadcast. + pluginsStore.onExtensionsChanged(); + // Frontend plugins (plan 03): the load lifecycle's extensions-changed + // trigger — newly-loaded/enabled frontend plugins import + activate; + // unloaded/disabled ones are swept (their tiles fall back). + void pluginHost.sync(); + // A layout plugin enabled/disabled mid-session starts/stops serving — + // the probe picks the new provider state up without a reload (§2.6). + void layoutStore.probe(deps.activeWorkspaceId()); + void deps.refreshers.refreshModels(); + void deps.refreshers.refreshProviderStatus(); + }, + onLayout(msg: LayoutChangedMessage) { + // §2.5 multi-client coherence: a layout write on ANY client (or a + // plugin's op) re-renders here — the store filters non-active + // workspaces (the server fans out to everyone). + layoutStore.onServerTree(msg.workspaceId, msg.tree); + }, + onTileLayout(msg: TileLayoutChangedMessage) { + // Multi-client coherence for the home grid's synced layouts: a write + // on ANY client re-renders here — the store filters the OTHER + // orientation (the server fans out to everyone). + tileLayoutStore.onServerTileLayout(msg.mode, msg.data); + }, + }; +} diff --git a/src/core/chunks/groups.test.ts b/src/core/chunks/groups.test.ts index c8b0fa2..1196590 100644 --- a/src/core/chunks/groups.test.ts +++ b/src/core/chunks/groups.test.ts @@ -3,16 +3,16 @@ import { describe, expect, it } from "vitest"; import { groupRenderedChunks } from "./groups"; import type { RenderedChunk } from "./types"; -const text = (seq: number, role: Role, t: string, provisional = false): RenderedChunk => ({ +const text = (seq: number, role: Role, t: string): RenderedChunk => ({ seq, role, chunk: { type: "text", text: t }, - provisional, + provisional: false, }); - const call = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({ seq, role: "assistant", + provisional, chunk: { type: "tool-call", toolCallId: id, @@ -20,12 +20,11 @@ const call = (seq: number, id: string, stepId?: string, provisional = false): Re input: { id }, ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), }, - provisional, }); - const result = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({ seq, role: "tool", + provisional, chunk: { type: "tool-result", toolCallId: id, @@ -34,92 +33,142 @@ const result = (seq: number, id: string, stepId?: string, provisional = false): isError: false, ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), }, - provisional, }); +function toolGroups(rows: readonly RenderedChunk[]) { + return groupRenderedChunks(rows).map((g) => { + if (g.kind !== "tool") throw new Error("expected a tool group"); + return g; + }); +} + describe("groupRenderedChunks", () => { 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("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("passes non-tool chunks through unchanged and in order", () => { + const rows = [text(1, "user", "hi"), text(2, "assistant", "hello")]; + expect(groupRenderedChunks(rows)).toEqual(rows.map((chunk) => ({ kind: "single", chunk }))); }); - - 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"), + it("pairs a single call and result into one group", () => { + const c = call(1, "a", "s1"); + const r = result(2, "a", "s1"); + expect(toolGroups([c, r])).toEqual([ + { + kind: "tool", + entry: { toolCallId: "a", toolName: "tool-a", call: c.chunk, result: r.chunk }, + stepId: "s1", + provisional: false, + }, ]); - 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([ + it("renders parallel calls independently in call order, with out-of-order results", () => { + const groups = toolGroups([ call(1, "a", "s1"), call(2, "b", "s1"), - result(3, "a", "s1"), - result(4, "b", "s1"), + result(3, "b", "s1"), + result(4, "a", "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"); + expect(groups.map((g) => g.entry.toolCallId)).toEqual(["a", "b"]); + expect(groups.map((g) => g.entry.result?.content)).toEqual(["result-a", "result-b"]); }); - - it("positions the batch at the first call and keeps surrounding chunks in order", () => { + it("pairs legacy chunks without stepId and mismatching stepIds by toolCallId", () => { + const groups = toolGroups([ + call(1, "a"), + result(2, "a"), + call(3, "b", "s1"), + result(4, "b", "s2"), + ]); + expect(groups).toHaveLength(2); + expect(groups[0]?.entry.result).not.toBeNull(); + expect(groups[1]?.stepId).toBe("s1"); + }); + it("keeps surrounding text at its position and absorbs results", () => { 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"), + text(3, "assistant", "between"), + call(4, "b", "s1"), + result(5, "a"), + result(6, "b"), + text(7, "assistant", "after"), ]); - expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]); + expect(groups.map((g) => g.kind)).toEqual(["single", "tool", "single", "tool", "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("keeps pending calls and orphan results", () => { + const groups = toolGroups([call(1, "pending"), result(2, "orphan", "s2", true)]); + expect(groups[0]?.entry.result).toBeNull(); + expect(groups[1]?.entry.call).toBeNull(); + expect(groups[1]?.entry.toolName).toBe("tool-orphan"); + expect(groups[1]?.provisional).toBe(true); + expect(groups[1]?.stepId).toBe("s2"); }); - - 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"), + it.each([ + [true, false], + [false, true], + [false, false], + ])("combines call/result provisional flags (%s, %s)", (cp, rp) => { + expect(toolGroups([call(1, "a", "s1", cp), result(2, "a", "s1", rp)])[0]?.provisional).toBe( + cp || rp, + ); + }); + it("prefers call provenance, falling back to result provenance", () => { + const groups = toolGroups([ + { ...call(1, "a", "s1"), turnId: "t1" }, + { ...result(2, "a", "s2"), turnId: "t2" }, + call(3, "b"), + { ...result(4, "b", "s3"), turnId: "t3" }, + ]); + expect(groups.map((g) => [g.stepId, g.turnId])).toEqual([ + ["s1", "t1"], + ["s3", "t3"], + ]); + }); + it("dedupes calls/results across steps, first occurrence wins", () => { + const first = call(1, "a", "s1", true); + const firstResult = result(4, "a", "s1"); + const groups = toolGroups([ + first, + call(2, "a", "s2"), + call(3, "b"), + firstResult, + result(5, "a", "s2"), + result(6, "b"), + result(7, "b"), ]); - 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"); + expect(groups).toHaveLength(2); + expect(groups[0]?.entry.call).toBe(first.chunk); + expect(groups[0]?.entry.result).toBe(firstResult.chunk); + expect(groups[0]?.provisional).toBe(true); + }); + it("dedupes orphan results too", () => { + expect(toolGroups([result(1, "a"), result(2, "a")])).toHaveLength(1); + }); + it("positions a card at its call even when its result came first", () => { + const c = call(3, "a"); + const groups = groupRenderedChunks([result(1, "a"), text(2, "assistant", "before call"), c]); + expect(groups.map((g) => g.kind)).toEqual(["single", "tool"]); }); }); + +it("keeps windowed committed orphan calls in Show earlier even while another payload recovers", () => { + const groups = groupRenderedChunks( + [ + { + seq: 50, + role: "tool", + provisional: false, + chunk: { + type: "tool-result", + toolCallId: "older", + toolName: "run_shell", + content: "output", + isError: false, + }, + }, + ], + true, + ); + expect(groups[0]).toMatchObject({ kind: "tool", entry: { call: null } }); + if (groups[0]?.kind === "tool") expect(groups[0].entry.recovering).toBeUndefined(); +}); diff --git a/src/core/chunks/groups.ts b/src/core/chunks/groups.ts index 53a2873..6e0f8ea 100644 --- a/src/core/chunks/groups.ts +++ b/src/core/chunks/groups.ts @@ -1,95 +1,75 @@ import type { ToolCallChunk, ToolResultChunk } from "@dispatch/wire"; import type { RenderedChunk } from "./types"; -/** - * One tool call within a batch, paired with its result (matched by `toolCallId`). - * `result` is null while the call is still pending (no result chunk yet). - */ -export interface ToolBatchEntry { - readonly call: ToolCallChunk; +/** One tool call and its response. Null call = unloaded history; null result = pending. */ +export interface ToolEntry { + readonly recovering?: boolean; + readonly toolCallId: string; + readonly toolName: string; + readonly call: ToolCallChunk | null; readonly result: ToolResultChunk | null; } -/** - * A render group: either a single rendered chunk (rendered as today) or a batch - * of tool calls the model emitted together in one step (shared `stepId`), to be - * 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 kind: "tool"; + readonly entry: ToolEntry; readonly provisional: boolean; + readonly stepId?: string; + readonly turnId?: string; }; /** - * Group a flat rendered-chunk stream for display. Tool calls sharing a `stepId` - * (the backend's authoritative batch key) where the step has 2+ calls become one - * `tool-batch` group, positioned at the first call and pairing each call with its - * `tool-result` (by `toolCallId`); the absorbed result chunks are not emitted on - * their own. Single tool calls (one per step, or no `stepId` — e.g. pre-0.2.0 - * replay rows) and every non-tool chunk render as `single` groups, in order. - * - * Pure: input → output, no DOM, no Svelte. + * One independent group per call, at the call's position, absorbing its result + * by toolCallId (including legacy chunks without stepId). Orphan results stay + * visible at their own position. First occurrence wins for duplicate ids, so + * the stable t<toolCallId> render keys are always unique, even on corrupt replay. + * Pure: input → output; no DOM, effects, or 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); - } - - // 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>(); +export function groupRenderedChunks( + rendered: readonly RenderedChunk[], + recovering = false, +): readonly RenderGroup[] { + const calls = new Map<string, RenderedChunk>(); + const results = new Map<string, RenderedChunk>(); 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); - } + const c = rc.chunk; + if (c.type === "tool-call" && !calls.has(c.toolCallId)) calls.set(c.toolCallId, rc); + if (c.type === "tool-result" && !results.has(c.toolCallId)) results.set(c.toolCallId, rc); } - // 3. Emit groups in stream order; each batch lands at its first call. const groups: RenderGroup[] = []; - const emittedSteps = new Set<string>(); + const emitted = 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); - - 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 }); + const c = rc.chunk; + if (c.type !== "tool-call" && c.type !== "tool-result") { + groups.push({ kind: "single", chunk: rc }); continue; } - - if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) { - continue; // absorbed into its batch - } - - groups.push({ kind: "single", chunk: rc }); + if (emitted.has(c.toolCallId)) continue; + // A matching call owns the card's position, even if the result came first. + if (c.type === "tool-result" && calls.has(c.toolCallId)) continue; + emitted.add(c.toolCallId); + const callRow = calls.get(c.toolCallId); + const resultRow = results.get(c.toolCallId); + const call = callRow?.chunk.type === "tool-call" ? callRow.chunk : null; + const result = resultRow?.chunk.type === "tool-result" ? resultRow.chunk : null; + const stepId = call?.stepId ?? result?.stepId; + const turnId = callRow?.turnId ?? resultRow?.turnId; + groups.push({ + kind: "tool", + entry: { + toolCallId: c.toolCallId, + toolName: call?.toolName ?? c.toolName, + call, + result, + ...(recovering && call === null && resultRow?.provisional ? { recovering: true } : {}), + }, + provisional: Boolean(callRow?.provisional || resultRow?.provisional), + ...(stepId !== undefined ? { stepId } : {}), + ...(turnId !== undefined ? { turnId } : {}), + }); } - return groups; } diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts index bdd6ce4..03d687c 100644 --- a/src/core/chunks/index.ts +++ b/src/core/chunks/index.ts @@ -1,4 +1,4 @@ -export type { RenderGroup, ToolBatchEntry } from "./groups"; +export type { RenderGroup, ToolEntry } from "./groups"; export { groupRenderedChunks } from "./groups"; export { resolveImageUrl } from "./image-url"; export { @@ -7,6 +7,8 @@ export { clearGenerating, foldEvent, initialState, + markHistoryRecovering, + removeLocalEcho, } from "./reducer"; export type { ProviderRetryView } from "./retry-banner"; export { formatRetryDelay, viewProviderRetry } from "./retry-banner"; diff --git a/src/core/chunks/local-echo.test.ts b/src/core/chunks/local-echo.test.ts new file mode 100644 index 0000000..565017c --- /dev/null +++ b/src/core/chunks/local-echo.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { appendUserMessage, applyHistory, foldEvent, initialState, removeLocalEcho } from "./index"; + +describe("request-local optimistic echo", () => { + it("retains identity through turn-start tags without conflating identical sends", () => { + let state = appendUserMessage(initialState(), "same", [{ url: "data:first" }], "r1"); + state = appendUserMessage(state, "same", [{ url: "data:second" }], "r2"); + state = foldEvent(state, { type: "turn-start", conversationId: "c1", turnId: "t1" }); + state = removeLocalEcho(state, "r1"); + expect(state.provisional.map((p) => p.localRequestId)).toEqual(["r2", "r2"]); + expect(state.provisional.map((p) => p.chunk)).toEqual([ + { type: "text", text: "same" }, + { type: "image", url: "data:second" }, + ]); + expect(state.generating).toBe(true); + expect(removeLocalEcho(state, "r1")).toBe(state); + }); + + it("a live user-message confirms only its deduplicated text, not every identical local send", () => { + let state = appendUserMessage(initialState(), "same", [{ url: "data:first" }], "r1"); + state = appendUserMessage(state, "same", [{ url: "data:second" }], "r2"); + state = foldEvent(state, { type: "turn-start", conversationId: "c1", turnId: "t1" }); + state = foldEvent(state, { + type: "user-message", + conversationId: "c1", + turnId: "t1", + text: "same", + }); + state = removeLocalEcho(removeLocalEcho(state, "r1"), "r2"); + expect(state.provisional).toEqual([ + { role: "user", turnId: "t1", opening: true, chunk: { type: "text", text: "same" } }, + ]); + }); + + it("does not remove committed rows even if their content is identical", () => { + let state = appendUserMessage(initialState(), "same", undefined, "r1"); + state = applyHistory(state, [{ seq: 1, role: "user", chunk: { type: "text", text: "same" } }]); + const committed = state.committed; + state = removeLocalEcho(state, "r1"); + expect(state.committed).toBe(committed); + expect(state.provisional).toEqual([]); + expect(removeLocalEcho(state, "r1")).toBe(state); + expect(removeLocalEcho(initialState(), "r1")).toEqual(initialState()); + }); +}); diff --git a/src/core/chunks/reconcile.test.ts b/src/core/chunks/reconcile.test.ts new file mode 100644 index 0000000..3d90a51 --- /dev/null +++ b/src/core/chunks/reconcile.test.ts @@ -0,0 +1,143 @@ +import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { applyHistory, foldEvent, initialState, markHistoryRecovering } from "./reducer"; +import { selectChunks } from "./selectors"; +import type { TranscriptState } from "./types"; + +const stepId = "t1#1" as StepId; +function fold(s: TranscriptState, e: AgentEvent) { + return foldEvent(s, e); +} +const call = (toolCallId = "a", turnId = "t1", step = stepId): AgentEvent => ({ + type: "tool-call", + conversationId: "c1", + turnId, + stepId: step, + toolCallId, + toolName: "run_shell", + input: "partial", +}); +const result = (toolCallId = "a"): AgentEvent => ({ + type: "tool-result", + conversationId: "c1", + turnId: "t1", + stepId, + toolCallId, + toolName: "run_shell", + content: "partial", + isError: false, +}); +const stored: StoredChunk[] = [ + { seq: 50, role: "assistant", turnId: "t1", chunk: { type: "text", text: "full" } }, + { + seq: 51, + role: "assistant", + turnId: "t1", + chunk: { + type: "tool-call", + stepId, + toolCallId: "a", + toolName: "run_shell", + input: { command: "full" }, + }, + }, + { + seq: 52, + role: "tool", + turnId: "t1", + chunk: { + type: "tool-result", + stepId, + toolCallId: "a", + toolName: "run_shell", + content: "full result", + isError: false, + }, + }, +]; + +describe("history recovery identities", () => { + it.each([ + "before-live", + "after-live", + ])("reconciles committed tool/text identity %s, not payload equality", (order) => { + let s = markHistoryRecovering(initialState(), "t1"); + if (order === "before-live") s = applyHistory(s, stored); + s = fold(s, { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "short" }); + s = fold(s, call()); + s = fold(s, result()); + if (order === "after-live") s = applyHistory(s, stored); + expect(selectChunks(s).map((c) => c.chunk)).toEqual(stored.map((c) => c.chunk)); + // Idempotent refresh; different step's identical text must remain live. + s = fold(s, { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "full" }); + s = applyHistory(s, stored); + expect(selectChunks(s).at(-1)).toMatchObject({ + provisional: true, + streaming: true, + chunk: { type: "text", text: "full" }, + }); + }); + + it("does not conflate call/result, another turn, or another step's tool identity", () => { + let s = markHistoryRecovering(initialState(), "t1"); + s = fold(s, call("a", "other")); + s = fold(s, call("a", "t1", "other-step" as StepId)); + s = fold(s, result("a")); + s = applyHistory(s, stored); + expect(s.provisional).toHaveLength(2); + expect(s.provisional.map((p) => p.turnId)).toEqual(["other", "t1"]); + expect(s.provisional.every((p) => p.chunk.type === "tool-call")).toBe(true); + }); + + it("terminal sync removes only that turn and keeps optimistic/unrelated/carried content", () => { + let s = markHistoryRecovering(initialState(), "t1"); + s = fold(s, call()); + s = fold(s, { type: "turn-sealed", conversationId: "c1", turnId: "t1" }); + s = fold(s, { type: "turn-start", conversationId: "c1", turnId: "t2" }); + s = fold(s, { type: "text-delta", conversationId: "c1", turnId: "t2", delta: "queued tail" }); + s = applyHistory(s, stored, ["t1"]); + expect(s.generating).toBe(true); + expect(s.currentTurnId).toBe("t2"); + expect(s.accumulating?.text).toBe("queued tail"); + expect(s.historyRecoveryTurnIds).toEqual([]); + }); + + it("does not resurrect watermark-hidden history; older content stays available to Show earlier", () => { + let s = markHistoryRecovering({ ...initialState(), hiddenBeforeSeq: 51 }, "t1"); + s = fold(s, call()); + s = applyHistory(s, stored); + expect(s.committed.map((c) => c.seq)).toEqual([51, 52]); + expect(s.provisional).toEqual([]); + expect(s.hiddenBeforeSeq).toBe(51); + }); +}); + +it("keeps identical Stop→carry openings as distinct turn identities", () => { + let s = markHistoryRecovering(initialState(), "t1"); + s = fold(s, { type: "user-message", conversationId: "c1", turnId: "t1", text: "same" }); + s = fold(s, { type: "turn-ended", conversationId: "c1", turnId: "t1", reason: "aborted" }); + s = fold(s, { type: "user-message", conversationId: "c1", turnId: "t2", text: "same" }); + s = fold(s, { type: "turn-start", conversationId: "c1", turnId: "t2" }); + s = applyHistory( + s, + [{ seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "same" } }], + ["t1"], + ); + expect(selectChunks(s).map((c) => [c.turnId, c.provisional])).toEqual([ + ["t1", false], + ["t2", true], + ]); + expect(s.generating).toBe(true); +}); + +it("reconciles steering by its completed tool batch, not repeated user text", () => { + let s = markHistoryRecovering(initialState(), "t1"); + s = fold(s, result()); + s = fold(s, { type: "steering", conversationId: "c1", turnId: "t1", text: "short" }); + s = applyHistory(s, [ + ...stored, + { seq: 53, role: "user", turnId: "t1", chunk: { type: "text", text: "full steering" } }, + ]); + expect(s.provisional).toEqual([]); + expect(selectChunks(s).at(-1)?.chunk).toEqual({ type: "text", text: "full steering" }); +}); diff --git a/src/core/chunks/reconcile.ts b/src/core/chunks/reconcile.ts new file mode 100644 index 0000000..277d25b --- /dev/null +++ b/src/core/chunks/reconcile.ts @@ -0,0 +1,54 @@ +import type { StoredChunk } from "@dispatch/wire"; +import type { ProvisionalChunk, TranscriptState } from "./types"; + +/** + * A persisted tool call proves its entire assistant step committed atomically. + * Text/thinking wire chunks have no stepId: their provisional provenance is + * learned from tool-call/step-complete, never from text equality. This also + * handles a window starting midway through the step and an omitted prefix. + * Also usable when HTTP finished before replay/live flush. + */ +export function historyBackedProvisional( + state: TranscriptState, + committed: readonly StoredChunk[] = state.committed, +): ReadonlySet<ProvisionalChunk> { + const backed = new Set<ProvisionalChunk>(); + if (!state.historyRecoveryTurnIds?.length) return backed; + const steps = new Set<string>(); + const steeringAnchors = new Set<string>(); + let lastResult: StoredChunk | undefined; + const toolRows = new Map<string, Set<string | undefined>>(); + for (const row of committed) { + const c = row.chunk; + if (row.role === "assistant" || row.turnId !== lastResult?.turnId) lastResult = undefined; + if (c.type === "tool-result") lastResult = row; + if (row.role === "user" && c.type === "text" && lastResult?.chunk.type === "tool-result") { + steeringAnchors.add(`${row.turnId}\0${lastResult.chunk.toolCallId}`); + } + if (c.type === "tool-call" || c.type === "tool-result") { + const key = `${row.turnId}\0${c.type}\0${c.toolCallId}`; + const ids = toolRows.get(key) ?? new Set<string | undefined>(); + ids.add(c.stepId); + toolRows.set(key, ids); + if (c.type === "tool-call" && c.stepId !== undefined) steps.add(`${row.turnId}\0${c.stepId}`); + } + } + for (const p of state.provisional) { + if (p.turnId === undefined || !state.historyRecoveryTurnIds?.includes(p.turnId)) continue; + const c = p.chunk; + if (c.type === "tool-call" || c.type === "tool-result") { + const ids = toolRows.get(`${p.turnId}\0${c.type}\0${c.toolCallId}`); + if (ids !== undefined && (c.stepId === undefined || ids.has(undefined) || ids.has(c.stepId))) + backed.add(p); + } else if (p.afterToolCallId !== undefined && p.role === "user") { + if (steeringAnchors.has(`${p.turnId}\0${p.afterToolCallId}`)) backed.add(p); + } else if (p.opening && c.type === "text") { + // The opening is the first user row of this turn; steering is NOT opening. + const first = committed.find((row) => row.turnId === p.turnId); + if (first?.role === "user" && first.chunk.type === "text") backed.add(p); + } else if (p.stepId !== undefined && p.role === "assistant") { + if (steps.has(`${p.turnId}\0${p.stepId}`)) backed.add(p); + } + } + return backed; +} diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts index 058552e..10d662e 100644 --- a/src/core/chunks/reducer.test.ts +++ b/src/core/chunks/reducer.test.ts @@ -7,6 +7,7 @@ import type { TurnInputEvent, TurnProviderRetryEvent, TurnReasoningDeltaEvent, + TurnReasoningEndEvent, TurnSealedEvent, TurnStartEvent, TurnSteeringEvent, @@ -45,6 +46,12 @@ const reasoningDelta = (turnId: string, delta: string): TurnReasoningDeltaEvent delta, }); +const reasoningEnd = (turnId: string): TurnReasoningEndEvent => ({ + type: "reasoning-end", + conversationId: "c1", + turnId, +}); + const toolCall = ( turnId: string, toolCallId: string, @@ -226,7 +233,7 @@ describe("clearGenerating", () => { 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.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); expect(cleared.currentTurnId).toBe("t1"); }); }); @@ -236,7 +243,7 @@ describe("foldEvent — text-delta", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "hello")); - expect(s.accumulating).toEqual({ kind: "text", text: "hello" }); + expect(s.accumulating).toEqual({ kind: "text", text: "hello", turnId: "t1" }); expect(s.provisional).toEqual([]); }); @@ -245,7 +252,7 @@ describe("foldEvent — text-delta", () => { 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.accumulating).toEqual({ kind: "text", text: "hello world", turnId: "t1" }); expect(s.provisional).toEqual([]); }); @@ -254,7 +261,7 @@ describe("foldEvent — text-delta", () => { 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.accumulating).toEqual({ kind: "text", text: "answer", turnId: "t1" }); expect(s.provisional).toHaveLength(1); expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." }); expect(s.provisional[0]?.role).toBe("assistant"); @@ -266,7 +273,7 @@ describe("foldEvent — reasoning-delta", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, reasoningDelta("t1", "hmm")); - expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" }); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm", turnId: "t1" }); }); it("successive reasoning-deltas extend the same chunk", () => { @@ -274,7 +281,73 @@ describe("foldEvent — reasoning-delta", () => { 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" }); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok", turnId: "t1" }); + }); +}); + +describe("foldEvent — reasoning-end (thinking block boundary)", () => { + it("closes each block so consecutive deltas become separate chunks", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningDelta("t1", "a")); + s = foldEvent(s, reasoningEnd("t1")); + s = foldEvent(s, reasoningDelta("t1", "b")); + s = foldEvent(s, reasoningEnd("t1")); + expect(s.provisional.map((p) => p.chunk)).toEqual([ + { type: "thinking", text: "a" }, + { type: "thinking", text: "b" }, + ]); + expect(s.accumulating).toBeNull(); + }); + + it("still merges legacy deltas with no boundary (no reasoning-end)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningDelta("t1", "a")); + s = foldEvent(s, reasoningDelta("t1", "b")); + expect(s.accumulating).toEqual({ kind: "thinking", text: "ab", turnId: "t1" }); + }); + + it("end with nothing open appends an empty thinking chunk for the turn", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningEnd("t1")); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "" }); + expect(s.provisional[0]?.turnId).toBe("t1"); + expect(s.accumulating).toBeNull(); + }); + + it("text-delta then end flushes the text, then appends an empty thinking chunk", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "x")); + s = foldEvent(s, reasoningEnd("t1")); + expect(s.provisional.map((p) => p.chunk)).toEqual([ + { type: "text", text: "x" }, + { type: "thinking", text: "" }, + ]); + }); + + it("end for another turn flushes the open thinking and appends an empty one", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningDelta("t1", "open")); + s = foldEvent(s, reasoningEnd("t2")); + expect(s.provisional.map((p) => [p.turnId, p.chunk])).toEqual([ + ["t1", { type: "thinking", text: "open" }], + ["t2", { type: "thinking", text: "" }], + ]); + expect(s.accumulating).toBeNull(); + }); + + it("clears a pending provider-retry banner", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, reasoningEnd("t1")); + expect(selectProviderRetry(s)).toBeNull(); }); }); @@ -531,7 +604,7 @@ describe("clearGenerating also clears a stale provider-retry banner (reconnect)" 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(cleared.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); expect(selectProviderRetry(cleared)).toBeNull(); }); }); @@ -859,7 +932,7 @@ describe("appendUserMessage", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "partial")); - expect(s.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(s.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); s = appendUserMessage(s, "user msg"); expect(s.accumulating).toBeNull(); @@ -1022,3 +1095,115 @@ describe("applyHistory — multi-chunk image echo is superseded by committed", ( expect(msgs[0]?.chunks[1]).toEqual({ type: "image", url: PNG, mimeType: "image/png" }); }); }); + +describe("turnId provenance on live chunks", () => { + it("tags every live chunk with its event's turnId, and the optimistic echo on turn-start", () => { + let s = initialState(); + s = appendUserMessage(s, "q", [{ url: "data:image/png;base64,AA", mimeType: "image/png" }]); + expect(selectChunks(s).map((c) => c.turnId)).toEqual([undefined, undefined]); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "a")); + const chunks = selectChunks(s); + expect(chunks.map((c) => c.turnId)).toEqual(["t1", "t1", "t1"]); + }); + + it("turn-start does not re-tag an echo already owned by an earlier turn", () => { + let s = initialState(); + s = appendUserMessage(s, "q1"); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "a1")); + s = foldEvent(s, turnStart("t2")); + expect(selectChunks(s).map((c) => c.turnId)).toEqual(["t1", "t1"]); + }); + + it("committed chunks expose StoredChunk.turnId", () => { + const s = applyHistory(initialState(), [ + { seq: 1, role: "user", chunk: { type: "text", text: "q" }, turnId: "t9" }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "a" } }, + ]); + expect(selectChunks(s).map((c) => c.turnId)).toEqual(["t9", undefined]); + }); +}); + +describe("foldEvent — additive wire compatibility", () => { + it.each(["future-event"])("preserves transcript identity for %s", (type) => { + const state = foldEvent(foldEvent(initialState(), turnStart("t1")), textDelta("t1", "partial")); + const before = structuredClone(state); + const event = { type, conversationId: "c1", turnId: "t1", reason: "sealed" }; + expect(foldEvent(state, event as Parameters<typeof foldEvent>[1])).toBe(state); + expect(state).toEqual(before); + }); +}); + +describe("foldEvent — authoritative turn terminal", () => { + it.each([ + "sealed", + "aborted", + "error", + "rejected", + ] as const)("ends generation on %s", (reason) => { + const before = foldEvent( + foldEvent(initialState(), turnStart("t1")), + textDelta("t1", "partial"), + ); + const next = foldEvent(before, { + type: "turn-ended", + conversationId: "c1", + turnId: "t1", + reason, + }); + expect(next.generating).toBe(false); + expect(next.accumulating).toBeNull(); + expect(selectChunks(next).map((c) => c.chunk)).toEqual([{ type: "text", text: "partial" }]); + expect(before.accumulating?.text).toBe("partial"); + }); +}); + +describe("foldEvent — re-replay of an in-flight turn (re-subscribe after tab switch / reconnect)", () => { + const userMessage = (turnId: string, text: string): TurnInputEvent => ({ + type: "user-message", + conversationId: "c1", + turnId, + text, + }); + const replay = (upTo: number) => + [ + userMessage("t1", "go"), + turnStart("t1"), + reasoningDelta("t1", "plan"), + toolCall("t1", "tc1", "read", {}), + toolResult("t1", "tc1", "read", "out"), + { type: "steering", conversationId: "c1", turnId: "t1", text: "also this" } as const, + reasoningDelta("t1", "more"), + toolCall("t1", "tc2", "read", {}), + ].slice(0, upTo); + + it("replaces the stale copy instead of appending a duplicate turn", () => { + let s = replay(7).reduce(foldEvent, initialState()); // first view, mid-thinking + s = replay(8).reduce(foldEvent, s); // came back: the whole turn replays again + const once = replay(8).reduce(foldEvent, initialState()); + expect(selectChunks(s)).toEqual(selectChunks(once)); + expect(s.accumulating).toBeNull(); + }); + + it("drops a stale steering bubble that ended the old copy", () => { + let s = replay(6).reduce(foldEvent, initialState()); // old copy ends in steering + s = replay(8).reduce(foldEvent, s); + expect(selectChunks(s).filter((c) => c.role === "user")).toHaveLength(2); // opening + steer + }); + + it("keeps other turns' provisional chunks", () => { + let s = [turnStart("t0"), textDelta("t0", "earlier"), doneEvent("t0")].reduce( + foldEvent, + initialState(), + ); + s = replay(4).reduce(foldEvent, s); + s = replay(4).reduce(foldEvent, s); + expect(selectChunks(s).map((c) => c.chunk.type)).toEqual([ + "text", + "text", + "thinking", + "tool-call", + ]); + }); +}); diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts index 64e41b9..71df4dd 100644 --- a/src/core/chunks/reducer.ts +++ b/src/core/chunks/reducer.ts @@ -1,5 +1,6 @@ import type { AgentEvent, Chunk, ImageInput, Role, StoredChunk } from "@dispatch/wire"; import { assertChunkExhaustive } from "../wire/conformance"; +import { historyBackedProvisional } from "./reconcile"; import type { AccumulatingChunk, ProvisionalChunk, TranscriptState } from "./types"; /** The initial empty transcript state. */ @@ -40,7 +41,59 @@ function flushAccumulating( 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 }]; + return [ + ...provisional, + withTurn( + { role: "assistant", chunk, ...(acc.stepId !== undefined ? { stepId: acc.stepId } : {}) }, + acc.turnId, + ), + ]; +} + +/** Attach a `turnId` to a provisional chunk (omitted when unknown). */ +function withTurn(p: ProvisionalChunk, turnId: string | undefined): ProvisionalChunk { + return turnId !== undefined ? { ...p, turnId } : p; +} + +/** + * Tag the trailing UNTAGGED user run (the sender's optimistic echo, appended + * before the server assigned a turn id) with the turn that just started. + */ +function tagTrailingUserEcho( + provisional: readonly ProvisionalChunk[], + turnId: string, +): readonly ProvisionalChunk[] { + let start = provisional.length; + while (start > 0) { + const p = provisional[start - 1]; + if (p === undefined || p.role !== "user" || p.turnId !== undefined) break; + start--; + } + if (start === provisional.length) return provisional; + return [ + ...provisional.slice(0, start), + ...provisional.slice(start).map((p) => ({ ...p, turnId })), + ]; +} + +/** + * Drop the previous copy of a turn that is being replayed from its start. + * Keeps the trailing user run — the opening this replay's `user-message` just + * appended (or the sender's echo that `turn-start` just tagged) — and every + * other turn's chunks. + */ +function dropReplayedTurn( + provisional: readonly ProvisionalChunk[], + turnId: string, +): readonly ProvisionalChunk[] { + let tail = provisional.length; + while (tail > 0 && provisional[tail - 1]?.role === "user") tail--; + // A stale steering bubble (replayed again as its own `steering` event) can + // end the old copy and so sit inside the trailing user run. + const stale = (p: ProvisionalChunk, i: number): boolean => + p.turnId === turnId && (i < tail || p.afterToolCallId !== undefined); + if (!provisional.some(stale)) return provisional; + return provisional.filter((p, i) => !stale(p, i)); } /** @@ -103,8 +156,8 @@ function trailingRun( /** * Merge authoritative seq-keyed chunks into the committed history. * Dedupes by seq (new wins), keeps seq-monotonic order, idempotent. - * When sealedTurnId is set, drops all provisional chunks (now superseded) - * and clears sealedTurnId. + * A sealed/finally-synced turn supersedes only its own provisional chunks; + * unrelated turns and an already-running queued carry remain live. * * Chunks below the chat-limit unload watermark (`hiddenBeforeSeq`) are * REJECTED: a full-cache or tail merge must not resurrect what the trim @@ -113,6 +166,7 @@ function trailingRun( export function applyHistory( state: TranscriptState, chunks: readonly StoredChunk[], + finishedTurnIds: readonly string[] = [], ): TranscriptState { const seqMap = new Map<number, StoredChunk>(); for (const c of state.committed) seqMap.set(c.seq, c); @@ -123,14 +177,49 @@ export function applyHistory( seqMap.set(c.seq, c); } const committed = Array.from(seqMap.values()).sort((a, b) => a.seq - b.seq); + const identityRows = new Map(seqMap); + for (const c of chunks) identityRows.set(c.seq, c); + const backed = historyBackedProvisional( + state, + [...identityRows.values()].sort((a, b) => a.seq - b.seq), + ); + const finished = new Set(finishedTurnIds); + if (state.sealedTurnId !== null) finished.add(state.sealedTurnId); + state = { + ...state, + provisional: state.provisional.filter((p) => !backed.has(p) && !finished.has(p.turnId ?? "")), + accumulating: + state.accumulating?.turnId !== undefined && finished.has(state.accumulating.turnId) + ? null + : state.accumulating, + }; if (state.sealedTurnId !== null) { + // Legacy (non-recovery) histories may carry no turn provenance at all. + // Retain their seal cleanup, but never erase an already-live queued carry. + const legacySeal = + (state.historyRecoveryTurnIds?.length ?? 0) === 0 && finishedTurnIds.length === 0; return { ...state, committed, - provisional: [], - accumulating: null, + provisional: legacySeal + ? state.provisional.filter( + (p) => + state.generating && + p.turnId === state.currentTurnId && + p.turnId !== state.sealedTurnId, + ) + : state.provisional, + accumulating: + legacySeal && state.accumulating?.turnId === state.sealedTurnId ? null : state.accumulating, sealedTurnId: null, + ...(state.historyRecoveryTurnIds !== undefined + ? { + historyRecoveryTurnIds: state.historyRecoveryTurnIds.filter( + (id) => !finishedTurnIds.includes(id), + ), + } + : {}), }; } @@ -141,7 +230,14 @@ export function applyHistory( // 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) { + if ( + addedNew && + state.generating && + state.provisional.length > 0 && + !state.provisional.some( + (p) => p.turnId !== undefined && state.historyRecoveryTurnIds?.includes(p.turnId), + ) + ) { const provRun = trailingRun(state.provisional, "user"); if (provRun.length > 0) { const commRun = trailingRun(committed, "user"); @@ -153,7 +249,11 @@ export function applyHistory( commRun.length >= provRun.length && provRun.every((p, i) => { const c = commRun[i]; - return c !== undefined && chunkContentEquals(p.chunk, c.chunk); + return ( + c !== undefined && + (c.turnId === undefined || p.turnId === undefined || c.turnId === p.turnId) && + chunkContentEquals(p.chunk, c.chunk) + ); }); if (fullyBacked) { const dropStart = state.provisional.length - provRun.length; @@ -163,7 +263,17 @@ export function applyHistory( } } - return { ...state, committed }; + return { + ...state, + committed, + ...(state.historyRecoveryTurnIds !== undefined + ? { + historyRecoveryTurnIds: state.historyRecoveryTurnIds.filter( + (id) => !finishedTurnIds.includes(id), + ), + } + : {}), + }; } /** @@ -174,6 +284,8 @@ export function applyHistory( * optimistic echo) so a watcher renders it mid-turn. * - `text-delta` extends the current accumulating TextChunk (or starts one). * - `reasoning-delta` extends the current accumulating ThinkingChunk (or starts one). + * - `reasoning-end` closes the open ThinkingChunk as its own chunk; with none open + * (a redacted/empty block) it appends an empty thinking chunk, mirroring history. * - `tool-call` / `tool-result` / `error` finalize any accumulating chunk and * add a new provisional chunk. * - `steering` appends a user bubble mid-turn (drained from the message queue @@ -198,8 +310,26 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState case "tool-output": return state; - case "turn-start": - return { ...state, currentTurnId: event.turnId, generating: true }; + case "turn-start": { + // A turn starts exactly once live, so a `turn-start` for a turn that + // already has provisional content is a late-join REPLAY restarting from + // the top (re-subscribe after a tab switch / WS reconnect): the server + // re-delivers the whole in-flight turn. Replace the stale copy instead of + // appending a second one (a duplicate copy rendered as a tail of thinking + // blocks with its tool cards collapsed onto the first copy's). + const stale = state.accumulating?.turnId === event.turnId; + const provisional = tagTrailingUserEcho( + flushAccumulating(state.provisional, stale ? null : state.accumulating), + event.turnId, + ); + return { + ...state, + provisional: dropReplayedTurn(provisional, event.turnId), + accumulating: null, + currentTurnId: event.turnId, + generating: true, + }; + } case "user-message": { // The turn's USER prompt, surfaced on the event stream (backend CR-3) so a @@ -215,14 +345,37 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState // 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 echoed = run.find( + (p) => + (p.turnId === undefined || p.turnId === event.turnId) && + p.chunk.type === "text" && + p.chunk.text === event.text, + ); + if (echoed !== undefined) { + return { + ...state, + // Preserve the live text independently of the local send. A delayed + // queued ack may remove its images, but cannot erase this server event. + provisional: tagTrailingUserEcho(state.provisional, event.turnId).map((p) => { + if (p.chunk !== echoed.chunk) return p; + const { localRequestId: _local, ...confirmed } = p; + return { ...confirmed, opening: true }; + }), + generating: true, + }; } const provisional = flushAccumulating(state.provisional, state.accumulating); return { ...state, - provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }], + provisional: [ + ...provisional, + { + role: "user", + chunk: { type: "text", text: event.text }, + turnId: event.turnId, + opening: true, + }, + ], accumulating: null, generating: true, }; @@ -230,10 +383,10 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState case "text-delta": { const acc = state.accumulating; - if (acc !== null && acc.kind === "text") { + if (acc !== null && acc.kind === "text" && acc.turnId === event.turnId) { return { ...state, - accumulating: { kind: "text", text: acc.text + event.delta }, + accumulating: { ...acc, text: acc.text + event.delta }, generating: true, }; } @@ -241,17 +394,17 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState return { ...state, provisional, - accumulating: { kind: "text", text: event.delta }, + accumulating: { kind: "text", text: event.delta, turnId: event.turnId }, generating: true, }; } case "reasoning-delta": { const acc = state.accumulating; - if (acc !== null && acc.kind === "thinking") { + if (acc !== null && acc.kind === "thinking" && acc.turnId === event.turnId) { return { ...state, - accumulating: { kind: "thinking", text: acc.text + event.delta }, + accumulating: { ...acc, text: acc.text + event.delta }, generating: true, }; } @@ -259,7 +412,31 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState return { ...state, provisional, - accumulating: { kind: "thinking", text: event.delta }, + accumulating: { kind: "thinking", text: event.delta, turnId: event.turnId }, + generating: true, + }; + } + + case "reasoning-end": { + const acc = state.accumulating; + // Close the open thinking block of this turn: flush it as its own provisional chunk. + if (acc !== null && acc.kind === "thinking" && acc.turnId === event.turnId) { + return { + ...state, + provisional: flushAccumulating(state.provisional, acc), + accumulating: null, + generating: true, + }; + } + // No open thinking (a redacted/empty block with no deltas): history persists an + // EMPTY thinking chunk for it, so mirror that 1:1. + return { + ...state, + provisional: [ + ...flushAccumulating(state.provisional, acc), + { role: "assistant", chunk: { type: "thinking", text: "" }, turnId: event.turnId }, + ], + accumulating: null, generating: true, }; } @@ -275,7 +452,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState }; return { ...state, - provisional: [...provisional, { role: "assistant", chunk }], + provisional: [...provisional, { role: "assistant", chunk, turnId: event.turnId }], accumulating: null, generating: true, }; @@ -293,7 +470,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState }; return { ...state, - provisional: [...provisional, { role: "tool", chunk }], + provisional: [...provisional, { role: "tool", chunk, turnId: event.turnId }], accumulating: null, generating: true, }; @@ -307,7 +484,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState : { type: "error", message: event.message }; return { ...state, - provisional: [...provisional, { role: "assistant", chunk }], + provisional: [...provisional, { role: "assistant", chunk, turnId: event.turnId }], accumulating: null, generating: false, }; @@ -317,8 +494,24 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState return { ...state, latestUsage: event.usage }; case "step-complete": - // Timing metadata — no content chunk; handled by the telemetry reducer. - return state; + // A persistence boundary may lag this notification. Stamp provenance and + // finish the generation chunk, but keep it until history really backs it. + return { + ...state, + provisional: flushAccumulating(state.provisional, state.accumulating), + accumulating: null, + }; + + case "turn-ended": + return { + ...state, + provisional: + state.accumulating?.turnId === event.turnId + ? flushAccumulating(state.provisional, state.accumulating) + : state.provisional, + accumulating: state.accumulating?.turnId === event.turnId ? null : state.accumulating, + generating: state.currentTurnId === event.turnId ? false : state.generating, + }; case "done": { const provisional = flushAccumulating(state.provisional, state.accumulating); @@ -350,9 +543,22 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState // into the transcript by the sender). if (event.text.length === 0) return state; const provisional = flushAccumulating(state.provisional, state.accumulating); + const preceding = [...state.committed, ...provisional] + .reverse() + .find((p) => p.turnId === event.turnId && p.chunk.type === "tool-result"); + const afterToolCallId = + preceding?.chunk.type === "tool-result" ? preceding.chunk.toolCallId : undefined; return { ...state, - provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }], + provisional: [ + ...provisional, + { + role: "user", + chunk: { type: "text", text: event.text }, + turnId: event.turnId, + ...(afterToolCallId !== undefined ? { afterToolCallId } : {}), + }, + ], accumulating: null, generating: true, }; @@ -367,6 +573,9 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState // (already true from `turn-start`) is left untouched. return { ...state, providerRetry: event }; } + + default: + return state; // Forward-compatible with additive wire events. } } @@ -385,14 +594,37 @@ const RETRY_CLEARING_EVENTS: ReadonlySet<AgentEvent["type"]> = new Set([ "turn-start", "text-delta", "reasoning-delta", + "reasoning-end", "tool-call", "tool-result", "error", "done", "turn-sealed", + "turn-ended", ]); export function foldEvent(state: TranscriptState, event: AgentEvent): TranscriptState { + if ( + event.type === "tool-call" || + event.type === "tool-result" || + event.type === "step-complete" + ) { + state = { + ...state, + provisional: state.provisional.map((p) => + p.turnId === event.turnId && + p.role === "assistant" && + p.stepId === undefined && + (p.chunk.type === "text" || p.chunk.type === "thinking") + ? { ...p, stepId: event.stepId } + : p, + ), + accumulating: + state.accumulating?.turnId === event.turnId + ? { ...state.accumulating, stepId: event.stepId } + : state.accumulating, + }; + } 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) { @@ -419,6 +651,7 @@ export function appendUserMessage( state: TranscriptState, text: string, images?: readonly ImageInput[], + localRequestId?: string, ): TranscriptState { const provisional = flushAccumulating(state.provisional, state.accumulating); const userChunks: Chunk[] = []; @@ -440,7 +673,35 @@ export function appendUserMessage( } return { ...state, - provisional: [...provisional, ...userChunks.map((chunk) => ({ role: "user", chunk }) as const)], + provisional: [ + ...provisional, + ...userChunks.map( + (chunk) => + ({ + role: "user", + chunk, + ...(localRequestId !== undefined ? { localRequestId } : {}), + }) as const, + ), + ], accumulating: null, }; } + +/** Remove only still-local artifacts of one accepted queued send; never history/live text. */ +export function removeLocalEcho(state: TranscriptState, requestId: string): TranscriptState { + const provisional = state.provisional.filter((p) => p.localRequestId !== requestId); + return provisional.length === state.provisional.length ? state : { ...state, provisional }; +} + +/** A resync notice is lifecycle evidence, never fabricated transcript content. */ +export function markHistoryRecovering(state: TranscriptState, turnId: string): TranscriptState { + return { + ...state, + currentTurnId: turnId, + generating: true, + historyRecoveryTurnIds: state.historyRecoveryTurnIds?.includes(turnId) + ? state.historyRecoveryTurnIds + : [...(state.historyRecoveryTurnIds ?? []), turnId], + }; +} diff --git a/src/core/chunks/selectors.test.ts b/src/core/chunks/selectors.test.ts new file mode 100644 index 0000000..90bb5cc --- /dev/null +++ b/src/core/chunks/selectors.test.ts @@ -0,0 +1,373 @@ +import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { applyHistory, foldEvent, initialState, markHistoryRecovering } from "./reducer"; +import { selectChunks } from "./selectors"; +import { trimTranscript } from "./trim"; +import type { TranscriptState } from "./types"; + +const C = "conv"; +const T = "turn-1"; + +function fold(state: TranscriptState, events: readonly AgentEvent[]): TranscriptState { + return events.reduce(foldEvent, state); +} + +const turnStart: AgentEvent = { type: "turn-start", conversationId: C, turnId: T }; +const userMsg: AgentEvent = { type: "user-message", conversationId: C, turnId: T, text: "hi" }; +const think = (delta: string): AgentEvent => ({ + type: "reasoning-delta", + conversationId: C, + turnId: T, + delta, +}); +const thinkEnd: AgentEvent = { type: "reasoning-end", conversationId: C, turnId: T }; +const text = (delta: string): AgentEvent => ({ + type: "text-delta", + conversationId: C, + turnId: T, + delta, +}); +const call = (id: string, step = 0): AgentEvent => ({ + type: "tool-call", + conversationId: C, + turnId: T, + stepId: `${T}#${step}` as StepId, + toolCallId: id, + toolName: "run_shell", + input: {}, +}); +const result = (id: string, step = 0): AgentEvent => ({ + type: "tool-result", + conversationId: C, + turnId: T, + stepId: `${T}#${step}` as StepId, + toolCallId: id, + toolName: "run_shell", + content: "ok", + isError: false, +}); + +/** The persisted chunks for steps 0 (user + think + call/result). */ +const committedStep0: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: T }, + { seq: 2, role: "assistant", chunk: { type: "thinking", text: "plan" }, turnId: T }, + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "run_shell", + input: {}, + stepId: `${T}#0` as StepId, + }, + turnId: T, + }, + { + seq: 4, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "run_shell", + content: "ok", + isError: false, + stepId: `${T}#0` as StepId, + }, + turnId: T, + }, +]; + +describe("selectChunks — de-dup of a late-join replay against committed history", () => { + it("hides replayed provisional chunks already persisted for the in-flight turn", () => { + // Fresh load mid-turn: history has step 0; the replay re-delivers step 0 + // and then step 1 (still streaming). + let s = applyHistory(initialState(), committedStep0); + s = fold(s, [ + userMsg, + turnStart, + think("pl"), + think("an"), + call("a"), + result("a"), + text("now"), + ]); + + const rendered = selectChunks(s); + expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([ + [1, "text"], + [2, "thinking"], + [3, "tool-call"], + [4, "tool-result"], + [null, "text"], + ]); + expect(rendered[4]?.streaming).toBe(true); + }); + + it("keeps provisional chunks beyond the committed run", () => { + let s = applyHistory(initialState(), committedStep0); + s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), call("b", 1)]); + const rendered = selectChunks(s); + expect(rendered).toHaveLength(5); + expect(rendered[4]?.chunk).toMatchObject({ type: "tool-call", toolCallId: "b" }); + expect(rendered[4]?.provisional).toBe(true); + }); + + it("hides the accumulating chunk once its content is committed", () => { + const committed: StoredChunk[] = [ + ...committedStep0, + { seq: 5, role: "assistant", chunk: { type: "text", text: "final" }, turnId: T }, + ]; + let s = applyHistory(initialState(), committed); + s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), text("final")]); + const rendered = selectChunks(s); + expect(rendered).toHaveLength(5); + expect(rendered.every((r) => !r.provisional)).toBe(true); + }); + + it("stops de-duping at the first mismatch (never hides divergent content)", () => { + let s = applyHistory(initialState(), committedStep0); + s = fold(s, [userMsg, turnStart, think("DIFFERENT"), call("a"), result("a")]); + const rendered = selectChunks(s); + // user echo matched (1 hidden); everything from the mismatch on is shown. + expect(rendered.filter((r) => r.provisional)).toHaveLength(3); + }); + + it("preserves an uncommitted accumulating tail even when its text repeats history", () => { + const committed: StoredChunk[] = [ + ...committedStep0, + { seq: 5, role: "assistant", chunk: { type: "text", text: "final" }, turnId: T }, + ]; + let s = applyHistory(initialState(), committed); + s = markHistoryRecovering(s, T); + s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), text("final")]); + const rendered = selectChunks(s); + expect(rendered).toHaveLength(6); + expect(rendered.slice(0, 5).every((r) => !r.provisional)).toBe(true); + expect(rendered[5]).toMatchObject({ + provisional: true, + streaming: true, + chunk: { type: "text", text: "final" }, + }); + }); + + it("reconciles the same step identity even when provisional payload differs", () => { + let s = applyHistory(initialState(), committedStep0); + s = markHistoryRecovering(s, T); + s = fold(s, [userMsg, turnStart, think("DIFFERENT"), call("a"), result("a")]); + const rendered = selectChunks(s); + // Authoritative identity wins; payload equality cannot identify omitted/truncated content. + expect(rendered.filter((r) => r.provisional)).toHaveLength(0); + expect(rendered.map((r) => r.chunk)).toEqual(committedStep0.map((r) => r.chunk)); + }); + + it("does nothing when the committed tail belongs to another turn", () => { + const other = committedStep0.map((c) => ({ ...c, turnId: "older" })); + let s = applyHistory(initialState(), other); + s = fold(s, [userMsg, turnStart, think("plan")]); + expect(selectChunks(s).filter((r) => r.provisional)).toHaveLength(2); + }); + + it("de-dups the replay when the committed turn's head was unloaded (windowed/trimmed)", () => { + // A long in-flight turn: the chat-limit window/trim unloaded the turn's + // head (seq 1–2), so the committed run starts mid-turn while the late-join + // replay re-delivers the WHOLE turn from its opening. Positional alignment + // from the turn start fails at index 0; without re-anchoring, the whole + // replay rendered as a duplicate whose tool cards collapsed onto the + // committed ones — leaving a tail of thinking blocks with no tool calls. + let s = trimTranscript(applyHistory(initialState(), committedStep0), 2); + expect(s.committed.map((c) => c.seq)).toEqual([3, 4]); + s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), think("next")]); + const rendered = selectChunks(s); + expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([ + [3, "tool-call"], + [4, "tool-result"], + [null, "thinking"], + ]); + expect(rendered[2]).toMatchObject({ streaming: true, chunk: { text: "next" } }); + }); + + it("de-dups when the provisional replay starts after the committed run's head", () => { + // Provisional trimmed past the committed head (e.g. committed restored via + // "Show earlier" after an all-provisional overflow): re-anchor on the + // provisional head instead. + let s = applyHistory(initialState(), committedStep0); + s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), call("b", 1)]); + s = { ...s, provisional: s.provisional.slice(2) }; // drop user + thinking + const rendered = selectChunks(s); + expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([ + [1, "text"], + [2, "thinking"], + [3, "tool-call"], + [4, "tool-result"], + [null, "tool-call"], + ]); + }); + + it("re-anchors on a persisted tool call even if the replay diverges after it", () => { + // A toolCallId is a unique identity: everything the replay delivered before + // it was persisted earlier in the turn (append-only), so it is unloaded + // history — not divergent content. The divergence itself stays visible. + let s = applyHistory(initialState(), committedStep0.slice(2)); + s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("OTHER")]); + const provisional = selectChunks(s).filter((r) => r.provisional); + expect(provisional.map((r) => r.chunk)).toMatchObject([ + { type: "tool-result", toolCallId: "OTHER" }, + ]); + }); + + it("does not re-anchor on an unverified text match (never hides divergent content)", () => { + const committed: StoredChunk[] = [ + { seq: 7, role: "assistant", chunk: { type: "thinking", text: "plan" }, turnId: T }, + { seq: 8, role: "assistant", chunk: { type: "text", text: "persisted" }, turnId: T }, + ]; + let s = applyHistory(initialState(), committed); + s = fold(s, [userMsg, turnStart, think("plan"), text("diverged"), call("z")]); + // "plan" matches but the next chunk diverges: no anchor → nothing hidden. + expect(selectChunks(s).filter((r) => r.provisional)).toHaveLength(4); + }); + + it("steps over persisted images the replay can never carry", () => { + // A tool attached an image mid-turn: history persists it as a user image + // chunk, but no live event carries it, so the replay lacks it. + const image: StoredChunk = { + seq: 5, + role: "user", + chunk: { type: "image", url: "/images/conv/x.png" }, + turnId: T, + }; + const thinking: StoredChunk = { + seq: 6, + role: "assistant", + chunk: { type: "thinking", text: "look" }, + turnId: T, + }; + let s = applyHistory(initialState(), [...committedStep0, image, thinking]); + s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), think("look")]); + s = fold(s, [call("b", 1)]); + const provisional = selectChunks(s).filter((r) => r.provisional); + expect(provisional.map((r) => r.chunk)).toMatchObject([{ type: "tool-call", toolCallId: "b" }]); + }); + + it("matches one merged thinking block against its split committed blocks (Claude)", () => { + // Claude may return several thinking blocks in one step; history persists + // each as its own chunk, but reasoning-deltas carry no block boundary, so + // the replay delivers them merged into one thinking chunk. + const split: StoredChunk[] = [ + committedStep0[0] as StoredChunk, + { seq: 2, role: "assistant", chunk: { type: "thinking", text: "first. " }, turnId: T }, + { seq: 3, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T }, + { ...(committedStep0[2] as StoredChunk), seq: 4 }, + { ...(committedStep0[3] as StoredChunk), seq: 5 }, + ]; + let s = applyHistory(initialState(), split); + s = fold(s, [userMsg, turnStart, think("first. "), think("second."), call("a"), result("a")]); + s = fold(s, [think("next")]); + const rendered = selectChunks(s); + expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([ + [1, "text"], + [2, "thinking"], + [3, "thinking"], + [4, "tool-call"], + [5, "tool-result"], + [null, "thinking"], + ]); + }); + + it("hides an accumulating merged block once its split blocks are committed", () => { + const split: StoredChunk[] = [ + ...committedStep0, + { seq: 5, role: "assistant", chunk: { type: "thinking", text: "a" }, turnId: T }, + { seq: 6, role: "assistant", chunk: { type: "thinking", text: "b" }, turnId: T }, + ]; + let s = applyHistory(initialState(), split); + s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), think("ab")]); + expect(selectChunks(s).every((r) => !r.provisional)).toBe(true); + }); + + it("re-anchors on a tool chunk when the window cut between split blocks", () => { + // The committed head is the SECOND of two split blocks: it equals no + // provisional chunk, so alignment anchors on the run's first tool chunk. + const committed: StoredChunk[] = [ + { seq: 3, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T }, + { ...(committedStep0[2] as StoredChunk), seq: 4 }, + { ...(committedStep0[3] as StoredChunk), seq: 5 }, + ]; + let s = applyHistory(initialState(), committed); + s = fold(s, [userMsg, turnStart, think("first. second."), call("a"), result("a")]); + s = fold(s, [call("b", 1)]); + const provisional = selectChunks(s).filter((r) => r.provisional); + expect(provisional.map((r) => r.chunk)).toMatchObject([{ type: "tool-call", toolCallId: "b" }]); + }); + + it("aligns a boundary-carrying replay 1:1 against split committed blocks", () => { + const split: StoredChunk[] = [ + committedStep0[0] as StoredChunk, + { seq: 2, role: "assistant", chunk: { type: "thinking", text: "first. " }, turnId: T }, + { seq: 3, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T }, + { ...(committedStep0[2] as StoredChunk), seq: 4 }, + { ...(committedStep0[3] as StoredChunk), seq: 5 }, + ]; + let s = applyHistory(initialState(), split); + s = fold(s, [ + userMsg, + turnStart, + think("first. "), + thinkEnd, + think("second."), + thinkEnd, + call("a"), + result("a"), + text("next"), + ]); + const rendered = selectChunks(s); + expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([ + [1, "text"], + [2, "thinking"], + [3, "thinking"], + [4, "tool-call"], + [5, "tool-result"], + [null, "text"], + ]); + expect(rendered[5]?.streaming).toBe(true); + }); + + it("aligns a redacted empty block with a bare reasoning-end", () => { + const split: StoredChunk[] = [ + committedStep0[0] as StoredChunk, + { seq: 2, role: "assistant", chunk: { type: "thinking", text: "first. " }, turnId: T }, + { seq: 3, role: "assistant", chunk: { type: "thinking", text: "" }, turnId: T }, + { seq: 4, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T }, + { ...(committedStep0[2] as StoredChunk), seq: 5 }, + { ...(committedStep0[3] as StoredChunk), seq: 6 }, + ]; + let s = applyHistory(initialState(), split); + s = fold(s, [ + userMsg, + turnStart, + think("first. "), + thinkEnd, + thinkEnd, + think("second."), + thinkEnd, + call("a"), + result("a"), + text("next"), + ]); + const rendered = selectChunks(s); + expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([ + [1, "text"], + [2, "thinking"], + [3, "thinking"], + [4, "thinking"], + [5, "tool-call"], + [6, "tool-result"], + [null, "text"], + ]); + }); + + it("is the identity when there is no committed history", () => { + const s = fold(initialState(), [userMsg, turnStart, text("hello")]); + expect(selectChunks(s)).toHaveLength(2); + }); +}); diff --git a/src/core/chunks/selectors.ts b/src/core/chunks/selectors.ts index e46d5f5..8d32fcf 100644 --- a/src/core/chunks/selectors.ts +++ b/src/core/chunks/selectors.ts @@ -1,24 +1,278 @@ -import type { ChatMessage, Chunk, TurnProviderRetryEvent } from "@dispatch/wire"; -import type { RenderedChunk, TranscriptState } from "./types"; +import type { ChatMessage, Chunk, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire"; +import { historyBackedProvisional } from "./reconcile"; +import type { ProvisionalChunk, RenderedChunk, TranscriptState } from "./types"; + +/** + * Whether a provisional chunk is the same content as a committed chunk at the + * same position of the same turn (identity for de-dup, not deep equality). + * Tool chunks match by `toolCallId`; text-bearing chunks by exact text. + */ +function sameChunk(p: ProvisionalChunk, c: StoredChunk): boolean { + if (p.role !== c.role || p.chunk.type !== c.chunk.type) return false; + const a = p.chunk; + const b = c.chunk; + switch (a.type) { + case "tool-call": + case "tool-result": + return a.toolCallId === (b as typeof a).toolCallId; + case "text": + case "thinking": + case "system": + return a.text === (b as typeof a).text; + case "error": + return a.message === (b as typeof a).message; + case "image": + return true; + } +} + +/** + * A committed chunk with no live-event counterpart, so a late-join replay can + * never contain it: an image persisted into the turn's history (pasted-image + * openings, images a tool attaches mid-turn). Alignment steps over it. + */ +function replayless(c: StoredChunk): boolean { + return c.chunk.type === "image"; +} + +/** Where an alignment walk stopped, and whether it stopped on a mismatch. */ +interface Walk { + /** First provisional index NOT matched. */ + readonly p: number; + /** First committed index NOT matched (replayless chunks stepped over). */ + readonly c: number; + readonly matched: number; + /** Ended because a side ran out — no mismatch. */ + readonly complete: boolean; +} + +/** + * How many consecutive committed chunks starting at `c` one provisional text / + * thinking chunk stands for: their texts must concatenate EXACTLY to its text + * (≥ 2 chunks; 0 = no such run). A provider may return several blocks in one + * step (Claude emits multiple thinking blocks, each with its own signature) + * which history persists as separate chunks, while the delta stream carries + * no block boundary — so live (and in the replay) they arrive merged. + */ +function mergedRun( + state: TranscriptState, + text: string, + kind: string, + role: string, + c: number, +): number { + let joined = ""; + for (let k = c; k < state.committed.length; k++) { + const row = state.committed[k]; + if (row === undefined || row.role !== role || row.chunk.type !== kind) return 0; + if (row.chunk.type !== "text" && row.chunk.type !== "thinking") return 0; + joined += row.chunk.text; + if (joined === text) return k - c + 1 >= 2 ? k - c + 1 : 0; + if (!text.startsWith(joined)) return 0; + } + return 0; +} + +/** + * Walk provisional (from `p`) and committed (from `c`) of turn `turnId` in + * lockstep, stepping over committed chunks the replay cannot carry and + * matching one merged provisional block against its split committed run. + * Stops at the first mismatch or when either side runs out. + */ +function walk(state: TranscriptState, turnId: string, p: number, c: number): Walk { + let matched = 0; + for (;;) { + const prov = state.provisional[p]; + let row = state.committed[c]; + while (row !== undefined && replayless(row) && prov?.chunk.type !== "image") { + row = state.committed[++c]; + } + if (prov === undefined || row === undefined) return { p, c, matched, complete: true }; + if (prov.turnId !== turnId) return { p, c, matched, complete: false }; + let span = sameChunk(prov, row) ? 1 : 0; + if (span === 0 && (prov.chunk.type === "text" || prov.chunk.type === "thinking")) { + span = mergedRun(state, prov.chunk.text, prov.chunk.type, prov.role, c); + } + if (span === 0) return { p, c, matched, complete: false }; + p++; + c += span; + matched++; + } +} + +/** + * Accept a re-anchored alignment. A tool chunk is a unique identity + * (`toolCallId`), so a match on one is trusted outright; a text-bearing match + * must be confirmed by the WHOLE overlap aligning (no mismatch), so a repeated + * text can never hide divergent content. + */ +function anchored(w: Walk, anchor: StoredChunk): boolean { + if (w.matched === 0) return false; + return w.complete || anchor.chunk.type === "tool-call" || anchor.chunk.type === "tool-result"; +} + +/** + * Align the provisional run with the committed run of turn T (which begins at + * committed index `start`). Returns how many leading provisional chunks are + * hidden as already committed, and the walk's end. + * + * Normally both start at the turn's opening. But the chat-limit window (fresh + * load `?limit=` / `windowTranscript`) and `trimTranscript` unload the OLDEST + * committed chunks, so in a long in-flight turn the committed run is a SUFFIX + * of the turn while the late-join replay re-delivers the whole turn from its + * `turn-start`: re-anchor on the committed head inside the replay, and hide + * the replayed chunks before it too (persisted history, unloaded — pageable + * via "Show earlier"). Conversely, provisional overflow trimming can leave the + * replay starting after the committed head: re-anchor on the replay's head + * inside the committed run. Unverifiable → the plain prefix walk (fail-safe: + * a misalignment shows a duplicate, never hides content). + */ +function align(state: TranscriptState, turnId: string, start: number): Walk { + const direct = walk(state, turnId, 0, start); + // Aligned from the turn's head (or fully consistent): plain prefix semantics. + if (direct.complete || direct.matched > 0) return direct; + let headAt = start; + while ( + state.committed[headAt] !== undefined && + replayless(state.committed[headAt] as StoredChunk) + ) + headAt++; + // Anchor on the committed head — or, when the head is unmatchable (e.g. the + // window cut between two blocks a merged provisional chunk spans), on the + // run's first tool chunk, a unique identity. + const anchorsAt = [headAt]; + for (let i = headAt + 1; i < state.committed.length; i++) { + const t = state.committed[i]?.chunk.type; + if (t === "tool-call" || t === "tool-result") { + anchorsAt.push(i); + break; + } + } + for (const at of anchorsAt) { + const head = state.committed[at]; + if (head === undefined) continue; + for (let k = 1; k < state.provisional.length; k++) { + const p = state.provisional[k]; + if (p?.turnId !== turnId || !sameChunk(p, head)) continue; + const w = walk(state, turnId, k, at); + if (anchored(w, head)) return w; + } + } + const p0 = state.provisional[0]; + if (p0 !== undefined) { + for (let j = headAt + 1; j < state.committed.length; j++) { + const row = state.committed[j]; + if (row === undefined || !sameChunk(p0, row)) continue; + const w = walk(state, turnId, 0, j); + if (anchored(w, row)) return w; + } + } + return direct; +} + +/** + * How many LEADING provisional chunks are already backed by committed chunks. + * + * The backend persists each step of an in-flight turn as it completes (CR-6), + * so a client that opens a conversation mid-turn gets those steps from history + * AND again from the late-join event replay (as provisional chunks). Both + * carry the turn's chunks in the same order, so the provisional run of turn T + * is aligned chunk-by-chunk with the committed run of turn T (see `align`). + * Skip the aligned prefix; stop at the first mismatch. + * + * A failed alignment renders the ENTIRE replay as a duplicate — and because + * tool cards de-dup by `toolCallId` onto the first (committed) occurrence, + * that duplicate tail shows only thinking/text blocks ("the tool calls + * disappear"). Hence the re-anchoring and the replayless-image stepping. + * + * Also reports whether the ACCUMULATING chunk is already committed (the step + * completed + history synced, but the next live event hasn't flushed it yet). + */ +function committedOverlap(state: TranscriptState): { skip: number; hideAccumulating: boolean } { + const first = state.provisional[0]; + const turnId = first?.turnId ?? state.accumulating?.turnId; + if (turnId === undefined || state.historyRecoveryTurnIds?.includes(turnId)) + return { skip: 0, hideAccumulating: false }; + // The committed run for this turn (contiguous at the tail of `committed`). + let start = state.committed.length; + while (start > 0 && state.committed[start - 1]?.turnId === turnId) start--; + if (start === state.committed.length) return { skip: 0, hideAccumulating: false }; + + const end = align(state, turnId, start); + const skip = end.p; + + let hideAccumulating = false; + const acc = state.accumulating; + if (acc !== null && acc.turnId === turnId && skip === state.provisional.length) { + let next = end.c; + while (state.committed[next] !== undefined && replayless(state.committed[next] as StoredChunk)) + next++; + const c = state.committed[next]; + hideAccumulating = + (c !== undefined && + c.role === "assistant" && + c.chunk.type === acc.kind && + c.chunk.text === acc.text) || + mergedRun(state, acc.text, acc.kind, "assistant", next) > 0; + } + return { skip, hideAccumulating }; +} + +/** + * How many chunks `selectChunks` would render, without materializing them. + * Provisional chunks hidden as history-backed / replay duplicates of committed + * chunks are NOT counted — the chat limit bounds what is on screen, and + * counting invisible duplicates would unload real history to make room for them. + */ +export function selectRenderedCount(state: TranscriptState): number { + const backed = historyBackedProvisional(state); + const { skip, hideAccumulating } = committedOverlap(state); + let count = state.committed.length; + for (let i = skip; i < state.provisional.length; i++) { + const p = state.provisional[i]; + if (p !== undefined && !backed.has(p)) count++; + } + if (state.accumulating !== null && !hideAccumulating) count++; + return count; +} /** * Select all chunks for rendering: committed first (seq order), - * then provisional (seq: null). + * then provisional (seq: null), excluding history-backed identities. Unknown + * accumulating content stays live, even if its text equals an earlier step. */ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] { const result: RenderedChunk[] = []; + const backed = historyBackedProvisional(state); + const { skip, hideAccumulating } = committedOverlap(state); for (const c of state.committed) { - result.push({ seq: c.seq, role: c.role, chunk: c.chunk, provisional: false }); + result.push( + c.turnId !== undefined + ? { seq: c.seq, role: c.role, chunk: c.chunk, provisional: false, turnId: c.turnId } + : { 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 }); + for (let i = skip; i < state.provisional.length; i++) { + const p = state.provisional[i]; + if (p === undefined || backed.has(p)) continue; + result.push( + p.turnId !== undefined + ? { seq: null, role: p.role, chunk: p.chunk, provisional: true, turnId: p.turnId } + : { seq: null, role: p.role, chunk: p.chunk, provisional: true }, + ); } - if (state.accumulating !== null) { + const acc = state.accumulating; + if (acc !== null && !hideAccumulating) { 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 }); + acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text }; + result.push({ + seq: null, + role: "assistant", + chunk, + provisional: true, + streaming: true, + ...(acc.turnId !== undefined ? { turnId: acc.turnId } : {}), + }); } return result; } diff --git a/src/core/chunks/trim.test.ts b/src/core/chunks/trim.test.ts index 7c4bbef..dcc1755 100644 --- a/src/core/chunks/trim.test.ts +++ b/src/core/chunks/trim.test.ts @@ -103,6 +103,19 @@ describe("trimTranscript", () => { expect(next.accumulating).not.toBeNull(); }); + it("does not count a late-join replay's duplicates of committed chunks", () => { + // An in-flight turn of 8 persisted chunks + its late-join replay (8 more + // provisional copies). Only 8 are rendered, so a limit of 10 keeps them all + // instead of unloading real history to make room for invisible duplicates. + const T = "turn"; + const committed = chunks(1, 8).map((c) => ({ ...c, turnId: T })); + const state: TranscriptState = { + ...stateWith(committed), + provisional: committed.map((c) => ({ role: c.role, chunk: c.chunk, turnId: T })), + }; + expect(trimTranscript(state, 10)).toBe(state); + }); + it("drops oldest provisional when committed is exhausted", () => { const base = stateWith(chunks(1, 2)); const provisional = Array.from({ length: 20 }, (_, i) => ({ @@ -146,7 +159,7 @@ describe("windowTranscript", () => { it("is the identity within the window", () => { const state = stateWith(chunks(1, 50)); expect(windowTranscript(state, 75)).toBe(state); - expect(selectHasEarlier(state)).toBe(false); + expect(selectHasEarlier(state, 1)).toBe(false); }); }); @@ -178,21 +191,21 @@ describe("restoreEarlier", () => { expect(selectHasEarlier(restored)).toBe(true); }); - it("restoring down to seq 1 reaches the contractual origin (hasEarlier clears)", () => { + it("restoring down to a known boundary clears hasEarlier", () => { 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); + expect(selectHasEarlier(restored, 1)).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. + // The server boundary is unknown, so another page may exist. expect(selectHasEarlier(restored)).toBe(true); }); @@ -203,7 +216,7 @@ describe("restoreEarlier", () => { 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 + // with hiddenBeforeSeq still 0 — the server boundary is unknown, and a // backfilled run merges below it. const state = stateWith(chunks(809, 1000)); expect(state.hiddenBeforeSeq).toBe(0); @@ -230,6 +243,15 @@ describe("restoreEarlier", () => { const restored = restoreEarlier(trimmed, original, 1000); expect(restored.committed).toEqual(original); expect(restored.hiddenBeforeSeq).toBe(1); - expect(selectHasEarlier(restored)).toBe(false); + expect(selectHasEarlier(restored, 1)).toBe(false); }); }); + +it("uses a non-1 history boundary rather than the seq origin", () => { + const state = stateWith(chunks(101, 110)); + expect(selectHasEarlier(state, 101)).toBe(false); + expect(selectHasEarlier(state, null)).toBe(true); + const windowed = windowTranscript(state, 5); + expect(selectHasEarlier(windowed, 101)).toBe(true); + expect(selectHasEarlier(restoreEarlier(windowed, state.committed, 5), 101)).toBe(false); +}); diff --git a/src/core/chunks/trim.ts b/src/core/chunks/trim.ts index 9846357..6612bfe 100644 --- a/src/core/chunks/trim.ts +++ b/src/core/chunks/trim.ts @@ -14,6 +14,7 @@ // "Show earlier messages" affordance knows where to page back in from. import type { StoredChunk } from "@dispatch/wire"; +import { selectRenderedCount } from "./selectors"; import type { TranscriptState } from "./types"; /** Default chat limit (max loaded chunks per conversation). */ @@ -46,9 +47,14 @@ export function initialWindowSize(limit: number): number { return Math.max(1, Math.floor(limit * 0.75)); } -/** Total loaded (rendered) chunk count: committed + provisional + accumulating. */ +/** + * Total loaded (rendered) chunk count: committed + provisional + accumulating, + * EXCLUDING provisional replay duplicates of committed chunks (a late-join + * replay re-delivers an in-flight turn's already-persisted steps). Counting + * those invisible duplicates unloaded real history to make room for them. + */ function totalCount(state: TranscriptState): number { - return state.committed.length + state.provisional.length + (state.accumulating !== null ? 1 : 0); + return selectRenderedCount(state); } function countThinking(chunks: readonly StoredChunk[]): number { @@ -150,8 +156,7 @@ function oldestLoadedSeq(state: TranscriptState): number { * `?beforeSeq=` backfill; chunks at/inside the window are ignored). The newest * `count` of them are merged back in front of `committed`, and the watermark * follows the new window start so history merges still can't resurrect what - * remains unloaded. Identity when the window already starts at seq 1 (the - * contractual origin) or nothing older is known locally. + * remains unloaded. Identity when nothing older is known locally. */ export function restoreEarlier( state: TranscriptState, @@ -159,7 +164,7 @@ export function restoreEarlier( count: number, ): TranscriptState { const oldest = oldestLoadedSeq(state); - if (oldest <= 1) return state; + if (oldest === 0) 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)); @@ -172,14 +177,11 @@ export function restoreEarlier( }; } -/** - * Whether earlier history exists below the loaded window — drives the - * "Show earlier messages" affordance. Derived from the [email protected] CONTRACT - * that per-conversation seqs are 1-based and gap-free: a loaded window that - * starts above seq 1 means older chunks exist (locally cached or server-side), - * whether the window came from a local trim or a server-windowed (`?limit=`) - * fresh load. - */ -export function selectHasEarlier(state: TranscriptState): boolean { - return oldestLoadedSeq(state) > 1; +/** Earlier availability uses the known server boundary, never a seq origin. */ +export function selectHasEarlier( + state: TranscriptState, + earliestSeq: number | null = null, +): boolean { + const oldest = oldestLoadedSeq(state); + return oldest > 0 && (earliestSeq === null || oldest > earliestSeq); } diff --git a/src/core/chunks/types.ts b/src/core/chunks/types.ts index 2ced736..2e2ee0c 100644 --- a/src/core/chunks/types.ts +++ b/src/core/chunks/types.ts @@ -2,18 +2,36 @@ import type { Chunk, Role, StoredChunk, TurnProviderRetryEvent, Usage } from "@d /** A chunk being accumulated from streaming deltas (text or thinking). */ export interface AccumulatingChunk { + readonly stepId?: string; readonly kind: "text" | "thinking"; readonly text: string; + /** The turn generating this chunk (from the live event's `turnId`). */ + readonly turnId?: string; } /** A provisional chunk that has no authoritative seq yet. */ export interface ProvisionalChunk { + /** Local provenance learned from tool-call/step-complete, not content. */ + readonly stepId?: string; + readonly opening?: boolean; + /** Steering is persisted immediately after this completed tool batch. */ + readonly afterToolCallId?: string; + /** Local send identity only; turn-start tagging does NOT confirm this content. */ + readonly localRequestId?: string; readonly role: Role; readonly chunk: Chunk; + /** + * The turn this chunk belongs to (from the live event's `turnId`). Absent + * only on the sender's optimistic user echo until its `turn-start` arrives + * (which tags it). + */ + readonly turnId?: string; } /** The transcript reducer state. Holds committed history + live in-flight turn. */ export interface TranscriptState { + /** Turns with omitted delivery: never use payload equality as replay identity. */ + readonly historyRecoveryTurnIds?: readonly string[]; readonly committed: readonly StoredChunk[]; readonly provisional: readonly ProvisionalChunk[]; readonly accumulating: AccumulatingChunk | null; @@ -74,4 +92,11 @@ export interface RenderedChunk { * dots on streaming thinking) and drop it the moment generation moves on. */ readonly streaming?: boolean; + /** + * The turn that produced this chunk — `StoredChunk.turnId` for committed + * chunks, the live event's `turnId` for provisional ones. The exact join key + * for placing per-turn metrics. Absent on legacy rows (persisted before the + * backend stamped it) and on the optimistic echo before `turn-start`. + */ + readonly turnId?: string; } diff --git a/src/core/metrics/cache-timer.test.ts b/src/core/metrics/cache-timer.test.ts new file mode 100644 index 0000000..d450135 --- /dev/null +++ b/src/core/metrics/cache-timer.test.ts @@ -0,0 +1,359 @@ +import type { AgentEvent, StepId, TurnMetrics } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import type { RenderGroup } from "../chunks"; +import { + CACHE_TIMER_MAX_MS, + CACHE_TTL_MS, + type CacheTimerState, + foldCacheTimerEvent, + initialCacheTimerState, + lastTurnRowIndex, + seedTurnGap, + stopTurnGap, + viewCacheTimer, +} from "./cache-timer"; +import type { MetricsRow } from "./types"; + +const SEC = 1000; +const MIN = 60 * SEC; +const C = "conv"; + +const ev = { + turnStart: (turnId: string): AgentEvent => ({ type: "turn-start", conversationId: C, turnId }), + userMessage: (turnId: string): AgentEvent => ({ + type: "user-message", + conversationId: C, + turnId, + text: "hi", + }), + text: (turnId: string): AgentEvent => ({ + type: "text-delta", + conversationId: C, + turnId, + delta: "x", + }), + reasoningEnd: (turnId: string): AgentEvent => ({ + type: "reasoning-end", + conversationId: C, + turnId, + }), + call: (turnId: string, stepId: string, toolCallId: string): AgentEvent => ({ + type: "tool-call", + conversationId: C, + turnId, + stepId: stepId as StepId, + toolCallId, + toolName: "t", + input: {}, + }), + result: ( + turnId: string, + stepId: string, + toolCallId: string, + durationMs?: number, + ): AgentEvent => ({ + type: "tool-result", + conversationId: C, + turnId, + stepId: stepId as StepId, + toolCallId, + toolName: "t", + content: "ok", + isError: false, + ...(durationMs !== undefined ? { durationMs } : {}), + }), + stepComplete: (turnId: string, stepId: string): AgentEvent => ({ + type: "step-complete", + conversationId: C, + turnId, + stepId: stepId as StepId, + }), + usage: (turnId: string, stepId: string): AgentEvent => ({ + type: "usage", + conversationId: C, + turnId, + stepId: stepId as StepId, + usage: { inputTokens: 1, outputTokens: 1 }, + }), + done: (turnId: string): AgentEvent => ({ + type: "done", + conversationId: C, + turnId, + reason: "end-turn", + }), + error: (turnId: string): AgentEvent => ({ + type: "error", + conversationId: C, + turnId, + message: "boom", + }), +}; + +const noTurn = () => undefined; + +/** Fold `[time, event]` pairs from the initial state. */ +function run( + steps: readonly (readonly [number, AgentEvent])[], + start: CacheTimerState = initialCacheTimerState(), + lastTurnId: () => string | undefined = noTurn, +): CacheTimerState { + return steps.reduce((s, [at, e]) => foldCacheTimerEvent(s, e, at, lastTurnId), start); +} + +describe("foldCacheTimerEvent — step gaps", () => { + it("runs from step-complete while tools execute, and stamps when the last result returns", () => { + const s1 = run([ + [0, ev.turnStart("t1")], + [100, ev.call("t1", "t1#0", "a")], + [110, ev.call("t1", "t1#0", "b")], + [1_000, ev.stepComplete("t1", "t1#0")], + ]); + expect(s1.running).toMatchObject({ + kind: "step", + stepId: "t1#0", + startedAt: 1_000, + toolCount: 2, + }); + + const s2 = run([[4_000, ev.result("t1", "t1#0", "a")]], s1); + expect(s2.running?.kind).toBe("step"); // still waiting on "b" + expect(s2.stepStamps.size).toBe(0); + + const s3 = run([[13_000, ev.result("t1", "t1#0", "b")]], s2); + expect(s3.running).toBeNull(); + expect(s3.stepStamps.get("t1#0")).toBe(12_000); + }); + + it("stamps each step of a multi-step turn separately", () => { + const s = run([ + [0, ev.call("t1", "t1#0", "a")], + [1_000, ev.stepComplete("t1", "t1#0")], + [3_000, ev.result("t1", "t1#0", "a")], + [5_000, ev.call("t1", "t1#1", "b")], + [6_000, ev.stepComplete("t1", "t1#1")], + [66_000, ev.result("t1", "t1#1", "b")], + ]); + expect(s.stepStamps.get("t1#0")).toBe(2_000); + expect(s.stepStamps.get("t1#1")).toBe(60_000); + }); + + it("uses the backend tool duration when it exceeds the wall clock (late-join replay burst)", () => { + const s = run([ + [5_000, ev.call("t1", "t1#0", "a")], + [5_000, ev.stepComplete("t1", "t1#0")], + [5_000, ev.result("t1", "t1#0", "a", 42_000)], + ]); + expect(s.stepStamps.get("t1#0")).toBe(42_000); + }); + + it("stops at the next step's output when a result never matched (fallback)", () => { + const s = run([ + [0, ev.call("t1", "t1#0", "a")], + [1_000, ev.stepComplete("t1", "t1#0")], + [9_000, ev.text("t1")], + ]); + expect(s.running).toBeNull(); + expect(s.stepStamps.get("t1#0")).toBe(8_000); + }); + + it("treats a reasoning-end boundary as resumed output too", () => { + const s = run([ + [0, ev.call("t1", "t1#0", "a")], + [1_000, ev.stepComplete("t1", "t1#0")], + [9_000, ev.reasoningEnd("t1")], + ]); + expect(s.running).toBeNull(); + expect(s.stepStamps.get("t1#0")).toBe(8_000); + }); + + it("stops on a tool-call or usage from a DIFFERENT step, not its own", () => { + const base = run([ + [0, ev.call("t1", "t1#0", "a")], + [1_000, ev.stepComplete("t1", "t1#0")], + ]); + expect(run([[2_000, ev.usage("t1", "t1#0")]], base).running?.kind).toBe("step"); + expect(run([[3_000, ev.usage("t1", "t1#1")]], base).stepStamps.get("t1#0")).toBe(2_000); + expect(run([[4_000, ev.call("t1", "t1#1", "z")]], base).stepStamps.get("t1#0")).toBe(3_000); + }); + + it("discards a step with no tool calls (the final step of a turn)", () => { + const s = run([ + [1_000, ev.stepComplete("t1", "t1#0")], + [1_050, ev.done("t1")], + ]); + expect(s.stepStamps.size).toBe(0); + expect(s.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 1_050 }); + }); + + it("stamps a step whose tools were still running when the turn ended (stopped)", () => { + const s = run([ + [0, ev.call("t1", "t1#0", "a")], + [1_000, ev.stepComplete("t1", "t1#0")], + [21_000, ev.done("t1")], + ]); + expect(s.stepStamps.get("t1#0")).toBe(20_000); + expect(s.running?.kind).toBe("turn"); + }); +}); + +describe("foldCacheTimerEvent — turn gaps", () => { + it("starts at done and is stamped on that turn when the next turn starts", () => { + const s = run([ + [1_000, ev.done("t1")], + [91_000, ev.userMessage("t2")], + [91_500, ev.turnStart("t2")], + ]); + expect(s.running).toBeNull(); + expect(s.turnStamps.get("t1")).toBe(90_000); + }); + + it("an error also ends the turn", () => { + expect(run([[7, ev.error("t1")]]).running).toEqual({ + kind: "turn", + turnId: "t1", + startedAt: 7, + }); + }); + + it("stopTurnGap stamps on a local send", () => { + const s = stopTurnGap(run([[1_000, ev.done("t1")]]), 4_000, noTurn); + expect(s.running).toBeNull(); + expect(s.turnStamps.get("t1")).toBe(3_000); + }); + + it("stopTurnGap is a no-op while a step gap runs", () => { + const base = run([ + [0, ev.call("t1", "t1#0", "a")], + [1_000, ev.stepComplete("t1", "t1#0")], + ]); + expect(stopTurnGap(base, 2_000, noTurn)).toBe(base); + }); +}); + +describe("seedTurnGap", () => { + it("seeds a turn gap with no turnId, stamped on the most recent turn when stopped", () => { + const seeded = seedTurnGap(initialCacheTimerState(), 1_000); + expect(seeded.running).toEqual({ kind: "turn", turnId: null, startedAt: 1_000 }); + const s = stopTurnGap(seeded, 61_000, () => "t9"); + expect(s.turnStamps.get("t9")).toBe(60_000); + }); + + it("drops a seeded gap when the most recent turn is unknown", () => { + const s = stopTurnGap(seedTurnGap(initialCacheTimerState(), 1_000), 2_000, noTurn); + expect(s.running).toBeNull(); + expect(s.turnStamps.size).toBe(0); + }); + + it("keeps the later start, never overrides a step gap, ignores non-finite", () => { + const live = run([[5_000, ev.done("t1")]]); + expect(seedTurnGap(live, 4_000).running).toEqual({ + kind: "turn", + turnId: "t1", + startedAt: 5_000, + }); + expect(seedTurnGap(live, 6_000).running).toEqual({ + kind: "turn", + turnId: "t1", + startedAt: 6_000, + }); + const step = run([ + [0, ev.call("t1", "t1#0", "a")], + [1_000, ev.stepComplete("t1", "t1#0")], + ]); + expect(seedTurnGap(step, 9_000)).toBe(step); + expect(seedTurnGap(initialCacheTimerState(), Number.NaN).running).toBeNull(); + }); +}); + +describe("viewCacheTimer", () => { + it("formats m:ss up to 10 minutes", () => { + expect(viewCacheTimer(0).label).toBe("0:00"); + expect(viewCacheTimer(9 * SEC + 999).label).toBe("0:09"); + expect(viewCacheTimer(MIN + 5 * SEC).label).toBe("1:05"); + expect(viewCacheTimer(5 * MIN + 30 * SEC).label).toBe("5:30"); + expect(viewCacheTimer(9 * MIN + 59 * SEC).label).toBe("9:59"); + expect(viewCacheTimer(9 * MIN + 59 * SEC).capped).toBe(false); + }); + + it("reads '10+ min' and is capped at and past 10 minutes", () => { + expect(viewCacheTimer(CACHE_TIMER_MAX_MS)).toEqual({ + label: "10+ min", + level: "error", + capped: true, + }); + expect(viewCacheTimer(3 * 60 * MIN).label).toBe("10+ min"); + }); + + it("colours by the 5-minute cache TTL", () => { + expect(viewCacheTimer(3 * MIN + 59 * SEC).level).toBe("success"); + expect(viewCacheTimer(4 * MIN).level).toBe("warning"); + expect(viewCacheTimer(CACHE_TTL_MS).level).toBe("error"); + expect(viewCacheTimer(7 * MIN).level).toBe("error"); + }); + + it("clamps negative / non-finite elapsed (clock skew) to 0:00", () => { + expect(viewCacheTimer(-5 * SEC).label).toBe("0:00"); + expect(viewCacheTimer(Number.NaN).label).toBe("0:00"); + }); +}); + +function userRow(text = "hi"): MetricsRow { + const group: RenderGroup = { + kind: "single", + chunk: { seq: null, role: "user", chunk: { type: "text", text }, provisional: true }, + }; + return { kind: "group", group }; +} + +function assistantRow(turnId: string): MetricsRow { + const group: RenderGroup = { + kind: "single", + chunk: { + seq: 1, + role: "assistant", + chunk: { type: "text", text: "ok" }, + provisional: false, + turnId, + }, + }; + return { kind: "group", group }; +} + +function turnRow(turnId: string, turnNumber: number): MetricsRow { + const usage = { inputTokens: 10, outputTokens: 5 }; + const turn: TurnMetrics = { turnId, usage, steps: [{ stepId: `${turnId}#0` as StepId, usage }] }; + return { + kind: "turn-metrics", + turn, + turnNumber, + cumulativeUsage: usage, + prevLastStepUsage: null, + }; +} + +describe("lastTurnRowIndex", () => { + it("picks the LAST turn-metrics row", () => { + const rows = [ + userRow(), + assistantRow("t1"), + turnRow("t1", 1), + userRow(), + assistantRow("t2"), + turnRow("t2", 2), + ]; + expect(lastTurnRowIndex(rows)).toBe(5); + }); + + it("is null once a user message follows the last turn", () => { + expect(lastTurnRowIndex([userRow(), turnRow("t1", 1), userRow("next")])).toBeNull(); + }); + + it("skips trailing non-user rows after the turn row", () => { + expect(lastTurnRowIndex([userRow(), turnRow("t1", 1), assistantRow("t1")])).toBe(1); + }); + + it("is null with no turn-metrics rows", () => { + expect(lastTurnRowIndex([])).toBeNull(); + expect(lastTurnRowIndex([userRow(), assistantRow("t1")])).toBeNull(); + }); +}); diff --git a/src/core/metrics/cache-timer.ts b/src/core/metrics/cache-timer.ts new file mode 100644 index 0000000..3d7270d --- /dev/null +++ b/src/core/metrics/cache-timer.ts @@ -0,0 +1,286 @@ +import type { AgentEvent } from "@dispatch/wire"; +import type { MetricsRow } from "./types"; + +/** + * Pure core for the **cache timer** — how long the prompt cache has sat idle + * between model requests. Anthropic's prompt cache (as used by the Claude Code + * subscription) expires 5 minutes after its last hit, so the gaps BETWEEN + * requests decide whether the next request reads from cache: + * + * - **step gap**: a step (one LLM round-trip) finished and its tools are + * executing. Starts at `step-complete`; stops when the step's last tool + * result arrives (the next request fires right after) or, failing that, when + * anything from the next step shows up. Its final value is **stamped** on + * that step (keyed by `stepId`). + * - **turn gap**: the turn ended (`done` / `error`) and the conversation is + * waiting on the user. Stops when a new message is sent (locally, or a + * `user-message` / `turn-start` from another client) and is stamped on that + * turn (keyed by `turnId`). + * + * At most ONE gap runs at a time (`running`). Zero DOM, zero clock: every fold + * takes the current time as an argument. + */ + +/** Prompt-cache time-to-live: 5 minutes (drives the badge colour). */ +export const CACHE_TTL_MS = 5 * 60 * 1000; + +/** The timer counts up to this, then reads "10+ min". */ +export const CACHE_TIMER_MAX_MS = 10 * 60 * 1000; + +/** Within this much of the TTL the timer warns (colour only). */ +const WARN_AT_MS = CACHE_TTL_MS - 60 * 1000; + +/** A step gap in progress: the step's tools are executing. */ +export interface RunningStepGap { + readonly kind: "step"; + readonly turnId: string; + readonly stepId: string; + /** Epoch ms the step completed. */ + readonly startedAt: number; + /** How many tool calls the step made (`0` → nothing to show; never stamped). */ + readonly toolCount: number; + /** Tool calls still awaiting their result. */ + readonly pending: ReadonlySet<string>; + /** Longest backend-reported tool `durationMs` seen so far for this step. */ + readonly maxToolMs: number; +} + +/** A turn gap in progress: the turn ended and nothing has been sent since. */ +export interface RunningTurnGap { + readonly kind: "turn"; + /** + * The turn that ended, or `null` when seeded from the backend's + * `lastActivityAt` after a reload (no live `done` seen) — then it belongs to + * the most recent turn, resolved positionally / via `lastTurnId` on stop. + */ + readonly turnId: string | null; + /** Epoch ms the turn ended. */ + readonly startedAt: number; +} + +export type RunningGap = RunningStepGap | RunningTurnGap; + +export interface CacheTimerState { + readonly running: RunningGap | null; + /** Stamped step gaps: `stepId` → elapsed ms. */ + readonly stepStamps: ReadonlyMap<string, number>; + /** Stamped turn gaps: `turnId` → elapsed ms. */ + readonly turnStamps: ReadonlyMap<string, number>; + /** Tool calls seen per step in the CURRENT turn (`stepId` → call ids). */ + readonly calls: ReadonlyMap<string, ReadonlySet<string>>; + /** Tool results seen per step in the CURRENT turn (`stepId` → call ids). */ + readonly results: ReadonlyMap<string, ReadonlySet<string>>; + /** Longest tool `durationMs` per step in the CURRENT turn. */ + readonly toolMs: ReadonlyMap<string, number>; +} + +export function initialCacheTimerState(): CacheTimerState { + return { + running: null, + stepStamps: new Map(), + turnStamps: new Map(), + calls: new Map(), + results: new Map(), + toolMs: new Map(), + }; +} + +function addTo( + map: ReadonlyMap<string, ReadonlySet<string>>, + key: string, + value: string, +): ReadonlyMap<string, ReadonlySet<string>> { + const next = new Map(map); + next.set(key, new Set([...(map.get(key) ?? []), value])); + return next; +} + +function withEntry(map: ReadonlyMap<string, number>, key: string, value: number) { + const next = new Map(map); + next.set(key, value); + return next; +} + +/** + * Stop the running STEP gap (if any). A step that made tool calls is stamped + * with `max(wall-clock elapsed, longest backend tool duration)` — the backend + * figure keeps a late-join replay (whole step delivered in one burst, ~0 + * elapsed) honest. A step with no tool calls is discarded. + */ +function stopStep(state: CacheTimerState, now: number): CacheTimerState { + const r = state.running; + if (r === null || r.kind !== "step") return state; + if (r.toolCount === 0) return { ...state, running: null }; + const elapsed = Math.max(0, now - r.startedAt, r.maxToolMs); + return { ...state, running: null, stepStamps: withEntry(state.stepStamps, r.stepId, elapsed) }; +} + +/** + * Stop the running TURN gap (if any) and stamp it on its turn. A seeded gap + * (`turnId === null`) is stamped on `lastTurnId()` — the most recent turn's + * id — or dropped when that is unknown. + */ +export function stopTurnGap( + state: CacheTimerState, + now: number, + lastTurnId: () => string | undefined, +): CacheTimerState { + const r = state.running; + if (r === null || r.kind !== "turn") return state; + const turnId = r.turnId ?? lastTurnId(); + if (turnId === undefined) return { ...state, running: null }; + const elapsed = Math.max(0, now - r.startedAt); + return { ...state, running: null, turnStamps: withEntry(state.turnStamps, turnId, elapsed) }; +} + +/** A turn ended: close any step gap, forget the turn's tool bookkeeping, start the turn gap. */ +function endTurn(state: CacheTimerState, turnId: string, now: number): CacheTimerState { + const s = stopStep(state, now); + return { + ...s, + running: { kind: "turn", turnId, startedAt: now }, + calls: new Map(), + results: new Map(), + toolMs: new Map(), + }; +} + +/** + * Fold one live event into the cache-timer state at time `now`. `lastTurnId` + * resolves a seeded turn gap's turn when a new turn starts (see + * `stopTurnGap`); it is only called in that case. + */ +export function foldCacheTimerEvent( + state: CacheTimerState, + event: AgentEvent, + now: number, + lastTurnId: () => string | undefined, +): CacheTimerState { + const r = state.running; + switch (event.type) { + case "user-message": + case "turn-start": + // A new turn is starting — the user (here or elsewhere) sent a message. + return stopTurnGap(state, now, lastTurnId); + + case "tool-call": { + // A call from a DIFFERENT step means the next step is already streaming. + const s = r?.kind === "step" && r.stepId !== event.stepId ? stopStep(state, now) : state; + return { ...s, calls: addTo(s.calls, event.stepId, event.toolCallId) }; + } + + case "tool-result": { + const results = addTo(state.results, event.stepId, event.toolCallId); + const dur = event.durationMs ?? 0; + const toolMs = + dur > (state.toolMs.get(event.stepId) ?? 0) + ? withEntry(state.toolMs, event.stepId, dur) + : state.toolMs; + let s: CacheTimerState = { ...state, results, toolMs }; + if (r?.kind === "step" && r.stepId === event.stepId) { + const pending = new Set(r.pending); + pending.delete(event.toolCallId); + s = { ...s, running: { ...r, pending, maxToolMs: Math.max(r.maxToolMs, dur) } }; + // Every tool of the step has returned → the next request fires now. + if (pending.size === 0) s = stopStep(s, now); + } + return s; + } + + case "step-complete": { + const s = stopStep(stopTurnGap(state, now, lastTurnId), now); + const calls = s.calls.get(event.stepId) ?? new Set<string>(); + const done = s.results.get(event.stepId) ?? new Set<string>(); + const pending = new Set([...calls].filter((id) => !done.has(id))); + return { + ...s, + running: { + kind: "step", + turnId: event.turnId, + stepId: event.stepId, + startedAt: now, + toolCount: calls.size, + pending, + maxToolMs: s.toolMs.get(event.stepId) ?? 0, + }, + }; + } + + case "text-delta": + case "reasoning-delta": + case "reasoning-end": + case "provider-retry": + // Output (or a retry) from the next request: the gap is over. + return r?.kind === "step" ? stopStep(state, now) : state; + + case "usage": + return r?.kind === "step" && event.stepId !== undefined && event.stepId !== r.stepId + ? stopStep(state, now) + : state; + + case "done": + case "error": + return endTurn(state, event.turnId, now); + + default: + return state; + } +} + +/** + * Seed a turn gap from an authoritative "last activity" timestamp (the + * backend's `lastActivityAt`, for a conversation that is idle — e.g. after a + * reload, when no live `done` was seen). Never overrides a running step gap; + * a running turn gap keeps the LATER start. Non-finite input is ignored. + */ +export function seedTurnGap(state: CacheTimerState, at: number): CacheTimerState { + if (!Number.isFinite(at)) return state; + const r = state.running; + if (r === null) return { ...state, running: { kind: "turn", turnId: null, startedAt: at } }; + if (r.kind === "turn" && at > r.startedAt) return { ...state, running: { ...r, startedAt: at } }; + return state; +} + +// ── View ────────────────────────────────────────────────────────────────────── + +/** Formatted cache-timer view: label + colour severity + capped flag. */ +export interface CacheTimerView { + /** `"m:ss"` up to 10 minutes, then `"10+ min"`. */ + readonly label: string; + /** Colour by the 5-minute cache TTL (maps to DaisyUI `badge-{level}`). */ + readonly level: "success" | "warning" | "error"; + /** Whether the 10-minute display cap was reached (a live timer may stop ticking). */ + readonly capped: boolean; +} + +/** + * Build the view for `elapsedMs`. Colour tracks the cache TTL: success under + * 4 min, warning in the last minute, error once the cache has likely expired. + * Negative / non-finite elapsed (clock skew) clamps to `0:00`. + */ +export function viewCacheTimer(elapsedMs: number): CacheTimerView { + const ms = Number.isFinite(elapsedMs) && elapsedMs > 0 ? elapsedMs : 0; + const level = ms >= CACHE_TTL_MS ? "error" : ms >= WARN_AT_MS ? "warning" : "success"; + if (ms >= CACHE_TIMER_MAX_MS) return { label: "10+ min", level, capped: true }; + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return { label: `${minutes}:${seconds.toString().padStart(2, "0")}`, level, capped: false }; +} + +/** + * Index of the most recent turn's metrics row — the LAST `turn-metrics` row, + * provided no user message follows it. Where a SEEDED turn gap (no `turnId`) + * renders. `null` when there is no such row. + */ +export function lastTurnRowIndex(rows: readonly MetricsRow[]): number | null { + for (let i = rows.length - 1; i >= 0; i--) { + const row = rows[i]; + if (row === undefined) continue; + if (row.kind === "turn-metrics") return i; + if (row.kind === "group" && row.group.kind === "single" && row.group.chunk.role === "user") { + return null; + } + } + return null; +} diff --git a/src/core/metrics/format.test.ts b/src/core/metrics/format.test.ts index 97170d0..3e8ebdd 100644 --- a/src/core/metrics/format.test.ts +++ b/src/core/metrics/format.test.ts @@ -46,7 +46,7 @@ describe("viewStepMetrics", () => { }; const view = viewStepMetrics(step, 0); expect(view.label).toBe("step 1"); - expect(view.tokensLabel).toBe("1,801 tok"); + expect(view.tokensLabel).toBe("1,234 in / 567 out"); expect(view.tps).toBe("473 tok/s"); expect(view.ttft).toBe("820ms"); expect(view.decode).toBe("1.2s"); @@ -105,15 +105,14 @@ describe("viewStepMetrics", () => { expect(view.tps).toBe("100 tok/s"); }); - it("tps falls back to genTotalMs when decodeMs absent", () => { + it("tps is null when decodeMs is absent (never falls back to genTotalMs, which includes TTFT)", () => { 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"); + expect(view.tps).toBeNull(); }); }); @@ -133,8 +132,8 @@ describe("viewTurnMetrics", () => { ], }; const view = viewTurnMetrics(turn); - expect(view.tokensLabel).toBe("1,234 tok"); - expect(view.breakdown).toBe("1,000 in / 234 out"); + expect(view.tokensLabel).toBe("234 tok out"); + expect(view.breakdown).toBe("1,000 in"); expect(view.tps).toBe("78 tok/s"); expect(view.duration).toBe("5.0s"); }); @@ -146,7 +145,7 @@ describe("viewTurnMetrics", () => { steps: [], }; const view = viewTurnMetrics(turn); - expect(view.breakdown).toBe("1,000 in / 234 out / 500 cache"); + expect(view.breakdown).toBe("1,000 in (500 cached)"); }); it("breakdown omits cache when not present", () => { @@ -156,7 +155,7 @@ describe("viewTurnMetrics", () => { steps: [], }; const view = viewTurnMetrics(turn); - expect(view.breakdown).toBe("100 in / 50 out"); + expect(view.breakdown).toBe("100 in"); }); it("tps is null when no step has decodeMs or genTotalMs", () => { @@ -184,7 +183,7 @@ describe("viewTurnMetrics", () => { expect(view.duration).toBeNull(); }); - it("sums decodeMs across steps (fallback genTotalMs per step) for tps", () => { + it("turn tps counts only steps that report decodeMs (both tokens and time)", () => { const turn: TurnMetrics = { turnId: "t1", usage: { inputTokens: 300, outputTokens: 150 }, @@ -203,9 +202,9 @@ describe("viewTurnMetrics", () => { ], }; 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"); + // only step1 is timed: 50 / (800/1000) = 62.5 → 63. step2 (no decodeMs) + // contributes neither tokens nor time — never its TTFT-inflated genTotalMs. + expect(view.tps).toBe("63 tok/s"); }); }); @@ -260,40 +259,47 @@ describe("computeExpectedCachePct", () => { 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("null when this turn has no first step", () => { + expect(computeExpectedCachePct(undefined, { inputTokens: 100, outputTokens: 0 })).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("null when the prior prompt was empty (denominator 0)", () => { + const prev = { inputTokens: 0, outputTokens: 0 }; + expect(computeExpectedCachePct({ inputTokens: 10, outputTokens: 0 }, prev)).toBeNull(); }); - 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("100% when the first step reads back the whole prior final prompt", () => { + // prev turn's last step sent 5149 tokens; this turn's first step (a + // superset prompt) reads 5149 of them from cache. + const prevLast = { inputTokens: 5149, outputTokens: 40, cacheReadTokens: 3000 }; + const first = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5149 }; + expect(computeExpectedCachePct(first, prevLast)).toBe(100); + }); + + it("drops below 100% when the cache busted", () => { + const prevLast = { inputTokens: 1000, outputTokens: 0, cacheReadTokens: 900 }; + const first = { inputTokens: 1200, outputTokens: 0, cacheReadTokens: 500 }; + expect(computeExpectedCachePct(first, prevLast)).toBe(50); + }); + + it("0% (not null) on a full miss", () => { + const prevLast = { inputTokens: 1000, outputTokens: 0 }; + const first = { inputTokens: 1200, outputTokens: 0 }; + expect(computeExpectedCachePct(first, prevLast)).toBe(0); + }); + + it("is NOT inflated by multi-step turns (step-level, not turn sums)", () => { + // A 10-step turn reads the same prefix 10x; the old turn-sum ratio read + // ~10x the baseline and pinned at 100%. Step-level reflects the bust. + const prevLast = { inputTokens: 50_000, outputTokens: 0, cacheReadTokens: 49_000 }; + const first = { inputTokens: 51_000, outputTokens: 0, cacheReadTokens: 10_000 }; + expect(computeExpectedCachePct(first, prevLast)).toBe(20); }); - 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("clamps to 100", () => { + const prevLast = { inputTokens: 100, outputTokens: 0 }; + const first = { inputTokens: 300, outputTokens: 0, cacheReadTokens: 250 }; + expect(computeExpectedCachePct(first, prevLast)).toBe(100); }); }); @@ -303,9 +309,9 @@ describe("viewExpectedCache", () => { }); 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); + const prevLast = { inputTokens: 5146, outputTokens: 0 }; + const first = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5146 }; + const v = viewExpectedCache(first, prevLast); expect(v?.pct).toBe(100); expect(v?.level).toBe("success"); expect(v?.isHit).toBe(true); diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts index 894bd54..5a13a9a 100644 --- a/src/core/metrics/format.ts +++ b/src/core/metrics/format.ts @@ -77,26 +77,31 @@ export function computeTps(outputTokens: number, elapsedMs: number | undefined): return outputTokens / (elapsedMs / 1000); } -function totalTokens(u: Usage): number { - return u.inputTokens + u.outputTokens; -} - function formatBreakdown(u: Usage): string { - let s = `${formatTokens(u.inputTokens)} in / ${formatTokens(u.outputTokens)} out`; + let s = `${formatTokens(u.inputTokens)} in`; if (u.cacheReadTokens !== undefined && u.cacheReadTokens > 0) { - s += ` / ${formatTokens(u.cacheReadTokens)} cache`; + s += ` (${formatTokens(u.cacheReadTokens)} cached)`; } return s; } +/** + * A step's tokens-per-second: `outputTokens / decodeMs`. Decode time is the + * model's generation window (first output token → stream end) with tool + * execution excluded by the backend. `genTotalMs` is deliberately NOT a + * fallback: it includes time-to-first-token (prompt processing / queueing), + * which would understate the rate by multiples. No decode time → no TPS. + */ +function stepTps(step: StepMetrics): number | null { + return computeTps(step.usage.outputTokens, step.decodeMs); +} + /** 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), + tokensLabel: `${formatTokens(step.usage.inputTokens)} in / ${formatTokens(step.usage.outputTokens)} out`, + tps: formatTps(stepTps(step)), ttft: formatDuration(step.ttftMs), decode: formatDuration(step.decodeMs), genTotal: formatDuration(step.genTotalMs), @@ -130,19 +135,25 @@ export function viewCacheRate(u: Usage): CacheRateView { } /** - * Expected cache (retention): of the cache that existed going INTO this turn, how - * much was read back — `clamp01(cacheRead_N / (cacheRead_{N-1} + cacheWrite_{N-1}))`. - * The denominator is the PRIOR turn's cached prefix (what it read + what it wrote). - * Ideally ~100% on every turn after the first; <100% = the cache busted/expired. + * Expected cache (retention): of the prompt the PREVIOUS turn last sent (its + * final step's input), how much this turn's FIRST step read back from cache — + * `clamp01(firstStep.cacheRead / prevLastStep.input)`. Everything in that + * prompt is a prefix of this turn's first prompt, so ~100% means the cache + * survived between turns; lower means it busted/expired. Step-level on both + * sides: comparing whole-turn sums is meaningless (a multi-step turn reads + * the same prefix once per step). * - * Returns `null` when it cannot be derived: no prior turn (`prev === null`) or the - * prior turn cached nothing (denominator <= 0) — distinct from a real 0%. + * Returns `null` when it cannot be derived: no earlier turn, no first step, + * or the previous prompt was empty — 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); +export function computeExpectedCachePct( + firstStep: Usage | undefined, + prevLastStep: Usage | null, +): number | null { + if (prevLastStep === null || firstStep === undefined) return null; + const denom = prevLastStep.inputTokens; if (denom <= 0) return null; - const read = current.cacheReadTokens ?? 0; + const read = firstStep.cacheReadTokens ?? 0; const rate = read / denom; const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; return Math.round(clamped * 100); @@ -152,28 +163,36 @@ export function computeExpectedCachePct(current: Usage, prev: Usage | null): num * Build a view of the cross-turn retention (percentage + colour level + hit flag), * 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); +export function viewExpectedCache( + firstStep: Usage | undefined, + prevLastStep: Usage | null, +): CacheRateView | null { + const pct = computeExpectedCachePct(firstStep, prevLastStep); if (pct === null) return null; - return { pct, level: cacheLevel(pct), isHit: (current.cacheReadTokens ?? 0) > 0 }; + return { pct, level: cacheLevel(pct), isHit: (firstStep?.cacheReadTokens ?? 0) > 0 }; } -/** Build a formatted view of a turn's aggregate metrics. */ +/** + * Build a formatted view of a turn's aggregate metrics. + * + * Turn TPS = output tokens ÷ decode time, summed over ONLY the steps that + * report a decode time (both numerator and denominator), so a step without + * timing can neither inflate nor deflate it. Tool execution between steps is + * never part of any step's decode time. + */ export function viewTurnMetrics(turn: TurnMetrics, turnNumber?: number): TurnMetricsView { - const total = totalTokens(turn.usage); - let totalGenMs: number | undefined; + let timedOut = 0; + let decodeMs: number | undefined; for (const step of turn.steps) { - const stepMs = step.decodeMs ?? step.genTotalMs; - if (stepMs !== undefined) { - totalGenMs = (totalGenMs ?? 0) + stepMs; - } + if (step.decodeMs === undefined || step.decodeMs <= 0) continue; + timedOut += step.usage.outputTokens; + decodeMs = (decodeMs ?? 0) + step.decodeMs; } - const tps = computeTps(turn.usage.outputTokens, totalGenMs); return { label: turnNumber !== undefined ? `turn ${turnNumber}` : "turn", - tokensLabel: `${formatTokens(total)} tok`, + tokensLabel: `${formatTokens(turn.usage.outputTokens)} tok out`, breakdown: formatBreakdown(turn.usage), - tps: formatTps(tps), + tps: formatTps(computeTps(timedOut, decodeMs)), duration: formatDuration(turn.durationMs), }; } diff --git a/src/core/metrics/index.ts b/src/core/metrics/index.ts index d3c9669..d9df7f5 100644 --- a/src/core/metrics/index.ts +++ b/src/core/metrics/index.ts @@ -1,4 +1,19 @@ export { + CACHE_TIMER_MAX_MS, + CACHE_TTL_MS, + type CacheTimerState, + type CacheTimerView, + foldCacheTimerEvent, + initialCacheTimerState, + lastTurnRowIndex, + type RunningGap, + type RunningStepGap, + type RunningTurnGap, + seedTurnGap, + stopTurnGap, + viewCacheTimer, +} from "./cache-timer"; +export { type ContextUsage, computeCachePct, computeContextUsage, diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts index 9c925a3..7695e21 100644 --- a/src/core/metrics/place.test.ts +++ b/src/core/metrics/place.test.ts @@ -28,31 +28,36 @@ function assistantGroup(seq: number, text: string): RenderGroup { }; } -function toolCallGroup(seq: number, stepId: string, toolCallId: string): RenderGroup { +function toolCallGroup(stepId: string, toolCallId: string): RenderGroup { return { - kind: "single", - chunk: { - seq, - role: "assistant", - chunk: { + kind: "tool", + stepId, + provisional: false, + entry: { + toolCallId, + toolName: "test", + result: null, + call: { type: "tool-call", toolCallId, toolName: "test", input: {}, stepId: stepId as StepId, }, - provisional: false, }, }; } -function toolResultGroup(seq: number, stepId: string, toolCallId: string): RenderGroup { +function toolResultGroup(stepId: string, toolCallId: string): RenderGroup { return { - kind: "single", - chunk: { - seq, - role: "tool", - chunk: { + kind: "tool", + stepId, + provisional: false, + entry: { + toolCallId, + toolName: "test", + call: null, + result: { type: "tool-result", toolCallId, toolName: "test", @@ -60,29 +65,10 @@ function toolResultGroup(seq: number, stepId: string, toolCallId: string): Rende 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, - }; -} - function makeStep(stepId: string, inputTokens: number, outputTokens: number): StepMetrics { return { stepId: stepId as StepId, @@ -169,9 +155,9 @@ describe("interleaveTurnMetrics", () => { it("head-aligned: segment i gets entries[i]", () => { const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); + const g2 = toolCallGroup("s1", "c1"); const g3 = userGroup(3, "q2"); - const g4 = toolCallGroup(4, "s2", "c2"); + const g4 = toolCallGroup("s2", "c2"); const step1 = makeStep("s1", 100, 50); const step2 = makeStep("s2", 200, 80); const rows = interleaveTurnMetrics( @@ -192,7 +178,7 @@ describe("interleaveTurnMetrics", () => { 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 g2 = toolCallGroup("s1", "c1"); const g3 = userGroup(3, "q2"); const g4 = assistantGroup(4, "a2"); const step = makeStep("s1", 100, 50); @@ -220,26 +206,28 @@ describe("interleaveTurnMetrics", () => { expectTurnMetricsAt(rows, 2, "t1"); }); - it("tool step anchors inline after its tool-batch group", () => { + it("tool step anchors inline after its LAST independent tool card", () => { const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("t#0", ["c1", "c2"]); + const g2 = toolCallGroup("t#0", "c1"); + const parallel = toolCallGroup("t#0", "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]); + const rows = interleaveTurnMetrics([g1, g2, parallel, g3], [turn]); - expect(rows).toHaveLength(5); + expect(rows).toHaveLength(6); expectGroupAt(rows, 0, g1); expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "t#0", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); + expectGroupAt(rows, 2, parallel); + expectStepMetricsAt(rows, 3, "t#0", 0); + expectGroupAt(rows, 4, g3); + expectTurnMetricsAt(rows, 5, "t1"); }); it("single tool-call group anchors its step", () => { const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); + const g2 = toolCallGroup("s1", "c1"); const g3 = assistantGroup(3, "a1"); const step = makeStep("s1", 100, 50); const turn = makeEntry("t1", 100, 50, [step]); @@ -255,7 +243,7 @@ describe("interleaveTurnMetrics", () => { it("single tool-result group anchors its step", () => { const g1 = userGroup(1, "q1"); - const g2 = toolResultGroup(2, "s1", "c1"); + const g2 = toolResultGroup("s1", "c1"); const g3 = assistantGroup(3, "a1"); const step = makeStep("s1", 100, 50); const turn = makeEntry("t1", 100, 50, [step]); @@ -271,9 +259,9 @@ describe("interleaveTurnMetrics", () => { it("multi-step: each tool step inline, unanchored text step skipped", () => { const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("t#0", ["c1"]); + const g2 = toolCallGroup("t#0", "c1"); const g3 = assistantGroup(2, "thinking"); - const g4 = toolBatchGroup("t#1", ["c2", "c3"]); + const g4 = toolCallGroup("t#1", "c2"); const g5 = assistantGroup(3, "a1"); const step0 = makeStep("t#0", 100, 50); const step1 = makeStep("t#1", 200, 80); @@ -294,10 +282,10 @@ describe("interleaveTurnMetrics", () => { it("multiple turns head-aligned with inline steps", () => { const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); + const g2 = toolCallGroup("s1", "c1"); const g3 = assistantGroup(2, "a1"); const g4 = userGroup(3, "q2"); - const g5 = toolCallGroup(4, "s2", "c2"); + const g5 = toolCallGroup("s2", "c2"); const step1 = makeStep("s1", 100, 50); const step2 = makeStep("s2", 200, 80); const rows = interleaveTurnMetrics( @@ -332,7 +320,7 @@ describe("interleaveTurnMetrics", () => { it("fewer metrics than segments: trailing segments are bare", () => { const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); + const g2 = toolCallGroup("s1", "c1"); const g3 = userGroup(3, "q2"); const g4 = assistantGroup(4, "a2"); const g5 = userGroup(5, "q3"); @@ -361,7 +349,7 @@ describe("interleaveTurnMetrics", () => { // 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 g4 = toolCallGroup("s2", "c2"); const g5 = assistantGroup(4, "tool-reply"); const g6 = userGroup(5, "q3"); const g7 = assistantGroup(6, "text-reply"); @@ -401,7 +389,12 @@ describe("interleaveTurnMetrics", () => { total: { turnId: "t1", usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 500 }, - steps: [], + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 500 }, + }, + ], }, }, { @@ -424,14 +417,14 @@ describe("interleaveTurnMetrics", () => { // 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); + // Retention baseline is the prior finalized turn's LAST step (t1, even though trimmed). + expect(tmRows[0]?.prevLastStepUsage?.inputTokens).toBe(1000); + expect(tmRows[0]?.prevLastStepUsage?.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 g2 = toolCallGroup("s1", "c1"); const step = makeStep("s1", 100, 50); const turn: TurnMetricsEntry = { turnId: "t1", @@ -454,7 +447,7 @@ describe("interleaveTurnMetrics", () => { 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 g2 = toolCallGroup("s1", "c1"); const step = makeStep("s1", 100, 50); const rows = interleaveTurnMetrics([g0, g1, g2], [makeEntry("t1", 100, 50, [step])]); @@ -468,7 +461,7 @@ describe("interleaveTurnMetrics", () => { 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 g2 = toolCallGroup("s1", "c1"); const step1 = makeStep("s1", 100, 50); const step2 = makeStep("s2", 200, 80); const rows = interleaveTurnMetrics( @@ -504,7 +497,7 @@ describe("interleaveTurnMetrics", () => { 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 g2 = toolCallGroup("s1", "c1"); const g3 = assistantGroup(2, "a1"); const step1 = makeStep("s1", 100, 50); const entry = makeProgressiveEntry("t1", [step1]); @@ -519,7 +512,7 @@ describe("interleaveTurnMetrics", () => { it("entry with total emits step rows + a turn-metrics row", () => { const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); + const g2 = toolCallGroup("s1", "c1"); const g3 = assistantGroup(2, "a1"); const step1 = makeStep("s1", 100, 50); const entry = makeEntry("t1", 100, 50, [step1]); @@ -606,16 +599,36 @@ describe("interleaveTurnMetrics — cumulative usage (cache total)", () => { expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(500); }); - it("carries the prior finalized turn's usage as the retention baseline", () => { + it("carries the prior finalized turn's LAST STEP usage as the retention baseline", () => { + const steps = (id: string): StepMetrics[] => [ + { stepId: `${id}#0` as StepId, usage: { inputTokens: 1000, outputTokens: 5 } }, + { + stepId: `${id}#1` as StepId, + usage: { inputTokens: 1669, outputTokens: 5, cacheReadTokens: 384 }, + }, + ]; + const t1: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 2669, outputTokens: 10, cacheReadTokens: 384 }, + steps: steps("t1"), + }; + const t2: TurnMetrics = { + turnId: "t2", + usage: { inputTokens: 2737, outputTokens: 10, cacheReadTokens: 2560 }, + steps: steps("t2"), + }; 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)], + [ + { turnId: "t1", steps: t1.steps, total: t1 }, + { turnId: "t2", steps: t2.steps, total: t2 }, + ], ); 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); + expect(tm[0]?.prevLastStepUsage).toBeNull(); + // second turn's baseline is the first turn's LAST step (not the turn sum) + expect(tm[1]?.prevLastStepUsage?.inputTokens).toBe(1669); + expect(tm[1]?.prevLastStepUsage?.cacheReadTokens).toBe(384); }); }); diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts index 7122b09..d4c343a 100644 --- a/src/core/metrics/place.ts +++ b/src/core/metrics/place.ts @@ -1,11 +1,17 @@ import type { Usage } from "@dispatch/wire"; import type { RenderGroup } from "../chunks"; -import type { MetricsRow, TurnMetricsEntry } from "./types"; +import type { MetricsRow, StepMetrics, 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; + return g.kind === "tool" ? g.stepId : undefined; +} + +function groupTurnId(g: RenderGroup): string | undefined { + return g.kind === "tool" ? g.turnId : g.chunk.turnId; +} + +function isUser(g: RenderGroup): boolean { + return g.kind === "single" && g.chunk.role === "user"; } /** Element-wise sum of two token usages (cache fields included only when nonzero). */ @@ -21,277 +27,255 @@ function addUsage(a: Usage, b: Usage): Usage { return out; } +/** A run of consecutive groups that belong to one (possibly unknown) turn. */ +interface Segment { + readonly start: number; + readonly end: number; // exclusive + /** Turn resolved from the segment's own groups (turnId / stepId), if any. */ + readonly known: string | undefined; + /** Whether the segment holds model output (anything but user / error chunks). */ + readonly hasResponse: boolean; +} + /** - * Interleave turn metrics into the rendered transcript. + * Assign every group to the turn that produced it. EXACT whenever the data + * carries provenance — which is always for anything produced since the backend + * began stamping `StoredChunk.turnId` (and always for live chunks, which carry + * the live event's `turnId`): * - * 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, 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 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`. + * 1. A group's own `turnId` (committed `StoredChunk.turnId` / live event). + * 2. Else the turn whose metrics contain the group's `stepId`. * - * 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`. - * Steps whose `stepId` does not appear in any group ("unanchored"): - * - If the segment HAS stepId-bearing groups (tool chunks exist but this step's - * were trimmed): SKIPPED (no blank "step N · 0 tok" bubbles). - * - If the segment has NO stepId-bearing groups (text-only turn): placed at the - * segment tail before the turn-metrics row (the original behavior). - * - * A `turn-metrics` row is emitted ONLY when `entry.total !== null` (i.e. the turn - * is finalized via `done` or durable data). A still-generating turn emits no - * turn-total row. - * - * 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. + * Only LEGACY groups (persisted before turn provenance existed, with no + * stepId) are left; they are resolved per segment. A segment boundary is a + * user message start (a user chunk after a non-user one, or any user TEXT + * chunk — a message carries at most one, first; image chunks continue it) or a + * change of known turn. A segment with a known turn assigns it to its legacy + * groups. Runs of still-unknown (text-only legacy) segments are matched to the + * UNCLAIMED metrics entries that lie strictly between their resolved + * neighbours' entries — order-preserving, and only when the count is + * unambiguous (leading runs tail-align, since chat-limit trimming removes the + * oldest turns). An ambiguous run gets NO metrics rather than wrong ones. */ -export function interleaveTurnMetrics( +function assignTurns( groups: readonly RenderGroup[], entries: readonly TurnMetricsEntry[], -): readonly MetricsRow[] { - if (entries.length === 0) { - return groups.map((g) => ({ kind: "group" as const, group: g })); - } +): (string | undefined)[] { + const turnOfStep = new Map<string, string>(); + const entryIndex = new Map<string, number>(); + entries.forEach((e, i) => { + entryIndex.set(e.turnId, i); + for (const s of e.steps) turnOfStep.set(s.stepId, e.turnId); + }); + + const direct: (string | undefined)[] = groups.map((g) => { + const t = groupTurnId(g); + if (t !== undefined) return t; + const sid = groupStepId(g); + return sid !== undefined ? turnOfStep.get(sid) : undefined; + }); - const segmentStarts: number[] = []; + // Fast path: everything resolved directly (the normal case). + if (direct.every((t) => t !== undefined)) return direct; + + // Segment the transcript (legacy resolution). + const segments: Segment[] = []; + let segStart = 0; + let segKnown: string | undefined; + let segHasResponse = false; + const close = (end: number) => { + if (end > segStart) { + segments.push({ start: segStart, end, known: segKnown, hasResponse: segHasResponse }); + } + }; 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); + if (g === undefined) continue; + const prev = i > 0 ? groups[i - 1] : undefined; + const t = direct[i]; + const userStart = + isUser(g) && + (prev === undefined || + !isUser(prev) || + (g.kind === "single" && g.chunk.chunk.type === "text")); + const turnChange = t !== undefined && segKnown !== undefined && t !== segKnown; + if (i > segStart && (userStart || turnChange)) { + close(i); + segStart = i; + segKnown = undefined; + segHasResponse = false; + } + if (t !== undefined && segKnown === undefined) segKnown = t; + if (!isUser(g) && !(g.kind === "single" && g.chunk.chunk.type === "error")) { + segHasResponse = true; } } + close(groups.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; - } - - if (T === 0) { - return groups.map((g) => ({ kind: "group" as const, group: g })); + const segTurn: (string | undefined)[] = segments.map((s) => s.known); + const claimed = new Set<number>(); + for (const t of segTurn) { + const idx = t !== undefined ? entryIndex.get(t) : undefined; + if (idx !== undefined) claimed.add(idx); } + // Entry index of a resolved segment; a known turn with no entry yet (an + // in-flight turn whose first step hasn't completed) sits after every entry. + const segEntryIdx = (k: number): number => { + const t = segTurn[k]; + if (t === undefined) return -1; + return entryIndex.get(t) ?? 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))); - - // 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; - - 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); + let k = 0; + while (k < segments.length) { + if (segTurn[k] !== undefined) { + k++; + continue; } - if (segStepIds.size === 0) continue; // text-only — defer to pass 2 + let runEnd = k; + while (runEnd < segments.length && segTurn[runEnd] === undefined) runEnd++; + const leading = k === 0; + const lower = leading ? -1 : segEntryIdx(k - 1); + const upper = runEnd < segments.length ? segEntryIdx(runEnd) : entries.length; + const candidates: number[] = []; + for (let e = lower + 1; e < upper; e++) if (!claimed.has(e)) candidates.push(e); - 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; - } + let run: number[] = []; + for (let s = k; s < runEnd; s++) run.push(s); + if (run.length !== candidates.length) { + // A segment with no model output (only the user message, or an error) is + // a turn that failed before producing anything — or one just sent, whose + // turn hasn't started yet — and legitimately has no metrics. + const responsive = run.filter((s) => segments[s]?.hasResponse === true); + if (responsive.length <= candidates.length) run = responsive; } - if (bestEntry >= 0) { - usedEntries.add(bestEntry); - const e = entries[bestEntry]; - if (e !== undefined) { - segmentEntry.set(seg, e); - segmentEntryIndex.set(seg, bestEntry); - } + let pick: number[] | null = null; + if (run.length === candidates.length) pick = candidates; + else if (leading && run.length < candidates.length) pick = candidates.slice(-run.length); + if (pick !== null) { + run.forEach((s, i) => { + const e = pick?.[i]; + const entry = e !== undefined ? entries[e] : undefined; + if (entry !== undefined && e !== undefined) { + segTurn[s] = entry.turnId; + claimed.add(e); + } + }); } + k = runEnd; } - // 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++; - } - } + const out = [...direct]; + segments.forEach((s, si) => { + const t = segTurn[si]; + if (t === undefined) return; + for (let i = s.start; i < s.end; i++) if (out[i] === undefined) out[i] = t; + }); + return out; +} + +/** + * Interleave per-turn / per-step metrics into the rendered transcript. + * + * Every group is attributed to the turn that produced it (see `assignTurns` — + * exact via `turnId` provenance; a guarded order-preserving fallback only for + * legacy rows). Then: + * - each completed step's metrics row goes right after the LAST group bearing + * that step's `stepId` (steps with no visible content — text-only steps, or + * content unloaded by the chat limit — get no row); + * - a finalized turn's (`entry.total !== null`) metrics row goes right after + * the LAST group of that turn — so a turn whose user message was unloaded + * still shows its total under its visible tail, and a mid-turn steering + * message never pulls the total up. + * + * Because attribution never depends on counting user messages, sending a new + * message, a new turn streaming, steering, image attachments, errored turns, or + * chat-limit unloading can never shift one turn's metrics onto another. + * + * Turns whose content is fully unloaded are not rendered (no wall of stale + * rows at the top) but still count toward the running "chat total". + */ +export function interleaveTurnMetrics( + groups: readonly RenderGroup[], + entries: readonly TurnMetricsEntry[], +): readonly MetricsRow[] { + if (entries.length === 0) { + return groups.map((g) => ({ kind: "group" as const, group: g })); } - // 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 turnOf = assignTurns(groups, entries); + + const entryByTurn = new Map<string, { entry: TurnMetricsEntry; index: number }>(); + entries.forEach((entry, index) => { + entryByTurn.set(entry.turnId, { entry, index }); + }); + + // Running cumulative usage across ALL finalized turns (entry order), and the + // previous finalized turn's LAST step usage (the retention baseline: the + // prompt that turn last sent, which the next turn's first step should hit in + // cache). const cumulativeByEntry: Usage[] = []; - const prevUsageByEntry: (Usage | null)[] = []; + const prevLastStepByEntry: (Usage | null)[] = []; let runningUsage: Usage = { inputTokens: 0, outputTokens: 0 }; - let lastFinalizedUsage: Usage | null = null; + let lastFinalizedStep: Usage | null = null; for (const e of entries) { - prevUsageByEntry.push(lastFinalizedUsage); + prevLastStepByEntry.push(lastFinalizedStep); if (e.total !== null) { runningUsage = addUsage(runningUsage, e.total.usage); - lastFinalizedUsage = e.total.usage; + const last = e.total.steps[e.total.steps.length - 1]; + if (last !== undefined) lastFinalizedStep = last.usage; } cumulativeByEntry.push(runningUsage); } - const rows: MetricsRow[] = []; - - const firstUserIdx = segmentStarts[0] ?? 0; - - 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; - - const entry = segmentEntry.get(seg); + // Anchor positions: last group of each step (within its turn) / each turn. + const lastGroupOfStep = new Map<string, number>(); + const lastGroupOfTurn = new Map<string, number>(); + groups.forEach((g, i) => { + const t = turnOf[i]; + if (t === undefined) return; + lastGroupOfTurn.set(t, i); + const sid = groupStepId(g); + if (sid !== undefined) lastGroupOfStep.set(sid, i); + }); - 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; - - // 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); + const stepsAt = new Map<number, { step: StepMetrics; index: number }[]>(); + for (const entry of entries) { + entry.steps.forEach((step, index) => { + const at = lastGroupOfStep.get(step.stepId); + if (at === undefined || turnOf[at] !== entry.turnId) return; + let arr = stepsAt.get(at); + if (arr === undefined) { + arr = []; + stepsAt.set(at, arr); } - } - - // 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(); + arr.push({ step, index }); + }); + } - 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. - } + const rows: MetricsRow[] = []; + for (let i = 0; i < groups.length; i++) { + const g = groups[i]; + if (g === undefined) continue; + rows.push({ kind: "group", group: g }); - // 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 }); - } - } + const steps = stepsAt.get(i); + if (steps !== undefined) { + steps.sort((a, b) => a.index - b.index); + for (const { step, index } of steps) rows.push({ kind: "step-metrics", step, index }); } - // 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, - }); - } + const t = turnOf[i]; + if (t === undefined || lastGroupOfTurn.get(t) !== i) continue; + const found = entryByTurn.get(t); + if (found === undefined || found.entry.total === null) continue; + rows.push({ + kind: "turn-metrics", + turn: found.entry.total, + turnNumber: found.index + 1, + cumulativeUsage: cumulativeByEntry[found.index] ?? found.entry.total.usage, + prevLastStepUsage: prevLastStepByEntry[found.index] ?? null, + }); } return rows; diff --git a/src/core/metrics/placement-scenarios.test.ts b/src/core/metrics/placement-scenarios.test.ts new file mode 100644 index 0000000..ce3ed5b --- /dev/null +++ b/src/core/metrics/placement-scenarios.test.ts @@ -0,0 +1,376 @@ +// End-to-end placement scenarios: real transcript + metrics reducers fed real +// AgentEvents, rendered through groupRenderedChunks → interleaveTurnMetrics. +// The invariant under test: at EVERY point of a turn (not just after it ends), +// each turn's metrics sit under that turn — never under a neighbour. +import type { AgentEvent, StepId, StoredChunk, TurnMetrics, Usage } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { + appendUserMessage, + applyHistory, + foldEvent, + groupRenderedChunks, + initialState, + selectChunks, + type TranscriptState, + trimTranscript, +} from "../chunks"; +import { interleaveTurnMetrics } from "./place"; +import { + applyDurableMetrics, + foldMetricsEvent, + initialMetricsState, + selectOrderedTurnMetrics, +} from "./reducer"; +import type { MetricsRow, MetricsState } from "./types"; + +const C = "c1"; +const u = (i: number, o: number): Usage => ({ inputTokens: i, outputTokens: o }); + +/** Ordered list of "what follows each user text bubble / which turn rows appear where". */ +function layout(rows: readonly MetricsRow[]): string[] { + const out: string[] = []; + for (const r of rows) { + if (r.kind === "turn-metrics") out.push(`[turn ${r.turn.turnId}]`); + else if (r.kind === "step-metrics") out.push(`[step ${r.step.stepId}]`); + else if (r.group.kind === "single") { + const c = r.group.chunk.chunk; + if (c.type === "text") out.push(`${r.group.chunk.role}:${c.text}`); + else if (c.type === "image") out.push(`${r.group.chunk.role}:<img>`); + else if (c.type === "error") out.push(`error:${c.message}`); + } else { + out.push(`tool:${r.group.entry.toolCallId}`); + } + } + return out; +} + +class Sim { + t: TranscriptState = initialState(); + m: MetricsState = initialMetricsState(); + seq = 0; + /** Durable server-side log (what loadSince returns). */ + log: StoredChunk[] = []; + durable: TurnMetrics[] = []; + + constructor(private readonly legacy = false) {} + + private ev(e: AgentEvent): void { + this.t = foldEvent(this.t, e); + this.m = foldMetricsEvent(this.m, e); + } + + private persist(role: StoredChunk["role"], chunk: StoredChunk["chunk"], turnId: string): void { + this.seq++; + this.log.push( + this.legacy ? { seq: this.seq, role, chunk } : { seq: this.seq, role, chunk, turnId }, + ); + } + + /** The sender types + sends (optimistic echo), optionally with an image. */ + send(text: string, image = false): void { + this.t = appendUserMessage( + this.t, + text, + image ? [{ url: "data:image/png;base64,AA", mimeType: "image/png" }] : undefined, + ); + } + + start(turnId: string, text: string, image = false): void { + this.ev({ type: "turn-start", conversationId: C, turnId } as AgentEvent); + this.ev({ type: "user-message", conversationId: C, turnId, text } as AgentEvent); + this.persist("user", { type: "text", text }, turnId); + if (image) this.persist("user", { type: "image", url: "/images/c1/x.png" }, turnId); + } + + text(turnId: string, text: string): void { + this.ev({ type: "text-delta", conversationId: C, turnId, delta: text } as AgentEvent); + } + + toolStep(turnId: string, step: number, id: string): void { + const stepId = `${turnId}#${step}` as StepId; + this.ev({ + type: "tool-call", + conversationId: C, + turnId, + stepId, + toolCallId: id, + toolName: "t", + input: {}, + } as AgentEvent); + this.completeStep(turnId, step); + this.ev({ + type: "tool-result", + conversationId: C, + turnId, + stepId, + toolCallId: id, + toolName: "t", + content: "ok", + isError: false, + } as AgentEvent); + this.persist( + "assistant", + { type: "tool-call", toolCallId: id, toolName: "t", input: {}, stepId }, + turnId, + ); + this.persist( + "tool", + { type: "tool-result", toolCallId: id, toolName: "t", content: "ok", isError: false, stepId }, + turnId, + ); + } + + completeStep(turnId: string, step: number): void { + const stepId = `${turnId}#${step}` as StepId; + this.ev({ type: "usage", conversationId: C, turnId, stepId, usage: u(100, 10) } as AgentEvent); + this.ev({ + type: "step-complete", + conversationId: C, + turnId, + stepId, + ttftMs: 100, + decodeMs: 100, + genTotalMs: 200, + } as AgentEvent); + } + + steer(turnId: string, text: string): void { + this.ev({ type: "steering", conversationId: C, turnId, text } as AgentEvent); + this.persist("user", { type: "text", text }, turnId); + } + + /** Finish a turn: final text step, done, seal, then history + metrics sync. */ + finish(turnId: string, text: string, finalStep: number): void { + this.text(turnId, text); + this.completeStep(turnId, finalStep); + this.ev({ + type: "done", + conversationId: C, + turnId, + reason: "stop", + durationMs: 500, + usage: u(100, 10), + } as AgentEvent); + this.persist("assistant", { type: "text", text }, turnId); + this.ev({ type: "turn-sealed", conversationId: C, turnId } as AgentEvent); + this.sync(turnId, finalStep); + } + + /** A turn that errors (never seals); backend persists its partial metrics. */ + fail(turnId: string, message: string, steps: number): void { + this.ev({ type: "error", conversationId: C, turnId, message } as AgentEvent); + this.persist("assistant", { type: "error", message }, turnId); + this.t = applyHistory(this.t, this.log); + if (steps > 0) this.syncMetricsFor(turnId, steps - 1); + } + + private syncMetricsFor(turnId: string, lastStep: number): void { + const steps = []; + for (let i = 0; i <= lastStep; i++) { + steps.push({ stepId: `${turnId}#${i}` as StepId, usage: u(100, 10), decodeMs: 100 }); + } + this.durable.push({ turnId, usage: u(100 * steps.length, 10 * steps.length), steps }); + this.m = applyDurableMetrics(this.m, this.durable); + } + + private sync(turnId: string, lastStep: number): void { + this.t = applyHistory(this.t, this.log); + this.syncMetricsFor(turnId, lastStep); + } + + trim(limit: number): void { + this.t = trimTranscript(this.t, limit); + } + + layout(): string[] { + const groups = groupRenderedChunks(selectChunks(this.t)); + return layout(interleaveTurnMetrics(groups, selectOrderedTurnMetrics(this.m))); + } +} + +/** Build a history of N text-only turns (t1..tN), each "qK" → "aK". */ +function textHistory(sim: Sim, n: number): void { + for (let k = 1; k <= n; k++) { + sim.send(`q${k}`); + sim.start(`t${k}`, `q${k}`); + sim.finish(`t${k}`, `a${k}`, 0); + } +} + +for (const legacy of [false, true]) { + // `legacy`: the persisted rows carry NO turnId (history written before the + // backend stamped it). Live chunks always carry it. + describe(`placement stays exact through a turn (${legacy ? "legacy rows" : "turnId rows"})`, () => { + it("plain text turns", () => { + const sim = new Sim(legacy); + textHistory(sim, 3); + expect(sim.layout()).toEqual([ + "user:q1", + "assistant:a1", + "[turn t1]", + "user:q2", + "assistant:a2", + "[turn t2]", + "user:q3", + "assistant:a3", + "[turn t3]", + ]); + }); + + it("never shifts while a new turn is sent and streams — even after older turns were unloaded", () => { + const sim = new Sim(legacy); + textHistory(sim, 4); + sim.trim(5); // 8 chunks > 5 → unloads whole quarters: q1 a1 q2 a2 + const before = sim.layout(); + expect(before).toEqual([ + "user:q3", + "assistant:a3", + "[turn t3]", + "user:q4", + "assistant:a4", + "[turn t4]", + ]); + + const expectStable = () => expect(sim.layout().slice(0, before.length)).toEqual(before); + + sim.send("q5"); + expectStable(); // just sent (optimistic echo, no turn yet) + sim.start("t5", "q5"); + expectStable(); // turn started + sim.toolStep("t5", 0, "k1"); + expectStable(); // tool step completed (metrics entry exists, not done) + sim.text("t5", "partial…"); + expectStable(); // streaming text + sim.finish("t5", "a5", 1); + expect(sim.layout()).toEqual([ + ...before, + "user:q5", + "tool:k1", + "[step t5#0]", + "assistant:a5", + "[turn t5]", + ]); + }); + + it("image attachments never shift other turns' metrics", () => { + const sim = new Sim(legacy); + sim.send("q1"); + sim.start("t1", "q1"); + sim.finish("t1", "a1", 0); + sim.send("q2", true); + sim.start("t2", "q2", true); + sim.finish("t2", "a2", 0); + sim.send("q3"); + sim.start("t3", "q3"); + sim.finish("t3", "a3", 0); + expect(sim.layout()).toEqual([ + "user:q1", + "assistant:a1", + "[turn t1]", + "user:q2", + "user:<img>", + "assistant:a2", + "[turn t2]", + "user:q3", + "assistant:a3", + "[turn t3]", + ]); + }); + + it("a mid-turn steering message keeps the turn total at the END of the turn", () => { + const sim = new Sim(legacy); + textHistory(sim, 1); + sim.send("q2"); + sim.start("t2", "q2"); + sim.toolStep("t2", 0, "k1"); + sim.steer("t2", "steer!"); + // live, mid-turn: t1 untouched, steering bubble shows no totals + expect(sim.layout()).toEqual([ + "user:q1", + "assistant:a1", + "[turn t1]", + "user:q2", + "tool:k1", + "[step t2#0]", + "user:steer!", + ]); + sim.toolStep("t2", 1, "k2"); + sim.finish("t2", "a2", 2); + textHistoryFrom(sim, 3); + expect(sim.layout()).toEqual([ + "user:q1", + "assistant:a1", + "[turn t1]", + "user:q2", + "tool:k1", + "[step t2#0]", + "user:steer!", + "tool:k2", + "[step t2#1]", + "assistant:a2", + "[turn t2]", + "user:q3", + "assistant:a3", + "[turn t3]", + ]); + }); + + it("an errored turn gets its own metrics and never shifts the others", () => { + const sim = new Sim(legacy); + textHistory(sim, 1); + sim.send("q2"); + sim.start("t2", "q2"); + sim.text("t2", "partial"); + sim.completeStep("t2", 0); + sim.fail("t2", "boom", 1); + textHistoryFrom(sim, 3); + const l = sim.layout(); + expect(l.indexOf("[turn t1]")).toBe(l.indexOf("assistant:a1") + 1); + expect(l.indexOf("[turn t2]")).toBeGreaterThan(l.indexOf("error:boom")); + expect(l.indexOf("[turn t2]")).toBeLessThan(l.indexOf("user:q3")); + expect(l.indexOf("[turn t3]")).toBe(l.indexOf("assistant:a3") + 1); + }); + + it("an errored turn with NO metrics never steals another turn's", () => { + const sim = new Sim(legacy); + textHistory(sim, 1); + sim.send("q2"); + sim.start("t2", "q2"); + sim.fail("t2", "boom", 0); // failed before any step → no metrics row + textHistoryFrom(sim, 3); + expect(sim.layout()).toEqual([ + "user:q1", + "assistant:a1", + "[turn t1]", + "user:q2", + "error:boom", + "user:q3", + "assistant:a3", + "[turn t3]", + ]); + }); + + it("a turn cut in half by the chat limit still shows its total under its visible tail", () => { + const sim = new Sim(legacy); + sim.send("q1"); + sim.start("t1", "q1"); + sim.toolStep("t1", 0, "k1"); + sim.toolStep("t1", 1, "k2"); + sim.finish("t1", "a1", 2); + textHistoryFrom(sim, 2); + // log: q1 call result call result a1 | q2 a2 → keep newest 6 + sim.trim(7); + const l = sim.layout(); + expect(l).not.toContain("user:q1"); + expect(l.indexOf("[turn t1]")).toBe(l.indexOf("assistant:a1") + 1); + expect(l.indexOf("[turn t2]")).toBe(l.indexOf("assistant:a2") + 1); + }); + }); +} + +/** Continue a text history from turn `from` (one more turn). */ +function textHistoryFrom(sim: Sim, from: number): void { + sim.send(`q${from}`); + sim.start(`t${from}`, `q${from}`); + sim.finish(`t${from}`, `a${from}`, 0); +} diff --git a/src/core/metrics/types.ts b/src/core/metrics/types.ts index 84d1904..95b5b02 100644 --- a/src/core/metrics/types.ts +++ b/src/core/metrics/types.ts @@ -61,10 +61,11 @@ export type MetricsRow = /** 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). + * Usage of the LAST step of the most recent EARLIER finalized turn, or + * `null` when there is none. The baseline for cross-turn retention: that + * step's prompt is what this turn's first step should find in cache. */ - readonly prevTurnUsage: Usage | null; + readonly prevLastStepUsage: Usage | null; }; /** Formatted cache hit-rate view: percentage + colour severity + hit flag. */ @@ -80,6 +81,7 @@ export interface CacheRateView { /** Formatted per-step view for display. */ export interface StepMetricsView { readonly label: string; + /** `"<in> in / <out> out"` — this step's prompt and generated tokens. */ readonly tokensLabel: string; readonly tps: string | null; readonly ttft: string | null; @@ -90,7 +92,13 @@ export interface StepMetricsView { /** Formatted per-turn view for display. */ export interface TurnMetricsView { readonly label: string; + /** `"<out> tok out"` — tokens the model generated this turn. */ readonly tokensLabel: string; + /** + * Prompt-side breakdown: input tokens processed (summed over the turn's + * steps — each step re-sends the whole prompt, so this is NOT the context + * size) and how many of them were cache reads. + */ readonly breakdown: string; readonly tps: string | null; readonly duration: string | null; diff --git a/src/core/plugin-abi/driver.test.ts b/src/core/plugin-abi/driver.test.ts new file mode 100644 index 0000000..43d0af5 --- /dev/null +++ b/src/core/plugin-abi/driver.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, vi } from "vitest"; +import { createFrontendDriver, type FrontendDriverDeps } from "./driver.js"; +import type { FrontendCandidate } from "./load-diff.js"; + +/** + * Asymmetric testing: the ONLY fake is the OUTERMOST edge (the dynamic + * import + the host-API builder — injected effects). The driver's internals, + * the load diff, and the registry-facing sweep contract are real. + */ + +const c = (id: string, bust = "1"): FrontendCandidate => ({ + id, + url: `http://x/plugins/${id}/frontend?workspace=w&v=${bust}`, + apiVersion: "^1", +}); + +interface Harness { + readonly deps: FrontendDriverDeps & { + // The race test swaps the import edge before the driver exists — the + // driver reads the dep object per call, so the mutation is the swap. + importModule: (url: string) => Promise<unknown>; + }; + readonly imports: string[]; + readonly sweeps: string[]; + /** The modules the fake import() returns, keyed by the next id. */ + readonly modules: Map<string, unknown>; + readonly logs: string[]; +} + +function harness(): Harness { + const imports: string[] = []; + const sweeps: string[] = []; + const logs: string[] = []; + const modules = new Map<string, unknown>(); + const deps: FrontendDriverDeps = { + importModule: async (url) => { + imports.push(url); + const id = new URL(url).pathname.split("/")[2] ?? ""; + const mod = modules.get(id); + if (mod === undefined) throw new Error(`404: ${url}`); + return mod; + }, + buildHostApi: (pluginId) => ({ + abiVersion: "1.1", + registerView: () => () => {}, + surfaces: { + subscribe: () => {}, + unsubscribe: () => {}, + invoke: () => {}, + getSpec: () => null, + }, + logger: { info: () => {}, warn: () => {} }, + layout: { + applyOps: async () => ({ ok: false, error: "no layout provider active" }), + getTree: () => null, + }, + ...(pluginId === undefined ? {} : {}), + }), + unregisterViewsByPlugin: (pluginId) => sweeps.push(pluginId), + logger: { + info: (msg) => logs.push(msg), + warn: (msg) => logs.push(msg), + }, + }; + return { deps, imports, sweeps, modules, logs }; +} + +describe("createFrontendDriver — the load lifecycle", () => { + it("boot: imports newcomers and calls activate with the plugin's own host API", async () => { + const h = harness(); + const activate = vi.fn(); + h.modules.set("a", { default: { activate } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a")]); + expect(h.imports).toEqual([c("a").url]); + expect(activate).toHaveBeenCalledOnce(); + expect(driver.states().get("a")).toEqual({ kind: "loaded" }); + }); + + it("extensions-changed: a vanished plugin runs its deactivate + the registry sweep", async () => { + const h = harness(); + const deactivate = vi.fn(); + h.modules.set("a", { default: { activate: () => deactivate } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a")]); + await driver.sync([]); // the plugin is gone from the desired set + expect(deactivate).toHaveBeenCalledOnce(); + expect(h.sweeps).toEqual(["a"]); + expect(driver.states().has("a")).toBe(false); + }); + + it("unload disposes EXACTLY that plugin (the sweep is per-pluginId)", async () => { + const h = harness(); + h.modules.set("a", { default: { activate: () => () => {} } }); + h.modules.set("b", { default: { activate: () => () => {} } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a"), c("b")]); + await driver.sync([c("b")]); // only `a` vanishes + expect(h.sweeps).toEqual(["a"]); + }); + + it("workspace.set / re-enable: the SAME url re-activates the cached module", async () => { + const h = harness(); + const activate = vi.fn(); + h.modules.set("a", { default: { activate } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a")]); + await driver.sync([]); // disable → unload (disposer + sweep) + await driver.sync([c("a")]); // re-enable → activate re-runs + expect(h.imports).toEqual([c("a").url, c("a").url]); // re-fetched URL + expect(activate).toHaveBeenCalledTimes(2); + expect(h.sweeps).toEqual(["a"]); + }); + + it("a changed URL (backend reload) unloads then re-imports fresh code", async () => { + const h = harness(); + const activate = vi.fn(() => () => {}); + h.modules.set("a", { default: { activate } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a", "old")]); + await driver.sync([c("a", "new")]); + expect(h.sweeps).toEqual(["a"]); + expect(h.imports).toEqual([c("a", "old").url, c("a", "new").url]); + expect(activate).toHaveBeenCalledTimes(2); + }); + + it("a throwing activate is LOUD: failed state, no crash, others still load", async () => { + const h = harness(); + h.modules.set("bad", { + default: { + activate: () => { + throw new Error("boom"); + }, + }, + }); + h.modules.set("good", { default: { activate: () => () => {} } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("bad"), c("good")]); + expect(driver.states().get("bad")).toEqual({ kind: "failed", error: "boom" }); + expect(driver.states().get("good")).toEqual({ kind: "loaded" }); + // The failed plugin owns nothing — the sweep never ran for it. + expect(h.sweeps).toEqual([]); + }); + + it("a broken module (404 / missing activate) is a failed state, not a crash", async () => { + const h = harness(); + h.modules.set("no-activate", { default: {} }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("ghost"), c("no-activate")]); + expect(driver.states().get("ghost")?.kind).toBe("failed"); + expect(driver.states().get("no-activate")?.kind).toBe("failed"); + expect(h.logs.some((l) => l.includes("activation failed"))).toBe(true); + }); + + it("a throwing deactivate still gets the registry sweep (views never linger)", async () => { + const h = harness(); + h.modules.set("a", { + default: { + activate: () => () => { + throw new Error("disposer boom"); + }, + }, + }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a")]); + await driver.sync([]); + expect(h.sweeps).toEqual(["a"]); + }); + + it("handshake-incompatible candidates are never imported (reported for the UI)", async () => { + const h = harness(); + const driver = createFrontendDriver(h.deps); + await driver.sync([{ ...c("bad"), apiVersion: "^2" }]); + expect(h.imports).toEqual([]); + expect(driver.states().get("bad")).toEqual({ kind: "incompatible", apiVersion: "^2" }); + }); + + it("overlapping syncs serialize (boot + extensions-changed race)", async () => { + const h = harness(); + let releaseImport: (() => void) | undefined; + h.modules.set("a", { + default: { + activate: () => { + return () => {}; + }, + }, + }); + const blockingImport = async (url: string) => { + h.imports.push(url); + await new Promise<void>((resolve) => { + releaseImport = resolve; + }); + return h.modules.get("a"); + }; + // Swap the import edge BEFORE the driver exists (the driver reads the + // dep object per call — the swap is the injection point for the race). + h.deps.importModule = blockingImport; + const driver = createFrontendDriver(h.deps); + const first = driver.sync([c("a")]); + const second = driver.sync([]); // queued behind the first + // While the first sync is blocked on import, the second must not have + // started (no unload of `a` mid-import). + await new Promise((r) => setTimeout(r, 10)); + releaseImport?.(); + await Promise.all([first, second]); + // The second pass sees `a` already imported (the queued sync ran after) + // and unloads it — exactly one sweep, no interleaving. + expect(h.sweeps).toEqual(["a"]); + }); +}); diff --git a/src/core/plugin-abi/driver.ts b/src/core/plugin-abi/driver.ts new file mode 100644 index 0000000..7ba326f --- /dev/null +++ b/src/core/plugin-abi/driver.ts @@ -0,0 +1,140 @@ +/** + * The frontend-module DRIVER (plan 03 §3.2) — the effectful lifecycle host. + * Owns the imported-module set and runs ONE sync pass per trigger (boot, + * `extensions-changed`, `workspace.set`): + * + * 1. diff (pure, `decideFrontendLoads`) the desired set against the + * imported set; + * 2. UNLOAD stragglers: run the module's deactivate disposer (if it + * returned one) then the host's unregister-by-pluginId sweep — the UI is + * immediately correct. HONEST LIMITATION: ESM cannot be un-fetched; the + * bytes linger in the module cache. Re-enable re-runs `activate` on the + * cached module (same URL ⇒ no re-fetch). + * 3. IMPORT newcomers: dynamic-import the URL (THE injected edge), call + * `activate(api)` with the plugin's own host API (forced pluginId + * attribution). A throwing/missing activate is LOUD: the plugin's FE + * half is marked failed (the backend half stays loaded) and the failure + * never crashes the host or blocks the other plugins. + * + * Syncs are serialized (a chain) — boot + extensions-changed + workspace + * switches can overlap, and import/unload ordering must stay coherent. + * No internal mocks: tests inject the import edge + host-API builder. + */ +import type { + FrontendCandidate, + FrontendLoadDecision, + ImportedFrontendModule, +} from "./load-diff.js"; +import { decideFrontendLoads } from "./load-diff.js"; +import type { FrontendHostAPI, FrontendPluginModule, FrontendPluginState } from "./types.js"; + +export interface FrontendDriverDeps { + /** + * THE edge: dynamic ESM import. Injected (the composition root binds + * `import(url)`; tests bind a fake) so nothing else in the driver touches + * the module system. + */ + readonly importModule: (url: string) => Promise<unknown>; + /** + * Build the plugin's host API — the composition root closes over the + * registry + surfaces ports + logger and FORCES the pluginId attribution. + */ + readonly buildHostApi: (pluginId: string) => FrontendHostAPI; + /** The unregister-by-pluginId sweep (the registry-side unload path). */ + readonly unregisterViewsByPlugin: (pluginId: string) => void; + readonly logger: { info: (msg: string) => void; warn: (msg: string) => void }; +} + +export interface FrontendDriver { + /** Run one sync pass. Serialized; never throws (failures are states). */ + sync(candidates: readonly FrontendCandidate[]): Promise<void>; + /** The per-plugin states the manager UI renders. */ + states(): ReadonlyMap<string, FrontendPluginState>; +} + +interface ImportedEntry extends ImportedFrontendModule { + readonly deactivate: (() => void) | undefined; +} + +export function createFrontendDriver(deps: FrontendDriverDeps): FrontendDriver { + /** pluginId → the imported module's entry. */ + const imported = new Map<string, ImportedEntry>(); + /** pluginId → its last-known FE state (for the manager UI). */ + const pluginStates = new Map<string, FrontendPluginState>(); + /** The sync chain (serialization across triggers). */ + let chain: Promise<void> = Promise.resolve(); + + /** Unload one plugin: module disposer FIRST, then the registry sweep. */ + function unload(id: string, entry: ImportedEntry): void { + try { + entry.deactivate?.(); + } catch (err) { + // A broken deactivate must not block the sweep — the registration + // ledger's force-clean philosophy (the views must not linger). + deps.logger.warn(`frontend-plugin "${id}": deactivate threw — ${String(err)}`); + } + try { + deps.unregisterViewsByPlugin(id); + } catch (err) { + deps.logger.warn(`frontend-plugin "${id}": unregister sweep threw — ${String(err)}`); + } + imported.delete(id); + pluginStates.delete(id); + deps.logger.info(`frontend-plugin "${id}": unloaded`); + } + + /** Import + activate one candidate. Failures are LOUD states, not throws. */ + async function load(candidate: FrontendCandidate): Promise<void> { + try { + const mod = (await deps.importModule(candidate.url)) as FrontendPluginModule | undefined; + const activate = mod?.default?.activate; + if (typeof activate !== "function") { + throw new Error("the module has no default.activate export"); + } + const disposer = activate(deps.buildHostApi(candidate.id)); + imported.set(candidate.id, { + url: candidate.url, + deactivate: typeof disposer === "function" ? disposer : undefined, + }); + pluginStates.set(candidate.id, { kind: "loaded" }); + deps.logger.info(`frontend-plugin "${candidate.id}": activated`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + pluginStates.set(candidate.id, { kind: "failed", error: message }); + deps.logger.warn(`frontend-plugin "${candidate.id}": activation failed — ${message}`); + } + } + + return { + async sync(candidates: readonly FrontendCandidate[]): Promise<void> { + const run = chain.then(() => runSync(candidates)); + // The NEXT sync waits for this one; a rejected run must not poison the + // chain (runSync itself never throws, but belt-and-suspenders). + chain = run.catch(() => {}); + await run; + }, + + states(): ReadonlyMap<string, FrontendPluginState> { + return new Map(pluginStates); + }, + }; + + async function runSync(candidates: readonly FrontendCandidate[]): Promise<void> { + const decision: FrontendLoadDecision = decideFrontendLoads(candidates, imported); + + // The handshake verdicts persist even when nothing else changes — the + // manager UI renders "incompatible frontend plugin" until the plugin's + // manifest (or the host) changes. + for (const bad of decision.incompatible) { + pluginStates.set(bad.id, { kind: "incompatible", apiVersion: bad.apiVersion }); + } + + for (const id of decision.toUnload) { + const entry = imported.get(id); + if (entry !== undefined) unload(id, entry); + } + for (const candidate of decision.toLoad) { + await load(candidate); + } + } +} diff --git a/src/core/plugin-abi/index.ts b/src/core/plugin-abi/index.ts new file mode 100644 index 0000000..3c97eca --- /dev/null +++ b/src/core/plugin-abi/index.ts @@ -0,0 +1,27 @@ +/** + * The frontend plugin ABI (plan 03) — the contract + lifecycle machinery for + * dynamically-imported frontend plugin modules. See `types.ts` (the surface), + * `version.ts` (the handshake), `load-diff.ts` (pure decisions), and + * `driver.ts` (the effectful host). The composition root (src/app) wires the + * driver to the registry, the surfaces ports, and the plugin HTTP edge. + */ +export { createFrontendDriver, type FrontendDriver, type FrontendDriverDeps } from "./driver.js"; +export { + decideFrontendLoads, + type FrontendCandidate, + type FrontendCandidatesInput, + type FrontendLoadDecision, + frontendCandidates, + type ImportedFrontendModule, + usablePluginIds, +} from "./load-diff.js"; +export type { + FrontendHostAPI, + FrontendPluginModule, + FrontendPluginState, + FrontendViewRegistration, +} from "./types.js"; +export { + FRONTEND_ABI_VERSION, + isFrontendCompatible, +} from "./version.js"; diff --git a/src/core/plugin-abi/load-diff.test.ts b/src/core/plugin-abi/load-diff.test.ts new file mode 100644 index 0000000..f8ebf6c --- /dev/null +++ b/src/core/plugin-abi/load-diff.test.ts @@ -0,0 +1,183 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + decideFrontendLoads, + frontendCandidates, + type ImportedFrontendModule, + usablePluginIds, +} from "./load-diff.js"; +import { FRONTEND_ABI_VERSION } from "./version.js"; + +const httpBase = "http://localhost:24203"; + +function listWith( + ...rows: readonly { + readonly id: string; + readonly apiVersion?: string; + readonly bust?: string; + }[] +): PluginListResponse { + return { + loaded: rows.map((r) => ({ + id: r.id, + name: r.id, + version: "1.0.0", + tier: "standard" as const, + trust: "local" as const, + locked: false, + dependsOn: [], + ...(r.apiVersion === undefined + ? {} + : { frontend: { apiVersion: r.apiVersion, entry: "frontend.js", bust: r.bust ?? "1" } }), + })), + disabledWithReason: [], + discovered: [], + missing: [], + }; +} + +describe("frontendCandidates", () => { + it("derives candidates from loaded ∧ frontend-declaring ∧ workspace-enabled rows", () => { + const candidates = frontendCandidates( + listWith( + { id: "a", apiVersion: "^1", bust: "111" }, + { id: "no-fe" }, + { id: "b", apiVersion: "^1", bust: "222" }, + ), + { workspaceId: "team-a", usableIds: new Set(["a", "b", "no-fe"]), httpBase }, + ); + expect(candidates).toEqual([ + { + id: "a", + apiVersion: "^1", + url: `${httpBase}/plugins/a/frontend?workspace=team-a&v=111`, + }, + { + id: "b", + apiVersion: "^1", + url: `${httpBase}/plugins/b/frontend?workspace=team-a&v=222`, + }, + ]); + }); + + it("excludes plugins disabled in the active workspace", () => { + const candidates = frontendCandidates(listWith({ id: "a", apiVersion: "^1" }), { + workspaceId: "team-a", + usableIds: new Set<string>(), + httpBase, + }); + expect(candidates).toEqual([]); + }); + + it("URL-encodes the workspace and bust", () => { + const candidates = frontendCandidates(listWith({ id: "a", apiVersion: "^1", bust: "1 2" }), { + workspaceId: "team a", + usableIds: new Set(["a"]), + httpBase, + }); + expect(candidates[0]?.url).toBe(`${httpBase}/plugins/a/frontend?workspace=team%20a&v=1%202`); + }); +}); + +describe("usablePluginIds", () => { + it("mirrors isPluginUsable: core usable unless disabled, others enabled-only", () => { + const usable = usablePluginIds([ + { pluginId: "core-one", tier: "core", state: "undefined", loaded: true, locked: true }, + { pluginId: "core-off", tier: "core", state: "disabled", loaded: true, locked: true }, + { pluginId: "std-on", tier: "standard", state: "enabled", loaded: true, locked: false }, + { pluginId: "std-blank", tier: "standard", state: "undefined", loaded: true, locked: false }, + { pluginId: "prov-on", tier: "providers", state: "enabled", loaded: true, locked: false }, + { pluginId: "prov-off", tier: "providers", state: "disabled", loaded: true, locked: false }, + ]); + expect([...usable].sort()).toEqual(["core-one", "prov-on", "std-on"]); + }); +}); + +describe("decideFrontendLoads — the load diff (boot / extensions-changed / workspace.set)", () => { + const c = (id: string, bust = "1"): { id: string; url: string; apiVersion: string } => ({ + id, + url: `${httpBase}/plugins/${id}/frontend?workspace=w&v=${bust}`, + apiVersion: "^1", + }); + + it("boot: every desired candidate imports (nothing imported yet)", () => { + const d = decideFrontendLoads([c("a"), c("b")], new Map()); + expect(d.toLoad.map((x) => x.id)).toEqual(["a", "b"]); + expect(d.toUnload).toEqual([]); + expect(d.incompatible).toEqual([]); + }); + + it("extensions-changed: a removed plugin unloads, a newcomer imports", () => { + const imported = new Map<string, ImportedFrontendModule>([ + ["a", { url: c("a").url }], + ["gone", { url: `${httpBase}/plugins/gone/frontend?workspace=w&v=1` }], + ]); + const d = decideFrontendLoads([c("a"), c("new")], imported); + expect(d.toLoad.map((x) => x.id)).toEqual(["new"]); + expect(d.toUnload).toEqual(["gone"]); + }); + + it("workspace.set: the enablement set change drives load AND unload", () => { + // The candidate list already reflects the NEW workspace (composition + // root re-derives per sync) — previously-imported ids absent from it unload. + const imported = new Map<string, ImportedFrontendModule>([ + ["a", { url: c("a").url }], + ["b", { url: c("b").url }], + ]); + const d = decideFrontendLoads([c("a")], imported); + expect(d.toLoad).toEqual([]); + expect(d.toUnload).toEqual(["b"]); + }); + + it("a stable desired set is a no-op (idempotent sync)", () => { + const imported = new Map<string, ImportedFrontendModule>([ + ["a", { url: c("a").url }], + ["b", { url: c("b").url }], + ]); + const d = decideFrontendLoads([c("a"), c("b")], imported); + expect(d.toLoad).toEqual([]); + expect(d.toUnload).toEqual([]); + }); + + it("a changed URL (backend reload → new bust) is unload + re-import", () => { + const imported = new Map<string, ImportedFrontendModule>([["a", { url: c("a", "old").url }]]); + const d = decideFrontendLoads([c("a", "new")], imported); + expect(d.toLoad.map((x) => x.id)).toEqual(["a"]); + expect(d.toUnload).toEqual(["a"]); + }); + + it("handshake failure → incompatible, never imported, still unloaded if it was", () => { + const bad = { ...c("bad"), apiVersion: "^2" }; + const imported = new Map<string, ImportedFrontendModule>([["bad", { url: bad.url }]]); + const d = decideFrontendLoads([bad], imported); + expect(d.toLoad).toEqual([]); + expect(d.incompatible).toEqual([{ id: "bad", apiVersion: "^2" }]); + // The previously-imported instance must be swept (the manifest changed + // under us — the UI must not keep running incompatible code). + expect(d.toUnload).toEqual(["bad"]); + }); + + it("the handshake checks against FRONTEND_ABI_VERSION", () => { + // A range that includes the host version passes — proving the version + // constant feeds the check. + expect(FRONTEND_ABI_VERSION).toBe("1.1"); + const d = decideFrontendLoads([c("ok")], new Map()); + expect(d.incompatible).toEqual([]); + }); + + it("the additive bump keeps v1 plugins loading (plan 04: ^1 spans 1.1)", () => { + // c() declares apiVersion "^1" — a bare-major range is a + // major-compatibility claim, so the 1.1 host (layout member added) still + // loads every v1 plugin. + const d = decideFrontendLoads([c("v1-plugin")], new Map()); + expect(d.toLoad.map((x) => x.id)).toEqual(["v1-plugin"]); + expect(d.incompatible).toEqual([]); + }); + + it("plugins requiring the 1.1 ABI (^1.1) load on the 1.1 host", () => { + const layout = { ...c("layout-plugin"), apiVersion: "^1.1" }; + const d = decideFrontendLoads([layout], new Map()); + expect(d.toLoad.map((x) => x.id)).toEqual(["layout-plugin"]); + expect(d.incompatible).toEqual([]); + }); +}); diff --git a/src/core/plugin-abi/load-diff.ts b/src/core/plugin-abi/load-diff.ts new file mode 100644 index 0000000..b846a40 --- /dev/null +++ b/src/core/plugin-abi/load-diff.ts @@ -0,0 +1,137 @@ +/** + * The frontend-module load DIFF (plan 03 §3.2) — pure decision logic. On + * boot, on `extensions-changed`, and on `workspace.set`, the composition + * root derives the DESIRED set of frontend plugins (loaded ∧ + * workspace-enabled ∧ frontend-declaring) from `GET /plugins` + the active + * workspace's enablement rows, and diffs it against the IMPORTED set: + * newcomers are `import()`-ed + activated; stragglers are deactivated + * (module disposer + unregister-by-pluginId sweep); a changed import URL + * (backend reload → new content bust) is an unload + re-import. + * + * The handshake runs HERE: a plugin whose `frontend.apiVersion` range does + * not include FRONTEND_ABI_VERSION is reported "incompatible" and is NEVER + * imported — it is also (re)unloaded if a previous version of it was + * imported. + */ +import type { PluginListResponse, WorkspacePluginRow } from "@dispatch/transport-contract"; +import { FRONTEND_ABI_VERSION, isFrontendCompatible } from "./version.js"; + +/** One desired frontend plugin, fully URL-resolved. */ +export interface FrontendCandidate { + readonly id: string; + /** The import URL: the backend route + workspace + the content bust. */ + readonly url: string; + /** The plugin's declared frontend ABI semver range (the handshake input). */ + readonly apiVersion: string; +} + +/** What the currently-imported modules look like to the diff. */ +export interface ImportedFrontendModule { + /** The URL the module was imported from (a change → unload + re-import). */ + readonly url: string; +} + +/** The diff verdict for one sync pass. */ +export interface FrontendLoadDecision { + /** Newcomers (or bust-changed plugins) to import + activate. */ + readonly toLoad: readonly FrontendCandidate[]; + /** Imported plugin ids no longer desired (or superseded by a new URL). */ + readonly toUnload: readonly string[]; + /** Handshake failures — "incompatible frontend plugin" in the manager UI. */ + readonly incompatible: readonly { readonly id: string; readonly apiVersion: string }[]; +} + +/** The composition-root inputs for candidate derivation. */ +export interface FrontendCandidatesInput { + /** The active workspace whose enablement set applies. */ + readonly workspaceId: string; + /** The plugin ids enabled in that workspace (see `usablePluginIds`). */ + readonly usableIds: ReadonlySet<string>; + /** The backend HTTP base (the import URL's origin). */ + readonly httpBase: string; +} + +/** + * The desired frontend-plugin set from a `GET /plugins` response: LOADED ∧ + * workspace-enabled ∧ frontend-declaring rows, one URL each. The URL is + * `/plugins/:id/frontend?workspace=<ws>&v=<bust>` — `v` is the content + * bust the additive list field carries (the entry's mtimeMs), so a + * backend-side reload (new mtime) produces a NEW URL → the diff re-imports. + */ +export function frontendCandidates( + list: PluginListResponse, + input: FrontendCandidatesInput, +): readonly FrontendCandidate[] { + const out: FrontendCandidate[] = []; + for (const row of list.loaded) { + if (row.frontend === undefined) continue; + if (!input.usableIds.has(row.id)) continue; + out.push({ + id: row.id, + apiVersion: row.frontend.apiVersion, + url: `${input.httpBase}/plugins/${encodeURIComponent(row.id)}/frontend?workspace=${encodeURIComponent(input.workspaceId)}&v=${encodeURIComponent(row.frontend.bust)}`, + }); + } + return out; +} + +/** + * Diff the desired set against the imported set. Handshake failures are + * NEVER in `toLoad` — and if such a plugin WAS imported (its manifest + * changed to an incompatible range), it lands in `toUnload`. + */ +export function decideFrontendLoads( + candidates: readonly FrontendCandidate[], + imported: ReadonlyMap<string, ImportedFrontendModule>, +): FrontendLoadDecision { + const toLoad: FrontendCandidate[] = []; + const incompatible: { id: string; apiVersion: string }[] = []; + const desired = new Set<string>(); + /** Imported ids whose URL changed (unload + re-import — fresh code). */ + const superseded: string[] = []; + + for (const candidate of candidates) { + if (!isFrontendCompatible(candidate.apiVersion)) { + incompatible.push({ id: candidate.id, apiVersion: candidate.apiVersion }); + continue; + } + desired.add(candidate.id); + const current = imported.get(candidate.id); + if (current === undefined) { + toLoad.push(candidate); + } else if (current.url !== candidate.url) { + // The backend reloaded the plugin (a new content bust → new URL): + // unload the OLD instance (its disposer + registry sweep run), then + // import the fresh code. + superseded.push(candidate.id); + toLoad.push(candidate); + } + } + + // Everything imported that is no longer a (handshake-passing) desired id, + // plus same-id candidates whose URL changed. + const toUnload = [...[...imported.keys()].filter((id) => !desired.has(id)), ...superseded]; + + return { toLoad, toUnload, incompatible }; +} + +/** + * The workspace-usable plugin ids from a `GET /workspaces/:id/plugins` + * response (plan-06 rows) — the FE mirror of the backend's + * `isPluginUsable`: tier "core" is usable unless explicitly `disabled`; + * every other tier is usable only when explicitly `enabled`. The response's + * `loaded: false` rows (records for unloaded plugins) cannot be desired + * anyway (candidates come from the LOADED set). + */ +export function usablePluginIds(rows: readonly WorkspacePluginRow[]): ReadonlySet<string> { + const usable = new Set<string>(); + for (const row of rows) { + if (row.tier === "core" ? row.state !== "disabled" : row.state === "enabled") { + usable.add(row.pluginId); + } + } + return usable; +} + +/** Re-export for the composition root's single-import convenience. */ +export { FRONTEND_ABI_VERSION, isFrontendCompatible }; diff --git a/src/core/plugin-abi/types.ts b/src/core/plugin-abi/types.ts new file mode 100644 index 0000000..bcb8139 --- /dev/null +++ b/src/core/plugin-abi/types.ts @@ -0,0 +1,113 @@ +/** + * The frontend plugin ABI surface (plan 03 §3.2) — the ONLY contract between + * the host app and a dynamically-imported frontend plugin module. + * + * A frontend plugin is a SINGLE prebuilt ESM file (svelte externalized — + * resolved through the host's import map to the app's own Svelte 5 runtime) + * whose default export is `{ activate(api) }`. `activate` registers views / + * stashes ports and MAY return a deactivate disposer (plan 03's deactivate + * contract). Trust model: plugin code runs with full page privileges — no + * sandbox (documented non-goal); the trust signal is the manifest's `trust` + * field rendered in the manager UI. + * + * Views get data via SURFACES (the ui-contract philosophy: backend-declared + * data rendered generically) — never via the app store. `registerView` + * FORCES the importing plugin's pluginId attribution (the host wraps the + * registry call), which is what makes unload-time sweeps exact. + */ +import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract"; +import type { SurfaceSpec } from "@dispatch/ui-contract"; +import type { FRONTEND_ABI_VERSION } from "./version.js"; + +/** The registration input for one plugin view (as the ABI exposes it). */ +export interface FrontendViewRegistration { + /** Registry id — the same id a tile leaf's `view` field names. */ + readonly id: string; + /** Human label shown in panel dropdowns. */ + readonly label: string; + /** + * A Svelte 5 component (compiled by the plugin's build against the SHARED + * svelte runtime). Opaque here — the host casts it into the registry's + * heterogeneous component bag at the seam. + */ + readonly component: unknown; + /** Whether the view may appear in panel dropdowns (default true). */ + readonly panelEligible?: boolean; +} + +/** The per-plugin host API object handed to `activate`. */ +export interface FrontendHostAPI { + /** The host's ABI version (the other half of the semver handshake). */ + readonly abiVersion: typeof FRONTEND_ABI_VERSION; + /** + * Register a view into the SAME registry built-ins use. pluginId + * attribution is FORCED to the importing plugin (the host closes over it) + * — plugins cannot attribute views to another plugin. The returned + * disposer removes the view (call it from your deactivate). + */ + readonly registerView: (reg: FrontendViewRegistration) => () => void; + /** + * Talk to backend surfaces — the plugin-view data path. The app + * auto-subscribes every catalog entry, so `getSpec` typically returns + * data immediately for surfaces your plugin's backend half declares; + * subscribe/unsubscribe re-assert scopes explicitly. + */ + readonly surfaces: { + readonly subscribe: (surfaceId: string, conversationId?: string) => void; + readonly unsubscribe: (surfaceId: string, conversationId?: string) => void; + readonly invoke: ( + surfaceId: string, + actionId: string, + payload?: unknown, + conversationId?: string, + ) => void; + readonly getSpec: (surfaceId: string) => SurfaceSpec | null; + }; + readonly logger: { info: (msg: string) => void; warn: (msg: string) => void }; + /** + * Edit the ACTIVE workspace's layout through the provider (plan 04 §4.5 — + * the additive ABI bump; v1 plugins keep working, a plugin REQUIRING the + * layout member declares `frontend.apiVersion: "^1.1"`). A thin adapter + * over `POST /workspaces/:id/layout/ops` (the same kit `applyOps` the + * service exposes — one code path, two doors): + * - the ops are the `@dispatch/layout-contract` vocabulary (prefer the + * idempotent `ensure*` ops; address by node ID, never by numeric path); + * - the batch is ATOMIC — any failed op persists nothing and the result + * names it (`failedOp`); + * - no layout provider active (the route 404s) → the typed no-provider + * error `{ ok: false, error: "no layout provider active" }` — handle it; + * the provider may also load later (retry on workspace switches). + */ + readonly layout: { + readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>; + /** The ACTIVE workspace's tree; `null` in no-provider mode. */ + readonly getTree: () => LayoutTree | null; + }; +} + +/** + * What `activate` may return: nothing, or the deactivate disposer (the + * plan-03 deactivate contract). A named alias keeps the plan grammar + * readable; the suppression is needed because biome flags `void` inside a + * union type — here it is the plan-specified shape. + */ +// biome-ignore lint/suspicious/noConfusingVoidType: plan-specified activate grammar — "may return a disposer" (the deactivate contract) +export type FrontendActivateResult = void | (() => void); + +/** The module shape a frontend plugin's entry must default-export. */ +export interface FrontendPluginModule { + readonly default: { + /** + * Register the plugin's views/ports. MAY return a deactivate disposer — + * run on unload/disable BEFORE the host's unregister-by-pluginId sweep + * (the sweep is the safety net, not the primary path). + */ + activate(api: FrontendHostAPI): FrontendActivateResult; + }; +} + +/** The per-plugin FRONTEND lifecycle state the manager UI renders. */ +export type FrontendPluginState = + | { readonly kind: "loaded" } + | { readonly kind: "incompatible"; readonly apiVersion: string } + | { readonly kind: "failed"; readonly error: string }; diff --git a/src/core/plugin-abi/version.test.ts b/src/core/plugin-abi/version.test.ts new file mode 100644 index 0000000..ab4d34b --- /dev/null +++ b/src/core/plugin-abi/version.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { FRONTEND_ABI_VERSION, isFrontendCompatible } from "./version.js"; + +describe("FRONTEND_ABI_VERSION", () => { + it("is 1.1 (plan 04 — the additive layout member bumped the minor)", () => { + expect(FRONTEND_ABI_VERSION).toBe("1.1"); + }); +}); + +describe("isFrontendCompatible — handshake accept/reject matrix (host 1.1.0)", () => { + it("accepts the canonical authoring range ^1 (v1 plugins keep working — the plan-04 promise)", () => { + expect(isFrontendCompatible("^1")).toBe(true); + expect(isFrontendCompatible("^1.0")).toBe(true); + expect(isFrontendCompatible("^1.0.0")).toBe(true); + }); + + it("accepts the bare major version (a major-compatibility claim: additive bumps never strand it)", () => { + expect(isFrontendCompatible("1")).toBe(true); + expect(isFrontendCompatible("~1")).toBe(true); + }); + + it("accepts plugins that REQUIRE the 1.1 ABI (^1.1 — the layout-declaring grammar)", () => { + expect(isFrontendCompatible("^1.1")).toBe(true); + expect(isFrontendCompatible("^1.1.0")).toBe(true); + }); + + it("accepts wildcard and >= ranges", () => { + expect(isFrontendCompatible("*")).toBe(true); + expect(isFrontendCompatible(">=1.0.0")).toBe(true); + }); + + it("rejects a next-major caret range", () => { + expect(isFrontendCompatible("^2")).toBe(false); + expect(isFrontendCompatible("^2.0.0")).toBe(false); + }); + + it("rejects an older pinned range", () => { + expect(isFrontendCompatible("0.9.0")).toBe(false); + expect(isFrontendCompatible("^0.1.0")).toBe(false); + }); + + it("rejects a tilde/pin at an OLDER explicit minor (explicit pins reject additive bumps)", () => { + expect(isFrontendCompatible("~0.9.0")).toBe(false); + expect(isFrontendCompatible("~1.0.0")).toBe(false); + expect(isFrontendCompatible("1.0.0")).toBe(false); + expect(isFrontendCompatible("^1.0")).not.toBe(false); // caret 1.x tracks the major + }); + + it("rejects malformed ranges (fail closed, never throws)", () => { + expect(isFrontendCompatible("")).toBe(false); + expect(isFrontendCompatible("banana")).toBe(false); + expect(isFrontendCompatible("^abc")).toBe(false); + expect(isFrontendCompatible("1.0.0.0")).toBe(false); + }); + + it("the host version pins the answer (injectable for tests)", () => { + // At a hypothetical 1.0.0 host, ^1.1 (needing the layout member) excludes; + // at 1.1.0 it includes — the gate is real, not decorative. + expect(isFrontendCompatible("^1.1", "1.0.0")).toBe(false); + expect(isFrontendCompatible("^1.1", "1.1.0")).toBe(true); + // "~1.0.0" pins minor 0: excludes a 1.1.0 host, includes a 1.0.x host. + expect(isFrontendCompatible("~1.0.0", "1.1.0")).toBe(false); + expect(isFrontendCompatible("~1.0.0", "1.0.0")).toBe(true); + }); +}); diff --git a/src/core/plugin-abi/version.ts b/src/core/plugin-abi/version.ts new file mode 100644 index 0000000..e5a0168 --- /dev/null +++ b/src/core/plugin-abi/version.ts @@ -0,0 +1,99 @@ +/** + * The frontend plugin ABI version + the semver handshake (plan 03 §3.2). + * + * FRONTEND_ABI_VERSION is bumped on BREAKING ABI changes only — an ADDITIVE + * member bumps the MINOR (plan 04 added `layout`: "1" → "1.1"; v1 plugins + * keep working, see the bare-major rule below). A plugin's + * `manifest.frontend.apiVersion` is a semver RANGE that must include the + * host's version; a mismatch lists the plugin as "incompatible frontend + * plugin" in the manager UI and its module is NEVER imported. + * + * `isFrontendCompatible` mirrors the backend kernel's + * `isApiVersionCompatible` semantics (host/src/host/version.ts) so both + * sides of the handshake behave identically. It additionally accepts + * major-only versions/ranges ("1", "^1") — the ABI's version grammar is + * single-number, normalized here to x.y.z with zeros — because a frontend + * ABI ("1") is not a kernel release ("0.1.0") and authors declare `"^1"`. + * A BARE-major range ("1", "^1", "~1" — no explicit minor) is a + * MAJOR-compatibility claim: it accepts every additive (minor) bump of that + * major, which is what makes "^1"/"1" plugins keep working on the 1.1 ABI + * (an author pinning minor/patch declares it explicitly: "1.0.0", "~1.0"). + */ + +export const FRONTEND_ABI_VERSION = "1.1"; + +/** + * The host version the handshake checks against (normalized to semver) — + * tracks FRONTEND_ABI_VERSION's major.minor at a 0 patch. + */ +const HOST_VERSION = "1.1.0"; + +interface SemVer { + readonly major: number; + readonly minor: number; + readonly patch: number; + /** The range string named the minor EXPLICITLY (a pin, not a bare major). */ + readonly explicitMinor: boolean; +} + +/** Parse a 1–3 component version ("1", "1.0", "1.0.0") — missing parts are 0. */ +function parseSemver(version: string): SemVer | null { + const match = version.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2] ?? 0), + patch: Number(match[3] ?? 0), + explicitMinor: match[2] !== undefined, + }; +} + +function gte(a: SemVer, b: SemVer): boolean { + if (a.major !== b.major) return a.major > b.major; + if (a.minor !== b.minor) return a.minor > b.minor; + return a.patch >= b.patch; +} + +/** + * Whether the plugin's declared `frontend.apiVersion` range includes the + * host's FRONTEND_ABI_VERSION. A malformed range is NEVER compatible (a + * plugin-verify check catches it at authoring time; the handshake fails + * closed here). + */ +export function isFrontendCompatible(apiVersion: string, hostVersion = HOST_VERSION): boolean { + const range = apiVersion.trim(); + if (range === "*") return true; + const host = parseSemver(hostVersion); + if (host === null) return false; + + if (range.startsWith("^")) { + const min = parseSemver(range.slice(1)); + if (min === null) return false; + if (!gte(host, min)) return false; + if (min.major === 0) { + return host.major === 0 && (min.explicitMinor ? host.minor === min.minor : true); + } + return host.major === min.major; + } + if (range.startsWith("~")) { + const min = parseSemver(range.slice(1)); + if (min === null) return false; + if (!gte(host, min)) return false; + // A BARE "~1" is a major-compatibility claim (any 1.x.y); an explicit + // minor ("~1.0") pins the minor, as tilde always has. + if (!min.explicitMinor) return host.major === min.major; + return host.major === min.major && host.minor === min.minor; + } + if (range.startsWith(">=")) { + const min = parseSemver(range.slice(2)); + if (min === null) return false; + return gte(host, min); + } + const exact = parseSemver(range); + if (exact === null) return false; + // A BARE major ("1") names the major only — it accepts that major's + // additive bumps (the "v1 plugins keep working" rule). An explicit + // version ("1.0", "1.0.0") pins exactly, as before. + if (!exact.explicitMinor) return host.major === exact.major; + return host.major === exact.major && host.minor === exact.minor && host.patch === exact.patch; +} diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts index 0d955d5..6bafda1 100644 --- a/src/core/wire/conformance.test.ts +++ b/src/core/wire/conformance.test.ts @@ -1,4 +1,8 @@ -import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/transport-contract"; +import type { + ChatSendMessage, + ConversationHistoryResponse, + WsServerMessage, +} from "@dispatch/transport-contract"; import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { @@ -75,6 +79,7 @@ describe("classifies every AgentEvent type", () => { { type: "error", conversationId: "c1", turnId: "t1", message: "oops" }, { type: "done", conversationId: "c1", turnId: "t1", reason: "complete" }, { type: "turn-sealed", conversationId: "c1", turnId: "t1" }, + { type: "turn-ended", conversationId: "c1", turnId: "t1", reason: "sealed" }, { type: "steering", conversationId: "c1", turnId: "t1", text: "steer mid-turn" }, { type: "provider-retry", @@ -103,13 +108,14 @@ describe("classifies every AgentEvent type", () => { "error", "done", "turn-sealed", + "turn-ended", "steering", "provider-retry", ]); }); - it("covers all 15 AgentEvent variants", () => { - expect(samples).toHaveLength(15); + it("covers all 16 AgentEvent variants", () => { + expect(samples).toHaveLength(16); }); }); @@ -172,6 +178,23 @@ describe("classifies every WsServerMessage type", () => { event: { type: "done" as const, conversationId: "c", turnId: "t", reason: "r" }, }, { type: "chat.error" as const, message: "e" }, + { type: "chat.accepted" as const, conversationId: "c1", requestId: "r1" }, + { + type: "chat.subscription-rejected" as const, + conversationId: "c1", + reason: "subscription-limit" as const, + }, + { + type: "chat.queued" as const, + conversationId: "conv-1", + reason: "compaction" as const, + messageId: "queued-1", + }, + { + type: "chat.resync-required" as const, + conversationId: "c1", + reason: "replay-limit" as const, + }, { type: "conversation.open" as const, conversationId: "c1", workspaceId: "w1" }, { type: "conversation.statusChanged" as const, @@ -184,9 +207,32 @@ describe("classifies every WsServerMessage type", () => { conversationId: "c1", newConversationId: "c2", messagesSummarized: 10, + historyEpoch: 0, messagesKept: 5, }, + { + type: "conversation.tabGroupChanged" as const, + conversationId: "c1", + workspaceId: "w1", + tabGroupParentId: null, + }, + { type: "extensions-changed" as const, action: "scan" as const }, + { + type: "layout" as const, + workspaceId: "w1", + tree: { version: 1 as const, root: { kind: "leaf" as const, id: "leaf1", view: "chat" } }, + }, + { + type: "tile-layout" as const, + mode: "wide" as const, + data: { version: 2 as const, tiles: [] }, + }, ]; + // Runtime samples stay exhaustive too, not just the switch guard. + const covered: Exclude<WsServerMessage["type"], (typeof msgs)[number]["type"]> extends never + ? true + : false = true; + expect(covered).toBe(true); const labels = msgs.map(assertWsServerMessageExhaustive); expect(labels).toEqual([ "catalog", @@ -195,9 +241,17 @@ describe("classifies every WsServerMessage type", () => { "error", "chat.delta", "chat.error", + "chat.accepted", + "chat.subscription-rejected", + "chat.queued", + "chat.resync-required", "conversation.open", "conversation.statusChanged", "conversation.compacted", + "conversation.tabGroupChanged", + "extensions-changed", + "layout", + "tile-layout", ]); }); }); @@ -212,6 +266,7 @@ describe("classifies every WsClientMessage type", () => { { 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([ @@ -222,6 +277,7 @@ describe("classifies every WsClientMessage type", () => { "chat.subscribe", "chat.unsubscribe", "chat.queue", + "chat.queue.cancel", ]); }); }); @@ -266,6 +322,7 @@ describe("ConversationHistoryResponse shape is constructible", () => { it("constructs a response with chunks", () => { const resp: ConversationHistoryResponse = { chunks: [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }], + historyEpoch: 0, latestSeq: 1, }; expect(resp.chunks).toHaveLength(1); @@ -273,7 +330,7 @@ describe("ConversationHistoryResponse shape is constructible", () => { }); it("constructs an empty (caught-up) response", () => { - const resp: ConversationHistoryResponse = { chunks: [], latestSeq: 5 }; + const resp: ConversationHistoryResponse = { chunks: [], historyEpoch: 0, 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 bfa67dc..eff2330 100644 --- a/src/core/wire/conformance.ts +++ b/src/core/wire/conformance.ts @@ -18,6 +18,8 @@ export function assertAgentEventExhaustive(event: AgentEvent): string { return "text-delta"; case "reasoning-delta": return "reasoning-delta"; + case "reasoning-end": + return "reasoning-end"; case "tool-call": return "tool-call"; case "tool-result": @@ -32,6 +34,8 @@ export function assertAgentEventExhaustive(event: AgentEvent): string { return "done"; case "turn-sealed": return "turn-sealed"; + case "turn-ended": + return "turn-ended"; case "step-complete": return "step-complete"; case "steering": @@ -83,6 +87,14 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string { return "error"; case "chat.delta": return "chat.delta"; + case "chat.resync-required": + return "chat.resync-required"; + case "chat.accepted": + return "chat.accepted"; + case "chat.subscription-rejected": + return "chat.subscription-rejected"; + case "chat.queued": + return "chat.queued"; case "chat.error": return "chat.error"; case "conversation.open": @@ -91,6 +103,14 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string { return "conversation.statusChanged"; case "conversation.compacted": return "conversation.compacted"; + case "conversation.tabGroupChanged": + return "conversation.tabGroupChanged"; + case "extensions-changed": + return "extensions-changed"; + case "layout": + return "layout"; + case "tile-layout": + return "tile-layout"; default: return msg satisfies never; } @@ -116,6 +136,10 @@ export function assertWsClientMessageExhaustive(msg: WsClientMessage): string { return "chat.unsubscribe"; case "chat.queue": return "chat.queue"; + case "chat.queue.cancel": + return "chat.queue.cancel"; + case "workspace.set": + return "workspace.set"; default: return msg satisfies never; } diff --git a/src/features/account-usage/index.ts b/src/features/account-usage/index.ts new file mode 100644 index 0000000..76204dc --- /dev/null +++ b/src/features/account-usage/index.ts @@ -0,0 +1,41 @@ +export type { + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageResult, + AccountUsageSettingsResult, + AccountUsageState, + LoadAccountUsage, + SaveAccountUsageSettings, + UpdateAccountUsageSettingsRequest, +} from "./logic/types"; +export type { + BarTone, + DailyCreditsView, + ProviderCardView, + UsageBarView, +} from "./logic/view-model"; +export { + anchorFromTimeOfDay, + elapsedFraction, + formatAge, + formatDuration, + normalizeAccountUsage, + normalizeAccountUsageReport, + parseAllowanceInput, + toLocalTimeOfDay, + viewProviderCard, + viewProviderCards, + viewUsageBar, +} from "./logic/view-model"; +export { default as AccountUsageView } from "./ui/AccountUsageView.svelte"; +export { default as DailyCreditsSettings } from "./ui/DailyCreditsSettings.svelte"; +export { default as UsageBar } from "./ui/UsageBar.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "account-usage", + description: "Claude + Codex + Hyper subscription usage bars with on-track pace markers", +} as const; diff --git a/src/features/account-usage/logic/types.ts b/src/features/account-usage/logic/types.ts new file mode 100644 index 0000000..ff92590 --- /dev/null +++ b/src/features/account-usage/logic/types.ts @@ -0,0 +1,48 @@ +import type { + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageState, + UpdateAccountUsageSettingsRequest, +} from "@dispatch/transport-contract"; + +/** + * Pure core types for the account-usage feature — zero DOM, zero effects. + * + * The backend (`account-usage` extension, transport-contract 0.25.0) polls the + * Claude + Codex subscription usage endpoints and caches them (5 min TTL, + * failure cooldowns, last-good retention). The FE reads `GET /usage`; a manual + * refresh sends `?refresh=1`, which the server still rate-limits. + */ +export type { + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageState, + UpdateAccountUsageSettingsRequest, +}; + +/** Outcome of `GET /usage`. `unavailable` = the backend extension isn't loaded (503). */ +export type AccountUsageResult = + | { readonly ok: true; readonly usage: AccountUsageResponse } + | { readonly ok: false; readonly error: string; readonly unavailable?: boolean }; + +/** Injected port: load the account usage (`refresh` asks the server to bypass its TTL). */ +export type LoadAccountUsage = (opts?: { + readonly refresh?: boolean; +}) => Promise<AccountUsageResult>; + +/** Outcome of `PUT /usage/:provider/settings` — the provider's refreshed report. */ +export type AccountUsageSettingsResult = + | { readonly ok: true; readonly report: AccountUsageReport } + | { readonly ok: false; readonly error: string }; + +/** Injected port: update a provider's usage settings (today only `hyper`). */ +export type SaveAccountUsageSettings = ( + provider: AccountUsageProviderId, + settings: UpdateAccountUsageSettingsRequest, +) => Promise<AccountUsageSettingsResult>; diff --git a/src/features/account-usage/logic/view-model.test.ts b/src/features/account-usage/logic/view-model.test.ts new file mode 100644 index 0000000..78390cf --- /dev/null +++ b/src/features/account-usage/logic/view-model.test.ts @@ -0,0 +1,277 @@ +import type { AccountUsageLimit, AccountUsageReport } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + anchorFromTimeOfDay, + elapsedFraction, + formatDuration, + normalizeAccountUsage, + parseAllowanceInput, + toLocalTimeOfDay, + viewProviderCard, + viewUsageBar, +} from "./view-model"; + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; +const NOW = 1_760_000_000_000; + +function limit(over: Partial<AccountUsageLimit> = {}): AccountUsageLimit { + return { + id: "claude:5h", + label: "5 Hour", + kind: "window", + windowId: "5h", + shared: true, + tier: null, + durationMs: 5 * HOUR, + resetsAt: NOW + 2 * HOUR, + unit: "percent", + used: 40, + limit: 100, + remaining: 60, + usedFraction: 0.4, + remainingFraction: 0.6, + status: "ok", + ...over, + }; +} + +function report(over: Partial<AccountUsageReport> = {}): AccountUsageReport { + return { + provider: "claude", + label: "Claude", + state: "fresh", + account: { accountId: "a", email: "[email protected]", planType: "max" }, + limits: [limit()], + limitReached: null, + canServe: null, + creditOverage: false, + credits: null, + dailyCredits: null, + fetchedAt: NOW - 3 * 60_000, + lastAttemptAt: NOW - 3 * 60_000, + nextRefreshAt: NOW + 2 * 60_000, + error: null, + ...over, + }; +} + +describe("pace marker (elapsedFraction)", () => { + it("is halfway two weeks into a four-week window", () => { + const resetsAt = NOW + 14 * DAY; + expect(elapsedFraction(28 * DAY, resetsAt, NOW)).toBeCloseTo(0.5); + }); + + it("clamps to 0..1 and is null when the window is unknown", () => { + expect(elapsedFraction(5 * HOUR, NOW + 10 * HOUR, NOW)).toBe(0); + expect(elapsedFraction(5 * HOUR, NOW - HOUR, NOW)).toBe(1); + expect(elapsedFraction(null, NOW, NOW)).toBeNull(); + expect(elapsedFraction(5 * HOUR, null, NOW)).toBeNull(); + }); +}); + +describe("viewUsageBar", () => { + it("places the pace marker at elapsed % and labels ahead/under pace", () => { + // 5h window resetting in 2h ⇒ 3h elapsed ⇒ 60% pace; used 40% ⇒ 20% under. + const bar = viewUsageBar(limit(), NOW); + expect(bar.percent).toBe(40); + expect(bar.pacePercent).toBeCloseTo(60); + expect(bar.paceLabel).toBe("20% under pace"); + expect(bar.aheadOfPace).toBe(false); + expect(bar.resetLabel).toBe("resets in 2h"); + expect(bar.valueLabel).toBe("40%"); + expect(bar.tone).toBe("success"); + + const ahead = viewUsageBar(limit({ used: 80, usedFraction: 0.8 }), NOW); + expect(ahead.paceLabel).toBe("20% ahead of pace"); + expect(ahead.aheadOfPace).toBe(true); + expect(ahead.tone).toBe("warning"); + }); + + it("has no pace marker for spend meters; formats dollars", () => { + const spend = viewUsageBar( + limit({ + id: "claude:extra", + kind: "spend", + unit: "usd", + durationMs: null, + resetsAt: null, + used: 12.5, + limit: 50, + usedFraction: 0.25, + }), + NOW, + ); + expect(spend.pacePercent).toBeNull(); + expect(spend.valueLabel).toBe("$12.50 / $50.00"); + const uncapped = viewUsageBar( + limit({ + unit: "usd", + kind: "spend", + limit: null, + usedFraction: null, + used: 3, + durationMs: null, + }), + NOW, + ); + expect(uncapped.valueLabel).toBe("$3.00 (no cap)"); + expect(uncapped.percent).toBe(0); + }); + + it("exhausted → error tone", () => { + expect(viewUsageBar(limit({ used: 100, usedFraction: 1, status: "exhausted" }), NOW).tone).toBe( + "error", + ); + }); +}); + +describe("viewProviderCard", () => { + it("fresh card with subtitle and age", () => { + const c = viewProviderCard(report(), NOW); + expect(c.subtitle).toBe("Max · [email protected]"); + expect(c.stateBadge).toBeNull(); + expect(c.message).toBeNull(); + expect(c.ageLabel).toBe("3m ago"); + expect(c.bars).toHaveLength(1); + }); + + it("stale / not-configured / unsupported states", () => { + const stale = viewProviderCard(report({ state: "stale", error: "HTTP 429" }), NOW); + expect(stale.stateBadge).toBe("stale"); + expect(stale.message).toContain("HTTP 429"); + const nc = viewProviderCard( + report({ + provider: "codex", + label: "Codex", + state: "not-configured", + limits: [], + account: null, + }), + NOW, + ); + expect(nc.stateBadge).toBe("not configured"); + expect(nc.message).toContain("Codex"); + expect(nc.ageLabel).toBeNull(); + const un = viewProviderCard( + report({ state: "unsupported", error: "api key", limits: [] }), + NOW, + ); + expect(un.message).toBe("api key"); + }); + + it("credit overage note", () => { + const c = viewProviderCard( + report({ creditOverage: true, limitReached: true, canServe: true }), + NOW, + ); + expect(c.creditNote).toContain("credits"); + }); +}); + +describe("normalizeAccountUsage", () => { + it("coerces a well-formed body and drops malformed rows", () => { + const n = normalizeAccountUsage( + { + servedAt: 5, + providers: [ + { ...report(), limits: [limit(), { id: "bad" }] }, + { provider: "gemini" }, + "junk", + ], + }, + 0, + ); + expect(n.servedAt).toBe(5); + expect(n.providers).toHaveLength(1); + expect(n.providers[0]?.limits).toHaveLength(1); + }); + + it("malformed body → empty", () => { + expect(normalizeAccountUsage(null, 9)).toEqual({ providers: [], servedAt: 9 }); + }); +}); + +describe("formatDuration", () => { + it("formats", () => { + expect(formatDuration(30_000)).toBe("<1m"); + expect(formatDuration(45 * 60_000)).toBe("45m"); + expect(formatDuration(3 * HOUR + 12 * 60_000)).toBe("3h 12m"); + expect(formatDuration(2 * DAY + 4 * HOUR)).toBe("2d 4h"); + }); +}); + +describe("hyper daily credits", () => { + const hyperLimit = limit({ + id: "hyper:daily", + label: "Daily credits", + windowId: "1d", + unit: "credits", + durationMs: DAY, + resetsAt: NOW + 12 * HOUR, + used: 150, + limit: 250, + remaining: 100, + usedFraction: 0.6, + }); + + it("formats credits and places the pace marker halfway through the day", () => { + const bar = viewUsageBar(hyperLimit, NOW); + expect(bar.valueLabel).toBe("150 / 250"); + expect(bar.pacePercent).toBeCloseTo(50); + expect(bar.paceLabel).toBe("10% ahead of pace"); + }); + + it("card exposes balance + reset source", () => { + const c = viewProviderCard( + report({ + provider: "hyper", + label: "Hyper", + account: { accountId: null, email: null, planType: null }, + limits: [hyperLimit], + dailyCredits: { balance: 300, allowance: 250, resetAnchor: null, resetSource: null }, + }), + NOW, + ); + expect(c.daily).toMatchObject({ + balanceLabel: "300 credits left (incl. bundles)", + allowance: 250, + resetTime: "", + resetSource: null, + }); + expect(c.daily?.resetSourceLabel).toContain("unknown"); + }); + + it("round-trips a local time of day into a past anchor", () => { + const now = new Date(2026, 9, 14, 15, 30).getTime(); + const a = anchorFromTimeOfDay("09:00", now); + expect(a).toBe(new Date(2026, 9, 14, 9, 0).getTime()); + expect(anchorFromTimeOfDay("17:00", now)).toBe(new Date(2026, 9, 13, 17, 0).getTime()); + expect(toLocalTimeOfDay(a ?? 0)).toBe("09:00"); + expect(anchorFromTimeOfDay("25:00", now)).toBeNull(); + expect(anchorFromTimeOfDay("noon", now)).toBeNull(); + }); + + it("parses the allowance input", () => { + expect(parseAllowanceInput(" 250 ")).toBe(250); + expect(parseAllowanceInput("0")).toBeNull(); + expect(parseAllowanceInput("2.5")).toBeNull(); + }); + + it("normalizes hyper reports + credit units", () => { + const n = normalizeAccountUsage( + { + providers: [ + { + ...report({ provider: "hyper" }), + limits: [hyperLimit], + dailyCredits: { balance: 1, allowance: 250, resetAnchor: 5, resetSource: "manual" }, + }, + ], + }, + 0, + ); + expect(n.providers[0]?.limits[0]?.unit).toBe("credits"); + expect(n.providers[0]?.dailyCredits?.resetSource).toBe("manual"); + }); +}); diff --git a/src/features/account-usage/logic/view-model.ts b/src/features/account-usage/logic/view-model.ts new file mode 100644 index 0000000..17d37a6 --- /dev/null +++ b/src/features/account-usage/logic/view-model.ts @@ -0,0 +1,403 @@ +import type { + AccountUsageCredits, + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageLimitStatus, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageState, +} from "@dispatch/transport-contract"; + +/** + * Pure view-models for the account-usage feature — zero DOM, zero effects, + * zero Svelte. Coerces the untyped `GET /usage` JSON, and shapes each limit + * into a bar: fill %, colour, and the PACE MARKER — where usage "should" be + * to use the whole allowance exactly by the reset (elapsed fraction of the + * window: 2 weeks into a 4-week window ⇒ 50%). + */ + +// ── Normalization (network seam) ────────────────────────────────────────────── + +function isRecord(v: unknown): v is Record<string, unknown> { + return typeof v === "object" && v !== null && !Array.isArray(v); +} +function num(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} +function str(v: unknown): string | null { + return typeof v === "string" ? v : null; +} +function bool(v: unknown): boolean | null { + return typeof v === "boolean" ? v : null; +} + +const STATES: readonly AccountUsageState[] = [ + "fresh", + "stale", + "unavailable", + "unsupported", + "not-configured", +]; +const STATUSES: readonly AccountUsageLimitStatus[] = ["ok", "warning", "exhausted", "unknown"]; + +function normalizeLimit(r: Record<string, unknown>): AccountUsageLimit | null { + const id = str(r.id); + const used = num(r.used); + if (id === null || used === null) return null; + const status = STATUSES.find((s) => s === r.status) ?? "unknown"; + return { + id, + label: str(r.label) ?? id, + kind: r.kind === "spend" ? "spend" : "window", + windowId: str(r.windowId) ?? "", + shared: r.shared === true, + tier: str(r.tier), + durationMs: num(r.durationMs), + resetsAt: num(r.resetsAt), + unit: r.unit === "usd" ? "usd" : r.unit === "credits" ? "credits" : "percent", + used, + limit: num(r.limit), + remaining: num(r.remaining), + usedFraction: num(r.usedFraction), + remainingFraction: num(r.remainingFraction), + status, + }; +} + +function normalizeCredits(v: unknown): AccountUsageCredits | null { + if (!isRecord(v)) return null; + return { + hasCredits: bool(v.hasCredits), + unlimited: bool(v.unlimited), + overageLimitReached: bool(v.overageLimitReached), + balance: num(v.balance), + spendControlReached: bool(v.spendControlReached), + }; +} + +function normalizeDailyCredits(v: unknown): AccountUsageDailyCredits | null { + if (!isRecord(v)) return null; + const balance = num(v.balance); + const allowance = num(v.allowance); + if (balance === null || allowance === null) return null; + return { + balance, + allowance, + resetAnchor: num(v.resetAnchor), + resetSource: v.resetSource === "manual" || v.resetSource === "detected" ? v.resetSource : null, + }; +} + +const PROVIDER_LABELS: Record<AccountUsageProviderId, string> = { + claude: "Claude", + codex: "Codex", + hyper: "Hyper", +}; + +function normalizeReport(r: Record<string, unknown>): AccountUsageReport | null { + const provider: AccountUsageProviderId | null = + r.provider === "claude" || r.provider === "codex" || r.provider === "hyper" ? r.provider : null; + if (provider === null) return null; + const account = isRecord(r.account) + ? { + accountId: str(r.account.accountId), + email: str(r.account.email), + planType: str(r.account.planType), + } + : null; + const limits = Array.isArray(r.limits) + ? r.limits + .filter(isRecord) + .map(normalizeLimit) + .filter((l): l is AccountUsageLimit => l !== null) + : []; + return { + provider, + label: str(r.label) ?? PROVIDER_LABELS[provider], + state: STATES.find((s) => s === r.state) ?? "unavailable", + account, + limits, + limitReached: bool(r.limitReached), + canServe: bool(r.canServe), + creditOverage: r.creditOverage === true, + credits: normalizeCredits(r.credits), + dailyCredits: normalizeDailyCredits(r.dailyCredits), + fetchedAt: num(r.fetchedAt), + lastAttemptAt: num(r.lastAttemptAt), + nextRefreshAt: num(r.nextRefreshAt), + error: str(r.error), + }; +} + +/** Coerce one untyped report (e.g. the `PUT /usage/:provider/settings` body); null if malformed. */ +export function normalizeAccountUsageReport(data: unknown): AccountUsageReport | null { + return isRecord(data) ? normalizeReport(data) : null; +} + +/** Coerce the untyped `GET /usage` body. Malformed → empty provider list. */ +export function normalizeAccountUsage(data: unknown, fallbackNow: number): AccountUsageResponse { + if (!isRecord(data)) return { providers: [], servedAt: fallbackNow }; + const providers = Array.isArray(data.providers) + ? data.providers + .filter(isRecord) + .map(normalizeReport) + .filter((p): p is AccountUsageReport => p !== null) + : []; + return { providers, servedAt: num(data.servedAt) ?? fallbackNow }; +} + +// ── Formatting ──────────────────────────────────────────────────────────────── + +/** "2d 4h" / "3h 12m" / "45m" / "<1m". */ +export function formatDuration(ms: number): string { + if (ms < 60_000) return "<1m"; + const totalMin = Math.floor(ms / 60_000); + const d = Math.floor(totalMin / 1440); + const h = Math.floor((totalMin % 1440) / 60); + const m = totalMin % 60; + if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`; + if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`; + return `${m}m`; +} + +/** "updated 3m ago" style age, or "just now". */ +export function formatAge(fetchedAt: number | null, now: number): string | null { + if (fetchedAt === null) return null; + const ms = now - fetchedAt; + return ms < 60_000 ? "just now" : `${formatDuration(ms)} ago`; +} + +function formatUsd(v: number): string { + return `$${v.toFixed(2)}`; +} + +/** Whole credits as-is, fractional ones to 1 decimal ("150", "12.5"). */ +function formatCredits(v: number): string { + return Number.isInteger(v) ? String(v) : v.toFixed(1); +} + +// ── Bars ────────────────────────────────────────────────────────────────────── + +export type BarTone = "success" | "warning" | "error" | "neutral"; + +export interface UsageBarView { + readonly id: string; + readonly label: string; + /** Fill 0..100 for the `<progress>` value. */ + readonly percent: number; + /** "42%" or "$12.50 / $50.00". */ + readonly valueLabel: string; + readonly tone: BarTone; + /** + * Pace marker position 0..100 (% of the window elapsed) — the usage level + * that would be "on track" to exactly use the allowance by the reset. Null + * when the window's start can't be determined (no duration/reset). + */ + readonly pacePercent: number | null; + /** "+12% ahead of pace" / "8% under pace" / null. */ + readonly paceLabel: string | null; + /** True when usage is ahead of the pace marker. */ + readonly aheadOfPace: boolean; + /** "resets in 3h 12m" / null. */ + readonly resetLabel: string | null; +} + +/** + * Fraction (0..1) of the window elapsed at `now`. The window started at + * `resetsAt - durationMs`; returns null when either is unknown. + */ +export function elapsedFraction( + durationMs: number | null, + resetsAt: number | null, + now: number, +): number | null { + if (durationMs === null || resetsAt === null || durationMs <= 0) return null; + const start = resetsAt - durationMs; + return Math.min(1, Math.max(0, (now - start) / durationMs)); +} + +function toneFor(status: AccountUsageLimitStatus, usedFraction: number | null): BarTone { + if (status === "exhausted") return "error"; + if (status === "warning") return "warning"; + if (status === "ok") return usedFraction !== null && usedFraction >= 0.75 ? "warning" : "success"; + return "neutral"; +} + +export function viewUsageBar(limit: AccountUsageLimit, now: number): UsageBarView { + const fraction = limit.usedFraction; + const percent = fraction === null ? 0 : Math.min(100, Math.max(0, fraction * 100)); + let valueLabel: string; + if (limit.unit === "usd") { + valueLabel = + limit.limit === null + ? `${formatUsd(limit.used)} (no cap)` + : `${formatUsd(limit.used)} / ${formatUsd(limit.limit)}`; + } else if (limit.unit === "credits") { + valueLabel = + limit.limit === null + ? `${formatCredits(limit.used)} used` + : `${formatCredits(limit.used)} / ${formatCredits(limit.limit)}`; + } else { + valueLabel = `${Math.round(limit.used)}%`; + } + const elapsed = elapsedFraction(limit.durationMs, limit.resetsAt, now); + const pacePercent = elapsed === null ? null : elapsed * 100; + let paceLabel: string | null = null; + let aheadOfPace = false; + if (pacePercent !== null && fraction !== null) { + const diff = Math.round(percent - pacePercent); + aheadOfPace = diff > 0; + paceLabel = + diff === 0 ? "on pace" : diff > 0 ? `${diff}% ahead of pace` : `${-diff}% under pace`; + } + const resetLabel = + limit.resetsAt === null + ? null + : limit.resetsAt <= now + ? "resetting" + : `resets in ${formatDuration(limit.resetsAt - now)}`; + return { + id: limit.id, + label: limit.label, + percent, + valueLabel, + tone: toneFor(limit.status, fraction), + pacePercent, + paceLabel, + aheadOfPace, + resetLabel, + }; +} + +// ── Provider cards ──────────────────────────────────────────────────────────── + +export interface ProviderCardView { + readonly provider: AccountUsageProviderId; + readonly label: string; + /** "Pro · [email protected]" / null. */ + readonly subtitle: string | null; + readonly state: AccountUsageState; + /** Badge text for non-fresh states ("stale", "unavailable", …) or null when fresh. */ + readonly stateBadge: string | null; + /** Explanatory line for empty/non-fresh states, else null. */ + readonly message: string | null; + readonly bars: readonly UsageBarView[]; + /** "updated 3m ago" / null. */ + readonly ageLabel: string | null; + /** Credit-overage note (Codex), or null. */ + readonly creditNote: string | null; + /** Daily-credit details + editable settings (Hyper), or null. */ + readonly daily: DailyCreditsView | null; +} + +export interface DailyCreditsView { + /** "100 credits left" (+ " (incl. bundles)" when the balance exceeds the allowance). */ + readonly balanceLabel: string; + readonly allowance: number; + /** The reset anchor as a local `HH:MM` (for a time input), or "" when unknown. */ + readonly resetTime: string; + /** "set manually" / "detected from a refill (approx.)" / "unknown — set it to enable the pace marker". */ + readonly resetSourceLabel: string; + readonly resetSource: "manual" | "detected" | null; +} + +/** Local `HH:MM` of an epoch-ms instant (time-of-day only; resets recur daily). */ +export function toLocalTimeOfDay(epochMs: number): string { + const d = new Date(epochMs); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +/** + * Parse a local `HH:MM` into a reset anchor: the most recent occurrence of that + * local time at or before `now` (any occurrence works — resets recur daily). + * Null for malformed input. + */ +export function anchorFromTimeOfDay(hhmm: string, now: number): number | null { + const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim()); + if (!m) return null; + const h = Number(m[1]); + const min = Number(m[2]); + if (h > 23 || min > 59) return null; + const d = new Date(now); + d.setHours(h, min, 0, 0); + if (d.getTime() > now) d.setDate(d.getDate() - 1); + return d.getTime(); +} + +/** Parse a positive-integer allowance input; null when invalid. */ +export function parseAllowanceInput(raw: string): number | null { + const t = raw.trim(); + if (!/^\d+$/.test(t)) return null; + const n = Number(t); + return n > 0 && n <= 1_000_000_000 ? n : null; +} + +function viewDailyCredits(d: AccountUsageDailyCredits): DailyCreditsView { + return { + balanceLabel: `${formatCredits(d.balance)} credits left${d.balance > d.allowance ? " (incl. bundles)" : ""}`, + allowance: d.allowance, + resetTime: d.resetAnchor === null ? "" : toLocalTimeOfDay(d.resetAnchor), + resetSourceLabel: + d.resetSource === "manual" + ? "set manually" + : d.resetSource === "detected" + ? "detected from a refill (approx.)" + : "unknown — set it to enable the pace marker", + resetSource: d.resetSource, + }; +} + +function stateMessage(r: AccountUsageReport): string | null { + switch (r.state) { + case "fresh": + return r.limits.length === 0 ? "No quota windows reported." : null; + case "stale": + return `Showing last known usage — refresh failed${r.error ? ` (${r.error})` : ""}.`; + case "unavailable": + return `Usage unavailable${r.error ? `: ${r.error}` : "."}`; + case "unsupported": + return r.error ?? "This credential type can't report subscription usage."; + case "not-configured": + if (r.provider === "hyper") return "No Hyper API key configured (HYPER_API_KEY)."; + return r.provider === "claude" + ? "Not signed in to Claude (no OAuth credentials)." + : "Not signed in to Codex (no ChatGPT OAuth credentials)."; + } +} + +function capitalize(s: string): string { + return s.length === 0 ? s : s[0]?.toUpperCase() + s.slice(1); +} + +export function viewProviderCard(r: AccountUsageReport, now: number): ProviderCardView { + const parts = [ + r.account?.planType ? capitalize(r.account.planType) : null, + r.account?.email ?? null, + ].filter((p): p is string => p !== null && p !== ""); + let creditNote: string | null = null; + if (r.creditOverage) { + creditNote = "Plan allowance used — requests are being funded by credits."; + } else if (r.limitReached === true && r.canServe === false) { + creditNote = "Plan limit reached — requests are blocked until reset."; + } + return { + provider: r.provider, + label: r.label, + subtitle: parts.length > 0 ? parts.join(" · ") : null, + state: r.state, + stateBadge: r.state === "fresh" ? null : r.state.replace("-", " "), + message: stateMessage(r), + bars: r.limits.map((l) => viewUsageBar(l, now)), + ageLabel: r.state === "fresh" || r.state === "stale" ? formatAge(r.fetchedAt, now) : null, + creditNote, + daily: r.dailyCredits ? viewDailyCredits(r.dailyCredits) : null, + }; +} + +export function viewProviderCards( + usage: AccountUsageResponse, + now: number, +): readonly ProviderCardView[] { + return usage.providers.map((p) => viewProviderCard(p, now)); +} diff --git a/src/features/account-usage/ui/AccountUsageView.svelte b/src/features/account-usage/ui/AccountUsageView.svelte new file mode 100644 index 0000000..2a7a589 --- /dev/null +++ b/src/features/account-usage/ui/AccountUsageView.svelte @@ -0,0 +1,155 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { + AccountUsageReport, + AccountUsageResponse, + LoadAccountUsage, + SaveAccountUsageSettings, + } from "../logic/types"; + import { viewProviderCards } from "../logic/view-model"; + import DailyCreditsSettings from "./DailyCreditsSettings.svelte"; + import UsageBar from "./UsageBar.svelte"; + + let { + load, + saveSettings, + now = () => Date.now(), + pollMs = 60_000, + }: { + load: LoadAccountUsage; + /** Save per-provider settings (Hyper allowance + reset time). */ + saveSettings: SaveAccountUsageSettings; + /** Injected clock (tests). */ + now?: () => number; + /** How often to re-read `GET /usage` (served from the backend cache). */ + pollMs?: number; + } = $props(); + + let usage = $state<AccountUsageResponse | null>(null); + let error = $state<string | null>(null); + let refreshing = $state(false); + let inFlight = false; + /** Ticks so the pace marker + "resets in" labels advance between polls. */ + let clock = $state(untrack(() => now())); + + const cards = $derived(usage ? viewProviderCards(usage, clock) : []); + + async function refresh(force: boolean): Promise<void> { + if (inFlight) return; + inFlight = true; + if (force) refreshing = true; + const result = await load(force ? { refresh: true } : undefined); + inFlight = false; + refreshing = false; + clock = now(); + if (result.ok) { + usage = result.usage; + error = null; + } else { + error = result.unavailable + ? "Account usage is unavailable (backend extension not loaded)." + : result.error; + } + } + + /** Splice a provider's refreshed report (from a settings save) into the view. */ + function replaceReport(report: AccountUsageReport): void { + clock = now(); + if (!usage) return; + usage = { + ...usage, + providers: usage.providers.map((p) => (p.provider === report.provider ? report : p)), + }; + } + + $effect(() => { + const interval = untrack(() => pollMs); + untrack(() => void refresh(false)); + const poll = setInterval(() => void refresh(false), interval); + const tick = setInterval(() => { + clock = now(); + }, 30_000); + return () => { + clearInterval(poll); + clearInterval(tick); + }; + }); +</script> + +<div class="flex flex-col gap-3"> + <div class="flex items-center justify-between gap-2"> + <h3 class="text-xs font-semibold uppercase opacity-60">Subscription usage</h3> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => void refresh(true)} + disabled={refreshing} + aria-label="Refresh account usage" + > + {#if refreshing}<span class="loading loading-spinner loading-xs"></span>{/if} + Refresh + </button> + </div> + + {#if error} + <p class="text-xs text-error">{error}</p> + {/if} + + {#if usage === null && error === null} + <p class="text-xs opacity-60">Loading…</p> + {/if} + + {#each cards as card (card.provider)} + <section class="flex flex-col gap-2 rounded-box bg-base-200 p-2" aria-label="{card.label} usage"> + <div class="flex items-start justify-between gap-2"> + <div class="flex min-w-0 flex-col"> + <span class="text-sm font-semibold">{card.label}</span> + {#if card.subtitle} + <span class="truncate text-[11px] opacity-60">{card.subtitle}</span> + {/if} + </div> + <div class="flex shrink-0 flex-col items-end gap-0.5"> + {#if card.stateBadge} + <span + class="badge badge-xs" + class:badge-warning={card.state === "stale"} + class:badge-error={card.state === "unavailable"} + class:badge-ghost={card.state === "unsupported" || card.state === "not-configured"} + >{card.stateBadge}</span + > + {/if} + {#if card.ageLabel} + <span class="text-[11px] opacity-50">updated {card.ageLabel}</span> + {/if} + </div> + </div> + + {#if card.message} + <p class="text-xs opacity-70">{card.message}</p> + {/if} + {#if card.creditNote} + <p class="text-xs text-warning">{card.creditNote}</p> + {/if} + + {#each card.bars as bar (bar.id)} + <UsageBar {bar} /> + {/each} + + {#if card.daily} + <DailyCreditsSettings + daily={card.daily} + save={saveSettings} + onSaved={replaceReport} + {now} + /> + {/if} + </section> + {/each} + + {#if cards.some((c) => c.bars.some((b) => b.pacePercent !== null))} + <p class="flex items-center gap-1.5 text-[11px] opacity-50"> + <span class="inline-block size-2 rounded-full bg-base-content"></span> + on-track pace — usage that would exactly use the allowance by its reset + </p> + {/if} +</div> diff --git a/src/features/account-usage/ui/AccountUsageView.test.ts b/src/features/account-usage/ui/AccountUsageView.test.ts new file mode 100644 index 0000000..ec1e51b --- /dev/null +++ b/src/features/account-usage/ui/AccountUsageView.test.ts @@ -0,0 +1,184 @@ +import type { AccountUsageResponse } 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 { + AccountUsageResult, + AccountUsageSettingsResult, + SaveAccountUsageSettings, +} from "../logic/types"; +import AccountUsageView from "./AccountUsageView.svelte"; + +const NOW = 1_760_000_000_000; + +const noSave: SaveAccountUsageSettings = async () => ({ ok: false, error: "unused" }); +const DAY = 86_400_000; + +const usage: AccountUsageResponse = { + servedAt: NOW, + providers: [ + { + provider: "claude", + label: "Claude", + state: "fresh", + account: { accountId: "a", email: "[email protected]", planType: "max" }, + limits: [ + { + id: "claude:7d", + label: "7 Day", + kind: "window", + windowId: "7d", + shared: true, + tier: null, + durationMs: 28 * DAY, + resetsAt: NOW + 14 * DAY, + unit: "percent", + used: 30, + limit: 100, + remaining: 70, + usedFraction: 0.3, + remainingFraction: 0.7, + status: "ok", + }, + ], + limitReached: null, + canServe: null, + creditOverage: false, + credits: null, + dailyCredits: null, + fetchedAt: NOW, + lastAttemptAt: NOW, + nextRefreshAt: NOW + 300_000, + error: null, + }, + { + provider: "codex", + label: "Codex", + state: "not-configured", + account: null, + limits: [], + limitReached: null, + canServe: null, + creditOverage: false, + credits: null, + dailyCredits: null, + fetchedAt: null, + lastAttemptAt: NOW, + nextRefreshAt: NOW + 30_000, + error: "No Codex OAuth credentials", + }, + ], +}; + +describe("AccountUsageView", () => { + it("renders a daisyUI progress bar with a pace marker at the elapsed position", async () => { + const calls: Array<{ refresh?: boolean } | undefined> = []; + const load = async (opts?: { refresh?: boolean }): Promise<AccountUsageResult> => { + calls.push(opts); + return { ok: true, usage }; + }; + render(AccountUsageView, { load, saveSettings: noSave, now: () => NOW }); + const bar = await screen.findByLabelText("7 Day usage"); + expect(bar.tagName).toBe("PROGRESS"); + expect(bar.classList.contains("progress")).toBe(true); + expect(bar.getAttribute("value")).toBe("30"); + const marker = screen.getByTestId("pace-marker"); + expect(marker.getAttribute("style")).toContain("left: 50%"); + expect(screen.getByText("20% under pace")).toBeTruthy(); + expect(screen.getByText(/Not signed in to Codex/)).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: "Refresh account usage" })); + expect(calls.at(-1)).toEqual({ refresh: true }); + }); + + it("shows the unavailable message on 503", async () => { + const load = async (): Promise<AccountUsageResult> => ({ + ok: false, + error: "x", + unavailable: true, + }); + render(AccountUsageView, { load, saveSettings: noSave, now: () => NOW }); + expect(await screen.findByText(/backend extension not loaded/)).toBeTruthy(); + }); + + it("edits the Hyper allowance + reset time and splices the saved report", async () => { + const hyper = { + provider: "hyper" as const, + label: "Hyper", + state: "fresh" as const, + account: { accountId: null, email: null, planType: null }, + limits: [ + { + id: "hyper:daily", + label: "Daily credits", + kind: "window" as const, + windowId: "1d", + shared: true, + tier: null, + durationMs: DAY, + resetsAt: null, + unit: "credits" as const, + used: 150, + limit: 250, + remaining: 100, + usedFraction: 0.6, + remainingFraction: 0.4, + status: "ok" as const, + }, + ], + limitReached: null, + canServe: true, + creditOverage: false, + credits: null, + dailyCredits: { balance: 100, allowance: 250, resetAnchor: null, resetSource: null }, + fetchedAt: NOW, + lastAttemptAt: NOW, + nextRefreshAt: NOW + 300_000, + error: null, + }; + const load = async (): Promise<AccountUsageResult> => ({ + ok: true, + usage: { servedAt: NOW, providers: [hyper] }, + }); + const saved: Array<Parameters<SaveAccountUsageSettings>[1]> = []; + const saveSettings: SaveAccountUsageSettings = async (_p, settings) => { + saved.push(settings); + const r: AccountUsageSettingsResult = { + ok: true, + report: { + ...hyper, + limits: [ + { + ...(hyper.limits[0] as (typeof hyper.limits)[0]), + limit: 300, + resetsAt: NOW + DAY / 2, + }, + ], + dailyCredits: { + balance: 100, + allowance: 300, + resetAnchor: NOW - DAY / 2, + resetSource: "manual", + }, + }, + }; + return r; + }; + render(AccountUsageView, { load, saveSettings, now: () => NOW }); + expect(await screen.findByText("150 / 250")).toBeTruthy(); + expect(screen.queryByTestId("pace-marker")).toBeNull(); + + await userEvent.click(screen.getByRole("button", { name: "Edit Hyper credit settings" })); + const allowance = screen.getByLabelText("Daily credit allowance"); + await userEvent.clear(allowance); + await userEvent.type(allowance, "300"); + const time = screen.getByLabelText("Daily credit reset time") as HTMLInputElement; + await userEvent.type(time, "09:00"); + await userEvent.click(screen.getByRole("button", { name: "Save Hyper credit settings" })); + + expect(saved[0]?.dailyAllowance).toBe(300); + expect(typeof saved[0]?.resetAnchor).toBe("number"); + expect(await screen.findByText(/set manually/)).toBeTruthy(); + expect(screen.getByTestId("pace-marker")).toBeTruthy(); + }); +}); diff --git a/src/features/account-usage/ui/DailyCreditsSettings.svelte b/src/features/account-usage/ui/DailyCreditsSettings.svelte new file mode 100644 index 0000000..7ed710a --- /dev/null +++ b/src/features/account-usage/ui/DailyCreditsSettings.svelte @@ -0,0 +1,126 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { AccountUsageReport, SaveAccountUsageSettings } from "../logic/types"; + import { + anchorFromTimeOfDay, + type DailyCreditsView, + parseAllowanceInput, + } from "../logic/view-model"; + + let { + daily, + save, + onSaved, + now = () => Date.now(), + }: { + daily: DailyCreditsView; + save: SaveAccountUsageSettings; + /** Receives the refreshed report after a successful save. */ + onSaved: (report: AccountUsageReport) => void; + now?: () => number; + } = $props(); + + let editing = $state(false); + let allowanceInput = $state(""); + let timeInput = $state(""); + let saving = $state(false); + let error = $state<string | null>(null); + + const parsedAllowance = $derived(parseAllowanceInput(allowanceInput)); + const timeValid = $derived(timeInput === "" || anchorFromTimeOfDay(timeInput, now()) !== null); + + function startEdit(): void { + untrack(() => { + allowanceInput = String(daily.allowance); + timeInput = daily.resetTime; + }); + error = null; + editing = true; + } + + async function submit(): Promise<void> { + if (parsedAllowance === null || !timeValid) return; + saving = true; + error = null; + // Empty time clears a MANUAL anchor (a detected one may still apply). + const resetAnchor = timeInput === "" ? null : anchorFromTimeOfDay(timeInput, now()); + const result = await save("hyper", { dailyAllowance: parsedAllowance, resetAnchor }); + saving = false; + if (result.ok) { + editing = false; + onSaved(result.report); + } else { + error = result.error; + } + } +</script> + +<div class="flex flex-col gap-1 text-[11px]"> + <div class="flex items-center justify-between gap-2"> + <span class="opacity-70">{daily.balanceLabel}</span> + {#if !editing} + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={startEdit} + aria-label="Edit Hyper credit settings">Edit</button + > + {/if} + </div> + {#if !editing} + <span class="opacity-60"> + Daily allowance {daily.allowance} · resets {daily.resetTime || "—"} ({daily.resetSourceLabel}) + </span> + {:else} + <form + class="flex flex-col gap-1" + onsubmit={(e) => { + e.preventDefault(); + void submit(); + }} + > + <label class="flex items-center justify-between gap-2"> + <span>Daily allowance</span> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-20 font-mono" + class:input-error={parsedAllowance === null} + aria-label="Daily credit allowance" + bind:value={allowanceInput} + disabled={saving} + /> + </label> + <label class="flex items-center justify-between gap-2"> + <span>Reset time (local)</span> + <input + type="time" + class="input input-bordered input-xs w-24 font-mono" + class:input-error={!timeValid} + aria-label="Daily credit reset time" + bind:value={timeInput} + disabled={saving} + /> + </label> + <span class="opacity-50">Leave the time empty to use the auto-detected refill time.</span> + {#if error}<span class="text-error">{error}</span>{/if} + <div class="flex justify-end gap-1"> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => (editing = false)} + disabled={saving}>Cancel</button + > + <button + type="submit" + class="btn btn-primary btn-xs" + disabled={saving || parsedAllowance === null || !timeValid} + aria-label="Save Hyper credit settings" + > + {#if saving}<span class="loading loading-spinner loading-xs"></span>{/if} + Save + </button> + </div> + </form> + {/if} +</div> diff --git a/src/features/account-usage/ui/UsageBar.svelte b/src/features/account-usage/ui/UsageBar.svelte new file mode 100644 index 0000000..f4298c1 --- /dev/null +++ b/src/features/account-usage/ui/UsageBar.svelte @@ -0,0 +1,49 @@ +<script lang="ts"> + import type { UsageBarView } from "../logic/view-model"; + + let { bar }: { bar: UsageBarView } = $props(); + + const progressClass = $derived( + { + success: "progress-success", + warning: "progress-warning", + error: "progress-error", + neutral: "", + }[bar.tone], + ); +</script> + +<div class="flex flex-col gap-0.5" data-testid="usage-bar" data-limit-id={bar.id}> + <div class="flex items-baseline justify-between gap-2 text-xs"> + <span class="truncate font-medium">{bar.label}</span> + <span class="shrink-0 font-mono tabular-nums">{bar.valueLabel}</span> + </div> + <div class="relative flex items-center"> + <progress + class="progress h-2 w-full {progressClass}" + value={bar.percent} + max="100" + aria-label="{bar.label} usage" + ></progress> + {#if bar.pacePercent !== null} + <!-- Pace marker: where usage would be if spread evenly to the reset. --> + <span + class="pointer-events-none absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-base-100 bg-base-content" + style="left: {bar.pacePercent}%" + data-testid="pace-marker" + title="On-track pace: {Math.round(bar.pacePercent)}%" + aria-hidden="true" + ></span> + {/if} + </div> + {#if bar.resetLabel || bar.paceLabel} + <div class="flex justify-between gap-2 text-[11px] opacity-60"> + <span>{bar.resetLabel ?? ""}</span> + {#if bar.paceLabel} + <span class:text-warning={bar.aheadOfPace} class:opacity-100={bar.aheadOfPace} + >{bar.paceLabel}</span + > + {/if} + </div> + {/if} +</div> diff --git a/src/features/chat/adapter/image-encode.test.ts b/src/features/chat/adapter/image-encode.test.ts new file mode 100644 index 0000000..dbaa8ae --- /dev/null +++ b/src/features/chat/adapter/image-encode.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { convertToJpeg } from "./image-encode"; + +describe("convertToJpeg (canvas edge — real environment, no mocks)", () => { + it("skips non-convertible formats without touching the canvas", async () => { + const jpeg = new File([new Uint8Array([0xff, 0xd8])], "photo.jpg", { type: "image/jpeg" }); + await expect(convertToJpeg(jpeg)).resolves.toBe(null); + const gif = new File([new Uint8Array([0x47])], "anim.gif", { type: "image/gif" }); + await expect(convertToJpeg(gif)).resolves.toBe(null); + const unnamed = new File([new Uint8Array([0x00])], "blob", { type: "" }); + await expect(convertToJpeg(unnamed)).resolves.toBe(null); + }); + + it("falls back to null on decode failure (no real canvas in this environment)", async () => { + const png = new File([new Uint8Array([0x89, 0x50])], "shot.png", { type: "image/png" }); + // jsdom has no `createImageBitmap` → the decode throws → the failure path + // must resolve to `null` (never reject), so sending is never blocked. + await expect(convertToJpeg(png)).resolves.toBe(null); + }); +}); diff --git a/src/features/chat/adapter/image-encode.ts b/src/features/chat/adapter/image-encode.ts new file mode 100644 index 0000000..4570a5a --- /dev/null +++ b/src/features/chat/adapter/image-encode.ts @@ -0,0 +1,67 @@ +import { + computeResizedDimensions, + JPEG_QUALITY, + shouldConvertImage, + useConvertedImage, +} from "../logic/image-compress"; + +/** A successfully converted image: a JPEG blob at `JPEG_QUALITY`. */ +export interface ConvertedJpeg { + readonly blob: Blob; + readonly mimeType: "image/jpeg"; +} + +/** + * The injected browser edge for attach-time compression: a `File` in, a + * (maybe) smaller JPEG out. Returns `null` when the source is not converted + * (non-convertible format) or the conversion is skipped/failed (decode or + * encode error, smaller-wins rule) — the caller then sends the original + * bytes. Compression must never block or break sending, so every failure is + * swallowed into `null`. + */ +export type ImageJpegEncoder = (file: File) => Promise<ConvertedJpeg | null>; + +/** + * Canvas-based JPEG re-encode: decode the source, rescale to cap the longest + * edge at `MAX_IMAGE_EDGE_PX` (never upscale, high-quality smoothing), flatten + * any transparency onto a white background (JPEG has no alpha), and encode at + * `JPEG_QUALITY`. Applied only to convertible (lossless) sources; the result + * is used only under the smaller-wins rule. + * + * Thin by design — every decision comes from `../logic/image-compress`. + */ +export const convertToJpeg: ImageJpegEncoder = async (file: File) => { + try { + if (!shouldConvertImage(file.type)) return null; + + const bitmap = await createImageBitmap(file); + const target = computeResizedDimensions(bitmap.width, bitmap.height); + + const canvas = document.createElement("canvas"); + canvas.width = target.width; + canvas.height = target.height; + const ctx = canvas.getContext("2d"); + if (ctx === null) { + bitmap.close(); + return null; + } + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + // Flatten transparency: JPEG has no alpha channel. + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, target.width, target.height); + ctx.drawImage(bitmap, 0, 0, target.width, target.height); + bitmap.close(); + + const blob = await new Promise<Blob | null>((resolve) => { + canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY); + }); + if (blob === null) return null; + // Smaller-wins: keep the original when the re-encode didn't actually help. + if (!useConvertedImage(file.size, blob.size)) return null; + return { blob, mimeType: "image/jpeg" }; + } catch { + // Decode/encode failure → the original image is sent untouched. + return null; + } +}; diff --git a/src/features/chat/adapter/pending-inputs.ts b/src/features/chat/adapter/pending-inputs.ts new file mode 100644 index 0000000..5798fdb --- /dev/null +++ b/src/features/chat/adapter/pending-inputs.ts @@ -0,0 +1,49 @@ +import type { PendingInput, PendingInputStorage } from "../pending-inputs"; + +export function createPendingInputStorage( + storage: Storage, + conversationId: string, +): PendingInputStorage { + const prefix = `dispatch.pending-inputs:${conversationId}:`; + let known = new Map<string, string>(); + return { + load() { + const inputs: PendingInput[] = []; + const loaded = new Map<string, string>(); + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key === null || !key.startsWith(prefix)) continue; + const raw = storage.getItem(key); + if (raw === null) continue; + const input = JSON.parse(raw); + if ( + (input?.request?.type !== "chat.send" && input?.request?.type !== "chat.queue") || + input.request.conversationId !== conversationId || + typeof input.request.requestId !== "string" || + key !== prefix + input.request.requestId || + (input.request.type === "chat.send" + ? typeof input.request.message !== "string" + : typeof input.request.text !== "string") || + (input.rejection !== undefined && typeof input.rejection !== "string") + ) + throw new Error("Invalid saved input"); + inputs.push(input); + loaded.set(input.request.requestId, raw); + } + known = loaded; + return inputs; + }, + save(inputs) { + // Per-request keys prevent another browser tab's send/receipt from overwriting ours. + // Quota/security failures reach the composer BEFORE it clears its staged input. + const next = new Map(inputs.map((input) => [input.request.requestId, JSON.stringify(input)])); + for (const [id, raw] of next) { + if (known.get(id) !== raw) storage.setItem(prefix + id, raw); + } + for (const id of known.keys()) { + if (!next.has(id)) storage.removeItem(prefix + id); + } + known = next; + }, + }; +} diff --git a/src/features/chat/delivery.test.ts b/src/features/chat/delivery.test.ts new file mode 100644 index 0000000..99ad2d4 --- /dev/null +++ b/src/features/chat/delivery.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPendingInputStorage } from "./adapter/pending-inputs"; +import { createChatStore } from "./store.svelte"; +import { + createFakeCache, + createFakeHistorySync, + createFakeMetricsSync, + createFakeTransport, +} from "./test-helpers"; + +function storage(): Storage { + const values = new Map<string, string>(); + return { + get length() { + return values.size; + }, + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + values.set(key, value); + }, + removeItem: (key) => { + values.delete(key); + }, + clear: () => values.clear(), + key: (index) => [...values.keys()][index] ?? null, + }; +} + +function fixture(saved = storage(), metricsSync = createFakeMetricsSync().impl) { + const transport = createFakeTransport(); + let id = 0; + const store = createChatStore({ + conversationId: "c", + createRequestId: () => `request-${++id}`, + pendingInputStorage: createPendingInputStorage(saved, "c"), + transport: transport.impl, + historySync: createFakeHistorySync().impl, + metricsSync, + cache: createFakeCache().impl, + }); + return { store, transport, saved }; +} + +const images = [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }]; + +describe("input delivery", () => { + it.each([ + "send", + "queueMessage", + ] as const)("saves %s input through refresh without automatically retransmitting", (method) => { + const f = fixture(); + f.store[method]("keep me", images); + expect(f.store.pendingInputs).toHaveLength(1); + f.store.dispose(); + const reloaded = fixture(f.saved); + expect(reloaded.store.pendingInputs).toMatchObject([{ request: { images } }]); + expect(JSON.stringify(reloaded.store.pendingInputs)).toContain("keep me"); + expect(reloaded.transport.sent).toEqual([]); + expect(reloaded.transport.sentQueue).toEqual([]); + reloaded.store.dispose(); + }); + + it("streamed user echoes and wrong receipts cannot discard an unconfirmed send", () => { + const f = fixture(); + f.store.send("mine", images); + f.store.handleDelta({ + type: "chat.delta", + event: { type: "user-message", conversationId: "c", turnId: "t", text: "mine" }, + }); + f.store.handleDelta({ type: "chat.accepted", conversationId: "other", requestId: "request-1" }); + f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "other" }); + expect(f.store.pendingInputs).toHaveLength(1); + f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "request-1" }); + expect(f.store.pendingInputs).toEqual([]); + expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]); + f.store.dispose(); + }); + + it("retains explicitly rejected input and images for recovery until discarded", () => { + const f = fixture(); + f.store.send("mine", images); + f.store.handleDelta({ + type: "chat.error", + conversationId: "c", + requestId: "request-1", + message: "disk full", + }); + const reloaded = fixture(f.saved); + expect(reloaded.store.pendingInputs).toMatchObject([ + { rejection: "disk full", request: { message: "mine", images } }, + ]); + reloaded.store.discardPendingInput("request-1"); + expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]); + f.store.dispose(); + reloaded.store.dispose(); + }); + + it.each([ + "send", + "queueMessage", + ] as const)("does not clear or transmit %s input if saving fails", (method) => { + const saved = storage(); + saved.setItem = () => { + throw new Error("quota exceeded"); + }; + const f = fixture(saved); + expect(() => f.store[method]("mine", images)).toThrow("quota exceeded"); + expect(f.store.pendingInputs).toEqual([]); + expect(f.store.chunks).toEqual([]); + expect(f.transport.sent).toEqual([]); + expect(f.transport.sentQueue).toEqual([]); + f.store.dispose(); + }); + + it("coalesces a reconnect burst and permits one metrics catch-up after completion", async () => { + let resolve!: (value: { turns: [] }) => void; + const first = { + promise: new Promise<{ turns: [] }>((done) => { + resolve = done; + }), + resolve: (value: { turns: [] }) => resolve(value), + }; + const metricsSync = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue({ turns: [] }); + const f = fixture(storage(), metricsSync); + for (let i = 0; i < 100; i++) f.store.resync(); + expect(metricsSync).toHaveBeenCalledTimes(1); + first.resolve({ turns: [] }); + await vi.waitFor(() => expect(metricsSync).toHaveBeenCalledTimes(2)); + f.store.dispose(); + }); +}); + +it("concurrent browser tabs cannot overwrite each other's saved messages or receipts", () => { + const saved = storage(); + const first = createPendingInputStorage(saved, "c"); + const second = createPendingInputStorage(saved, "c"); + first.load(); + second.load(); + const input = (requestId: string) => ({ + request: { type: "chat.send" as const, conversationId: "c", requestId, message: requestId }, + }); + first.save([input("first")]); + second.save([input("second")]); + first.save([]); + expect(createPendingInputStorage(saved, "c").load()).toEqual([input("second")]); +}); diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index cf57cea..41cb06f 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -2,23 +2,50 @@ export type { ProviderRetryView, RenderedChunk, RenderGroup, - ToolBatchEntry, + ToolEntry, } from "../../core/chunks"; export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; -export { isVisionModel } from "./model-select"; +export { createPendingInputStorage } from "./adapter/pending-inputs"; +export type { NamedAgentLike, NamedAgentSnapshotLike } from "./model-select"; +export { + CLAUDE_SUBSCRIPTION_KEY, + isClaudeSubscriptionModel, + isNamedAgentSelection, + isNumberedOpenCode, + isVisionModel, + migrateLegacyModelName, + modelKeyLabel, + NAMED_AGENTS_PROVIDER, + namedAgentNames, + namedAgentSelectionLabel, + namedAgentsProviderLabel, + openCodeNumber, + providerKeysWithNamedAgents, +} from "./model-select"; +export type { PendingInput, PendingInputStorage } from "./pending-inputs"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; export type { EffortOption, ReasoningEffortSaveResult, SaveReasoningEffort, + SaveThinkingSelection, + SelectionOption, + SetThinkingRequest, + ThinkingResponse, + ThinkingSaveResult, + ThinkingSelection, + ThinkingSelectionSaveResult, } from "./reasoning-effort"; export { DEFAULT_REASONING_EFFORT, effectiveEffort, + effectiveSelection, effortOptions, isReasoningEffort, + isThinkingSelection, REASONING_EFFORT_LEVELS, + selectionOptions, } from "./reasoning-effort"; export type { ChatStore, ChatStoreDependencies } from "./store.svelte"; export { createChatStore } from "./store.svelte"; @@ -28,6 +55,7 @@ export { default as CompactionView } from "./ui/CompactionView.svelte"; export type { ComposerStatus } from "./ui/Composer.svelte"; export { default as Composer } from "./ui/Composer.svelte"; export { default as ModelSelector } from "./ui/ModelSelector.svelte"; +export { default as PendingInputs } from "./ui/PendingInputs.svelte"; export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ diff --git a/src/features/chat/logic/composer.test.ts b/src/features/chat/logic/composer.test.ts new file mode 100644 index 0000000..86e9dc4 --- /dev/null +++ b/src/features/chat/logic/composer.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { stopButtonLabels } from "./composer"; + +describe("stopButtonLabels", () => { + it.each([undefined, 0])("preserves plain Stop with queued count %s", (count) => { + expect(stopButtonLabels(count)).toEqual({ + text: "Stop", + ariaLabel: "Stop generation", + title: undefined, + }); + }); + + it.each([1, 2])("labels the queued Stop action Flush with queued count %s", (count) => { + expect(stopButtonLabels(count)).toEqual({ + text: "Flush", + ariaLabel: "Flush", + title: "Stop the current response and start the queued message now", + }); + }); +}); diff --git a/src/features/chat/logic/composer.ts b/src/features/chat/logic/composer.ts new file mode 100644 index 0000000..e56a007 --- /dev/null +++ b/src/features/chat/logic/composer.ts @@ -0,0 +1,11 @@ +/** Stop still uses the same action; the backend carries queued input into a new turn. */ +export function stopButtonLabels(queuedMessageCount: number | undefined) { + if (queuedMessageCount !== undefined && queuedMessageCount >= 1) { + return { + text: "Flush", + ariaLabel: "Flush", + title: "Stop the current response and start the queued message now", + }; + } + return { text: "Stop", ariaLabel: "Stop generation", title: undefined }; +} diff --git a/src/features/chat/logic/image-compress.test.ts b/src/features/chat/logic/image-compress.test.ts new file mode 100644 index 0000000..88726bf --- /dev/null +++ b/src/features/chat/logic/image-compress.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + computeResizedDimensions, + MAX_IMAGE_EDGE_PX, + shouldConvertImage, + useConvertedImage, +} from "./image-compress"; + +describe("shouldConvertImage", () => { + it("converts lossless sources (PNG, BMP)", () => { + expect(shouldConvertImage("image/png")).toBe(true); + expect(shouldConvertImage("image/bmp")).toBe(true); + }); + + it("skips already-lossy and animated formats", () => { + expect(shouldConvertImage("image/jpeg")).toBe(false); + expect(shouldConvertImage("image/webp")).toBe(false); + expect(shouldConvertImage("image/gif")).toBe(false); + }); + + it("leaves unknown or absent MIME types alone", () => { + expect(shouldConvertImage(undefined)).toBe(false); + expect(shouldConvertImage("")).toBe(false); + expect(shouldConvertImage("image/tiff")).toBe(false); + expect(shouldConvertImage("image/svg+xml")).toBe(false); + }); +}); + +describe("computeResizedDimensions", () => { + it("never upscales a source already within the cap", () => { + expect(computeResizedDimensions(100, 50)).toEqual({ width: 100, height: 50 }); + expect(computeResizedDimensions(2048, 1024)).toEqual({ width: 2048, height: 1024 }); + expect(computeResizedDimensions(640, 640)).toEqual({ width: 640, height: 640 }); + }); + + it("caps the longest edge at 2048", () => { + expect(computeResizedDimensions(4096, 2048)).toEqual({ width: 2048, height: 1024 }); + expect(computeResizedDimensions(8192, 4096)).toEqual({ width: 2048, height: 1024 }); + }); + + it("caps the longest edge regardless of orientation (portrait)", () => { + expect(computeResizedDimensions(2000, 3000)).toEqual({ width: 1365, height: 2048 }); + }); + + it("preserves aspect ratio on the rescale", () => { + const { width, height } = computeResizedDimensions(3000, 2000); + expect(width).toBe(2048); + // 2000 * (2048/3000) = 1365.33… → 1365 + expect(height).toBe(1365); + expect(width / height).toBeCloseTo(3000 / 2000, 2); + }); + + it("handles extreme aspect ratios without exceeding the cap", () => { + expect(computeResizedDimensions(5000, 10)).toEqual({ width: 2048, height: 4 }); + expect(computeResizedDimensions(10, 5000)).toEqual({ width: 4, height: 2048 }); + }); + + it("honors a custom cap", () => { + expect(computeResizedDimensions(1000, 500, 250)).toEqual({ width: 250, height: 125 }); + }); + + it("uses the default 2048px cap constant", () => { + expect(MAX_IMAGE_EDGE_PX).toBe(2048); + }); +}); + +describe("useConvertedImage", () => { + it("uses the conversion only when it is strictly fewer bytes", () => { + expect(useConvertedImage(3_000_000, 300_000)).toBe(true); + }); + + it("keeps the original when equal or larger", () => { + expect(useConvertedImage(300_000, 300_000)).toBe(false); + expect(useConvertedImage(100_000, 300_000)).toBe(false); + }); +}); diff --git a/src/features/chat/logic/image-compress.ts b/src/features/chat/logic/image-compress.ts new file mode 100644 index 0000000..a9037b8 --- /dev/null +++ b/src/features/chat/logic/image-compress.ts @@ -0,0 +1,77 @@ +/** + * Pure decision logic for attach-time image compression (the `Compress` + * toggle on the composer's staged-image thumbnails). + * + * The pipeline (re-encode to JPEG + rescale) runs at the browser edge + * (`../adapter/image-encode`); this module owns every DECISION so it stays + * pure `input → output` and unit-testable without a DOM: + * + * - `shouldConvertImage` — which source formats get converted. + * - `computeResizedDimensions` — the rescale math (cap the longest edge, + * never upscale, preserve aspect). + * - `useConvertedImage` — the smaller-wins rule. + */ + +/** JPEG encode quality for the canvas re-encode. */ +export const JPEG_QUALITY = 0.85; + +/** Longest-edge cap (px) for the rescale. Never upscale past the source. */ +export const MAX_IMAGE_EDGE_PX = 2048; + +/** + * Source formats that get converted. Only LOSSLESS sources are re-encoded: + * PNG (the screenshot case — multi-MB PNGs are the reason this exists) plus + * BMP (trivially canvas-decodable in every browser). Already-lossy formats + * (JPEG/WebP) and animated ones (GIF) are sent verbatim — recompressing them + * degrades quality and saves nothing, and re-encoding a GIF destroys its + * animation. TIFF is NOT listed: browsers can't natively decode it, so the + * canvas path would always fall back to the original anyway. An oversized + * JPEG stays untouched (the backend's body-budget safety net handles the + * extreme case). + */ +const CONVERTIBLE_MIME_TYPES = new Set(["image/png", "image/bmp"]); + +/** + * Whether a source MIME type is converted to JPEG at staging time. + * Unknown/absent MIME types are left alone (conservative — never gamble on + * bytes we can't classify). + */ +export function shouldConvertImage(mimeType: string | undefined): boolean { + return mimeType !== undefined && CONVERTIBLE_MIME_TYPES.has(mimeType); +} + +/** Pixel dimensions of a raster image. */ +export interface Dimensions { + readonly width: number; + readonly height: number; +} + +/** + * Target dimensions after the rescale: cap the LONGEST edge at `maxEdge` + * (default `MAX_IMAGE_EDGE_PX`), never upscale (a smaller source passes + * through unchanged), and preserve aspect ratio. + * + * Pure math — the canvas draw itself lives at the browser edge. + */ +export function computeResizedDimensions( + width: number, + height: number, + maxEdge: number = MAX_IMAGE_EDGE_PX, +): Dimensions { + const longest = Math.max(width, height); + if (longest <= maxEdge) return { width, height }; + const scale = maxEdge / longest; + // Rounding can never exceed the cap: each scaled edge is ≤ maxEdge before + // rounding, and the only values that round up land exactly ON the cap. + return { width: Math.round(width * scale), height: Math.round(height * scale) }; +} + +/** + * The smaller-wins rule: the converted JPEG is used only when it is actually + * FEWER bytes than the original (equal or larger → keep the original). + * Compared on raw byte counts (File/Blob `size`); base64 inflation affects + * both sides equally so the ratio is preserved. + */ +export function useConvertedImage(originalBytes: number, convertedBytes: number): boolean { + return convertedBytes < originalBytes; +} diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts new file mode 100644 index 0000000..c0caa0d --- /dev/null +++ b/src/features/chat/logic/tool-summary.test.ts @@ -0,0 +1,456 @@ +import { describe, expect, it } from "vitest"; +import type { ToolEntry } from "../../../core/chunks"; +import { toolIcon, toolLabel, viewToolEntry } from "./tool-summary"; + +function entry( + name: string, + input: unknown, + content: string | null = "ok", + isError = false, +): ToolEntry { + return { + toolCallId: "a", + toolName: name, + call: { type: "tool-call", toolCallId: "a", toolName: name, input }, + result: + content === null + ? null + : { type: "tool-result", toolCallId: "a", toolName: name, content, isError }, + }; +} + +const view = (name: string, input: unknown, content: string | null = "ok", isError = false) => + viewToolEntry(entry(name, input, content, isError)); +const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`); + +describe("tool labels", () => { + it.each([ + ["run_shell", "Run command"], + ["read_file", "Read file"], + ["edit_file", "Edit file"], + ["write_file", "Write file"], + ["web_search", "Web search"], + ["lsp", "Code intelligence"], + ["todo_write", "Update to-dos"], + ["load_skill", "Load skill"], + ["consult_vision", "Ask vision model"], + ["read_image", "Read image"], + ["youtube_transcript", "YouTube transcript"], + ["summon_agent", "Summon agent"], + ["send_agent_message", "Message agent"], + ["read_agent", "Read agent reply"], + ["open_agent", "Open agent"], + ["cancel_agent_message", "Cancel agent message"], + ["list_agents", "List agents"], + ["list_agent_models", "List agent models"], + ])("built-in %s → %s", (name, label) => { + expect(toolLabel(name)).toBe(label); + expect(view(name, {}).label).toBe(label); + expect(view(name, {}).name).toBe(name); + }); + it.each([ + ["github__create_issue", "Github: Create issue"], + ["mcp__github__create_issue", "Github: Create issue"], + ["playwright__browserTakeScreenshot", "Playwright: Browser take screenshot"], + ["scaffold_echo", "Scaffold echo"], + ["fetchUrl", "Fetch url"], + ["some-kebab-tool", "Some kebab tool"], + ["toString", "To string"], + ])("unknown %s is humanized → %s", (name, label) => { + expect(toolLabel(name)).toBe(label); + }); +}); + +describe("tool icons", () => { + const builtIns = [ + "run_shell", + "read_file", + "edit_file", + "write_file", + "web_search", + "lsp", + "todo_write", + "load_skill", + "consult_vision", + "read_image", + "youtube_transcript", + "summon_agent", + "send_agent_message", + "read_agent", + "open_agent", + "cancel_agent_message", + "list_agents", + "list_agent_models", + ]; + it("gives every built-in tool its own distinct icon", () => { + const icons = builtIns.map(toolIcon); + expect(icons).not.toContain("tool"); + expect(icons).not.toContain("mcp"); + expect(new Set(icons).size).toBe(builtIns.length); + expect(view("run_shell", {}).icon).toBe("terminal"); + }); + it.each([ + ["github__create_issue", "mcp"], + ["mcp__github__create_issue", "mcp"], + ["scaffold_echo", "tool"], + ["__proto__", "tool"], + ["constructor", "tool"], + ])("%s → %s", (name, icon) => { + expect(toolIcon(name)).toBe(icon); + }); +}); + +describe("tool summaries", () => { + it.each([ + ["run_shell", { command: "bun run test\n --run" }, "bun run test --run"], + ["read_file", { path: "src/a.ts", offset: 5, limit: 80 }, "src/a.ts:5-84"], + ["read_file", { path: "src/a.ts", limit: 12 }, "src/a.ts:1-12"], + ["read_file", { path: "src/a.ts", offset: 10 }, "src/a.ts:10"], + ["read_file", { path: "src/a.ts" }, "src/a.ts"], + ["edit_file", { path: "src/a.ts", oldString: "a", newString: "b" }, "src/a.ts"], + ["write_file", { path: "src/a.ts", content: "data" }, "src/a.ts"], + ["web_search", { query: "svelte 5 runes", url: "ignored" }, "svelte 5 runes"], + ["web_search", { url: "https://example.org", mode: "scrape" }, "https://example.org"], + [ + "lsp", + { operation: "references", path: "src/a.ts", line: 12, character: 3 }, + "references src/a.ts:12:3", + ], + ["lsp", { operation: "diagnostics", path: "src/a.ts" }, "diagnostics src/a.ts"], + [ + "todo_write", + { todos: [{ status: "pending" }, { status: "completed" }, null] }, + "3 tasks · 1 completed", + ], + ["load_skill", { name: "research_methodology" }, "research_methodology"], + [ + "consult_vision", + { question: "What is in this picture?", imageIds: [1] }, + "What is in this picture?", + ], + ["youtube_transcript", { url: "https://youtu.be/abc" }, "https://youtu.be/abc"], + ["summon_agent", { title: "Review", task: "long task", agent: "Opus" }, "Review"], + ["summon_agent", { task: "Review the patch" }, "Review the patch"], + ["summon_agent", { agent: "Opus" }, "Opus"], + ["send_agent_message", { conversationId: "abcd", message: "Continue" }, "abcd"], + ["read_agent", { conversationId: "abcd" }, "abcd"], + ["open_agent", { conversationId: "abcd" }, "abcd"], + ["cancel_agent_message", { conversationId: "abcd", messageId: "m1" }, "abcd"], + ["list_agents", { relationship: "descendants" }, "descendants"], + ["list_agents", { query: "review" }, "review"], + ["list_agent_models", { query: "Opus" }, "Opus"], + ])("%s derives a meaningful call summary from %j", (name, input, expected) => { + expect(view(name, input).callSummary).toBe(expected); + }); + + it("formats edit call badges and old/new details without hiding multiline text", () => { + const input = { path: "a.ts", oldString: "a\nb\n", newString: "x\ny\nz\n" }; + const card = view("edit_file", input); + expect(card.callBadges).toEqual([ + { label: "+3", tone: "success" }, + { label: "−2", tone: "error" }, + ]); + expect(card.callDetail).toEqual({ + kind: "edit", + path: "a.ts", + oldText: input.oldString, + newText: input.newString, + }); + expect(view("edit_file", { path: "a", oldString: "", newString: "x" }).callBadges).toEqual([ + { label: "+1", tone: "success" }, + { label: "−0", tone: "error" }, + ]); + expect(view("edit_file", { path: "a" }).callBadges).toEqual([]); + }); + it("omits edit diffstats after failure while retaining attempted edits in call details", () => { + const input = { path: "src/missing.ts", oldString: "foo", newString: "bar" }; + const card = view("edit_file", input, "Error: oldString not found", true); + expect(card.callBadges).toEqual([]); + expect(card.callDetail).toEqual({ + kind: "edit", + path: input.path, + oldText: "foo", + newText: "bar", + }); + expect(card.resultSummary).toBe("failed"); + }); + it("preserves exact shell commands in the expanded detail", () => { + expect(view("run_shell", { command: "ls\n pwd" }).callDetail).toEqual({ + kind: "command", + text: "ls\n pwd", + }); + }); + it("shows scalar fields and pretty nested JSON", () => { + expect(view("mcp__foo", { path: "a.ts", count: 2, nested: { a: true } }).callDetail).toEqual({ + kind: "fields", + fields: [ + { key: "path", value: "a.ts", multiline: false }, + { key: "count", value: "2", multiline: false }, + { key: "nested", value: '{\n "a": true\n}', multiline: true }, + ], + }); + }); + it("formats short scalar arrays inline, retaining quotes, escapes, nulls and empty arrays", () => { + expect( + view("mcp", { + labels: ["ui", "chat"], + scalars: [1, true, null], + empty: [], + escaped: ["a\nb"], + }).callDetail, + ).toEqual({ + kind: "fields", + fields: [ + { key: "labels", value: '["ui", "chat"]', multiline: false }, + { key: "scalars", value: "[1, true, null]", multiline: false }, + { key: "empty", value: "[]", multiline: false }, + { key: "escaped", value: '["a\\nb"]', multiline: false }, + ], + }); + }); + it("keeps long arrays and nested structures pretty-printed and complete", () => { + const input = { long: ["x".repeat(80)], nested: [{ a: 1 }], mixed: [1, { a: 1 }] }; + const card = view("mcp", input); + if (card.callDetail.kind !== "fields") throw new Error("expected fields"); + expect(card.callDetail.fields.map((field) => field.multiline)).toEqual([true, true, true]); + expect(card.callDetail.fields.map((field) => JSON.parse(field.value))).toEqual( + Object.values(input), + ); + }); + it.each([ + "command", + "path", + "file_path", + "query", + "url", + "pattern", + "name", + "question", + "title", + "task", + "message", + "conversationId", + ])("generic fallback recognizes %s", (key) => { + expect(view("mcp__foo", { [key]: "target" }).callSummary).toBe("target"); + }); + it("generic fallback prefers the first meaningful argument, else readable key: value pairs", () => { + expect(view("mcp__foo", { command: " ", path: "a", query: "b" }).callSummary).toBe("a"); + expect(view("mcp__foo", { a: 1, b: "x" }).callSummary).toBe("a: 1 · b: x"); + // Only nested values → no summary rather than a JSON dump. + expect(view("mcp__foo", { opts: { deep: true } }).callSummary).toBe(""); + }); + it.each([ + "not JSON", + "{bad", + 42, + true, + [1, "a"], + '"hi"', + ])("tolerates malformed/non-object input %j", (input) => { + expect(() => view("read_file", input)).not.toThrow(); + expect(view("read_file", input).callSummary).not.toBe(""); + expect(view("read_file", input).callDetail.kind).toBe("fields"); + }); + it.each([ + null, + undefined, + {}, + [], + "", + " ", + "null", + "{}", + ])("an argument-less call (%j) shows no summary and a 'no arguments' detail — never `{}`", (input) => { + const card = view("list_agent_models", input); + expect(card.callSummary).toBe(""); + expect(card.callDetail).toEqual({ kind: "none" }); + }); + it.each([ + "run_shell", + "read_file", + "edit_file", + "write_file", + "web_search", + "lsp", + "todo_write", + "load_skill", + "consult_vision", + "youtube_transcript", + "summon_agent", + "read_agent", + ])("%s falls back safely for missing arguments", (name) => { + for (const input of [null, undefined, {}]) { + expect(() => view(name, input)).not.toThrow(); + expect(view(name, input).callSummary).toBe(""); + expect(view(name, input).callDetail).toEqual({ kind: "none" }); + } + expect(view(name, "{bad").callSummary).toBe("{bad"); + }); + it("parses JSON-string inputs", () => { + const card = view("run_shell", '{"command":"bun run test"}'); + expect(card.callSummary).toBe("bun run test"); + expect(card.callDetail).toEqual({ kind: "command", text: "bun run test" }); + }); + it("handles unexpected built-in argument types and prototype-looking tool names", () => { + expect(view("todo_write", { todos: "wrong" }).callSummary).toBe("todos: wrong"); + expect(view("run_shell", { command: { nested: true } }).callDetail.kind).toBe("fields"); + expect(view("constructor", { path: "a" }).callSummary).toBe("a"); + expect(view("__proto__", {}).callSummary).toBe(""); + expect(view("__proto__", {}).label).toBe("Proto"); + expect( + view("mcp", JSON.parse('{"__proto__":{"command":"bad"},"path":"good"}')).callSummary, + ).toBe("good"); + }); + it("does not throw on unserializable input", () => { + const cyclic: Record<string, unknown> = {}; + cyclic.self = cyclic; + expect(view("mcp", cyclic).callSummary).toBe(""); + expect(view("mcp", cyclic).callDetail).toEqual({ + kind: "fields", + fields: [{ key: "self", value: "[unserializable input]", multiline: false }], + }); + expect(() => view("mcp", { integer: 1n })).not.toThrow(); + }); + it("rejects ancestor cycles but preserves shared non-cyclic tool values", () => { + const nested: Record<string, unknown> = { name: "nested" }; + const root = { nested }; + nested.parent = root; + expect(view("mcp", { payload: root }).callDetail).toEqual({ + kind: "fields", + fields: [{ key: "payload", value: "[unserializable input]", multiline: false }], + }); + const shared = { name: "shared" }; + const payload = { first: shared, second: shared, values: [shared, shared] }; + const detail = view("mcp", { payload }).callDetail; + expect(detail.kind).toBe("fields"); + if (detail.kind !== "fields") throw new Error("Expected fields"); + expect(detail.fields[0]?.value).toBe(JSON.stringify(payload, null, 2)); + }); + it("caps and whitespace-collapses header strings but retains full detail", () => { + const command = ` echo\n${"x".repeat(300)}`; + const card = view("run_shell", { command }); + expect(card.callSummary.length).toBe(120); + expect(card.callSummary.endsWith("…")).toBe(true); + expect(card.callSummary).not.toMatch(/\n/); + expect(card.callDetail).toEqual({ kind: "command", text: command }); + }); + it("represents pending entries with no response summary/output", () => { + expect(view("run_shell", { command: "sleep 1" }, null)).toMatchObject({ + status: "pending", + resultSummary: null, + output: null, + }); + }); + it("represents orphan results with unknown call details, not pending", () => { + const e = { ...entry("read_file", {}, "data"), call: null }; + expect(viewToolEntry(e)).toMatchObject({ + name: "read_file", + status: "ok", + callSummary: "Call in earlier history", + resultSummary: "1 line", + }); + expect(viewToolEntry(e).callDetail).toEqual({ + kind: "unavailable", + text: "This call is earlier in the conversation. Show earlier messages to see it.", + }); + }); +}); + +describe("tool response facts", () => { + it.each([ + ["run_shell", "one\ntwo\n", false, "2 lines"], + ["run_shell", "done", false, "1 line"], + ["read_file", "1\tfile text\n", false, "1 line"], + ["read_file", "(empty file: a.ts)", false, "empty file"], + ["mcp", "", false, "no output"], + ["mcp", " \n\t", false, "no output"], + ["mcp", "a short response", false, "a short response"], + ["mcp", "A successful response with more than forty characters of text", false, "1 line"], + ["mcp", "x".repeat(200), false, "1 line"], + ["web_search", "a\nb\nc", false, "3 lines"], + ["run_shell", "", true, "failed"], + ["mcp", "Permission denied", true, "failed"], + ["run_shell", "error\ndetail", true, "failed"], + ["run_shell", "partial\n\n[Command timed out]", true, "timed out"], + ["run_shell", "[Output truncated: 12000 bytes]", false, "1 line · truncated"], + ["run_shell", "bad\n[Output truncated: 12000 bytes]", true, "failed · truncated"], + ["edit_file", 'Replaced 1 occurrence(s) in "a.ts".', false, "1 replacement"], + [ + "edit_file", + 'Replaced 3 occurrences in "a.ts".\n\nLSP diagnostics: error at line 2', + false, + "3 replacements · diagnostics", + ], + ["write_file", 'Created "a.ts" (4200 bytes).', false, "created · 4.1 KB"], + ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote · 10 B"], + ["write_file", 'Created "a.ts" (1048576 bytes).', false, "created · 1.0 MB"], + ])("%s summarizes %j (error=%s)", (name, content, isError, expected) => { + const card = view(name, {}, content, isError); + expect(card.resultSummary).toBe(expected); + expect(card.status).toBe(isError ? "error" : "ok"); + expect(card.output?.isError).toBe(isError); + }); + it.each([ + ["run_shell", "oops\ndetails", true, "failed"], + ["run_shell", "partial\n[Command timed out]", true, "timed out"], + ["write_file", 'Created "a.ts" (4200 bytes).', false, "created"], + ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote"], + ["edit_file", 'Replaced 3 occurrences in "a.ts".\nDiagnostics: warning', false, "replaced"], + ["run_shell", "a\nb\nc\nd", false, "4 lines"], + ["mcp", "Created issue #42", false, "1 line"], + ["mcp", "", false, "no output"], + ["read_file", "(empty file: a.ts)", false, "empty file"], + ])("%s supplies a complete compact response fact", (name, content, isError, expected) => { + expect(view(name, {}, content, isError).compactResultSummary).toBe(expected); + }); + it("never puts error content in header summaries, but retains it verbatim in output", () => { + const content = 'Error: oldString not found in content of "src/missing.ts".'; + const card = view("edit_file", { path: "src/missing.ts" }, content, true); + expect(card.resultSummary).toBe("failed"); + expect(card.compactResultSummary).toBe("failed"); + expect(card.output).toEqual({ kind: "full", lines: [content], isError: true }); + expect(card.callSummary).toBe("src/missing.ts"); + expect(view("mcp", {}, null).compactResultSummary).toBeNull(); + }); + it("does not infer success on an error that resembles a write result", () => { + expect(view("write_file", {}, 'Created "a.ts" (10 bytes).', true).resultSummary).toMatch( + /^failed/, + ); + }); +}); + +describe("tool output elision", () => { + it.each([0, 1, 6, 11, 12])("shows %i lines in full", (n) => { + expect(view("mcp", {}, lines(n).join("\n")).output).toEqual({ + kind: "full", + lines: lines(n), + isError: false, + }); + }); + it.each([ + 13, 14, 100, + ])("shows first/last six of %i lines, retaining everything for expansion", (n) => { + expect(view("mcp", {}, lines(n).join("\n"), true).output).toEqual({ + kind: "elided", + head: lines(n).slice(0, 6), + hiddenCount: n - 12, + tail: lines(n).slice(-6), + all: lines(n), + isError: true, + }); + }); + it("ignores one final newline terminator at the 12/13 boundary (including CRLF)", () => { + expect(view("mcp", {}, `${lines(12).join("\r\n")}\r\n`).output?.kind).toBe("full"); + expect(view("mcp", {}, `${lines(13).join("\n")}\n`).output).toMatchObject({ + kind: "elided", + hiddenCount: 1, + }); + }); + it("preserves meaningful blank lines including an extra trailing blank line", () => { + expect(view("mcp", {}, "a\n\nb\n\n").output).toEqual({ + kind: "full", + lines: ["a", "", "b", ""], + isError: false, + }); + }); +}); diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts new file mode 100644 index 0000000..2380adc --- /dev/null +++ b/src/features/chat/logic/tool-summary.ts @@ -0,0 +1,460 @@ +import type { ToolEntry } from "../../../core/chunks"; + +export interface ToolCardView { + /** The raw wire tool name (tooltip / debugging). */ + readonly name: string; + /** English display name, e.g. `run_shell` → "Run command" (see {@link toolLabel}). */ + readonly label: string; + /** Which icon the card shows — a semantic key; the UI maps it to a Phosphor icon. */ + readonly icon: ToolIconKey; + readonly status: "pending" | "ok" | "error"; + readonly callSummary: string; + readonly callBadges: readonly { + readonly label: string; + readonly tone: "success" | "error"; + }[]; + readonly resultSummary: string | null; + readonly compactResultSummary: string | null; + readonly callDetail: + | { readonly kind: "unavailable"; readonly text: string } + /** The call took no arguments (`{}` / absent input) — rendered as a muted note. */ + | { readonly kind: "none" } + | { readonly kind: "command"; readonly text: string } + | { + readonly kind: "edit"; + readonly path: string; + readonly oldText: string; + readonly newText: string; + } + | { + readonly kind: "fields"; + readonly fields: readonly { + readonly key: string; + readonly value: string; + readonly multiline: boolean; + }[]; + }; + readonly output: ToolOutputView | null; +} + +export type ToolOutputView = + | { readonly kind: "full"; readonly lines: readonly string[]; readonly isError: boolean } + | { + readonly kind: "elided"; + readonly head: readonly string[]; + readonly hiddenCount: number; + readonly tail: readonly string[]; + readonly all: readonly string[]; + readonly isError: boolean; + }; + +type Args = Readonly<Record<string, unknown>>; +type CallSummary = (args: Args) => string; + +function oneLine(text: string, cap = 120): string { + const compact = text.replace(/\s+/g, " ").trim(); + return compact.length > cap ? `${compact.slice(0, cap - 1)}…` : compact; +} + +function normalize(input: unknown): unknown { + if (typeof input !== "string") return input; + try { + return JSON.parse(input); + } catch { + return input; + } +} + +function asArgs(input: unknown): Args { + return typeof input === "object" && input !== null && !Array.isArray(input) + ? (input as Args) + : {}; +} + +function serialize(input: unknown, pretty = false): string { + if (typeof input === "string") return input; + try { + // Detect ancestor cycles before the native serializer recurses. Bun's + // pretty JSON cycle detection can otherwise consume seconds of UI time. + // Track ancestors, not every visited object: shared non-cyclic values are valid. + const ancestors: object[] = []; + return ( + JSON.stringify( + input, + function (this: unknown, _key, value: unknown) { + if (value !== null && typeof value === "object") { + while (ancestors.length && ancestors.at(-1) !== this) ancestors.pop(); + if (ancestors.includes(value)) throw new TypeError("Circular tool input"); + ancestors.push(value); + } + return value; + }, + pretty ? 2 : undefined, + ) ?? String(input) + ); + } catch { + return "[unserializable input]"; + } +} + +/** Small scalar arrays are easier to scan inline; nested/long values stay pretty JSON. */ +function fieldValue(value: unknown): string { + if ( + Array.isArray(value) && + value.every((item) => item === null || ["string", "number", "boolean"].includes(typeof item)) + ) { + try { + const inline = `[${value.map((item) => JSON.stringify(item)).join(", ")}]`; + if (inline.length <= 80) return inline; + } catch { + // Non-wire values still use the safe serializer below. + } + } + return serialize(value, true); +} + +function arg(args: Args, ...keys: string[]): string { + for (const key of keys) { + const value = Object.hasOwn(args, key) ? args[key] : undefined; + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + } + return ""; +} + +function lineCount(text: string): number { + return text === "" ? 0 : outputLines(text).length; +} + +/** Drop one final newline terminator, not meaningful blank lines within the output. */ +function outputLines(content: string): string[] { + const normalized = content.replace(/\r\n/g, "\n"); + return normalized === "" ? [] : normalized.replace(/\n$/, "").split("\n"); +} + +function readSummary(args: Args): string { + const path = arg(args, "path"); + const offset = typeof args.offset === "number" && Number.isFinite(args.offset) ? args.offset : 1; + const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? args.limit : null; + return `${path}${limit !== null ? `:${offset}-${offset + limit - 1}` : args.offset !== undefined ? `:${offset}` : ""}`; +} + +function lspSummary(args: Args): string { + const line = arg(args, "line"); + const character = arg(args, "character"); + return `${arg(args, "operation")} ${arg(args, "path")}${line ? `:${line}${character ? `:${character}` : ""}` : ""}`; +} + +function todoSummary(args: Args): string { + if (!Array.isArray(args.todos)) return ""; + const done = args.todos.filter((t) => asArgs(t).status === "completed").length; + return `${args.todos.length} tasks · ${done} completed`; +} + +const summarizeAgent: CallSummary = (args) => arg(args, "title", "task", "agent", "model"); +const summarizeConversation: CallSummary = (args) => arg(args, "conversationId", "message"); + +/** English names for Dispatch's built-in tools; anything else goes through {@link humanize}. */ +const TOOL_LABELS: Readonly<Record<string, string>> = { + run_shell: "Run command", + read_file: "Read file", + edit_file: "Edit file", + write_file: "Write file", + web_search: "Web search", + lsp: "Code intelligence", + todo_write: "Update to-dos", + load_skill: "Load skill", + consult_vision: "Ask vision model", + read_image: "Read image", + youtube_transcript: "YouTube transcript", + summon_agent: "Summon agent", + send_agent_message: "Message agent", + read_agent: "Read agent reply", + open_agent: "Open agent", + cancel_agent_message: "Cancel agent message", + list_agents: "List agents", + list_agent_models: "List agent models", +}; + +/** + * Semantic icon keys (Svelte-free; `ui/tool-icons.ts` maps each to a Phosphor + * component). Unknown tools get `mcp` when namespaced `<server>__<tool>`, + * else the generic `tool` wrench. + */ +export type ToolIconKey = + | "terminal" + | "file-read" + | "file-edit" + | "file-write" + | "search" + | "code" + | "todos" + | "skill" + | "vision" + | "image" + | "youtube" + | "agent-summon" + | "agent-send" + | "agent-read" + | "agent-open" + | "agent-cancel" + | "agents" + | "models" + | "mcp" + | "tool"; + +const TOOL_ICONS: Readonly<Record<string, ToolIconKey>> = { + run_shell: "terminal", + read_file: "file-read", + edit_file: "file-edit", + write_file: "file-write", + web_search: "search", + lsp: "code", + todo_write: "todos", + load_skill: "skill", + consult_vision: "vision", + read_image: "image", + youtube_transcript: "youtube", + summon_agent: "agent-summon", + send_agent_message: "agent-send", + read_agent: "agent-read", + open_agent: "agent-open", + cancel_agent_message: "agent-cancel", + list_agents: "agents", + list_agent_models: "models", +}; + +/** The icon key for a tool (built-in table, else MCP plug / generic wrench). */ +export function toolIcon(name: string): ToolIconKey { + if (Object.hasOwn(TOOL_ICONS, name)) return TOOL_ICONS[name] ?? "tool"; + return name.split("__").filter((p) => p !== "").length >= 2 ? "mcp" : "tool"; +} + +/** `create_issue` / `createIssue` / `create-issue` → "Create issue". */ +function sentenceCase(raw: string): string { + const words = raw + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[\s_\-.]+/) + .filter((w) => w !== "") + .map((w) => w.toLowerCase()); + if (words.length === 0) return raw; + const text = words.join(" "); + return text.charAt(0).toUpperCase() + text.slice(1); +} + +/** + * Generic English name for an unknown tool. MCP tools arrive namespaced as + * `<serverId>__<toolName>` (backend `mcp/registry.ts`), shown as "Server: Tool name". + */ +function humanize(name: string): string { + const parts = name.split("__").filter((p) => p !== ""); + if (parts[0]?.toLowerCase() === "mcp" && parts.length > 1) parts.shift(); + if (parts.length >= 2) { + const server = sentenceCase(parts[0] ?? ""); + return `${server}: ${sentenceCase(parts.slice(1).join(" "))}`; + } + return sentenceCase(name); +} + +/** The English display name for a tool (built-in table, else humanized). */ +export function toolLabel(name: string): string { + return Object.hasOwn(TOOL_LABELS, name) ? (TOOL_LABELS[name] ?? name) : humanize(name); +} + +/** True for input that carries no arguments at all (absent, `null`, `{}`, `[]`, blank string). */ +function isEmptyInput(input: unknown): boolean { + if (input === undefined || input === null) return true; + if (typeof input === "string") return input.trim() === ""; + if (Array.isArray(input)) return input.length === 0; + return typeof input === "object" && Object.keys(input).length === 0; +} + +/** Fallback summary: scalar args as `key: value` (never raw JSON braces). */ +function scalarPairs(args: Args): string { + return Object.entries(args) + .filter( + ([, v]) => ["string", "number", "boolean"].includes(typeof v) && String(v).trim() !== "", + ) + .map(([k, v]) => `${k}: ${String(v)}`) + .join(" · "); +} + +/** Optional built-in polish; all unknown/MCP names use the same generic path. */ +const summaries: Readonly<Record<string, CallSummary>> = { + run_shell: (args) => arg(args, "command"), + read_file: readSummary, + edit_file: (args) => arg(args, "path"), + write_file: (args) => arg(args, "path"), + web_search: (args) => arg(args, "query", "url"), + lsp: lspSummary, + todo_write: todoSummary, + load_skill: (args) => arg(args, "name"), + consult_vision: (args) => arg(args, "question", "path"), + youtube_transcript: (args) => arg(args, "url"), + summon_agent: summarizeAgent, + send_agent_message: summarizeConversation, + read_agent: summarizeConversation, + open_agent: summarizeConversation, + cancel_agent_message: summarizeConversation, + list_agents: (args) => arg(args, "query", "relationship", "workspaceId"), + list_agent_models: (args) => arg(args, "query"), +}; + +function detail(name: string, input: unknown, args: Args): ToolCardView["callDetail"] { + if (name === "run_shell" && typeof args.command === "string") { + return { kind: "command", text: args.command }; + } + if (name === "edit_file" && typeof args.path === "string") { + return { + kind: "edit", + path: args.path, + oldText: typeof args.oldString === "string" ? args.oldString : "", + newText: typeof args.newString === "string" ? args.newString : "", + }; + } + if (isEmptyInput(input)) return { kind: "none" }; + const entries = Object.entries(args); + return { + kind: "fields", + fields: (entries.length > 0 ? entries : [["input", input]]).map(([key, value]) => { + const text = fieldValue(value); + return { key: String(key), value: text, multiline: text.includes("\n") }; + }), + }; +} + +function bytesLabel(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function responseSummary(name: string, content: string, isError: boolean): string { + const lines = outputLines(content); + const first = oneLine(lines.find((line) => line.trim()) ?? "", 72); + const count = `${lines.length} ${lines.length === 1 ? "line" : "lines"}`; + const timeout = /\[Command timed out\]/i.test(content); + const truncated = /\[Output truncated[^\]]*\]/i.test(content); + let summary: string; + if (timeout) summary = "timed out"; + else if (isError) summary = "failed"; + else if (!content.trim()) summary = "no output"; + else if (/^\(empty file\b/i.test(content)) summary = "empty file"; + else { + const replaced = + name === "edit_file" ? /^Replaced (\d+) occurrence(?:\(s\)|s)?/i.exec(content) : null; + const written = + name === "write_file" ? /^(Created|Overwrote)\b[^\n]*\((\d+) bytes\)/i.exec(content) : null; + if (replaced) { + summary = `${replaced[1]} ${replaced[1] === "1" ? "replacement" : "replacements"}`; + if (content.trim().includes("\n")) summary += " · diagnostics"; + } else if (written) { + summary = `${written[1]?.toLowerCase()} · ${bytesLabel(Number(written[2]))}`; + } else if ( + name === "run_shell" || + name === "read_file" || + lines.length > 1 || + first.length > 60 + ) { + summary = count; + } else summary = first; + } + const label = `${summary}${truncated ? " · truncated" : ""}`; + // Even the wide header must leave room for the call; prefer a whole short + // fact over chopping an arbitrary one-line response halfway through a word. + return label.length <= 40 ? label : compactResponseSummary(label, lines.length); +} + +/** Never CSS-clip response facts: swap to a whole short fact when space is scarce. */ +function compactResponseSummary(summary: string, count: number): string { + const fact = summary.split(" · ")[0] ?? summary; + if (/^(failed|timed out|no output|empty file|created|overwrote|\d+ lines?)$/.test(fact)) + return fact; + if (/^\d+ replacements?$/.test(fact)) return "replaced"; + return `${count} ${count === 1 ? "line" : "lines"}`; +} + +export function viewToolEntry(entry: ToolEntry): ToolCardView { + const input = normalize(entry.call?.input); + const args = asArgs(input); + const summarize = Object.hasOwn(summaries, entry.toolName) + ? summaries[entry.toolName] + : undefined; + const generic = arg( + args, + "command", + "path", + "file_path", + "query", + "url", + "pattern", + "name", + "question", + "title", + "task", + "message", + "conversationId", + ); + const builtInSummary = summarize?.(args).trim(); + const callSummary = + entry.call === null + ? entry.recovering + ? "Recovering tool input…" + : "Call in earlier history" + : isEmptyInput(input) + ? "" + : oneLine( + builtInSummary || + generic || + scalarPairs(args) || + // Non-object input (e.g. a bare string); objects with only nested + // values stay unsummarized rather than dumping JSON braces. + (Object.keys(args).length === 0 ? serialize(input) : ""), + ); + const callBadges: ToolCardView["callBadges"][number][] = []; + if (entry.toolName === "edit_file" && entry.call !== null && !entry.result?.isError) { + if (typeof args.oldString === "string" && typeof args.newString === "string") { + callBadges.push( + { label: `+${lineCount(args.newString)}`, tone: "success" }, + { label: `−${lineCount(args.oldString)}`, tone: "error" }, + ); + } + } + const result = entry.result; + const lines = result === null ? [] : outputLines(result.content); + const resultSummary = + result === null ? null : responseSummary(entry.toolName, result.content, result.isError); + return { + name: entry.toolName, + label: toolLabel(entry.toolName), + icon: toolIcon(entry.toolName), + status: result === null ? "pending" : result.isError ? "error" : "ok", + callSummary, + callBadges, + callDetail: + entry.call === null + ? { + kind: "unavailable", + text: entry.recovering + ? "Tool input is unavailable until it is persisted. Recovering from conversation history…" + : "This call is earlier in the conversation. Show earlier messages to see it.", + } + : detail(entry.toolName, input, args), + resultSummary, + compactResultSummary: + resultSummary === null ? null : compactResponseSummary(resultSummary, lines.length), + output: + result === null + ? null + : lines.length <= 12 + ? { kind: "full", lines, isError: result.isError } + : { + kind: "elided", + head: lines.slice(0, 6), + hiddenCount: lines.length - 12, + tail: lines.slice(-6), + all: lines, + isError: result.isError, + }, + }; +} diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts index 6d3081d..523f1d8 100644 --- a/src/features/chat/model-select.test.ts +++ b/src/features/chat/model-select.test.ts @@ -1,9 +1,19 @@ import { describe, expect, it } from "vitest"; import { + isClaudeSubscriptionModel, + isNamedAgentSelection, isVisionModel, joinModelName, + migrateLegacyModelName, + modelKeyLabel, modelKeys, modelsForKey, + NAMED_AGENTS_PROVIDER, + namedAgentNames, + namedAgentSelectionLabel, + namedAgentsProviderLabel, + openCodeNumber, + providerKeysWithNamedAgents, splitModelName, } from "./model-select"; @@ -47,11 +57,64 @@ describe("modelKeys", () => { ).toEqual(["openai", "anthropic", "google"]); }); + it("sorts numbered OpenCode keys numerically (#2 before #10)", () => { + expect( + modelKeys(["opencode-10/models", "opencode-2/models", "opencode-1/models", "openai/gpt-4"]), + ).toEqual(["opencode-1", "opencode-2", "opencode-10", "openai"]); + }); + it("is empty for no models", () => { expect(modelKeys([])).toEqual([]); }); }); +describe("openCodeNumber", () => { + it("parses a numbered OpenCode key", () => { + expect(openCodeNumber("opencode-1")).toBe(1); + expect(openCodeNumber("opencode-10")).toBe(10); + }); + + it("returns null for non-openCode / malformed keys", () => { + expect(openCodeNumber("umans")).toBeNull(); + expect(openCodeNumber("opencode")).toBeNull(); + expect(openCodeNumber("opencode-0")).toBeNull(); + expect(openCodeNumber("opencode-01")).toBeNull(); + }); +}); + +describe("modelKeyLabel", () => { + it("labels a numbered OpenCode key as OpenCode #N", () => { + expect(modelKeyLabel("opencode-2")).toBe("OpenCode #2"); + expect(modelKeyLabel("opencode-10")).toBe("OpenCode #10"); + }); + + it("leaves non-OpenCode keys unchanged", () => { + expect(modelKeyLabel("umans")).toBe("umans"); + }); +}); + +describe("migrateLegacyModelName", () => { + it("rewrites opencode/<model> to opencode-1/<model>", () => { + expect(migrateLegacyModelName("opencode/deepseek-v4-flash")).toBe( + "opencode-1/deepseek-v4-flash", + ); + expect(migrateLegacyModelName("opencode/nested/model")).toBe("opencode-1/nested/model"); + }); + + it("leaves already-numbered and other-provider names unchanged", () => { + expect(migrateLegacyModelName("opencode-1/deepseek-v4-flash")).toBe( + "opencode-1/deepseek-v4-flash", + ); + expect(migrateLegacyModelName("umans/umans-glm-5.2")).toBe("umans/umans-glm-5.2"); + expect(migrateLegacyModelName("opencode-2/x")).toBe("opencode-2/x"); + }); + + it("is idempotent", () => { + const once = migrateLegacyModelName("opencode/deepseek-v4-flash"); + expect(migrateLegacyModelName(once)).toBe(once); + }); +}); + describe("modelsForKey", () => { it("returns the model suffixes under a key, in order", () => { const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"]; @@ -87,3 +150,66 @@ describe("isVisionModel", () => { expect(isVisionModel({}, "kimi/k2")).toBe(false); }); }); + +describe("named-agent provider helpers", () => { + it("providerKeysWithNamedAgents puts the synthetic provider first, then raw keys", () => { + expect(providerKeysWithNamedAgents(["openai/gpt-4", "anthropic/claude"])).toEqual([ + NAMED_AGENTS_PROVIDER, + "openai", + "anthropic", + ]); + // Always present even with zero models. + expect(providerKeysWithNamedAgents([])).toEqual([NAMED_AGENTS_PROVIDER]); + }); + + it("namedAgentsProviderLabel + NAMED_AGENTS_PROVIDER are stable", () => { + expect(namedAgentsProviderLabel()).toBe("Named Agents"); + expect(NAMED_AGENTS_PROVIDER).toBe("__named_agents__"); + }); + + it("namedAgentNames returns the display names", () => { + expect(namedAgentNames([{ id: "a", name: "Coding", model: "m" }])).toEqual(["Coding"]); + }); + + it("isNamedAgentSelection is true only for a snapshot whose model equals the selection", () => { + expect( + isNamedAgentSelection("opencode-1/claude", { + namedAgentId: "a", + namedAgentName: "C", + model: "opencode-1/claude", + }), + ).toBe(true); + expect( + isNamedAgentSelection("other/model", { + namedAgentId: "a", + namedAgentName: "C", + model: "opencode-1/claude", + }), + ).toBe(false); + expect(isNamedAgentSelection("opencode-1/claude", null)).toBe(false); + }); + + it("namedAgentSelectionLabel renders 'Named Agents -> <name>'", () => { + expect(namedAgentSelectionLabel("Coding")).toBe("Named Agents -> Coding"); + }); +}); + +describe("isClaudeSubscriptionModel", () => { + it("is true for claude/<model> (the Claude Code subscription credential)", () => { + expect(isClaudeSubscriptionModel("claude/claude-opus-4-6")).toBe(true); + expect(isClaudeSubscriptionModel("claude/claude-sonnet-4-6")).toBe(true); + }); + + it("is false for Claude models served through other credentials", () => { + expect(isClaudeSubscriptionModel("opencode-1/claude-sonnet-4.5")).toBe(false); + expect(isClaudeSubscriptionModel("openrouter/anthropic/claude-opus-4")).toBe(false); + expect(isClaudeSubscriptionModel("openai/gpt-4o")).toBe(false); + }); + + it("is false for a bare key, a lookalike key, or undefined", () => { + expect(isClaudeSubscriptionModel("claude")).toBe(false); + expect(isClaudeSubscriptionModel("claude/")).toBe(false); + expect(isClaudeSubscriptionModel("claude-2/claude-opus-4-6")).toBe(false); + expect(isClaudeSubscriptionModel(undefined)).toBe(false); + }); +}); diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts index 602e0ef..bebad2c 100644 --- a/src/features/chat/model-select.ts +++ b/src/features/chat/model-select.ts @@ -29,15 +29,80 @@ export function joinModelName(key: string, model: string): string { /** Distinct keys across all models, in first-seen order. */ export function modelKeys(models: readonly string[]): string[] { const seen = new Set<string>(); - const out: string[] = []; + const openCodeKeys: Array<{ key: string; n: number }> = []; + const otherKeys: string[] = []; for (const full of models) { const { key } = splitModelName(full); - if (!seen.has(key)) { - seen.add(key); - out.push(key); - } + if (seen.has(key)) continue; + seen.add(key); + const n = openCodeNumber(key); + if (n !== null) openCodeKeys.push({ key, n }); + else otherKeys.push(key); } - return out; + // Numbered OpenCode credentials sort NUMERICALLY (#2 before #10), placed + // before other providers (the primary credential in this app). Others keep + // first-seen order. + openCodeKeys.sort((a, b) => a.n - b.n); + return [...openCodeKeys.map((e) => e.key), ...otherKeys]; +} + +/** + * The stable numeric identity of a numbered OpenCode provider id/key + * (`opencode-1` → 1, `opencode-10` → 10), or `null` when the id is not a + * numbered OpenCode key. Numbers are canonical positive integers with no + * leading zeros. + */ +export function openCodeNumber(key: string): number | null { + const m = /^opencode-([1-9][0-9]*)$/.exec(key); + return m === null ? null : Number(m[1]); +} + +/** True iff `id` is a numbered OpenCode provider/key (`opencode-<n>`). */ +export function isNumberedOpenCode(id: string): boolean { + return openCodeNumber(id) !== null; +} + +/** + * The credential key of the Claude Code subscription (the external Claude + * extension — `auth-claude` + `provider-anthropic`). The backend registers it + * under `DISPATCH_CLAUDE_CREDENTIAL`, default `"claude"`, so its models arrive + * as `claude/<model>`. + */ +export const CLAUDE_SUBSCRIPTION_KEY = "claude"; + +/** + * True iff `full` is a model served through the Claude Code subscription + * (`claude/<model>`). Gates the cache timer (that subscription's prompt cache + * expires after 5 minutes). A bare key with no model is not a model. + */ +export function isClaudeSubscriptionModel(full: string | undefined): boolean { + if (full === undefined) return false; + const { key, model } = splitModelName(full); + return key === CLAUDE_SUBSCRIPTION_KEY && model !== ""; +} + +/** + * A friendly human label for a model key/provider id: `opencode-<n>` → + * "OpenCode #<n>"; any other id is returned unchanged. The RAW id is still what + * gets selected/persisted — this only affects display. + */ +export function modelKeyLabel(key: string): string { + const n = openCodeNumber(key); + return n === null ? key : `OpenCode #${n}`; +} + +/** + * Migration helper: rewrite a legacy `opencode/<model>` full model name to the + * numbered `opencode-1/<model>` namespace. Returns the input unchanged for + * anything that is not `opencode/`-prefixed (already-numbered names, other + * providers). Idempotent. This is the FE-side persisted-state migration; the + * backend performs its own equivalent on its own store. + */ +export function migrateLegacyModelName(full: string): string { + const legacyPrefix = "opencode/"; + if (!full.startsWith(legacyPrefix)) return full; + const model = full.slice(legacyPrefix.length); + return model.length === 0 ? full : `opencode-1/${model}`; } /** The model suffixes available under a given key, in order. */ @@ -62,3 +127,73 @@ export function isVisionModel( ): boolean { return modelInfo[fullName]?.vision === true; } + +// ── Named Agents synthetic provider (plan §6) ──────────────────────────────── +// +// The regular model picker gains a "Named Agents" provider at the TOP of the +// provider list. Selecting it + a Named Agent name resolves to that agent's raw +// model for the conversation AND records a snapshot so the selection survives +// later edits to the shared preset. These helpers are structural (they take a +// minimal NamedAgent-ish shape) so `model-select` stays decoupled from the +// named-agents feature up the chain; the picker binds the real types. + +/** A minimal Named Agent shape the synthetic provider needs (structural). */ +export interface NamedAgentLike { + readonly id: string; + readonly name: string; + readonly model: string; +} + +/** A minimal per-conversation Named Agent selection snapshot (structural). */ +export interface NamedAgentSnapshotLike { + readonly namedAgentId: string; + readonly namedAgentName: string; + readonly model: string; +} + +/** + * The synthetic "Named Agents" provider id used by the regular model picker + * (plan §6). Deliberately a value that can never collide with a real provider + * (`<credentialName>`), so the provider dropdown treats it as the first option. + */ +export const NAMED_AGENTS_PROVIDER = "__named_agents__"; + +/** The friendly label of the synthetic Named Agents provider. */ +export function namedAgentsProviderLabel(): string { + return "Named Agents"; +} + +/** + * The provider dropdown options for the picker: the synthetic "Named Agents" + * provider ALWAYS first, then the raw providers (via `modelKeys`). The synthetic + * entry stays present even with zero Named Agents (plan §6 shows a disabled + * "No named agents configured" model option). + */ +export function providerKeysWithNamedAgents(models: readonly string[]): string[] { + return [NAMED_AGENTS_PROVIDER, ...modelKeys(models)]; +} + +/** The Named Agent names offered in the model dropdown when the synthetic provider is selected. */ +export function namedAgentNames(agents: readonly NamedAgentLike[]): string[] { + return agents.map((a) => a.name); +} + +/** + * Whether a full raw model string corresponds to a Named Agent snapshot — + * i.e. the conversation was selected via a Named Agent whose captured raw model + * equals `selected`. When true, the picker displays `Named Agents -> <name>`. + */ +export function isNamedAgentSelection( + selected: string, + snapshot: NamedAgentSnapshotLike | null, +): boolean { + return snapshot !== null && snapshot.model === selected; +} + +/** + * The snapshot label shown in the model dropdown for a Named Agent selection: + * "Named Agents -> <name>". Pure display. + */ +export function namedAgentSelectionLabel(name: string): string { + return `${namedAgentsProviderLabel()} -> ${name}`; +} diff --git a/src/features/chat/pending-inputs.ts b/src/features/chat/pending-inputs.ts new file mode 100644 index 0000000..d7fb0aa --- /dev/null +++ b/src/features/chat/pending-inputs.ts @@ -0,0 +1,12 @@ +import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract"; + +/** A saved copy, never automatically retransmitted: requestId is not idempotent. */ +export interface PendingInput { + readonly request: (ChatSendMessage | ChatQueueMessage) & { readonly requestId: string }; + readonly rejection?: string; +} + +export interface PendingInputStorage { + load(): readonly PendingInput[]; + save(inputs: readonly PendingInput[]): void; +} diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts index 2fe10dc..53ac236 100644 --- a/src/features/chat/ports.ts +++ b/src/features/chat/ports.ts @@ -1,4 +1,5 @@ import type { + ChatQueueCancelMessage, ChatQueueMessage, ChatSendMessage, ConversationHistoryResponse, @@ -6,12 +7,14 @@ import type { } from "@dispatch/transport-contract"; /** - * Injected transport port — sends chat messages to the server. Accepts both - * `chat.send` (start a turn) and `chat.queue` (enqueue a steering message; - * auto-starts a turn if idle). + * Injected transport port — sends chat messages to the server. Accepts + * `chat.send` (start a turn), `chat.queue` (enqueue a steering message; + * auto-starts a turn if idle), and `chat.queue.cancel` (remove a single queued + * message by id so it never runs — fire-and-forget, idempotent; the + * message-queue surface confirms the removal). */ export interface ChatTransport { - send(msg: ChatSendMessage | ChatQueueMessage): void; + send(msg: ChatSendMessage | ChatQueueMessage | ChatQueueCancelMessage): void; } /** diff --git a/src/features/chat/queued.test.ts b/src/features/chat/queued.test.ts new file mode 100644 index 0000000..3b579c6 --- /dev/null +++ b/src/features/chat/queued.test.ts @@ -0,0 +1,207 @@ +import type { ChatQueuedMessage } from "@dispatch/transport-contract"; +import type { AgentEvent } from "@dispatch/wire"; +import { describe, expect, it, vi } from "vitest"; +import { createChatStore } from "./store.svelte"; +import { + createFakeCache, + createFakeHistorySync, + createFakeMetricsSync, + createFakeTransport, +} from "./test-helpers"; + +const images = [ + { url: "data:image/png;base64,AAAA", mimeType: "image/png" }, + { url: "data:image/png;base64,BBBB" }, +]; + +function fixture(batched = false) { + const transport = createFakeTransport(); + const history = createFakeHistorySync(); + let nextId = 0; + const scheduled: (() => void)[] = []; + const store = createChatStore({ + conversationId: "c1", + createRequestId: () => `r${++nextId}`, + transport: transport.impl, + historySync: history.impl, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + ...(batched ? { scheduleFlush: (flush: () => void) => scheduled.push(flush) } : {}), + }); + function ack(requestId: string | undefined = "r1", conversationId = "c1") { + const msg: ChatQueuedMessage = { + type: "chat.queued", + conversationId, + reason: "compaction", + messageId: "q1", + ...(requestId !== undefined ? { requestId } : {}), + }; + store.handleDelta(msg); + } + function event(event: AgentEvent) { + store.handleDelta({ type: "chat.delta", event }); + } + function start(text?: string, turnId = "t1") { + event({ type: "turn-start", conversationId: "c1", turnId }); + if (text !== undefined) event({ type: "user-message", conversationId: "c1", turnId, text }); + } + return { store, transport, history, scheduled, ack, event, start }; +} + +describe("compaction-deferred chat.send", () => { + it.each([ + "hello", + "", + ])("removes just the accepted local text/images for %j without error, retry or queue sends", (text) => { + const f = fixture(); + f.store.send(text, images); + expect(f.store.chunks).toHaveLength(text ? 3 : 2); + f.ack(); + f.ack(); + expect(f.store.chunks).toEqual([]); + expect(f.store.generating).toBe(false); + expect(f.store.error).toBeNull(); + expect(f.transport.sent).toHaveLength(1); + expect(f.transport.sent[0]).toMatchObject({ requestId: "r1", images, message: text }); + expect(f.transport.sentQueue).toEqual([]); + f.store.dispose(); + }); + + it("uses distinct request identities for identical sends and preserves unrelated content", () => { + const f = fixture(); + f.start("another client's prompt"); + f.event({ type: "text-delta", conversationId: "c1", turnId: "t1", delta: "working" }); + f.store.send("identical", images); + f.store.send("identical", images); + expect(f.transport.sent.map((m) => m.requestId)).toEqual(["r1", "r2"]); + f.ack("r1"); + f.ack("r1"); + expect(f.store.chunks.map((c) => c.chunk)).toEqual([ + { type: "text", text: "another client's prompt" }, + { type: "text", text: "working" }, + { type: "text", text: "identical" }, + ...images.map((i) => ({ type: "image", ...i })), + ]); + f.ack("r2"); + expect(f.store.chunks.map((c) => c.chunk)).toEqual([ + { type: "text", text: "another client's prompt" }, + { type: "text", text: "working" }, + ]); + expect(f.store.generating).toBe(true); + f.store.dispose(); + }); + + it.each([ + "missing", + "unknown", + "wrong-conversation", + ])("tolerates %s correlation without deleting any echo", (kind) => { + const f = fixture(); + f.store.send("hello", images); + f.store.handleDelta({ + type: "chat.queued", + conversationId: kind === "wrong-conversation" ? "c2" : "c1", + reason: "compaction", + messageId: "q1", + ...(kind === "missing" ? {} : { requestId: kind === "unknown" ? "r999" : "r1" }), + }); + expect(f.store.chunks).toHaveLength(3); + expect(f.store.error).toBeNull(); + f.store.dispose(); + }); + + it.each([ + false, + true, + ])("preserves a live confirmed prompt before delayed ack (batched=%s)", (batched) => { + const f = fixture(batched); + f.store.send("hello", images); + f.start("hello"); + f.event({ type: "text-delta", conversationId: "c1", turnId: "t1", delta: "answer" }); + f.ack(); + f.ack(); + for (const flush of f.scheduled) flush(); + expect(f.store.chunks.map((c) => c.chunk)).toEqual([ + { type: "text", text: "hello" }, + { type: "text", text: "answer" }, + ]); + expect(f.store.generating).toBe(true); + expect(f.store.error).toBeNull(); + f.store.dispose(); + }); + + it("turn-start tagging alone does not protect a still-local echo", () => { + const f = fixture(true); + f.store.send("hello", images); + f.start(); + f.ack(); + expect(f.store.chunks).toEqual([]); + expect(f.store.generating).toBe(true); + f.event({ type: "user-message", conversationId: "c1", turnId: "t1", text: "hello" }); + for (const flush of f.scheduled) flush(); + expect(f.store.chunks.map((c) => c.chunk)).toEqual([{ type: "text", text: "hello" }]); + f.store.dispose(); + }); + + it("keeps a combined queue opening and steering; removes only the original individual echoes", () => { + const f = fixture(true); + f.store.send("one", images); + f.store.send("two", images); + f.start("one\n\ntwo"); + f.event({ type: "steering", conversationId: "c1", turnId: "t1", text: "steer" }); + f.ack("r2"); + f.ack("r1"); + expect(f.store.chunks.map((c) => c.chunk)).toEqual([ + { type: "text", text: "one\n\ntwo" }, + { type: "text", text: "steer" }, + ]); + expect(f.store.generating).toBe(true); + f.store.dispose(); + }); + + it.each([ + "ack-first", + "reset-first", + "history-first", + ])("handles epoch replacement idempotently: %s", async (order) => { + const f = fixture(); + await f.store.load(); + f.store.send("hello", images); + if (order === "ack-first") f.ack(); + f.history.historyEpoch = 1; + f.history.returnChunks = [ + { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hello" } }, + { seq: 2, role: "user", turnId: "t1", chunk: { type: "image", url: "/images/c1/saved.png" } }, + ]; + f.store.resync(1); + if (order === "reset-first") f.ack(); + await vi.waitFor(() => expect(f.store.pendingSync).toBe(false)); + f.start("hello"); + const before = f.store.chunks; + f.ack(); + f.ack(); + expect(f.store.chunks).toEqual(before); + expect(f.store.chunks.map((c) => c.chunk)).toEqual(f.history.returnChunks.map((c) => c.chunk)); + expect(f.store.generating).toBe(true); + expect(f.store.error).toBeNull(); + expect(f.transport.sent).toHaveLength(1); + f.store.dispose(); + }); + + it("keeps normal immediate-send text/image deduplication and authoritative history", async () => { + const f = fixture(); + f.store.send("hello", images); + f.start("hello"); + expect(f.store.chunks).toHaveLength(3); + f.history.returnChunks = [ + { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hello" } }, + { seq: 2, role: "user", turnId: "t1", chunk: { type: "image", url: "/images/c1/saved.png" } }, + ]; + f.event({ type: "turn-sealed", conversationId: "c1", turnId: "t1" }); + await vi.waitFor(() => expect(f.store.pendingSync).toBe(false)); + expect(f.store.chunks.map((c) => c.chunk)).toEqual(f.history.returnChunks.map((c) => c.chunk)); + expect(f.store.chunks.every((c) => !c.provisional)).toBe(true); + expect(f.transport.sent).toHaveLength(1); + f.store.dispose(); + }); +}); diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts index 6d409e9..e870bac 100644 --- a/src/features/chat/reasoning-effort.test.ts +++ b/src/features/chat/reasoning-effort.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_REASONING_EFFORT, effectiveEffort, + effectiveSelection, effortOptions, isReasoningEffort, + isThinkingSelection, REASONING_EFFORT_LEVELS, + selectionOptions, } from "./reasoning-effort"; describe("reasoning-effort helpers", () => { @@ -43,3 +46,39 @@ describe("reasoning-effort helpers", () => { } }); }); + +describe("thinking selection (the separate on/off axis)", () => { + it("selectionOptions lists 'off' first, then the ladder (default marked)", () => { + const options = selectionOptions(); + expect(options).toHaveLength(1 + REASONING_EFFORT_LEVELS.length); + expect(options[0]?.value).toBe("off"); + expect(options[0]?.label).toBe("Off"); + // the rest are the ladder, unchanged from effortOptions() + expect(options.slice(1).map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]); + expect(options.find((o) => o.value === "high")?.label).toBe("high (default)"); + }); + + it("isThinkingSelection narrows 'off' + ladder strings, rejects the rest", () => { + expect(isThinkingSelection("off")).toBe(true); + for (const level of REASONING_EFFORT_LEVELS) { + expect(isThinkingSelection(level)).toBe(true); + } + expect(isThinkingSelection("banana")).toBe(false); + expect(isThinkingSelection("")).toBe(false); + expect(isThinkingSelection("OFF")).toBe(false); + expect(isThinkingSelection("none")).toBe(false); // NOT a wire value we send + }); + + it("effectiveSelection shows 'off' when thinking is explicitly disabled", () => { + // thinking off is a SEPARATE axis: the effort level is irrelevant while off. + expect(effectiveSelection("xhigh", false)).toBe("off"); + expect(effectiveSelection(null, false)).toBe("off"); + }); + + it("effectiveSelection shows the effort level when thinking is on (default)", () => { + // null thinking = never set ⇒ thinking ON (default) ⇒ show the effort level. + expect(effectiveSelection(null, null)).toBe("high"); // default effort + expect(effectiveSelection("low", null)).toBe("low"); + expect(effectiveSelection("max", true)).toBe("max"); // explicitly on + }); +}); diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts index 1eb77b6..39e1c5a 100644 --- a/src/features/chat/reasoning-effort.ts +++ b/src/features/chat/reasoning-effort.ts @@ -36,7 +36,7 @@ export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEff return persisted ?? DEFAULT_REASONING_EFFORT; } -/** One `<option>` of the selector. */ +/** One `<option>` of the effort ladder. */ export interface EffortOption { readonly value: ReasoningEffort; readonly label: string; @@ -64,3 +64,99 @@ export type ReasoningEffortSaveResult = export type SaveReasoningEffort = ( level: ReasoningEffort, ) => Promise<ReasoningEffortSaveResult | null>; + +// ── Thinking on/off (a SEPARATE axis from the effort level) ───────────────── +// +// Per the umans API (and the user's mental model), "thinking off" is NOT a +// zero-effort level — it is a distinct "disable extended thinking entirely" +// signal. The umans route expresses it as `reasoning_effort: "none"`; Dispatch +// surfaces it as a SEPARATE per-conversation boolean so the effort LEVEL is +// preserved across an off→on toggle (turning thinking back on restores the +// previously-chosen depth). The per-conversation selector CONFLATES the two +// axes into one `<select>` (UX), but the WIRE keeps them separate. +// +// ⚠️ BACKEND CONTRACT GAP — see `backend-handoff.md`. The `thinking` endpoint + +// wire types below are the PROPOSED shape; the backend has NOT shipped them yet. +// They are defined FE-local (mirroring the shipped `ReasoningEffortResponse` / +// `SetReasoningEffortRequest` shape) so the FE is built + tested against the +// target contract. Re-pin + re-mirror once the backend ships them. + +/** + * Response of `GET /conversations/:id/thinking` (PROPOSED). `thinking` is null + * when never set (the server then resolves turns with thinking ON — the + * default), `false` when explicitly disabled, `true` when explicitly enabled. + */ +export interface ThinkingResponse { + readonly conversationId: string; + readonly thinking: boolean | null; +} + +/** Body of `PUT /conversations/:id/thinking` (PROPOSED). */ +export interface SetThinkingRequest { + readonly thinking: boolean; +} + +/** + * The per-conversation selector's value: `"off"` (thinking disabled — the + * separate axis) or a reasoning-effort LEVEL. NOT a widened ladder: `"off"` is + * not a degree of effort, it is the absence of thinking. + */ +export type ThinkingSelection = "off" | ReasoningEffort; + +/** One `<option>` of the combined selector (off or a level). */ +export interface SelectionOption { + readonly value: ThinkingSelection; + readonly label: string; +} + +/** + * The selector's options: `"off"` first (the separate disable signal), then the + * effort ladder with the server default marked `(default)`. A never-set + * conversation (thinking on, effort null) reads "high (default)". + */ +export function selectionOptions(): readonly SelectionOption[] { + return [{ value: "off", label: "Off" }, ...effortOptions()]; +} + +/** Narrow an untrusted `<select>` value to a {@link ThinkingSelection}. */ +export function isThinkingSelection(value: string): value is ThinkingSelection { + return value === "off" || isReasoningEffort(value); +} + +/** + * The selection the per-conversation selector should show as selected: `"off"` + * when thinking is explicitly disabled (`persistedThinking === false`), else the + * effective effort level. `persistedThinking === null` (never set) ⇒ thinking + * ON (the default) ⇒ the effort level is shown — NOT "off". + */ +export function effectiveSelection( + persistedEffort: ReasoningEffort | null, + persistedThinking: boolean | null, +): ThinkingSelection { + if (persistedThinking === false) return "off"; + return effectiveEffort(persistedEffort); +} + +// ── Injected port for the combined selector (off OR a level) ───────────────── + +/** Outcome of persisting a {@link ThinkingSelection} (one or two PUTs). */ +export type ThinkingSelectionSaveResult = + | { readonly ok: true; readonly selection: ThinkingSelection } + | { readonly ok: false; readonly error: string }; + +/** + * Persist a thinking selection (consumer-defines-port; the composition root + * adapts the store's `PUT .../thinking` + `PUT .../reasoning-effort` to this). + * - `"off"` → disable thinking (the separate signal); the effort level is left + * untouched so an off→on toggle restores it. + * - a level → set the effort level AND ensure thinking is ON (the level is + * meaningless while thinking is off). + */ +export type SaveThinkingSelection = ( + selection: ThinkingSelection, +) => Promise<ThinkingSelectionSaveResult | null>; + +/** Outcome of `PUT /conversations/:id/thinking`. */ +export type ThinkingSaveResult = + | { readonly ok: true; readonly thinking: boolean } + | { readonly ok: false; readonly error: string }; diff --git a/src/features/chat/recovery.test.ts b/src/features/chat/recovery.test.ts new file mode 100644 index 0000000..4b46288 --- /dev/null +++ b/src/features/chat/recovery.test.ts @@ -0,0 +1,529 @@ +import type { + ChatResyncRequiredMessage, + ConversationHistoryResponse, +} from "@dispatch/transport-contract"; +import type { AgentEvent, StepId, StoredChunk, TurnEndedEvent } from "@dispatch/wire"; +import { IDBFactory, IDBKeyRange } from "fake-indexeddb"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createIdbChunkStore } from "../../adapters/idb"; +import { createConversationCache } from "../conversation-cache"; +import type { HistorySync } from "./ports"; +import { createChatStore } from "./store.svelte"; +import { + createFakeCache, + createFakeHistorySync, + createFakeMetricsSync, + createFakeTransport, +} from "./test-helpers"; + +const step = "t1#0" as StepId; +const opening: StoredChunk = { + seq: 1, + role: "user", + turnId: "t1", + chunk: { type: "text", text: "prompt" }, +}; +const call: StoredChunk = { + seq: 3, + role: "assistant", + turnId: "t1", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "run_shell", + stepId: step, + input: { command: "full input" }, + }, +}; +const result: StoredChunk = { + seq: 4, + role: "tool", + turnId: "t1", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "run_shell", + stepId: step, + content: "full output", + isError: false, + }, +}; +const committed: StoredChunk[] = [ + opening, + { seq: 2, role: "assistant", turnId: "t1", chunk: { type: "text", text: "complete text" } }, + call, + result, +]; + +function fixture(historySync?: HistorySync, batched = false) { + const history = createFakeHistorySync(); + const transport = createFakeTransport(); + const cache = createFakeCache(); + const scheduled: (() => void)[] = []; + const store = createChatStore({ + conversationId: "c1", + createRequestId: () => "r1", + historySync: historySync ?? history.impl, + metricsSync: createFakeMetricsSync().impl, + cache: cache.impl, + transport: transport.impl, + ...(batched ? { scheduleFlush: (fn: () => void) => scheduled.push(fn) } : {}), + }); + const event = (event: AgentEvent) => store.handleDelta({ type: "chat.delta", event }); + const notice = ( + turnId: string | undefined = "t1", + reason: ChatResyncRequiredMessage["reason"] = "replay-limit", + ) => + store.handleDelta({ + type: "chat.resync-required", + conversationId: "c1", + reason, + ...(turnId !== undefined ? { turnId } : {}), + }); + const end = (reason: TurnEndedEvent["reason"] = "sealed", turnId = "t1") => + event({ type: "turn-ended", conversationId: "c1", turnId, reason }); + const text = (delta: string, turnId = "t1") => + event({ type: "text-delta", conversationId: "c1", turnId, delta }); + return { store, history, transport, cache, scheduled, event, notice, end, text }; +} + +async function settled(store: ReturnType<typeof createChatStore>) { + for (let i = 0; i < 80 && store.pendingSync; i++) await Promise.resolve(); + expect(store.pendingSync).toBe(false); +} +function response(chunks: readonly StoredChunk[], historyEpoch = 0): ConversationHistoryResponse { + return { chunks, historyEpoch, latestSeq: chunks.at(-1)?.seq ?? 0 }; +} +function deferred() { + let resolve!: (value: ConversationHistoryResponse) => void; + const promise = new Promise<ConversationHistoryResponse>((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("omitted chat recovery", () => { + it.each([ + false, + true, + ])("marks omitted replay active without content and recovers full HTTP payload (batched=%s)", async (batched) => { + vi.useFakeTimers(); + const f = fixture(undefined, batched); + f.text("partial"); + f.notice(); + expect(f.store.generating).toBe(true); + expect(f.store.recovering).toBe(true); + expect(f.store.chunks.map((r) => r.chunk)).toEqual([{ type: "text", text: "partial" }]); + await settled(f.store); + // No initial commit yet. A tool result follows an OMITTED call input. + f.event({ + type: "tool-result", + conversationId: "c1", + turnId: "t1", + stepId: step, + toolCallId: "a", + toolName: "run_shell", + content: "short replay", + isError: false, + }); + f.history.returnChunks = committed; + await vi.advanceTimersByTimeAsync(1000); + expect(f.store.chunks.filter((r) => r.chunk.type === "tool-call")).toEqual([ + expect.objectContaining({ chunk: call.chunk, provisional: false }), + ]); + expect(f.store.chunks.filter((r) => r.chunk.type === "tool-result")).toHaveLength(1); + expect(f.store.chunks.find((r) => r.chunk.type === "tool-result")?.chunk).toEqual(result.chunk); + expect(f.store.generating).toBe(true); + expect(f.store.error).toBeNull(); + expect(f.transport.sent).toEqual([]); + f.store.dispose(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("coalesces repeat notices and polls after step notification races persistence, without parallel requests", async () => { + vi.useFakeTimers(); + const pending = deferred(); + let calls = 0; + const f = fixture(async () => { + calls++; + return calls === 1 ? pending.promise : response(committed); + }); + f.notice(); + for (let i = 0; i < 20; i++) f.notice(); + for (let i = 0; i < 15; i++) await Promise.resolve(); + expect(calls).toBe(1); + f.event({ type: "step-complete", conversationId: "c1", turnId: "t1", stepId: step }); + await vi.advanceTimersByTimeAsync(10000); + expect(calls).toBe(1); + pending.resolve(response([])); + await settled(f.store); + expect(calls).toBe(1); + await vi.advanceTimersByTimeAsync(999); + expect(calls).toBe(1); + await vi.advanceTimersByTimeAsync(1); + expect(calls).toBe(2); + expect(f.store.chunks.map((r) => r.chunk)).toEqual(committed.map((r) => r.chunk)); + f.store.dispose(); + }); + + it.each([ + "sealed", + "aborted", + "error", + "rejected", + ] as const)("final-syncs %s behind an in-flight fetch and preserves immediate queued carry", async (reason) => { + vi.useFakeTimers(); + const pending = deferred(); + let calls = 0; + const f = fixture(async () => (++calls === 1 ? pending.promise : response(committed))); + f.notice(); + for (let i = 0; i < 10; i++) await Promise.resolve(); + f.text("old tail"); + f.end(reason); + f.event({ type: "user-message", conversationId: "c1", turnId: "t2", text: "queued input" }); + f.event({ type: "turn-start", conversationId: "c1", turnId: "t2" }); + f.text("carried tail", "t2"); + pending.resolve(response([opening])); + await settled(f.store); + expect(calls).toBe(2); + expect(f.store.recovering).toBe(false); + expect(f.store.generating).toBe(true); + expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([ + { type: "text", text: "queued input" }, + { type: "text", text: "carried tail" }, + ]); + expect(f.store.chunks.filter((r) => r.turnId === "t1").every((r) => !r.provisional)).toBe(true); + await vi.advanceTimersByTimeAsync(5000); + expect(calls).toBe(2); + f.end("sealed", "t2"); + expect(f.store.generating).toBe(false); + f.store.dispose(); + }); + + it.each([ + false, + true, + ])("notice during %s-cache load requests one rerun, preserving current window", async (warm) => { + vi.useFakeTimers(); + const pending = deferred(); + let calls = 0; + const f = fixture(async (_id, _since, window) => { + calls++; + if (!warm) expect(window?.limit).toBe(192); + return calls === 1 ? pending.promise : response([opening]); + }); + if (warm) f.cache.store.set("c1", [opening]); + const load = f.store.load(); + for (let i = 0; i < 15; i++) await Promise.resolve(); + f.notice(); + pending.resolve(response([opening])); + await load; + await settled(f.store); + expect(calls).toBe(2); + expect(f.store.chunks).toHaveLength(1); + expect(f.store.recovering).toBe(true); + expect(f.store.generating).toBe(true); + f.store.dispose(); + }); + + it("payload notice after a completed HTTP load preserves live tail, unrelated turn and tool progress", async () => { + vi.useFakeTimers(); + const f = fixture(); + await f.store.load(); + f.event({ type: "turn-start", conversationId: "c1", turnId: "other" }); + f.text("unrelated", "other"); + f.event({ type: "turn-start", conversationId: "c1", turnId: "t1" }); + f.text("partial text"); + f.event({ + type: "tool-call", + conversationId: "c1", + turnId: "t1", + stepId: step, + toolCallId: "a", + toolName: "run_shell", + input: { command: "short" }, + }); + f.event({ + type: "tool-result", + conversationId: "c1", + turnId: "t1", + stepId: step, + toolCallId: "a", + toolName: "run_shell", + content: "short", + isError: false, + }); + f.text("complete text"); // same payload as earlier step; NOT the same identity + f.event({ + type: "tool-output", + conversationId: "c1", + turnId: "t1", + toolCallId: "next", + data: "progress", + stream: "stdout", + }); + f.history.returnChunks = committed; + f.notice("t1", "payload-limit"); + await settled(f.store); + expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([ + { type: "text", text: "unrelated" }, + { type: "text", text: "complete text" }, + ]); + expect(f.store.chunks.filter((r) => r.chunk.type === "tool-call")).toHaveLength(1); + expect(f.store.chunks.filter((r) => r.chunk.type === "tool-result")).toHaveLength(1); + f.store.dispose(); + }); + + it("bounds epoch churn during recovery and cancels its retry timer on disposal", async () => { + vi.useFakeTimers(); + let calls = 0; + const f = fixture(async () => { + f.store.invalidateHistory(++calls); + return response([], calls - 1); + }); + f.notice(); + await settled(f.store); + expect(calls).toBe(3); + expect(f.store.error).toContain("sync paused"); + await vi.advanceTimersByTimeAsync(10000); + expect(calls).toBe(3); + f.store.dispose(); + await vi.advanceTimersByTimeAsync(10000); + expect(calls).toBe(3); + expect(vi.getTimerCount()).toBe(0); + }); + + it("ignores other conversations and disposed notices; accepts a notice without turnId", async () => { + vi.useFakeTimers(); + const f = fixture(); + f.store.handleDelta({ + type: "chat.resync-required", + conversationId: "other", + reason: "payload-limit", + }); + expect(f.store.recovering).toBe(false); + f.store.handleDelta({ + type: "chat.resync-required", + conversationId: "c1", + reason: "payload-limit", + }); + await settled(f.store); + expect(f.store.generating).toBe(false); + expect(f.store.recovering).toBe(false); + expect(vi.getTimerCount()).toBe(0); + f.event({ type: "turn-start", conversationId: "c1", turnId: "t1" }); + f.store.handleDelta({ + type: "chat.resync-required", + conversationId: "c1", + reason: "payload-limit", + }); + expect(f.store.generating).toBe(true); + expect(f.store.recovering).toBe(true); + f.end("rejected"); + await settled(f.store); + expect(f.store.recovering).toBe(false); + expect(f.store.generating).toBe(false); + f.store.dispose(); + f.notice(); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +it("recovers a windowed rollback with real IndexedDB cache while preserving lifecycle evidence", async () => { + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() })); + await cache.commit( + "c1", + [{ seq: 1, role: "assistant", chunk: { type: "text", text: "old epoch" } }], + 5, + ); + const history = createFakeHistorySync(); + history.historyEpoch = 5; + const timers = new Map<object, () => void>(); + const store = createChatStore({ + conversationId: "c1", + createRequestId: () => "r", + cache, + historySync: history.impl, + metricsSync: createFakeMetricsSync().impl, + transport: createFakeTransport().impl, + chatLimit: 10, + recoveryTimer: { + schedule(fn) { + const key = {}; + timers.set(key, fn); + return key; + }, + cancel(key) { + timers.delete(key as object); + }, + }, + }); + try { + await store.load(); + history.historyEpoch = 0; + history.returnChunks = Array.from({ length: 20 }, (_, i) => ({ + seq: i + 1, + role: "assistant" as const, + turnId: "t1", + chunk: { type: "text" as const, text: `restored-${i}` }, + })); + const before = history.calls.length; + store.handleDelta({ + type: "chat.resync-required", + conversationId: "c1", + turnId: "t1", + reason: "replay-limit", + }); + await vi.waitFor(() => expect(store.pendingSync).toBe(false)); + expect(history.calls.length - before).toBe(2); + expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } }); + expect(await cache.historyEpoch("c1")).toBe(0); + expect(store.chunks.map((c) => c.seq)).toEqual([14, 15, 16, 17, 18, 19, 20]); + expect(store.hasEarlier).toBe(true); + expect(store.generating).toBe(true); + expect(timers.size).toBe(1); + store.handleDelta({ + type: "chat.delta", + event: { type: "turn-ended", conversationId: "c1", turnId: "t1", reason: "aborted" }, + }); + await vi.waitFor(() => expect(store.pendingSync).toBe(false)); + expect(store.generating).toBe(false); + expect(timers.size).toBe(0); + expect(store.hasEarlier).toBe(true); + } finally { + store.dispose(); + } +}); + +it("ignores an unrelated terminal and stays recovering through done/seal until its actual terminal", async () => { + vi.useFakeTimers(); + const f = fixture(); + f.notice(); + await settled(f.store); + f.end("aborted", "unrelated"); + f.event({ type: "done", conversationId: "c1", turnId: "t1", reason: "aborted" }); + expect(f.store.recovering).toBe(true); + expect(f.store.generating).toBe(true); + f.event({ type: "turn-sealed", conversationId: "c1", turnId: "t1" }); + await settled(f.store); + expect(f.store.recovering).toBe(true); + expect(f.store.generating).toBe(true); + f.end("aborted"); + await settled(f.store); + expect(f.store.recovering).toBe(false); + expect(f.store.generating).toBe(false); + expect(vi.getTimerCount()).toBe(0); + f.store.dispose(); +}); + +it("makes a post-terminal request even when the terminal races the third bounded epoch attempt", async () => { + vi.useFakeTimers(); + const third = deferred(); + let calls = 0; + const f = fixture(async () => { + calls++; + if (calls < 3) { + f.store.invalidateHistory(calls); + return response([], calls - 1); + } + return calls === 3 ? third.promise : response(committed, 2); + }); + f.notice(); + for (let i = 0; i < 60 && calls < 3; i++) await Promise.resolve(); + expect(calls).toBe(3); + f.end("error"); + third.resolve(response([], 2)); + await settled(f.store); + expect(calls).toBe(4); + expect(f.store.chunks.map((r) => r.chunk)).toEqual(committed.map((r) => r.chunk)); + expect(f.store.recovering).toBe(false); + expect(vi.getTimerCount()).toBe(0); + f.store.dispose(); +}); + +it("retains active recovery across a compaction epoch refresh and cancels it on reconnect/dispose", async () => { + vi.useFakeTimers(); + const f = fixture(); + f.notice(); + await settled(f.store); + f.history.historyEpoch = 1; + f.store.resync(1); + await settled(f.store); + expect(f.store.recovering).toBe(true); + expect(f.store.generating).toBe(true); + expect(vi.getTimerCount()).toBe(1); + f.store.resync(); + await settled(f.store); + expect(f.store.recovering).toBe(false); + expect(f.store.generating).toBe(false); + expect(vi.getTimerCount()).toBe(0); + f.store.dispose(); +}); + +it("flushes a batched aborted terminal plus carried turn before applying an older HTTP response", async () => { + vi.useFakeTimers(); + const pending = deferred(); + let calls = 0; + const f = fixture(async () => (++calls === 1 ? pending.promise : response(committed)), true); + f.notice(); + for (let i = 0; i < 15; i++) await Promise.resolve(); + f.text("old partial"); + f.end("aborted"); + f.event({ type: "user-message", conversationId: "c1", turnId: "t2", text: "queued" }); + f.event({ type: "turn-start", conversationId: "c1", turnId: "t2" }); + f.text("live carried output", "t2"); + pending.resolve(response([opening])); + await settled(f.store); + expect(calls).toBe(2); + expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([ + { type: "text", text: "queued" }, + { type: "text", text: "live carried output" }, + ]); + expect(f.store.generating).toBe(true); + expect(f.store.recovering).toBe(false); + expect(vi.getTimerCount()).toBe(0); + f.store.dispose(); +}); + +it("does not truncate a payload larger than the WS budget recovered over HTTP", async () => { + vi.useFakeTimers(); + const large = "x".repeat(4 * 1024 * 1024 + 1); + const full: StoredChunk[] = [ + { + ...call, + chunk: { + ...call.chunk, + type: "tool-call", + toolCallId: "a", + toolName: "run_shell", + input: { command: large }, + stepId: step, + }, + }, + { + ...result, + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "run_shell", + content: large, + isError: false, + stepId: step, + }, + }, + ]; + const f = fixture(); + f.history.returnChunks = full; + f.notice(); + await settled(f.store); + expect(f.store.chunks).toHaveLength(2); + expect(f.store.chunks.map((r) => r.chunk)).toEqual(full.map((r) => r.chunk)); + expect(f.transport.sent).toEqual([]); + f.store.dispose(); +}); diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index 9911438..dacbe06 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -1,8 +1,13 @@ import type { + ChatAcceptedMessage, ChatDeltaMessage, ChatErrorMessage, + ChatQueueCancelMessage, + ChatQueuedMessage, ChatQueueMessage, + ChatResyncRequiredMessage, ChatSendMessage, + ChatSubscriptionRejectedMessage, } from "@dispatch/transport-contract"; import type { ChatMessage, ImageInput, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire"; import type { RenderedChunk, TranscriptState } from "../../core/chunks"; @@ -13,7 +18,9 @@ import { foldEvent, initialState, initialWindowSize, + markHistoryRecovering, normalizeChatLimit, + removeLocalEcho, restoreEarlier, selectChunks, selectGenerating, @@ -24,19 +31,28 @@ import { unloadCount, windowTranscript, } from "../../core/chunks"; -import type { MetricsState, TurnMetricsEntry } from "../../core/metrics"; +import type { CacheTimerState, MetricsState, TurnMetricsEntry } from "../../core/metrics"; import { applyDurableMetrics, + foldCacheTimerEvent, foldMetricsEvent, + initialCacheTimerState, initialMetricsState, + seedTurnGap, selectCurrentContextSize, selectOrderedTurnMetrics, + stopTurnGap, } from "../../core/metrics"; import type { ConversationCache } from "../conversation-cache"; +import type { NamedAgentSelectionSnapshot } from "../named-agents"; +import type { PendingInput, PendingInputStorage } from "./pending-inputs"; import type { ChatTransport, HistorySync, MetricsSync } from "./ports"; export interface ChatStoreDependencies { + readonly pendingInputStorage?: PendingInputStorage; readonly conversationId: string; + /** Injected unique client-local correlation token for each new send. */ + readonly createRequestId: () => string; readonly model?: string; readonly transport: ChatTransport; readonly historySync: HistorySync; @@ -67,9 +83,27 @@ export interface ChatStoreDependencies { * `reportError` → full-screen error modal. Absent → errors are logged only. */ readonly onError?: (context: string, err: unknown) => void; + /** + * Schedules a deferred flush of buffered live events (e.g. once per animation + * frame). When provided, `handleDelta` QUEUES events and folds the whole batch + * in one pass on the scheduled callback — so a burst (the late-join replay on + * opening a conversation mid-turn, or a fast stream on a slow device) costs ONE + * derivation + render instead of one per WS frame. Absent → every event is + * folded synchronously (the original behavior; used by tests). + */ + readonly scheduleFlush?: (flush: () => void) => void; + /** Wall clock (epoch ms) — times the cache-timer gaps. Absent → `Date.now`. */ + readonly now?: () => number; + /** Recovery polling clock; browser effects are replaceable by deterministic tests. */ + readonly recoveryTimer?: { + schedule(callback: () => void, delayMs: number): unknown; + cancel(handle: unknown): void; + }; } export interface ChatStore { + readonly pendingInputs: readonly PendingInput[]; + discardPendingInput(requestId: string): void; readonly messages: readonly ChatMessage[]; readonly chunks: readonly RenderedChunk[]; readonly turnMetrics: readonly TurnMetricsEntry[]; @@ -96,6 +130,8 @@ export interface ChatStore { * to the newest attempt + delay; cleared when content resumes or the turn ends. */ readonly providerRetry: TurnProviderRetryEvent | null; + /** Omitted live payloads may not exist in HTTP history until step persistence. */ + readonly recovering: boolean; readonly pendingSync: boolean; readonly error: string | null; readonly model: string | undefined; @@ -111,7 +147,29 @@ export interface ChatStore { * a trim (see `TranscriptState.hiddenThinkingCount`). */ readonly thinkingKeyBase: number; - handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void; + /** + * The cache timer (see `core/metrics/cache-timer.ts`): the running gap + * between model requests (a step's tools executing, or the wait after a turn + * ended) plus the stamped durations of finished gaps, keyed by `stepId` / + * `turnId`. Timed with the local clock from live events; in-memory only (a + * reload keeps just a turn gap seeded via `noteLastActivity`). + */ + readonly cacheTimer: CacheTimerState; + /** + * Seed the turn gap from an authoritative activity timestamp (the + * conversation list's `lastActivityAt`) — call only for an IDLE conversation. + * Ignored while a turn is generating; a running turn gap keeps the later start. + */ + noteLastActivity(at: number): void; + handleDelta( + msg: + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage, + ): void; /** * Send a user message (start a turn via `chat.send`). Optimistically echoes * the text + any `images` as provisional user chunks (`[text, image, …]` in @@ -128,11 +186,32 @@ export interface ChatStore { * auto-starts a turn with the message as its opening prompt (equivalent to * `chat.send`). No optimistic transcript echo — the queue SURFACE carries the * pending message until drain; the `steering` event places it in the - * transcript. `text` must be non-empty (the server 400/errors otherwise). + * transcript. Empty `text` is allowed when images are supplied. WS delivery + * is fire-and-forget; transport send is not a persistence acknowledgement. */ - queueMessage(text: string): void; + queueMessage(text: string, images?: readonly ImageInput[]): void; + /** + * Cancel (remove) a single queued steering message by id so it never runs + * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: success is + * confirmed by the `message-queue` SURFACE updating (the cancelled message + * leaves the snapshot); a cancel of an already-drained / unknown message is a + * silent server-side no-op. The caller optimistically hides the row; the + * surface update reconciles. `messageId` is the stable `QueuedMessage.id` + * the queue surface snapshot carries. No transcript change — a cancelled + * message is never delivered as steering. + */ + cancelQueuedMessage(messageId: string): void; setModel(model: string): void; /** + * Set (or clear, with null) the Named Agent selection snapshot this chat sends + * with `chat.send` (plan §7). The backend persists it on the conversation at + * creation so it freezes the friendly id/name + resolved model — later edits to + * the Named Agent do not change this conversation's selection. The raw model + * (already set via `setModel`) is sent alongside; the snapshot is display/identity + * metadata that preserves HOW it was selected. + */ + setNamedAgent(snapshot: NamedAgentSelectionSnapshot | null): void; + /** * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window. * Lowering it unloads older committed chunks (deferred via the gate while the * reader is scrolled up, catching up on the next mutation). Raising it @@ -158,20 +237,165 @@ export interface ChatStore { * running, the server's post-subscribe replay re-asserts `generating`. The * app store pairs this with a `chat.subscribe` for the conversation. */ - resync(): void; + resync(historyEpoch?: number): void; + /** Discard pre-replacement transcript state without loading an unseen conversation. */ + invalidateHistory(historyEpoch: number): void; dispose(): void; } export function createChatStore(deps: ChatStoreDependencies): ChatStore { + let pendingInputs = $state<readonly PendingInput[]>(deps.pendingInputStorage?.load() ?? []); + function savePendingInputs(next: readonly PendingInput[]): void { + deps.pendingInputStorage?.save(next); + pendingInputs = next; + } + function confirmInput(requestId: string): void { + savePendingInputs(pendingInputs.filter((input) => input.request.requestId !== requestId)); + } let transcript = $state<TranscriptState>(initialState()); let metrics = $state<MetricsState>(initialMetricsState()); let _pendingSync = $state(false); let _error = $state<string | null>(null); let _model = $state<string | undefined>(deps.model); + let _namedAgent = $state<NamedAgentSelectionSnapshot | null>(null); + let cacheTimer = $state<CacheTimerState>(initialCacheTimerState()); + const now = deps.now ?? Date.now; let disposed = false; + let rerunRequested = false; + let historyEpoch: number | undefined; + let revision = 0; + let reloadWindow = false; + let earliestSeq = $state<number | null>(null); + let recoveringTurnId: string | undefined; + let recovering = $state(false); + const finishedRecoveryTurns = new Set<string>(); + let finalSyncRequested = false; + let recoveryPaused = false; + const timer = deps.recoveryTimer ?? { + schedule: (callback: () => void, delay: number) => setTimeout(callback, delay), + cancel: (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>), + }; + let recoveryTimer: unknown; + + function cancelRecoveryTimer(): void { + if (recoveryTimer !== undefined) timer.cancel(recoveryTimer); + recoveryTimer = undefined; + } + + function scheduleRecovery(): void { + if (disposed || !recovering || recoveryPaused || recoveryTimer !== undefined) return; + recoveryTimer = timer.schedule(() => { + recoveryTimer = undefined; + flushEvents(); + void syncTail(initialWindowSize(chatLimit)); + }, 1000); + } + + function resetTranscript(epoch: number): void { + historyEpoch = epoch; + revision++; + reloadWindow = true; + transcript = initialState(); + if (recoveringTurnId !== undefined) + transcript = markHistoryRecovering(transcript, recoveringTurnId); + earliestSeq = null; + pendingEvents = []; + } + function hasEarlier(): boolean { + return selectHasEarlier(transcript, earliestSeq); + } + + /** The most recent turn's id (resolves a seeded turn gap when it is stamped). */ + function lastTurnIdOf(m: MetricsState): string | undefined { + const entries = selectOrderedTurnMetrics(m); + return entries[entries.length - 1]?.turnId; + } let chatLimit = normalizeChatLimit(deps.chatLimit); + // Memoized projections, keyed by state-object identity (the reducers are + // immutable, so identity == content). The getters are read several times per + // render (ChatPane + ChatView); returning the SAME array for an unchanged + // state keeps downstream `$derived`s (grouping, metrics placement) from + // recomputing on every read. Plain closures, not `$derived` — a store factory + // may run inside a component effect, which would own (and later destroy) it. + const memo = <S, R>(fn: (s: S) => R): ((s: S) => R) => { + let lastIn: S | undefined; + let lastOut: R | undefined; + return (s) => { + if (lastOut === undefined || s !== lastIn) { + lastIn = s; + lastOut = fn(s); + } + return lastOut; + }; + }; + const chunksOf = memo(selectChunks); + const turnMetricsOf = memo(selectOrderedTurnMetrics); + + // Buffered live events awaiting a scheduled flush (see `scheduleFlush`). + let pendingEvents: ChatDeltaMessage["event"][] = []; + let flushScheduled = false; + + /** Fold every buffered event in one pass, then trim + sync once. */ + function flushEvents(): void { + flushScheduled = false; + if (disposed || pendingEvents.length === 0) return; + const batch = pendingEvents; + pendingEvents = []; + applyEvents(batch); + } + + function applyEvents(events: readonly ChatDeltaMessage["event"][]): void { + let t = transcript; + let m = metrics; + let sealed = false; + let failed = false; + let recoveryEnded = false; + let c = cacheTimer; + const at = now(); + for (const event of events) { + t = foldEvent(t, event); + m = foldMetricsEvent(m, event); + const mNow = m; + c = foldCacheTimerEvent(c, event, at, () => lastTurnIdOf(mNow)); + if ( + event.type === "turn-ended" && + recovering && + (recoveringTurnId === undefined || event.turnId === recoveringTurnId) + ) { + finishedRecoveryTurns.add(event.turnId); + finalSyncRequested = true; + recoveringTurnId = undefined; + recovering = false; + recoveryEnded = true; + cancelRecoveryTimer(); + } + if (event.type === "turn-sealed") sealed = true; + else if (event.type === "error") failed = true; + } + transcript = t; + metrics = m; + cacheTimer = c; + maybeTrim(); + if (recovering && recoveringTurnId !== undefined) { + // done/error/seal can precede persistence and the real terminal. Keep + // lifecycle evidence until turn-ended, not merely until a content event. + transcript = markHistoryRecovering(transcript, recoveringTurnId); + } + if (recoveryEnded) { + void syncTail(initialWindowSize(chatLimit)); + void syncMetrics(); + } else if (sealed) { + void syncTail(); + void syncMetrics(); + } else if (failed) { + // A turn that FAILS never seals, but the backend still persists the + // metrics it accumulated — pull them so the turn gets its row. + void syncMetrics(); + } + } + /** * Enforce the chat limit after a transcript mutation — unless the injected * gate says the reader is scrolled up (then defer; the next mutation retries @@ -191,27 +415,142 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { * between the cache and the fetched window. */ async function syncTail(coldLimit?: number): Promise<void> { - if (disposed || _pendingSync) return; + if (disposed) return; + if (_pendingSync) { + rerunRequested = true; + return; + } _pendingSync = true; - try { - const since = await deps.cache.sinceSeq(deps.conversationId); - const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined; - const res = await deps.historySync(deps.conversationId, since, window); - const merged = await deps.cache.commit(deps.conversationId, res.chunks); - transcript = applyHistory(transcript, merged); - maybeTrim(); - _error = null; - } catch (err) { - _error = err instanceof Error ? err.message : String(err); - } finally { - _pendingSync = false; + // One request plus at most two sequential reruns. Continued churn waits for + // the next reconnect/seal/explicit resync, never a microtask refetch loop. + let attempts = 0; + do { + attempts++; + rerunRequested = false; + try { + const currentRevision = revision; + let cachedEpoch = await deps.cache.historyEpoch(deps.conversationId); + if (historyEpoch !== undefined && cachedEpoch !== historyEpoch) { + await deps.cache.reset(deps.conversationId, historyEpoch); + cachedEpoch = await deps.cache.historyEpoch(deps.conversationId); + } + const since = + reloadWindow || cachedEpoch === undefined + ? 0 + : await deps.cache.sinceSeq(deps.conversationId); + if (disposed) break; + if (revision !== currentRevision) { + rerunRequested = true; + continue; + } + const limit = + since === 0 + ? reloadWindow || recovering || finishedRecoveryTurns.size > 0 + ? initialWindowSize(chatLimit) + : coldLimit + : undefined; + const finishedAtRequest = [...finishedRecoveryTurns]; + finalSyncRequested = false; + const res = await deps.historySync( + deps.conversationId, + since, + limit === undefined ? undefined : { limit }, + ); + if (disposed) break; + if (revision !== currentRevision) { + rerunRequested = true; + continue; + } + const mismatch = + (cachedEpoch !== undefined && cachedEpoch !== res.historyEpoch) || + (historyEpoch !== undefined && historyEpoch !== res.historyEpoch); + if (mismatch && since !== 0) { + // A cursor response cannot replace a different history. Confirm with + // a new full-window request; don't mistake a lower epoch for staleness. + reloadWindow = true; + rerunRequested = true; + continue; + } + let merged: readonly StoredChunk[]; + if (mismatch || cachedEpoch === undefined) { + const replaced = await deps.cache.replace( + deps.conversationId, + res.chunks, + res.historyEpoch, + cachedEpoch, + ); + const replacedEpoch = await deps.cache.historyEpoch(deps.conversationId); + if (disposed) break; + if (revision !== currentRevision || !replaced || replacedEpoch !== res.historyEpoch) { + rerunRequested = true; + continue; + } + if (mismatch) resetTranscript(res.historyEpoch); + merged = res.chunks; + } else { + merged = await deps.cache.commit(deps.conversationId, res.chunks, res.historyEpoch); + const committedEpoch = await deps.cache.historyEpoch(deps.conversationId); + if (disposed) break; + if (revision !== currentRevision || committedEpoch !== res.historyEpoch) { + rerunRequested = true; + continue; + } + } + reloadWindow = false; + historyEpoch = res.historyEpoch; + if (since === 0 && (limit === undefined || res.chunks.length < limit)) + earliestSeq = merged[0]?.seq ?? 0; + flushEvents(); + transcript = applyHistory(transcript, merged, finishedAtRequest); + for (const turnId of finishedAtRequest) finishedRecoveryTurns.delete(turnId); + maybeTrim(); + _error = null; + } catch (err) { + _error = err instanceof Error ? err.message : String(err); + } + } while (rerunRequested && !disposed && attempts < 3); + if (rerunRequested && !disposed) { + recoveryPaused = true; + cancelRecoveryTimer(); + _error = "Conversation history changed repeatedly; sync paused until the next refresh."; + } + _pendingSync = false; + // Schedule AFTER the bounded sync burst. No interval can queue unbounded + // reruns behind a slow request; repeated notices coalesce on the same clock. + scheduleRecovery(); + // A terminal landing during the third (bounded epoch) attempt still owns + // one post-terminal request. It is a new external trigger, not an epoch + // retry loop; consume the flag before starting its bounded burst. + if (finalSyncRequested && !disposed) { + finalSyncRequested = false; + void syncTail(initialWindowSize(chatLimit)); } } - async function syncMetrics(): Promise<void> { + let pendingMetrics: Promise<void> | undefined; + let rerunMetrics = false; + function syncMetrics(): Promise<void> { + if (pendingMetrics !== undefined) { + rerunMetrics = true; + return pendingMetrics; + } + pendingMetrics = (async () => { + for (let attempt = 0; attempt < 2; attempt++) { + rerunMetrics = false; + await fetchMetrics(); + if (!rerunMetrics || disposed) break; + } + })().finally(() => { + pendingMetrics = undefined; + }); + return pendingMetrics; + } + + async function fetchMetrics(): Promise<void> { if (disposed) return; try { const res = await deps.metricsSync(deps.conversationId); + if (disposed) return; metrics = applyDurableMetrics(metrics, res.turns); } catch (err) { // Metrics fetch failure must not block history sync or throw; @@ -229,14 +568,27 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { * newest `count` of them). Shared by `showEarlier` and the raise-refill. */ async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> { + const currentRevision = revision; let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest); + if (disposed || revision !== currentRevision) return []; const oldestKnown = earlier[0]?.seq ?? oldest; - if (earlier.length < want && oldestKnown > 1) { + if (earlier.length < want && (earliestSeq === null || oldestKnown > earliestSeq)) { const res = await deps.historySync(deps.conversationId, 0, { beforeSeq: oldestKnown, limit: want - earlier.length, }); - const merged = await deps.cache.commit(deps.conversationId, res.chunks); + if (disposed || revision !== currentRevision) return []; + if (res.historyEpoch !== historyEpoch) { + // Confirm a backfill mismatch with a fresh full window as well. Bump + // the local revision so an overlapping old tail cannot be applied. + revision++; + reloadWindow = true; + await syncTail(initialWindowSize(chatLimit)); + return []; + } + const merged = await deps.cache.commit(deps.conversationId, res.chunks, res.historyEpoch); + if (disposed || revision !== currentRevision) return []; + if (res.chunks.length < want - earlier.length) earliestSeq = merged[0]?.seq ?? 0; earlier = merged.filter((c) => c.seq < oldest); } return earlier; @@ -246,15 +598,15 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { * Refill toward the fresh-load window after a limit RAISE: pull older * history (cache first, then server) so the loaded set grows to match what a * fresh `load()` would show at the new limit. No-op when already at the - * origin (seq 1) or already within the window. `restoreEarlier` re-derives + * known history boundary or already within the window. `restoreEarlier` re-derives * the window start at apply time, so a delta landing during the await can't * corrupt the merge. NOT gated (refilling prepends above the viewport; the * caller preserves scroll position). */ async function refill(): Promise<void> { if (disposed) return; + if (!hasEarlier()) return; const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq; - if (oldest <= 1) return; const want = initialWindowSize(chatLimit) - transcript.committed.length; if (want <= 0) return; try { @@ -268,14 +620,18 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { } return { + get pendingInputs() { + return pendingInputs; + }, + discardPendingInput: confirmInput, get messages(): readonly ChatMessage[] { return selectMessages(transcript); }, get chunks(): readonly RenderedChunk[] { - return selectChunks(transcript); + return chunksOf(transcript); }, get turnMetrics(): readonly TurnMetricsEntry[] { - return selectOrderedTurnMetrics(metrics); + return turnMetricsOf(metrics); }, get currentContextSize(): number | undefined { return selectCurrentContextSize(metrics); @@ -286,6 +642,9 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { get providerRetry(): TurnProviderRetryEvent | null { return selectProviderRetry(transcript); }, + get recovering(): boolean { + return recovering; + }, get pendingSync(): boolean { return _pendingSync; }, @@ -296,55 +655,164 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { return _model; }, get hasEarlier(): boolean { - return selectHasEarlier(transcript); + return hasEarlier(); }, get thinkingKeyBase(): number { return transcript.hiddenThinkingCount; }, + get cacheTimer(): CacheTimerState { + return cacheTimer; + }, + + noteLastActivity(at: number): void { + if (selectGenerating(transcript)) return; + cacheTimer = seedTurnGap(cacheTimer, at); + }, - handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void { + handleDelta( + msg: + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage, + ): void { + if (disposed) return; + if (msg.type === "chat.accepted") { + if (msg.conversationId === deps.conversationId) confirmInput(msg.requestId); + return; + } + if (msg.type === "chat.subscription-rejected") { + if (msg.conversationId === deps.conversationId) { + _error = + "Live updates could not be subscribed. Message delivery is independent; saved inputs remain available below."; + void syncTail(); + } + return; + } + if (msg.type === "chat.resync-required") { + if (msg.conversationId !== deps.conversationId) return; + flushEvents(); + const target = + msg.turnId ?? (transcript.generating ? transcript.currentTurnId : null) ?? undefined; + // Oversized status packets need not identify an active turn. Refresh + // once, but do not invent generation or poll an idle conversation. + if (target === undefined) { + void syncTail(initialWindowSize(chatLimit)); + return; + } + const first = !recovering || recoveryPaused; + recoveryPaused = false; + recovering = true; + recoveringTurnId = target; + if (recoveringTurnId !== undefined) + transcript = markHistoryRecovering(transcript, recoveringTurnId); + // Immediate first handoff, including a coalesced rerun behind a cold + // load. Repeats wait for the existing fetch/one-second timer. + if (first) void syncTail(initialWindowSize(chatLimit)); + return; + } if (msg.type === "chat.error") { if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) { return; } _error = msg.message; + if (msg.requestId !== undefined) { + savePendingInputs( + pendingInputs.map((input) => + input.request.requestId === msg.requestId + ? { ...input, rejection: msg.message } + : input, + ), + ); + transcript = removeLocalEcho(transcript, msg.requestId); + } + return; + } + if (msg.type === "chat.queued") { + if (disposed || msg.conversationId !== deps.conversationId) return; + // A queued live opening may already have confirmed the echoed text. + // Fold it first; turn-start alone must not protect still-local artifacts. + flushEvents(); + if (msg.requestId !== undefined) { + confirmInput(msg.requestId); + transcript = removeLocalEcho(transcript, msg.requestId); + } return; } if (msg.event.conversationId !== deps.conversationId) { return; } - transcript = foldEvent(transcript, msg.event); - metrics = foldMetricsEvent(metrics, msg.event); - maybeTrim(); - if (transcript.sealedTurnId !== null) { - void syncTail(); - void syncMetrics(); + if (deps.scheduleFlush === undefined) { + applyEvents([msg.event]); + return; + } + pendingEvents.push(msg.event); + if (!flushScheduled) { + flushScheduled = true; + deps.scheduleFlush(flushEvents); } }, send(text: string, images?: readonly ImageInput[]): void { - transcript = appendUserMessage(transcript, text, images); - maybeTrim(); - const msg: ChatSendMessage = { + flushEvents(); + // Sending ends the wait since the last turn: stamp it on that turn. + cacheTimer = stopTurnGap(cacheTimer, now(), () => lastTurnIdOf(metrics)); + const requestId = deps.createRequestId(); + const msg: ChatSendMessage & { requestId: string } = { type: "chat.send", + requestId, conversationId: deps.conversationId, message: text, ...(_model !== undefined ? { model: _model } : {}), + ...(_namedAgent !== null ? { namedAgent: _namedAgent } : {}), ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}), ...(images !== undefined && images.length > 0 ? { images: [...images] } : {}), }; + try { + savePendingInputs([...pendingInputs, { request: msg }]); + } catch (error) { + deps.onError?.("Unable to save input; the message has not been sent", error); + throw error; + } + transcript = appendUserMessage(transcript, text, images, requestId); + maybeTrim(); deps.transport.send(msg); }, - queueMessage(text: string): void { + queueMessage(text: string, images?: readonly ImageInput[]): void { const trimmed = text.trim(); - if (trimmed.length === 0) return; - const msg: ChatQueueMessage = { + if (trimmed.length === 0 && !images?.length) return; + const msg: ChatQueueMessage & { requestId: string } = { type: "chat.queue", + requestId: deps.createRequestId(), conversationId: deps.conversationId, text: trimmed, + ...(images !== undefined && images.length > 0 ? { images: [...images] } : {}), ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}), }; + try { + savePendingInputs([...pendingInputs, { request: msg }]); + } catch (error) { + deps.onError?.("Unable to save input; the message has not been queued", error); + throw error; + } + deps.transport.send(msg); + }, + + cancelQueuedMessage(messageId: string): void { + // Fire-and-forget + idempotent (per the contract). The caller optimistically + // hides the row; the message-queue surface update reconciles. A cancel of + // an already-drained / unknown message is a silent server no-op, so there + // is no local-state change to make and nothing to roll back on a stray + // `chat.error` (which only fires for a malformed send — a client that + // sends the id it just rendered never hits it). + const msg: ChatQueueCancelMessage = { + type: "chat.queue.cancel", + conversationId: deps.conversationId, + messageId, + }; deps.transport.send(msg); }, @@ -352,6 +820,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { _model = model; }, + setNamedAgent(snapshot: NamedAgentSelectionSnapshot | null): void { + _namedAgent = snapshot; + }, + async setChatLimit(limit: number): Promise<void> { const prev = chatLimit; chatLimit = normalizeChatLimit(limit); @@ -369,8 +841,11 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { // server instead (CR-5 `?limit=`), so a huge conversation never ships // whole. The post-sync window re-asserts the cap either way. const windowSize = initialWindowSize(chatLimit); + const currentRevision = revision; + const cachedEpoch = await deps.cache.historyEpoch(deps.conversationId); const cached = await deps.cache.load(deps.conversationId); - if (cached.length > 0) { + if (!disposed && revision === currentRevision && cachedEpoch !== undefined) { + historyEpoch = cachedEpoch; transcript = windowTranscript(applyHistory(transcript, cached), windowSize); } await syncTail(windowSize); @@ -381,7 +856,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { async showEarlier(): Promise<void> { if (disposed) return; const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq; - if (oldest <= 1) return; + if (!hasEarlier()) return; const want = unloadCount(chatLimit); try { const earlier = await backfillOlder(oldest, want); @@ -392,18 +867,34 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { } }, - resync(): void { + invalidateHistory(epoch): void { + if (!disposed && (historyEpoch === undefined || epoch > historyEpoch)) resetTranscript(epoch); + }, + + resync(epoch?: number): void { if (disposed) return; + if (epoch !== undefined && (historyEpoch === undefined || epoch > historyEpoch)) + resetTranscript(epoch); // A turn may have sealed while we were disconnected (missed `turn-sealed`): // clear the now-stale spinner BEFORE re-subscribing, so a finished turn // doesn't spin forever. A still-running turn's replay re-asserts it. - transcript = clearGenerating(transcript); + flushEvents(); + recoveryPaused = false; + if (epoch === undefined) { + recovering = false; + recoveringTurnId = undefined; + cancelRecoveryTimer(); + transcript = clearGenerating(transcript); + } void syncTail(); void syncMetrics(); }, dispose(): void { disposed = true; + cancelRecoveryTimer(); + finishedRecoveryTurns.clear(); + pendingEvents = []; }, }; } diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts index 8f36994..5040e67 100644 --- a/src/features/chat/store.test.ts +++ b/src/features/chat/store.test.ts @@ -1,5 +1,8 @@ import type { AgentEvent, ImageInput, StepId, StoredChunk } from "@dispatch/wire"; +import { IDBFactory, IDBKeyRange } from "fake-indexeddb"; import { describe, expect, it, vi } from "vitest"; +import { createIdbChunkStore } from "../../adapters/idb"; +import { createConversationCache } from "../conversation-cache"; import { createChatStore } from "./store.svelte"; import { createFakeCache, @@ -29,6 +32,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -61,6 +65,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -104,6 +109,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -128,6 +134,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, model: "openai/gpt-4", transport: transport.impl, @@ -150,6 +157,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -182,6 +190,7 @@ describe("createChatStore", () => { it("send omits images on the wire when none are staged (backward compatible)", () => { const transport = createFakeTransport(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: createFakeHistorySync().impl, @@ -198,6 +207,7 @@ describe("createChatStore", () => { it("send omits images on the wire for an empty array", () => { const transport = createFakeTransport(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: createFakeHistorySync().impl, @@ -214,6 +224,7 @@ describe("createChatStore", () => { it("send allows an images-only message (empty text)", () => { const transport = createFakeTransport(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: createFakeHistorySync().impl, @@ -238,6 +249,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -252,16 +264,52 @@ describe("createChatStore", () => { expect(transport.sentQueue[0]?.type).toBe("chat.queue"); expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID); expect(transport.sentQueue[0]?.text).toBe("steer left"); + expect(transport.sentQueue[0]).not.toHaveProperty("images"); store.dispose(); }); + it.each([" describe this ", "", " "])("queues images with text %j", (text) => { + const transport = createFakeTransport(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + workspaceId: "images-workspace", + transport: transport.impl, + historySync: createFakeHistorySync().impl, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + }); + const images: readonly ImageInput[] = [ + { url: "data:image/png;base64,AAAA", mimeType: "image/png" }, + { url: "https://example.com/photo.jpg" }, + ]; + + store.queueMessage(text, images); + + expect(transport.sentQueue).toEqual([ + { + type: "chat.queue", + requestId: expect.any(String), + conversationId: CONV_ID, + workspaceId: "images-workspace", + text: text.trim(), + images, + }, + ]); + expect(transport.sentQueue[0]?.images).not.toBe(images); + expect(transport.sent).toHaveLength(0); + expect(store.chunks).toHaveLength(0); + store.dispose(); + }); + it("trims whitespace before sending", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -282,6 +330,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -291,6 +340,7 @@ describe("createChatStore", () => { store.queueMessage(" "); store.queueMessage(""); + store.queueMessage("", []); expect(transport.sentQueue).toHaveLength(0); @@ -303,6 +353,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -318,12 +369,88 @@ describe("createChatStore", () => { }); }); + describe("cancelQueuedMessage (chat.queue.cancel)", () => { + it("posts a chat.queue.cancel with conversationId + messageId", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.cancelQueuedMessage("msg-42"); + + expect(transport.sent).toHaveLength(0); // chat.send stays empty + expect(transport.sentQueue).toHaveLength(0); // chat.queue stays empty + expect(transport.sentCancels).toHaveLength(1); + expect(transport.sentCancels[0]?.type).toBe("chat.queue.cancel"); + expect(transport.sentCancels[0]?.conversationId).toBe(CONV_ID); + expect(transport.sentCancels[0]?.messageId).toBe("msg-42"); + + store.dispose(); + }); + + it("does NOT touch the transcript (a cancelled message never runs)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.cancelQueuedMessage("msg-42"); + + expect(store.chunks).toHaveLength(0); // no transcript echo / change + expect(store.error).toBeNull(); + + store.dispose(); + }); + + it("sends for any messageId (cancel is idempotent server-side, no FE guard)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // The server no-ops an already-drained / unknown id; the FE fires-and- + // forgetgets, so even a repeat cancel is forwarded. + store.cancelQueuedMessage("msg-42"); + store.cancelQueuedMessage("msg-42"); + + expect(transport.sentCancels).toHaveLength(2); + expect(transport.sentCancels[1]?.messageId).toBe("msg-42"); + + store.dispose(); + }); + }); + it("chat.error sets error", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -353,6 +480,7 @@ describe("createChatStore", () => { historySync.returnChunks = [makeStoredChunk(3, "assistant")]; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -381,6 +509,7 @@ describe("createChatStore", () => { historySync.returnChunks = [makeStoredChunk(1, "assistant")]; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -402,6 +531,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -434,6 +564,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -456,7 +587,7 @@ describe("createChatStore", () => { store.dispose(); }); - it("overlapping syncs are guarded", async () => { + it("overlapping syncs coalesce into a sequential rerun", async () => { const transport = createFakeTransport(); const _historySync = createFakeHistorySync(); const metricsSync = createFakeMetricsSync(); @@ -474,10 +605,11 @@ describe("createChatStore", () => { if (callCount === 1) { await firstSyncPromise; } - return { chunks: [], latestSeq: sinceSeq }; + return { chunks: [], latestSeq: sinceSeq, historyEpoch: 0 }; }; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: slowHistorySync, @@ -501,7 +633,7 @@ describe("createChatStore", () => { // Release the first sync resolveFirstSync?.(); - await new Promise((r) => setTimeout(r, 10)); + await vi.waitFor(() => expect(callCount).toBe(2)); store.dispose(); }); @@ -512,6 +644,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -557,6 +690,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, model: "openai/gpt-4", transport: transport.impl, @@ -581,6 +715,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -604,6 +739,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -634,6 +770,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -654,6 +791,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -678,6 +816,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -716,6 +855,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -777,6 +917,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -821,6 +962,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -893,6 +1035,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -959,6 +1102,7 @@ describe("createChatStore", () => { ]; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -983,6 +1127,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1014,6 +1159,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1038,6 +1184,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1072,6 +1219,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1118,6 +1266,7 @@ describe("createChatStore", () => { const cache = createFakeCache(); let atBottom = false; // reader scrolled up const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1172,6 +1321,7 @@ describe("createChatStore", () => { const cache = createFakeCache(); let atBottom = false; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1210,6 +1360,7 @@ describe("createChatStore", () => { ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1239,6 +1390,7 @@ describe("createChatStore", () => { historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1274,6 +1426,7 @@ describe("createChatStore", () => { historySync.returnChunks = [makeStoredChunk(3)]; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1298,6 +1451,7 @@ describe("createChatStore", () => { historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1335,6 +1489,7 @@ describe("createChatStore", () => { ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1367,6 +1522,7 @@ describe("createChatStore", () => { ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1398,6 +1554,7 @@ describe("createChatStore", () => { ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1430,6 +1587,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1467,6 +1625,7 @@ describe("createChatStore", () => { Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)), ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1498,6 +1657,7 @@ describe("createChatStore", () => { // Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200. historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1525,6 +1685,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1564,6 +1725,7 @@ describe("createChatStore", () => { Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)), ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1573,6 +1735,7 @@ describe("createChatStore", () => { }); await store.load(); // only 50 chunks → all loaded, window starts at seq 1 expect(store.chunks).toHaveLength(50); + await store.showEarlier(); // Probe server exhaustion; seq 1 alone is not a boundary. expect(store.hasEarlier).toBe(false); const callsAfterLoad = historySync.calls.length; @@ -1590,6 +1753,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1619,6 +1783,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1634,3 +1799,467 @@ describe("createChatStore", () => { expect(metricsSync.calls).toHaveLength(0); }); }); + +describe("createChatStore — batched delta application (scheduleFlush)", () => { + function setup() { + const scheduled: (() => void)[] = []; + const historySync = createFakeHistorySync(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: historySync.impl, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + scheduleFlush: (flush) => scheduled.push(flush), + }); + const runScheduled = (): void => { + for (const f of scheduled.splice(0)) f(); + }; + return { store, scheduled, runScheduled, historySync }; + } + const text = (delta: string) => + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta }); + + it("queues a burst and applies it in ONE scheduled flush", () => { + const { store, scheduled, runScheduled } = setup(); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + for (let i = 0; i < 500; i++) store.handleDelta(text("x")); + + expect(scheduled).toHaveLength(1); + expect(store.chunks).toHaveLength(0); + + runScheduled(); + expect(store.chunks).toHaveLength(1); + expect((store.chunks[0]?.chunk as { text: string }).text).toBe("x".repeat(500)); + expect(store.generating).toBe(true); + store.dispose(); + }); + + it("schedules a new flush for events arriving after a flush", () => { + const { store, scheduled, runScheduled } = setup(); + store.handleDelta(text("a")); + runScheduled(); + store.handleDelta(text("b")); + expect(scheduled).toHaveLength(1); + runScheduled(); + expect((store.chunks[0]?.chunk as { text: string }).text).toBe("ab"); + store.dispose(); + }); + + it("a sealed turn in the batch still triggers a history sync", async () => { + const { store, runScheduled, historySync } = setup(); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(text("done")); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + expect(historySync.calls).toHaveLength(0); + runScheduled(); + await vi.waitFor(() => expect(historySync.calls.length).toBeGreaterThan(0)); + store.dispose(); + }); + + it("send() flushes queued events first so the user echo lands after them", () => { + const { store } = setup(); + store.handleDelta(text("earlier reply")); + store.send("next question"); + const roles = store.chunks.map((c) => c.role); + expect(roles).toEqual(["assistant", "user"]); + store.dispose(); + }); + + it("chunks getter returns a stable array for an unchanged transcript", () => { + const { store, runScheduled } = setup(); + store.handleDelta(text("a")); + runScheduled(); + expect(store.chunks).toBe(store.chunks); + store.dispose(); + }); + + it("a flush after dispose is a no-op", () => { + const { store, scheduled } = setup(); + store.handleDelta(text("a")); + store.dispose(); + for (const f of scheduled) f(); + expect(store.chunks).toHaveLength(0); + }); +}); + +describe("createChatStore — cacheTimer", () => { + function makeStore(now: () => number) { + return createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: createFakeHistorySync().impl, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + now, + }); + } + const call = (stepId: string, toolCallId: string) => + deltaEvent({ + type: "tool-call", + conversationId: CONV_ID, + turnId: "t1", + stepId: stepId as StepId, + toolCallId, + toolName: "read_file", + input: {}, + }); + const result = (stepId: string, toolCallId: string) => + deltaEvent({ + type: "tool-result", + conversationId: CONV_ID, + turnId: "t1", + stepId: stepId as StepId, + toolCallId, + toolName: "read_file", + content: "ok", + isError: false, + }); + const stepComplete = (stepId: string) => + deltaEvent({ + type: "step-complete", + conversationId: CONV_ID, + turnId: "t1", + stepId: stepId as StepId, + }); + const done = (turnId: string) => + deltaEvent({ type: "done", conversationId: CONV_ID, turnId, reason: "end-turn" }); + + it("is idle until something happens", () => { + const store = makeStore(() => 1000); + expect(store.cacheTimer.running).toBeNull(); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + expect(store.cacheTimer.running).toBeNull(); + store.dispose(); + }); + + it("times a step's tool execution with the injected clock and stamps it", () => { + let clock = 0; + const store = makeStore(() => clock); + store.handleDelta(call("t1#0", "a")); + clock = 1_000; + store.handleDelta(stepComplete("t1#0")); + expect(store.cacheTimer.running).toMatchObject({ kind: "step", startedAt: 1_000 }); + clock = 31_000; + store.handleDelta(result("t1#0", "a")); + expect(store.cacheTimer.running).toBeNull(); + expect(store.cacheTimer.stepStamps.get("t1#0")).toBe(30_000); + store.dispose(); + }); + + it("starts a turn gap on done and stamps it on send", () => { + let clock = 5_000; + const store = makeStore(() => clock); + store.handleDelta(done("t1")); + expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 5_000 }); + clock = 125_000; + store.send("next"); + expect(store.cacheTimer.running).toBeNull(); + expect(store.cacheTimer.turnStamps.get("t1")).toBe(120_000); + store.dispose(); + }); + + it("ignores another conversation's events", () => { + const store = makeStore(() => 5_000); + store.handleDelta( + deltaEvent({ type: "done", conversationId: "other", turnId: "t1", reason: "end-turn" }), + ); + expect(store.cacheTimer.running).toBeNull(); + store.dispose(); + }); + + it("noteLastActivity seeds a turn gap, keeps the later start, and is ignored while generating", () => { + const store = makeStore(() => 5_000); + store.noteLastActivity(3_000); + expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: null, startedAt: 3_000 }); + store.handleDelta(done("t1")); + expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 5_000 }); + store.noteLastActivity(4_000); // an older seed never rewinds it + expect(store.cacheTimer.running).toMatchObject({ startedAt: 5_000 }); + store.dispose(); + + const busy = makeStore(() => 5_000); + busy.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); + busy.noteLastActivity(4_000); + expect(busy.cacheTimer.running).toBeNull(); + busy.dispose(); + }); +}); + +describe("history epochs", () => { + it.each([ + "tail", + "backfill", + ])("recovers missed compaction on %s and exhausts non-1 history", async (path) => { + const history = createFakeHistorySync(); + const cache = createFakeCache(); + history.returnChunks = Array.from({ length: 20 }, (_, i) => makeStoredChunk(i + 1)); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: history.impl, + metricsSync: createFakeMetricsSync().impl, + cache: cache.impl, + chatLimit: 10, + }); + await store.load(); + history.historyEpoch = 1; + history.returnChunks = [makeStoredChunk(101), makeStoredChunk(102)]; + if (path === "tail") { + store.resync(); + await vi.waitFor(() => expect(store.chunks[0]?.seq).toBe(101)); + } else await store.showEarlier(); + expect(store.chunks.map((c) => c.seq)).toEqual([101, 102]); + expect((await cache.impl.load(CONV_ID)).map((c) => c.seq)).toEqual([101, 102]); + expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } }); + expect(store.hasEarlier).toBe(false); + const calls = history.calls.length; + await store.showEarlier(); + expect(history.calls).toHaveLength(calls); + store.dispose(); + }); +}); + +it("a compaction broadcast during an in-flight fetch discards the old response", async () => { + const history = createFakeHistorySync(); + history.returnChunks = [makeStoredChunk(101)]; + let resolve!: (res: import("@dispatch/transport-contract").ConversationHistoryResponse) => void; + const first = new Promise<import("@dispatch/transport-contract").ConversationHistoryResponse>( + (r) => { + resolve = r; + }, + ); + const sync = vi + .fn<import("./ports").HistorySync>() + .mockImplementationOnce(() => first) + .mockImplementation(history.impl); + const cache = createFakeCache(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: sync, + metricsSync: createFakeMetricsSync().impl, + cache: cache.impl, + }); + const loading = store.load(); + await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(1)); + history.historyEpoch = 1; + store.resync(1); + resolve({ chunks: [makeStoredChunk(1)], latestSeq: 1, historyEpoch: 0 }); + await loading; + expect(sync).toHaveBeenCalledTimes(2); + expect(store.chunks.map((c) => c.seq)).toEqual([101]); + expect((await cache.impl.load(CONV_ID)).map((c) => c.seq)).toEqual([101]); + store.dispose(); +}); + +it("reloads unknown legacy cache epochs rather than merging pre-upgrade rows", async () => { + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + const storage = createIdbChunkStore({ indexedDB: new IDBFactory() }); + const cache = createConversationCache(storage); + await storage.append(CONV_ID, [makeStoredChunk(1)]); + const history = createFakeHistorySync(); + history.returnChunks = [makeStoredChunk(101)]; + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: history.impl, + metricsSync: createFakeMetricsSync().impl, + cache, + }); + try { + await store.load(); + expect(history.calls[0]).toMatchObject({ sinceSeq: 0 }); + expect(store.chunks.map((c) => c.seq)).toEqual([101]); + expect(await cache.historyEpoch(CONV_ID)).toBe(0); + expect((await cache.load(CONV_ID)).map((c) => c.seq)).toEqual([101]); + } finally { + store.dispose(); + vi.unstubAllGlobals(); + } +}); + +describe("server epoch rollback", () => { + it.each([ + "tail", + "backfill", + ])("converges on %s with a real cache and a fresh bounded fetch", async (path) => { + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + const storage = createIdbChunkStore({ indexedDB: new IDBFactory() }); + const cache = createConversationCache(storage); + const old = Array.from({ length: 20 }, (_, i) => makeStoredChunk(i + 1)); + await cache.commit(CONV_ID, path === "backfill" ? old.slice(-7) : old, 5); + const history = createFakeHistorySync(); + history.historyEpoch = 5; + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: history.impl, + metricsSync: createFakeMetricsSync().impl, + cache, + chatLimit: 10, + }); + try { + await store.load(); + history.historyEpoch = 0; + // Recreated ids can reuse seqs with completely different content. + const restored: StoredChunk = { + seq: 1, + role: "user", + chunk: { type: "text", text: "restored" }, + }; + history.returnChunks = [restored]; + const before = history.calls.length; + if (path === "tail") { + store.resync(); + await vi.waitFor(() => expect(store.pendingSync).toBe(false)); + } else await store.showEarlier(); + expect(history.calls.length - before).toBeLessThanOrEqual(2); + expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } }); + expect(await cache.historyEpoch(CONV_ID)).toBe(0); + expect(await cache.load(CONV_ID)).toEqual([restored]); + expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk]); + expect(store.hasEarlier).toBe(false); + expect(store.error).toBeNull(); + } finally { + store.dispose(); + vi.unstubAllGlobals(); + } + }); + + it("bounds reruns even when every response is invalidated by another compaction", async () => { + let calls = 0; + let churn = true; + let store!: ReturnType<typeof createChatStore>; + const sync: import("./ports").HistorySync = async () => { + calls++; + // Stop the old broken loop safely rather than hanging the test worker. + if (churn && calls <= 6) store.resync(calls); + return { chunks: [], latestSeq: 0, historyEpoch: calls - 1 }; + }; + store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: sync, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + }); + try { + await store.load(); + expect(calls).toBe(3); + expect(store.pendingSync).toBe(false); + expect(store.error).toMatch(/history changed repeatedly/i); + churn = false; + store.resync(); + await vi.waitFor(() => expect(store.pendingSync).toBe(false)); + expect(calls).toBe(4); + expect(store.error).toBeNull(); + } finally { + store.dispose(); + } + }); +}); + +it.each([ + "tail", + "backfill", +])("rejects a delayed old %s after a lower-epoch history is observed", async (delayed) => { + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() })); + await cache.commit( + CONV_ID, + Array.from({ length: 7 }, (_, i) => makeStoredChunk(14 + i)), + 5, + ); + const restored: StoredChunk = { + seq: 1, + role: "user", + chunk: { type: "text", text: "new history" }, + }; + type Response = import("@dispatch/transport-contract").ConversationHistoryResponse; + let resolve!: (res: Response) => void; + const slow = new Promise<Response>((r) => { + resolve = r; + }); + let delayNext = false; + let epoch = 5; + const sync = vi.fn<import("./ports").HistorySync>().mockImplementation(async () => { + if (delayNext) { + delayNext = false; + return slow; + } + return { + chunks: epoch === 5 ? [] : [restored], + latestSeq: epoch === 5 ? 20 : 1, + historyEpoch: epoch, + }; + }); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: sync, + metricsSync: createFakeMetricsSync().impl, + cache, + chatLimit: 10, + }); + try { + await store.load(); + delayNext = true; + let earlier: Promise<void>; + if (delayed === "tail") { + store.resync(); + await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(2)); + epoch = 0; + earlier = store.showEarlier(); + await earlier; // Invalidates the old tail and requests a sequential rerun. + } else { + earlier = store.showEarlier(); + await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(2)); + epoch = 0; + store.resync(); + await vi.waitFor(() => expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk])); + } + resolve({ chunks: [makeStoredChunk(21)], latestSeq: 21, historyEpoch: 5 }); + await earlier; + await vi.waitFor(() => expect(store.pendingSync).toBe(false)); + expect(await cache.historyEpoch(CONV_ID)).toBe(0); + expect(await cache.load(CONV_ID)).toEqual([restored]); + expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk]); + expect(sync).toHaveBeenCalledTimes(4); + } finally { + store.dispose(); + vi.unstubAllGlobals(); + } +}); + +it("replaces an empty high-epoch cache with the server's empty lower epoch in one request", async () => { + const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() })); + await cache.reset(CONV_ID, 8); + const history = createFakeHistorySync(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: history.impl, + metricsSync: createFakeMetricsSync().impl, + cache, + }); + try { + await store.load(); + expect(history.calls).toHaveLength(1); + expect(await cache.historyEpoch(CONV_ID)).toBe(0); + expect(await cache.load(CONV_ID)).toEqual([]); + expect(store.chunks).toEqual([]); + expect(store.hasEarlier).toBe(false); + } finally { + store.dispose(); + } +}); diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts index 26c5590..41cfb3b 100644 --- a/src/features/chat/test-helpers.ts +++ b/src/features/chat/test-helpers.ts @@ -1,4 +1,8 @@ -import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract"; +import type { + ChatQueueCancelMessage, + ChatQueueMessage, + ChatSendMessage, +} from "@dispatch/transport-contract"; import type { StoredChunk } from "@dispatch/wire"; import type { ConversationCache } from "../conversation-cache"; import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; @@ -8,19 +12,25 @@ export interface FakeTransport { readonly sent: ChatSendMessage[]; /** All `chat.queue` messages sent through the fake transport. */ readonly sentQueue: ChatQueueMessage[]; + /** All `chat.queue.cancel` messages sent through the fake transport. */ + readonly sentCancels: ChatQueueCancelMessage[]; readonly impl: ChatTransport; } export function createFakeTransport(): FakeTransport { const sent: ChatSendMessage[] = []; const sentQueue: ChatQueueMessage[] = []; + const sentCancels: ChatQueueCancelMessage[] = []; return { sent, sentQueue, + sentCancels, impl: { send(msg) { if (msg.type === "chat.queue") { sentQueue.push(msg); + } else if (msg.type === "chat.queue.cancel") { + sentCancels.push(msg); } else { sent.push(msg); } @@ -33,14 +43,22 @@ export interface FakeHistorySync { readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>; /** Set the chunks to return on the next call. */ returnChunks: readonly StoredChunk[]; + historyEpoch: number; readonly impl: HistorySync; } export function createFakeHistorySync(): FakeHistorySync { const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = []; let returnChunks: readonly StoredChunk[] = []; + let historyEpoch = 0; return { calls, + get historyEpoch() { + return historyEpoch; + }, + set historyEpoch(v: number) { + historyEpoch = v; + }, get returnChunks() { return returnChunks; }, @@ -62,7 +80,7 @@ export function createFakeHistorySync(): FakeHistorySync { chunks = chunks.slice(-window.limit); } const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq; - return { chunks, latestSeq }; + return { chunks, latestSeq, historyEpoch }; }, }; } @@ -112,13 +130,28 @@ export interface FakeCache { export function createFakeCache(): FakeCache { const store = new Map<string, StoredChunk[]>(); + const epochs = new Map<string, number>(); return { store, impl: { + async historyEpoch(id) { + return epochs.get(id) ?? 0; + }, + async reset(id, epoch) { + store.delete(id); + epochs.set(id, epoch); + }, + async replace(id, incoming, epoch, expectedEpoch) { + if ((epochs.get(id) ?? 0) !== expectedEpoch) return false; + store.set(id, [...incoming]); + epochs.set(id, epoch); + return true; + }, async load(conversationId) { return store.get(conversationId) ?? []; }, - async commit(conversationId, incoming) { + async commit(conversationId, incoming, epoch) { + if (epoch !== undefined) epochs.set(conversationId, epoch); const existing = store.get(conversationId) ?? []; const seen = new Set(existing.map((c) => c.seq)); const toAppend = incoming.filter((c) => !seen.has(c.seq)); diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 5f8067d..f98fdbb 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -3,12 +3,26 @@ import { render, screen, within } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { RenderedChunk } from "../../core/chunks"; -import type { TurnMetricsEntry } from "../../core/metrics"; +import { + type CacheTimerState, + initialCacheTimerState, + type TurnMetricsEntry, +} from "../../core/metrics"; +import type { ConvertedJpeg } from "./adapter/image-encode"; +import CacheTimerBadge from "./ui/CacheTimerBadge.svelte"; import ChatView from "./ui/ChatView.svelte"; import Composer from "./ui/Composer.svelte"; import ModelSelector from "./ui/ModelSelector.svelte"; import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte"; +/** Collapsed card bodies render lazily — open every card so its body mounts. */ +async function openAllCollapses(container: HTMLElement): Promise<void> { + const boxes = container.querySelectorAll<HTMLInputElement>('.collapse > input[type="checkbox"]'); + for (const box of boxes) { + if (!box.checked) await userEvent.click(box); + } +} + describe("ChatView", () => { it("renders a message's text chunk", () => { const chunks: RenderedChunk[] = [ @@ -42,6 +56,25 @@ describe("ChatView", () => { expect(screen.getByText("Hello!")).toBeInTheDocument(); }); + it("renders local-path Markdown images through the backend image route", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "text", text: "" }, + provisional: false, + }, + ]; + + const { container } = render(ChatView, { + props: { chunks, apiBaseUrl: "http://localhost:24203", cwd: "/w/foo" }, + }); + + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "http://localhost:24203/local-image?path=%2Fhome%2Fu%2Fpic.png", + ); + }); + it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => { const chunks: RenderedChunk[] = [ { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false }, @@ -81,7 +114,7 @@ describe("ChatView", () => { expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument(); }); - it("renders tool-call chunks", () => { + it("renders tool-call chunks", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -96,16 +129,17 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks } }); + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); - expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("Read file")).toBeInTheDocument(); const pre = screen.getByText((content, element) => { return element?.tagName === "PRE" && content.includes("/tmp/test.txt"); }); expect(pre).toBeInTheDocument(); }); - it("renders tool-result chunks", () => { + it("renders tool-result chunks", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -121,9 +155,10 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks } }); + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); - expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("Read file")).toBeInTheDocument(); expect(screen.getByText("file contents here")).toBeInTheDocument(); }); @@ -199,7 +234,7 @@ describe("ChatView", () => { expect(log.children).toHaveLength(0); }); - it("groups batched tool calls (shared stepId) into one DaisyUI list", () => { + it("renders parallel tool calls (shared stepId) as separate bubbles", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -241,15 +276,18 @@ describe("ChatView", () => { ]; const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); - // Batched calls render as collapsible cards (one per call), not a list. + // Parallel calls render as independent collapsible cards (one per bubble). const collapses = container.querySelectorAll(".collapse"); expect(collapses).toHaveLength(2); + expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2); + // Both call names + the available result are shown; the result is absorbed // (no standalone tool-result card). - expect(screen.getByText("read_file")).toBeInTheDocument(); - expect(screen.getByText("list_dir")).toBeInTheDocument(); + expect(screen.getByText("Read file")).toBeInTheDocument(); + expect(screen.getByText("List dir")).toBeInTheDocument(); expect(screen.getByText("contents-of-a")).toBeInTheDocument(); }); @@ -271,8 +309,7 @@ describe("ChatView", () => { expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon expect(collapse).not.toHaveClass("collapse-plus"); // Visible bubble, like tool cards. - expect(collapse).toHaveClass("bg-base-200"); - expect(collapse).toHaveClass("rounded-box"); + expect(collapse).toHaveClass("inset-well"); // shared recessed panel (app.css) expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument(); }); @@ -372,11 +409,39 @@ describe("ChatView", () => { expect(screen.getByText("Hi")).toBeInTheDocument(); expect(screen.getByText("Hello!")).toBeInTheDocument(); expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/150 tok/)).toHaveLength(2); - expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument(); + expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 50 tok out · 100 in/)).toBeInTheDocument(); expect(screen.getByText(/1\.2s/)).toBeInTheDocument(); }); + it("renders a per-step cache hit badge coloured by level", () => { + const step = { + stepId: "t1#0" as StepId, + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 750 }, + decodeMs: 100, + }; + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "t", + input: {}, + stepId: step.stepId, + }, + provisional: false, + }, + ]; + const turnMetrics: TurnMetricsEntry[] = [{ turnId: "t1", steps: [step], total: null }]; + render(ChatView, { props: { chunks, turnMetrics } }); + const badge = screen.getByTestId("step-cache-badge"); + expect(badge).toHaveTextContent("75%"); + expect(badge).toHaveClass("badge-success"); + }); + it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, @@ -477,11 +542,11 @@ describe("ChatView", () => { // Both step-metrics and turn-metrics render expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 20 tok out/)).toBeInTheDocument(); // They are in separate elements (different rows) - const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div"); - const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div"); + const stepEl = screen.getByText(/step 1 · 80 in \/ 20 out/).closest("div"); + const turnEl = screen.getByText(/turn 1 · 20 tok out/).closest("div"); expect(stepEl).not.toBe(turnEl); }); @@ -553,9 +618,9 @@ describe("ChatView", () => { // Step metrics rendered expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/15 tok/)).toHaveLength(2); + expect(screen.getByText(/step 1 · 10 in \/ 5 out/)).toBeInTheDocument(); // Turn metrics rendered - expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 5 tok out · 10 in/)).toBeInTheDocument(); // No "null" or "undefined" in the DOM expect(screen.queryByText("null")).toBeNull(); expect(screen.queryByText("undefined")).toBeNull(); @@ -601,8 +666,7 @@ describe("ChatView", () => { render(ChatView, { props: { chunks, turnMetrics } }); // Step metrics should render - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/150 tok/)).toBeInTheDocument(); + expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument(); // Turn total should NOT render (total is null — turn still in progress) expect(screen.queryByText(/^turn/)).toBeNull(); @@ -707,7 +771,7 @@ describe("ChatView", () => { expect(container.querySelector("img")?.getAttribute("src")).toBe(url); }); - it("renders a consult_vision tool call/result like any other tool", () => { + it("renders a consult_vision tool call/result like any other tool", async () => { // read_image is GONE — replaced by consult_vision (opens a vision-model // conversation, attaches the image + question, returns the answer). It is // a normal tool call: rendered generically by toolName. @@ -737,9 +801,10 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks } }); + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); - expect(screen.getAllByText("consult_vision").length).toBeGreaterThan(0); + expect(screen.getAllByText("Ask vision model").length).toBeGreaterThan(0); expect(screen.getByText("a red square on a white background")).toBeInTheDocument(); }); @@ -784,6 +849,71 @@ describe("ChatView", () => { }); describe("Composer", () => { + it.each([ + { status: "idle", queuedMessageCount: 0, text: "", label: "Send" }, + { status: "running", queuedMessageCount: 0, text: "next", label: "Queue" }, + { status: "running", queuedMessageCount: 0, text: "", label: "Stop generation" }, + { status: "running", queuedMessageCount: 1, text: "", label: "Flush" }, + ] as const)("keeps $label at the same fixed width", async ({ label, text, ...props }) => { + const user = userEvent.setup(); + render(Composer, { props: { ...props, onSend: vi.fn(), onQueue: vi.fn(), onStop: vi.fn() } }); + if (text) await user.type(screen.getByRole("textbox", { name: "Message input" }), text); + const button = screen.getByRole("button", { name: label }); + expect(button).toHaveClass("w-20", "shrink-0"); + expect(button).not.toHaveClass("w-36"); + }); + + it.each([ + "running", + "queued", + ] as const)("relabels Stop only with queued input while %s and preserves its action", async (status) => { + const onStop = vi.fn(); + const onSend = vi.fn(); + const onQueue = vi.fn(); + const user = userEvent.setup(); + const { rerender } = render(Composer, { props: { onSend, onQueue, onStop, status } }); + const expectPlainStop = () => { + const button = screen.getByRole("button", { name: "Stop generation" }); + expect(button).toHaveTextContent(/^Stop$/); + expect(button).not.toHaveAttribute("title"); + }; + expectPlainStop(); // No queue snapshot available. + await rerender({ queuedMessageCount: 0 }); + expectPlainStop(); + await user.click(screen.getByRole("button", { name: "Stop generation" })); + expect(onStop).toHaveBeenCalledTimes(1); + + for (const queuedMessageCount of [1, 2]) { + await rerender({ queuedMessageCount }); + const button = screen.getByRole("button", { name: "Flush" }); + expect(button).toHaveTextContent(/^Flush$/); + expect(button).toHaveAttribute( + "title", + "Stop the current response and start the queued message now", + ); + } + await user.click(screen.getByRole("button", { name: "Flush" })); + expect(onStop).toHaveBeenCalledTimes(2); + expect(onSend).not.toHaveBeenCalled(); + expect(onQueue).not.toHaveBeenCalled(); + + const input = screen.getByRole("textbox", { name: "Message input" }); + await user.type(input, "next"); + expect(screen.getByRole("button", { name: "Queue" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Flush" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Queue" })); + expect(onQueue).toHaveBeenCalledExactlyOnceWith("next", undefined); + expect(screen.getByRole("button", { name: "Flush" })).toBeInTheDocument(); + await rerender({ queuedMessageCount: 0 }); + expectPlainStop(); // Queue drained or cancelled. + await rerender({ queuedMessageCount: 1, status: "idle" }); + expect(screen.getByRole("button", { name: "Send" })).toBeDisabled(); + await rerender({ status: "error" }); + expect(screen.getByRole("button", { name: "Send" })).toBeDisabled(); + await rerender({ status, queuedMessageCount: undefined }); + expectPlainStop(); + }); + it("calls onSend with the typed text and clears", async () => { const onSend = vi.fn(); const user = userEvent.setup(); @@ -964,17 +1094,28 @@ describe("Composer", () => { expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument(); }); - it("queues (steers) text-only and never forwards images", async () => { - // While running, the Send button becomes "Queue"; steering is text-only. + it("still queues text-only with images omitted", async () => { const onQueue = vi.fn(); const onSend = vi.fn(); const user = userEvent.setup(); - const { container } = render(Composer, { props: { onSend, onQueue, status: "running" } }); + render(Composer, { props: { onSend, onQueue, status: "running" } }); + await user.type(screen.getByRole("textbox", { name: "Message input" }), "steer{Enter}"); + expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer", undefined); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("queues text and images while running and clears the composer", async () => { + const onQueue = vi.fn(); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { + props: { onSend, onQueue, status: "running", encodeJpeg: async () => null }, + }); const textarea = screen.getByRole("textbox", { name: "Message input" }); await user.type(textarea, "steer here"); - // Also stage an image — it must NOT be forwarded on a queue. + // Stage an image along with the steering text. const file = new File(["PNG"], "shot.png", { type: "image/png" }); const input = container.querySelector('input[type="file"]') as HTMLInputElement; Object.defineProperty(input, "files", { value: [file], writable: false }); @@ -984,8 +1125,187 @@ describe("Composer", () => { }); await user.click(screen.getByRole("button", { name: "Queue" })); - expect(onQueue).toHaveBeenCalledWith("steer here"); + expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer here", [ + { url: "data:image/png;base64,UE5H", mimeType: "image/png" }, + ]); expect(onSend).not.toHaveBeenCalled(); + expect(textarea).toHaveValue(""); + expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument(); + }); + + it.each(["running", "queued"] as const)("queues images-only while %s", async (status) => { + const onQueue = vi.fn(); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, onQueue, status } }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { + value: [new File(["GIF"], "shot.gif", { type: "image/gif" })], + }); + input.dispatchEvent(new Event("change", { bubbles: true })); + await screen.findByRole("button", { name: "Remove image" }); + + await user.click(screen.getByRole("button", { name: "Queue" })); + + expect(onQueue).toHaveBeenCalledExactlyOnceWith("", [ + { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" }, + ]); + expect(onSend).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument(); + }); + + it.each([ + "running", + "queued", + ] as const)("keeps images and text staged while %s without onQueue", async (status) => { + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container, rerender } = render(Composer, { props: { onSend, status } }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { + value: [new File(["GIF"], "shot.gif", { type: "image/gif" })], + }); + input.dispatchEvent(new Event("change", { bubbles: true })); + await screen.findByRole("button", { name: "Remove image" }); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "{Enter}"); // image-only: no queue callback + await user.type(textarea, "keep this{Enter}"); + container + .querySelector("form") + ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + + expect(onSend).not.toHaveBeenCalled(); + expect(textarea).toHaveValue("keep this"); + expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Queue" })).toBeDisabled(); + // Once idle, the same staged input can actually be sent. + await rerender({ status: "idle" }); + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSend).toHaveBeenCalledExactlyOnceWith("keep this", [ + { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" }, + ]); + expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument(); + }); +}); + +describe("Composer image compression", () => { + /** Stage a PNG through the file picker and wait for the thumbnail. */ + async function stagePng(container: HTMLElement, content = "PNGDATA"): Promise<void> { + const file = new File([content], "shot.png", { type: "image/png" }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { value: [file], writable: false }); + input.dispatchEvent(new Event("change", { bubbles: true })); + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument(); + }); + } + + /** A fake converted JPEG (the outermost-edge stub — no canvas involved). */ + function fakeJpeg(): ConvertedJpeg { + return { blob: new Blob(["SMALLJPEG"], { type: "image/jpeg" }), mimeType: "image/jpeg" }; + } + + it("shows the compress toggle CHECKED by default for a staged PNG", async () => { + const { container } = render(Composer, { + props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) }, + }); + await stagePng(container); + + const toggle = screen.getByRole("checkbox", { name: "Compress image" }); + expect(toggle).toBeChecked(); + }); + + it("hides the compress toggle for already-lossy formats (JPEG)", async () => { + const { container } = render(Composer, { + props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) }, + }); + const file = new File(["JPG"], "photo.jpg", { type: "image/jpeg" }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { value: [file], writable: false }); + input.dispatchEvent(new Event("change", { bubbles: true })); + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument(); + }); + + expect(screen.queryByRole("checkbox", { name: "Compress image" })).not.toBeInTheDocument(); + }); + + it("sends the converted JPEG (image/jpeg) when compress is on, encoding exactly once", async () => { + const encodeJpeg = vi.fn(async () => fakeJpeg()); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, encodeJpeg } }); + await stagePng(container); + + // The async conversion lands and the staged URL reflects what will be sent. + await vi.waitFor(() => { + expect(encodeJpeg).toHaveBeenCalledTimes(1); + }); + await vi.waitFor(() => { + const thumb = container.querySelector("img[alt='image/jpeg']"); + expect(thumb).not.toBeNull(); + expect(thumb?.getAttribute("src")).toMatch(/^data:image\/jpeg;base64,/); + }); + + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSend).toHaveBeenCalledTimes(1); + const [, images] = onSend.mock.calls[0] ?? []; + expect(images).toHaveLength(1); + expect(images[0]?.mimeType).toBe("image/jpeg"); + expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/); + }); + + it("sends the ORIGINAL untouched bytes when the toggle is unchecked", async () => { + const encodeJpeg = vi.fn(async () => fakeJpeg()); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, encodeJpeg } }); + await stagePng(container); + await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole("checkbox", { name: "Compress image" })); + expect(screen.getByRole("checkbox", { name: "Compress image" })).not.toBeChecked(); + + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSend).toHaveBeenCalledTimes(1); + const [, images] = onSend.mock.calls[0] ?? []; + expect(images[0]?.mimeType).toBe("image/png"); + expect(images[0]?.url).toMatch(/^data:image\/png;base64,/); + }); + + it("re-checking uses the CACHED conversion — no second encode", async () => { + const encodeJpeg = vi.fn(async () => fakeJpeg()); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, encodeJpeg } }); + await stagePng(container); + await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1)); + + const toggle = screen.getByRole("checkbox", { name: "Compress image" }); + await user.click(toggle); // OFF → original + await user.click(toggle); // ON again → must reuse the cached JPEG + + await user.click(screen.getByRole("button", { name: "Send" })); + expect(encodeJpeg).toHaveBeenCalledTimes(1); // still exactly one encode + const [, images] = onSend.mock.calls[0] ?? []; + expect(images[0]?.mimeType).toBe("image/jpeg"); + expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/); + }); + + it("falls back to the original image when conversion fails", async () => { + const encodeJpeg = vi.fn(async () => { + throw new Error("decode exploded"); + }); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, encodeJpeg } }); + await stagePng(container); + await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole("button", { name: "Send" })); + const [, images] = onSend.mock.calls[0] ?? []; + expect(images[0]?.mimeType).toBe("image/png"); + expect(images[0]?.url).toMatch(/^data:image\/png;base64,/); }); }); @@ -1006,7 +1326,9 @@ describe("ModelSelector", () => { expect(keySelect).toHaveValue("anthropic"); expect(modelSelect).toHaveValue("claude-3"); - expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]); + // The synthetic "Named Agents" provider is ALWAYS first (plan §6), even with + // zero configured agents. + expect(optionValues(keySelect)).toEqual(["__named_agents__", "openai", "anthropic", "google"]); // only the models under the selected key expect(optionValues(modelSelect)).toEqual(["claude-3"]); }); @@ -1041,6 +1363,133 @@ describe("ModelSelector", () => { expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o"); }); + it("lists Named Agents under the synthetic provider and resolves a selection to a snapshot", async () => { + const onSelectNamedAgent = vi.fn(); + const user = userEvent.setup(); + const models = ["openai/gpt-4"]; + const namedAgents = [ + { id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + { id: "a2", name: "Research Agent", model: "umans/umans-glm-5.2" }, + ]; + + const { rerender } = render(ModelSelector, { + props: { + models, + selected: "openai/gpt-4", + onSelect: vi.fn(), + namedAgents, + onSelectNamedAgent, + }, + }); + + // Switching the provider to "Named Agents" jumps to the first agent (mirrors + // a raw provider jumping to its first model). + await user.selectOptions( + screen.getByRole("combobox", { name: "Key selector" }), + "__named_agents__", + ); + expect(onSelectNamedAgent).toHaveBeenCalledWith({ + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }); + + // The parent (store) applies the selection → re-render with the resolved raw + // model + snapshot, exactly as `selectModel(model, snapshot)` would do. + const codingAgent = namedAgents[0]; + await rerender({ + models, + selected: codingAgent?.model ?? "", + onSelect: vi.fn(), + namedAgents, + namedAgentSnapshot: { + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }, + onSelectNamedAgent, + }); + + // The model dropdown now lists the named agents; pick a different one. + await user.selectOptions( + screen.getByRole("combobox", { name: "Model selector" }), + "Research Agent", + ); + expect(onSelectNamedAgent).toHaveBeenLastCalledWith({ + namedAgentId: "a2", + namedAgentName: "Research Agent", + model: "umans/umans-glm-5.2", + }); + }); + + it("shows a disabled placeholder when the synthetic provider has no configured agents", () => { + render(ModelSelector, { + props: { models: ["openai/gpt-4"], selected: "openai/gpt-4", onSelect: vi.fn() }, + }); + const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); + const options = within(modelSelect).getAllByRole("option"); + // With the raw openai provider selected the model dropdown lists gpt-4; the + // "No named agents configured" placeholder appears only under the synthetic + // provider (which we'd select to see it). Assert the disabled/empty handling + // by checking the synthetic provider is present in the key selector. + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + expect(optionValues(keySelect)).toContain("__named_agents__"); + // Switch to the (empty) synthetic provider → disabled "No named agents configured". + expect(() => screen.getByRole("option", { name: /No named agents configured/ })).toThrow(); + expect(options).toHaveLength(1); + }); + + it("keeps showing a snapshot of a renamed/removed Named Agent (unavailable)", () => { + // A conversation selected via a Named Agent retains its snapshot even after + // the agent is gone from the current list (plan §7). + const namedAgentSnapshot = { + namedAgentId: "gone", + namedAgentName: "Old Agent", + model: "opencode-1/claude-sonnet-4.5", + }; + render(ModelSelector, { + props: { + models: [], + selected: "opencode-1/claude-sonnet-4.5", + onSelect: vi.fn(), + namedAgents: [], + namedAgentSnapshot, + }, + }); + + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + expect(keySelect).toHaveValue("__named_agents__"); + const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); + expect(modelSelect).toHaveValue("Old Agent"); + expect(screen.getByRole("option", { name: /Old Agent.*unavailable/ })).toBeInTheDocument(); + }); + + it("selecting a raw model from a Named Agent conversation clears the snapshot via onSelect", async () => { + const onSelect = vi.fn(); + const onSelectNamedAgent = vi.fn(); + const user = userEvent.setup(); + const namedAgentSnapshot = { + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }; + render(ModelSelector, { + props: { + models: ["openai/gpt-4"], + selected: "opencode-1/claude-sonnet-4.5", + onSelect, + namedAgents: [{ id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }], + namedAgentSnapshot, + onSelectNamedAgent, + }, + }); + + // Switch from Named Agents to a raw provider → raw model selection clears the snapshot. + await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "openai"); + expect(onSelect).toHaveBeenCalledWith("openai/gpt-4"); + expect(onSelectNamedAgent).not.toHaveBeenCalled(); + }); + it("marks vision-capable models in the model dropdown", () => { const models = ["kimi/k2", "kimi/k1.5"]; const modelInfo = { @@ -1089,33 +1538,95 @@ describe("ModelSelector", () => { }); expect(screen.getByText(/auto-described/i)).toBeInTheDocument(); }); + + it("labels numbered OpenCode keys as OpenCode #N and sorts them numerically", () => { + const models = ["opencode-10/m1", "opencode-2/m2", "opencode-1/m3"]; + const onSelect = vi.fn(); + render(ModelSelector, { + props: { models, selected: "opencode-2/m2", onSelect }, + }); + + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + const options = within(keySelect).getAllByRole("option"); + // Numeric order: #1 < #2 < #10 (after the always-first synthetic provider), + // and values stay RAW (opencode-N). + expect(options.map((o) => (o as HTMLOptionElement).value)).toEqual([ + "__named_agents__", + "opencode-1", + "opencode-2", + "opencode-10", + ]); + expect(options[1]?.textContent).toContain("OpenCode #1"); + expect(options[2]?.textContent).toContain("OpenCode #2"); + expect(options[3]?.textContent).toContain("OpenCode #10"); + }); + + it("shows a disappeared saved credential as unavailable without silently falling back", () => { + // Saved selection references opencode-5, but the catalog no longer has it. + const models = ["opencode-1/deepseek-v4-flash"]; + const onSelect = vi.fn(); + render(ModelSelector, { + props: { models, selected: "opencode-5/deepseek-v4-flash", onSelect }, + }); + + // The unavailable key is surfaced as an explicit (disabled) option + notice. + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + const unavailable = within(keySelect).getByText(/OpenCode #5 \(unavailable\)/i); + expect(unavailable).toBeInTheDocument(); + expect(screen.getByText(/Selected model .* unavailable/)).toBeInTheDocument(); + // No silent switch is attempted. + expect(onSelect).not.toHaveBeenCalled(); + }); }); describe("ReasoningEffortSelector", () => { - it("renders null (never set) as the default level, marked '(default)'", () => { - render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } }); + it("renders null effort + null thinking (never set) as the default level, marked '(default)'", () => { + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save: vi.fn() }, + }); const select = screen.getByRole("combobox", { name: "Reasoning effort" }); expect(select).toHaveValue("high"); expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument(); - // All five ladder levels are offered. - expect(within(select).getAllByRole("option")).toHaveLength(5); + // "Off" first, then the five ladder levels. + const options = within(select).getAllByRole("option"); + expect(options).toHaveLength(6); + expect(options[0]).toHaveValue("off"); + expect(within(select).getByRole("option", { name: "Off" })).toBeInTheDocument(); }); - it("renders a persisted level as selected", () => { - render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } }); + it("renders a persisted level as selected when thinking is on", () => { + render(ReasoningEffortSelector, { + props: { persistedEffort: "xhigh", persistedThinking: null, save: vi.fn() }, + }); expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh"); }); + it("renders 'off' as selected when thinking is disabled (effort level is preserved but hidden)", () => { + // thinking off is a SEPARATE axis: even with a persisted effort level, the + // selector shows "off" while thinking is disabled. + render(ReasoningEffortSelector, { + props: { persistedEffort: "xhigh", persistedThinking: false, save: vi.fn() }, + }); + + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off"); + // the level option is still present (restored on an off→on toggle) + expect( + within(screen.getByRole("combobox")).getByRole("option", { name: "xhigh" }), + ).toBeInTheDocument(); + }); + it("selecting a level saves it via the injected port and confirms", async () => { - const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({ + const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({ ok: true as const, - reasoningEffort: level, + selection, })); const user = userEvent.setup(); - render(ReasoningEffortSelector, { props: { persisted: null, save } }); + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save }, + }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); @@ -1127,11 +1638,35 @@ describe("ReasoningEffortSelector", () => { expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max"); }); - it("a failed save shows the error and reverts to the persisted value", async () => { + it("selecting 'off' saves the separate disable signal (not a level)", async () => { + const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({ + ok: true as const, + selection, + })); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { + props: { persistedEffort: "high", persistedThinking: null, save }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "off"); + + expect(save).toHaveBeenCalledTimes(1); + // "off" is the separate thinking-disable signal — NOT a zero-effort level. + expect(save).toHaveBeenCalledWith("off"); + await vi.waitFor(() => { + expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument(); + }); + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off"); + }); + + it("a failed save shows the error and reverts to the persisted selection", async () => { const save = vi.fn(async () => ({ ok: false as const, error: "nope" })); const user = userEvent.setup(); - render(ReasoningEffortSelector, { props: { persisted: "low", save } }); + render(ReasoningEffortSelector, { + props: { persistedEffort: "low", persistedThinking: null, save }, + }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); @@ -1142,24 +1677,215 @@ describe("ReasoningEffortSelector", () => { }); it("disables the select while a save is in flight (no double-fire)", async () => { - let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined; + let resolveSave: ((r: { ok: true; selection: "max" }) => void) | undefined; const save = vi.fn( () => - new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => { + new Promise<{ ok: true; selection: "max" }>((resolve) => { resolveSave = resolve; }), ); const user = userEvent.setup(); - render(ReasoningEffortSelector, { props: { persisted: null, save } }); + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save }, + }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled(); - resolveSave?.({ ok: true, reasoningEffort: "max" }); + resolveSave?.({ ok: true, selection: "max" }); await vi.waitFor(() => { expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled(); }); }); }); + +describe("ChatView — cache timer", () => { + const usage = { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900 }; + function step(stepId: string) { + return { stepId: stepId as StepId, usage, decodeMs: 100 }; + } + function turn(turnId: string, stepIds: string[] = [`${turnId}#0`]): TurnMetricsEntry { + const steps = stepIds.map(step); + return { turnId, steps, total: { turnId, usage, steps } }; + } + function exchange(turnId: string, base: number): RenderedChunk[] { + return [ + { + seq: base, + role: "user", + chunk: { type: "text", text: `q-${turnId}` }, + provisional: false, + turnId, + }, + { + seq: base + 1, + role: "assistant", + chunk: { type: "text", text: `a-${turnId}` }, + provisional: false, + turnId, + }, + ]; + } + function toolChunk(turnId: string, stepId: string, seq: number): RenderedChunk { + return { + seq, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: `call-${stepId}`, + toolName: "read_file", + input: {}, + stepId: stepId as StepId, + }, + provisional: false, + turnId, + }; + } + function state(partial: Partial<CacheTimerState>): CacheTimerState { + return { ...initialCacheTimerState(), ...partial }; + } + + it("renders nothing when the feature is off (cacheTimer null)", () => { + render(ChatView, { props: { chunks: exchange("t1", 1), turnMetrics: [turn("t1")] } }); + expect(screen.queryByTestId("cache-timer-live")).toBeNull(); + expect(screen.queryByTestId("cache-timer-stamp")).toBeNull(); + }); + + it("shows a live timer under executing tools, counting up to 10+ min", async () => { + vi.useFakeTimers(); + try { + const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)]; + render(ChatView, { + props: { + chunks, + turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }], + cacheTimer: state({ + running: { + kind: "step", + turnId: "t1", + stepId: "t1#0", + startedAt: Date.now(), + toolCount: 1, + pending: new Set(["call-t1#0"]), + maxToolMs: 0, + }, + }), + }, + }); + const row = screen.getByTestId("cache-timer-tools"); + expect(row).toHaveTextContent(/Running tools/); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00"); + await vi.advanceTimersByTimeAsync(5 * 60_000 + 30_000); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("5:30"); + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("10+ min"); + } finally { + vi.useRealTimers(); + } + }); + + it("hides the live tools row for a step with no tool calls", () => { + render(ChatView, { + props: { + chunks: exchange("t1", 1), + turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }], + cacheTimer: state({ + running: { + kind: "step", + turnId: "t1", + stepId: "t1#0", + startedAt: Date.now(), + toolCount: 0, + pending: new Set(), + maxToolMs: 0, + }, + }), + }, + }); + expect(screen.queryByTestId("cache-timer-tools")).toBeNull(); + }); + + it("stamps a finished step gap on that step's line, next to its cache badge", () => { + const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)]; + render(ChatView, { + props: { + chunks, + turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }], + cacheTimer: state({ stepStamps: new Map([["t1#0", 65_000]]) }), + }, + }); + const stamp = screen.getByTestId("cache-timer-stamp"); + expect(stamp).toHaveTextContent("1:05"); + // Sized to match the step's (xs) cache badge. + expect(stamp).toHaveClass("badge-xs"); + expect(stamp).not.toHaveClass("badge-sm"); + // Same line as the step's cache badge. + expect(stamp.parentElement).toContainElement(screen.getByTestId("step-cache-badge")); + expect(screen.queryByTestId("cache-timer-tools")).toBeNull(); + }); + + it("runs a turn gap live on that turn's row, and shows stamps on earlier turns", () => { + render(ChatView, { + props: { + chunks: [...exchange("t1", 1), ...exchange("t2", 3)], + turnMetrics: [turn("t1"), turn("t2")], + cacheTimer: state({ + running: { kind: "turn", turnId: "t2", startedAt: Date.now() }, + turnStamps: new Map([["t1", 11 * 60_000]]), + }), + }, + }); + const live = screen.getByTestId("cache-timer-live"); + expect(live.closest(".chat-bubble")).toHaveTextContent(/turn 2/); + expect(live).toHaveClass("badge-sm"); + const stamp = screen.getByTestId("cache-timer-stamp"); + expect(stamp).toHaveTextContent("10+ min"); + expect(stamp.closest(".chat-bubble")).toHaveTextContent(/turn 1/); + }); + + it("a live timer mounts without re-running its effect on a moving clock (no render stall)", async () => { + // Regression: the badge's effect once READ the `current` state it wrote, so + // with a real (always-advancing) clock it re-ran itself until Svelte threw + // `effect_update_depth_exceeded` — aborting the flush, so turn data showed late. + let calls = 0; + const now = () => { + calls++; + return 1_000_000 + calls; + }; + render(CacheTimerBadge, { props: { since: 1_000_000, now } }); + await new Promise((r) => setTimeout(r, 20)); + expect(calls).toBeLessThan(5); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00"); + }); + + it("renders the turn's metrics alongside a live turn timer", () => { + render(ChatView, { + props: { + chunks: exchange("t1", 1), + turnMetrics: [turn("t1")], + cacheTimer: state({ running: { kind: "turn", turnId: "t1", startedAt: Date.now() } }), + }, + }); + expect(screen.getByText(/turn 1 · 10 tok out/)).toBeInTheDocument(); + expect(screen.getByText("a-t1")).toBeInTheDocument(); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent(/^0:00$/); + }); + + it("puts a SEEDED turn gap (no turnId) on the most recent turn's row", () => { + render(ChatView, { + props: { + chunks: [...exchange("t1", 1), ...exchange("t2", 3)], + turnMetrics: [turn("t1"), turn("t2")], + cacheTimer: state({ + running: { kind: "turn", turnId: null, startedAt: Date.now() - 2 * 60_000 }, + }), + }, + }); + const badges = screen.getAllByTestId("cache-timer-live"); + expect(badges).toHaveLength(1); + expect(badges[0]).toHaveTextContent("2:00"); + expect(badges[0]?.closest(".chat-bubble")).toHaveTextContent(/turn 2/); + }); +}); diff --git a/src/features/chat/ui/CacheTimerBadge.svelte b/src/features/chat/ui/CacheTimerBadge.svelte new file mode 100644 index 0000000..9e6f96d --- /dev/null +++ b/src/features/chat/ui/CacheTimerBadge.svelte @@ -0,0 +1,70 @@ +<script lang="ts"> + /** + * One cache-timer badge. LIVE when given `since` (counts up from that epoch + * ms, ticking once a second, and stops ticking at the 10-minute cap); STAMPED + * when given `elapsedMs` (a finished gap — static). Formatting lives in + * `core/metrics` (`viewCacheTimer`). Owning the tick here means only this + * badge re-renders each second, not the transcript. + */ + import { untrack } from "svelte"; + import { CACHE_TIMER_MAX_MS, viewCacheTimer } from "../../../core/metrics"; + + let { + since, + elapsedMs, + size = "sm", + now = Date.now, + }: { + /** Live: epoch ms the gap started. */ + since?: number; + /** Stamped: the gap's final duration (wins over `since`). */ + elapsedMs?: number; + /** Badge size — match the neighbouring cache badge (`xs` on step lines). */ + size?: "xs" | "sm"; + /** Wall clock (injected for tests). */ + now?: () => number; + } = $props(); + + // Initial read only (the effect below keeps it current while live). + let current = $state(untrack(() => now())); + + // Depends ONLY on `since`/`elapsedMs`/`now`. It must never READ `current` + // (it writes it): reading its own write makes the effect re-run itself on + // every clock change → `effect_update_depth_exceeded`, which aborts the whole + // reactive flush and stalls the transcript (turn data rendering late). + $effect(() => { + if (elapsedMs !== undefined || since === undefined) return; + const start = since; + const clock = now; + const t0 = clock(); + current = t0; + if (t0 - start >= CACHE_TIMER_MAX_MS) return; + const id = setInterval(() => { + const t = clock(); + current = t; + if (t - start >= CACHE_TIMER_MAX_MS) clearInterval(id); + }, 1000); + return () => clearInterval(id); + }); + + const badgeClass = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + } as const; + + const live = $derived(elapsedMs === undefined); + const view = $derived(viewCacheTimer(elapsedMs ?? current - (since ?? current))); +</script> + +<span + class="badge font-mono tabular-nums {badgeClass[view.level]}" + class:badge-xs={size === "xs"} + class:badge-sm={size === "sm"} + class:badge-outline={!live} + title={live + ? "Cache timer (running): time since the last model request. The Claude prompt cache expires after 5 minutes idle." + : "Cache timer (stopped): how long the cache sat idle before the next request. The Claude prompt cache expires after 5 minutes idle."} + data-testid={live ? "cache-timer-live" : "cache-timer-stamp"} + >{view.label}</span +> diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index e67ca5b..84e66d8 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -2,14 +2,19 @@ import type { TurnProviderRetryEvent } from "@dispatch/wire"; import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index"; import { + type CacheTimerState, interleaveTurnMetrics, + lastTurnRowIndex, viewCacheRate, viewExpectedCache, viewStepMetrics, viewTurnMetrics, type TurnMetricsEntry, } from "../../../core/metrics"; - import { Markdown } from "../../markdown"; + import { Markdown, type TextFileClient } from "../../markdown"; + import CacheTimerBadge from "./CacheTimerBadge.svelte"; + import LazyCollapse from "./LazyCollapse.svelte"; + import ToolCard from "./ToolCard.svelte"; const badgeClass = { success: "badge-success", @@ -19,14 +24,19 @@ let { chunks, + recovering = false, turnMetrics = [], hasEarlier = false, onShowEarlier, thinkingKeyBase = 0, providerRetry = null, apiBaseUrl = "", + cwd = null, + textFiles = undefined, + cacheTimer = null, }: { chunks: readonly RenderedChunk[]; + recovering?: boolean; turnMetrics?: readonly TurnMetricsEntry[]; /** Earlier history is unloaded (chat limit) and can be paged back in. */ hasEarlier?: boolean; @@ -53,10 +63,27 @@ * "" (root-relative — a browser resolves `/images/…` against its origin). */ apiBaseUrl?: string; + /** + * The active conversation's working directory (null when unset). Relative + * Markdown image srcs (``) resolve against it; local absolute + * and `file://` paths do not need it. Passed to `Markdown` with `apiBaseUrl`. + */ + cwd?: string | null; + /** Context-bound, injected live-file display port (never model history). */ + textFiles?: TextFileClient | undefined; + /** + * The cache timer (gaps between model requests): a running step gap shows + * live at the bottom (under the executing tools), a running turn gap live + * on that turn's metrics row; finished gaps are stamped on their step / + * turn metrics line. `null` (the default) hides it entirely — the caller + * gates on the model. + */ + cacheTimer?: CacheTimerState | null; } = $props(); // True while a show-earlier page-in is awaited (disables the button). let loadingEarlier = $state(false); + let failedImages = $state<string[]>([]); async function showEarlier() { if (!onShowEarlier || loadingEarlier) return; @@ -68,10 +95,36 @@ } } - const groups = $derived(groupRenderedChunks(chunks)); + const groups = $derived(groupRenderedChunks(chunks, recovering)); const rows = $derived(interleaveTurnMetrics(groups, turnMetrics)); + // A SEEDED turn gap (no turnId — e.g. after a reload) renders on the most + // recent turn's row (when no user message follows it). + const seededTurnRow = $derived( + cacheTimer?.running?.kind === "turn" && cacheTimer.running.turnId === null + ? lastTurnRowIndex(rows) + : null, + ); + + /** Start of the running turn gap if it belongs on this turn row, else undefined. */ + function liveTurnSince(turnId: string, rowIndex: number): number | undefined { + const r = cacheTimer?.running; + if (r?.kind !== "turn") return undefined; + if (r.turnId === turnId || (r.turnId === null && rowIndex === seededTurnRow)) { + return r.startedAt; + } + return undefined; + } + + // A running step gap with tool calls → the live timer under the executing tools. + const liveStepGap = $derived( + cacheTimer?.running?.kind === "step" && cacheTimer.running.toolCount > 0 + ? cacheTimer.running + : null, + ); + + // Tool cards use toolCallId keys across pending/result/seal and orphan page-in. // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that // survives the provisional→committed (seq null → seq N) transition, so the // collapse's open/close state is NOT lost when a turn seals. The ordinal @@ -88,8 +141,8 @@ } const group = row.group; let key: string; - if (group.kind === "tool-batch") { - key = `b${group.stepId}`; + if (group.kind === "tool") { + key = `t${group.entry.toolCallId}`; } else if (group.chunk.chunk.type === "thinking") { key = `think${thinking++}`; } else if (group.chunk.seq != null) { @@ -102,6 +155,12 @@ }); </script> +{#if recovering} + <p role="status" class="px-4 py-2 text-sm text-base-content/70"> + Recovering conversation history… Omitted tool input/output is unavailable until persisted. + </p> +{/if} + {#snippet chunkRow(rendered: RenderedChunk)} {#if rendered.role === "user"} <!-- User: a speech bubble, left-aligned. A user message may be multi-chunk @@ -114,13 +173,22 @@ {#if rendered.chunk.type === "text"} <p>{rendered.chunk.text}</p> {:else if rendered.chunk.type === "image"} - <img - src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)} - alt={rendered.chunk.mimeType ?? "pasted image"} - loading="lazy" - decoding="async" - class="max-h-80 max-w-full rounded" - /> + {#if failedImages.includes(rendered.chunk.url)} + <span class="text-sm opacity-70">image expired</span> + {:else} + <img + src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)} + alt={rendered.chunk.mimeType ?? "pasted image"} + loading="lazy" + decoding="async" + onerror={() => { + if (rendered.chunk.type === "image" && rendered.chunk.url.startsWith("/images/")) { + failedImages = [...failedImages, rendered.chunk.url]; + } + }} + class="max-h-80 max-w-full rounded" + /> + {/if} {/if} </div> </div> @@ -130,83 +198,15 @@ while generating, then "Thoughts" with no dots once complete. --> <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> <div class="chat-bubble w-full bg-transparent"> - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle thoughts" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> + <LazyCollapse label="Toggle thoughts"> + {#snippet title()} <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span> {#if rendered.streaming} <span class="loading loading-dots loading-sm" aria-label="Generating"></span> {/if} - </div> - <div class="collapse-content"> - <p class="whitespace-pre-wrap">{rendered.chunk.text}</p> - </div> - </div> - </div> - </div> - {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} - <!-- Single tool call/result: a collapsible card (collapsed by default, - like thinking). Title shows the tool name; content shows the - input/output. Same chat-start grid shim as the thinking block. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> - <div class="chat-bubble w-full bg-transparent"> - {#if rendered.chunk.type === "tool-call"} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool call" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> - <svg - class="h-4 w-4 opacity-60" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - ><path - d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" - /></svg - > - <span>{rendered.chunk.toolName}</span> - </div> - <div class="collapse-content"> - <pre class="overflow-x-auto text-xs">{JSON.stringify( - rendered.chunk.input, - null, - 2, - )}</pre> - </div> - </div> - {:else} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool result" /> - <div - class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium" - class:text-error={rendered.chunk.isError} - > - <svg - class="h-4 w-4 opacity-60" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - ><path - d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" - /></svg - > - <span>{rendered.chunk.toolName}</span> - {#if rendered.chunk.isError} - <span class="badge badge-error badge-xs">error</span> - {/if} - </div> - <div class="collapse-content"> - <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre> - </div> - </div> - {/if} + {/snippet} + <p class="whitespace-pre-wrap">{rendered.chunk.type === "thinking" ? rendered.chunk.text : ""}</p> + </LazyCollapse> </div> </div> {:else} @@ -215,7 +215,12 @@ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> <div class="chat-bubble w-full bg-transparent"> {#if rendered.chunk.type === "text"} - <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} /> + <Markdown + text={rendered.chunk.text} + streaming={rendered.streaming ?? false} + image={{ apiBase: apiBaseUrl, cwd }} + {textFiles} + /> {:else if rendered.chunk.type === "error"} <div class="text-error" role="alert"> {rendered.chunk.message} @@ -245,17 +250,33 @@ </button> </div> {/if} - {#each keyedRows as { row, key } (key)} + {#each keyedRows as { row, key }, i (key)} + <!-- `.chat-row`: content-visibility:auto — the browser skips layout/paint + for rows outside the viewport (see app.css). --> + <div class="chat-row"> {#if row.kind === "step-metrics"} {@const sv = viewStepMetrics(row.step, row.index)} + {@const stepCache = viewCacheRate(row.step.usage)} + {@const stepStamp = cacheTimer?.stepStamps.get(row.step.stepId)} <div class="chat chat-start"> <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> - <div class="text-xs opacity-70"> - {sv.label} · {sv.tokensLabel} - {#if sv.tps} - · {sv.tps}{/if} - {#if sv.genTotal} - · {sv.genTotal}{/if} + <div class="flex flex-wrap items-center gap-x-1 text-xs"> + <span class="opacity-70"> + {sv.label} · {sv.tokensLabel} + {#if sv.tps} + · {sv.tps}{/if} + {#if sv.ttft} + · {sv.ttft} to first token{/if} + · + </span> + <span class="opacity-70">cache:</span> + <span + class="badge badge-xs {badgeClass[stepCache.level]}" + data-testid="step-cache-badge">{stepCache.pct}%</span + > + {#if stepStamp !== undefined} + <CacheTimerBadge elapsedMs={stepStamp} size="xs" /> + {/if} </div> </div> </div> @@ -263,12 +284,14 @@ {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)} {@const lastCache = viewCacheRate(row.turn.usage)} {@const chatCache = viewCacheRate(row.cumulativeUsage)} - {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} + {@const retention = viewExpectedCache(row.turn.steps[0]?.usage, row.prevLastStepUsage)} + {@const turnLive = liveTurnSince(row.turn.turnId, i)} + {@const turnStamp = cacheTimer?.turnStamps.get(row.turn.turnId)} <div class="chat chat-start"> <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> <div class="flex flex-col gap-1 text-xs"> <div class="opacity-70"> - {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown}) + {turnView.label} · {turnView.tokensLabel} · {turnView.breakdown} {#if turnView.tps} · {turnView.tps}{/if} {#if turnView.duration} @@ -289,6 +312,11 @@ <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span> </span> {/if} + {#if turnLive !== undefined} + <CacheTimerBadge since={turnLive} /> + {:else if turnStamp !== undefined} + <CacheTimerBadge elapsedMs={turnStamp} /> + {/if} </div> </div> </div> @@ -296,54 +324,27 @@ {:else if row.group.kind === "single"} {@render chunkRow(row.group.chunk)} {:else} - <!-- Batched tool calls (one step): each entry is a collapsible card. - Click to expand and see the input/output. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> - <div class="chat-bubble w-full bg-transparent"> - <div class="flex flex-col gap-1"> - {#each row.group.entries as entry (entry.call.toolCallId)} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool call" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> - <svg - class="h-4 w-4 opacity-60" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - ><path - d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" - /></svg - > - <span>{entry.call.toolName}</span> - {#if entry.result?.isError} - <span class="badge badge-error badge-xs">error</span> - {:else if entry.result === null} - <span class="loading loading-spinner loading-xs" aria-label="Running"></span> - {/if} - </div> - <div class="collapse-content"> - <pre class="overflow-x-auto text-xs">{JSON.stringify( - entry.call.input, - null, - 2, - )}</pre> - {#if entry.result} - <pre - class="mt-1 max-h-96 overflow-auto text-xs" - class:text-error={entry.result.isError}>{entry.result.content}</pre> - {/if} - </div> - </div> - {/each} - </div> + <!-- Each call owns one bubble; the matching result is folded into its card. --> + <div class="chat chat-start [grid-template-columns:auto_minmax(0,1fr)] [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> + <div class="chat-bubble min-w-0 w-full bg-transparent"> + <ToolCard entry={row.group.entry} /> </div> </div> {/if} + </div> {/each} + {#if liveStepGap} + <!-- Live cache timer while the last step's tools execute; stamped onto that + step's metrics line once they return (or the next step starts). --> + <div class="chat chat-start"> + <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> + <div class="flex items-center gap-1 text-xs" data-testid="cache-timer-tools"> + <span class="opacity-70">Running tools</span> + <CacheTimerBadge since={liveStepGap.startedAt} size="xs" /> + </div> + </div> + </div> + {/if} {#if providerRetry} {@const rv = viewProviderRetry(providerRetry)} <!-- Transient yellow warning: a provider error is being retried with backoff. diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts new file mode 100644 index 0000000..157ccac --- /dev/null +++ b/src/features/chat/ui/ChatView.test.ts @@ -0,0 +1,558 @@ +import type { StepId } from "@dispatch/wire"; +import { fireEvent, render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { RenderedChunk } from "../../../core/chunks"; +import ChatView from "./ChatView.svelte"; + +/** Collapsed card bodies render lazily — open every card so its body mounts. */ +async function openAllCollapses(container: HTMLElement): Promise<void> { + const boxes = container.querySelectorAll<HTMLInputElement>('.collapse > input[type="checkbox"]'); + for (const box of boxes) { + if (!box.checked) await userEvent.click(box); + } +} + +// Defensive rendering: a conversation transcript can contain duplicate +// `toolCallId`s (persisted provider double-emission). The +// grouped render path MUST dedupe them so the keyed {#each} never throws +// Svelte `each_key_duplicate` (which aborts the reactive flush and makes the +// tab appear dead). See fix-toolcall-duplicate-keys plan Part 1. +describe("ChatView — duplicate toolCallIds", () => { + it("renders duplicated toolCallIds without throwing, showing one bubble per unique call", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "read_file", + input: { path: "/a" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 2, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "b", + toolName: "list_dir", + input: { path: "/b" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + // The duplicate-emission pattern: the same ids re-emitted immediately after. + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "read_file", + input: { path: "/a" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 4, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "b", + toolName: "list_dir", + input: { path: "/b" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 5, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "read_file", + content: "contents-of-a", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 6, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "b", + toolName: "list_dir", + content: "contents-of-b", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + // Duplicate results too — absorbed into the corresponding cards. + { + seq: 7, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "read_file", + content: "contents-of-a", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 8, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "b", + toolName: "list_dir", + content: "contents-of-b", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); + + // No each_key_duplicate crash. Calls render as independent collapsible cards — + // exactly ONE per unique toolCallId (deduped), not 4. + const collapses = container.querySelectorAll(".collapse"); + expect(collapses).toHaveLength(2); + expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2); + + // Both unique tool names shown; each result rendered once (deduped). + expect(screen.getByText("Read file")).toBeInTheDocument(); + expect(screen.getByText("List dir")).toBeInTheDocument(); + expect(screen.getByText("contents-of-a")).toBeInTheDocument(); + expect(screen.getByText("contents-of-b")).toBeInTheDocument(); + }); +}); + +describe("ChatView — lazy collapse bodies", () => { + it("does not mount a collapsed card's body until it is first opened, then keeps it", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "x", + toolName: "run_shell", + content: "HUGE-OUTPUT", + isError: false, + }, + provisional: false, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + // English display name; the raw wire name stays available as the tooltip. + expect(screen.getByText("Run command")).toHaveAttribute("title", "run_shell"); + expect(screen.queryByText("HUGE-OUTPUT")).toBeNull(); + + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + await userEvent.click(box); + expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument(); + + await userEvent.click(box); // close: body stays mounted (close animation) + expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument(); + expect(container.querySelectorAll(".chat-row")).toHaveLength(1); + }); +}); + +function shellChunks(content: string | null, isError = false, committed = false): RenderedChunk[] { + const call: RenderedChunk = { + seq: committed ? 1 : null, + role: "assistant", + provisional: !committed, + chunk: { + type: "tool-call", + toolCallId: "shell-1", + toolName: "run_shell", + input: { command: "bun run test" }, + }, + }; + return content === null + ? [call] + : [ + call, + { + seq: committed ? 2 : null, + role: "tool", + provisional: !committed, + chunk: { + type: "tool-result", + toolCallId: "shell-1", + toolName: "run_shell", + content, + isError, + }, + }, + ]; +} + +describe("ChatView — tool cards", () => { + it("pairs a single call/result into one collapsed bubble with both summaries and a success icon", async () => { + const { container } = render(ChatView, { props: { chunks: shellChunks("first\nsecond") } }); + expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1); + expect(container.querySelectorAll(".collapse")).toHaveLength(1); + expect(screen.getByText("bun run test")).toBeInTheDocument(); + expect(screen.getByText("→ 2 lines")).toBeInTheDocument(); + expect(screen.getByLabelText("Succeeded")).toHaveTextContent("✓"); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + expect(box).not.toBeChecked(); + expect(container.querySelector("pre")).toBeNull(); + await userEvent.click(box); + expect(screen.getByText("$ bun run test")).toBeInTheDocument(); + expect(screen.getByText("first second")).toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: /tool result/ })).toBeNull(); + }); + it("transitions pending → result → sealed without losing open state", async () => { + const { rerender } = render(ChatView, { props: { chunks: shellChunks(null) } }); + expect(screen.getByLabelText("Running")).toHaveClass("loading-dots", "loading-sm"); + expect(screen.getByText("→ running…")).toHaveClass("tool-result", "text-base-content/60"); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + await userEvent.click(box); + expect(screen.getByText("running…")).toBeInTheDocument(); + await rerender({ chunks: shellChunks("done") }); + expect(screen.queryByLabelText("Running")).toBeNull(); + expect(screen.queryByText("→ running…")).toBeNull(); + expect(screen.getByLabelText("Succeeded")).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box); + expect(box).toBeChecked(); + await rerender({ chunks: shellChunks("done", false, true) }); + expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box); + expect(box).toBeChecked(); + expect(screen.getByText("done")).toBeInTheDocument(); + }); + it("keeps errors collapsed with a failure icon and tints the expanded output", async () => { + const { container } = render(ChatView, { + props: { chunks: shellChunks("Permission denied", true) }, + }); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + expect(box).not.toBeChecked(); + expect(screen.getByLabelText("Failed")).toHaveClass("text-error"); + expect(screen.getByLabelText("Failed")).toHaveTextContent("✗"); + expect(screen.getByText("→ failed").closest(".tool-result")).toHaveClass("text-error"); + expect(container.querySelector(".tool-header")).not.toHaveTextContent("Permission denied"); + expect(container.querySelector("pre")).toBeNull(); + await userEvent.click(box); + expect(screen.getByText("Permission denied").parentElement).toHaveClass("text-error"); + }); + it("renders an orphan result as the same card and retains state when its call is paged back in", async () => { + const chunks = shellChunks("contents", false, true); + const { container, rerender } = render(ChatView, { props: { chunks: chunks.slice(1) } }); + expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1); + expect(screen.getByText("Call in earlier history")).toHaveClass("earlier-history"); + expect(screen.getByLabelText("Succeeded")).toHaveClass("opacity-60"); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + await userEvent.click(box); + expect(screen.getByRole("note")).toHaveTextContent( + "This call is earlier in the conversation. Show earlier messages to see it.", + ); + expect(screen.getByRole("note")).toHaveClass("italic", "text-base-content/60"); + expect(screen.queryByRole("heading", { name: "Call" })).toBeNull(); + expect(screen.getByText("contents")).toBeInTheDocument(); + await rerender({ chunks }); + expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box); + expect(box).toBeChecked(); + expect(screen.getByText("$ bun run test")).toBeInTheDocument(); + }); + it("folds 13 output lines as 6 + toggle + 6, expands and collapses without closing the card", async () => { + const output = Array.from({ length: 13 }, (_, i) => `output-${i + 1}`).join("\n"); + const { rerender } = render(ChatView, { props: { chunks: shellChunks(output) } }); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + await userEvent.click(box); + const response = screen.getByRole("region", { name: "Response" }); + expect(response).toHaveTextContent("output-1"); + expect(response).toHaveTextContent("output-6"); + expect(response).not.toHaveTextContent("output-7"); + expect(response).toHaveTextContent("output-8"); + expect(response).toHaveTextContent("output-13"); + const toggle = screen.getByRole("button", { name: "⋯ 1 more lines" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(toggle); + expect(response).toHaveTextContent("output-7"); + expect(response.querySelectorAll("pre")).toHaveLength(1); + expect(response.querySelector("pre")?.textContent).toBe(output); + const fewer = screen.getByRole("button", { name: "Show fewer lines" }); + expect(fewer).toHaveClass("btn-soft", "btn-xs"); + expect(fewer.parentElement).toHaveClass("sticky-controls"); + const fullOutput = response.querySelector("pre"); + if (fullOutput === null) throw new Error("expected full output"); + expect(fewer.compareDocumentPosition(fullOutput)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(box).toBeChecked(); + expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute( + "aria-expanded", + "true", + ); + await userEvent.click(screen.getByRole("button", { name: "Show fewer lines" })); + expect(response).not.toHaveTextContent("output-7"); + expect(box).toBeChecked(); + // The separate output-fold state also survives sealing. + await userEvent.click(screen.getByRole("button", { name: "⋯ 1 more lines" })); + expect(response).toHaveTextContent("output-7"); + await rerender({ chunks: shellChunks(output, false, true) }); + expect(box).toBeChecked(); + expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute( + "aria-expanded", + "true", + ); + expect(response).toHaveTextContent("output-7"); + }); + it("shows 12 output lines without an inner toggle", async () => { + const output = Array.from({ length: 12 }, (_, i) => `output-${i + 1}`).join("\n"); + render(ChatView, { props: { chunks: shellChunks(output) } }); + await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })); + expect(screen.queryByRole("button", { name: /more lines|fewer lines/ })).toBeNull(); + expect(screen.getByRole("region", { name: "Response" })).toHaveTextContent("output-12"); + }); + it("renders edit details and treats tool output as text, not executable markup", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { + type: "tool-call", + toolCallId: "edit", + toolName: "edit_file", + input: { path: "a.ts", oldString: "before", newString: "after\nsecond" }, + }, + }, + { + seq: 2, + role: "tool", + provisional: false, + chunk: { + type: "tool-result", + toolCallId: "edit", + toolName: "edit_file", + content: 'Replaced 1 occurrence(s) in "a.ts".\n<script>alert(1)</script>', + isError: false, + }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + expect(screen.getByText("+2")).toHaveClass("text-success"); + expect(screen.getByText("−1")).toHaveClass("text-error"); + expect(screen.getByText("→ 1 replacement · diagnostics")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Edit file" })); + // Unified diff: one tinted row per line, each with its own −/+ gutter marker. + const diff = screen.getByRole("group", { name: "Edit diff" }); + const removed = diff.querySelectorAll(".edit-diff-del"); + const added = diff.querySelectorAll(".edit-diff-add"); + expect(Array.from(removed, (r) => r.textContent?.trim())).toEqual(["−before"]); + expect(Array.from(added, (r) => r.textContent?.trim())).toEqual(["+after", "+second"]); + expect(container.querySelector("script")).toBeNull(); + expect(container).toHaveTextContent("<script>alert(1)</script>"); + }); +}); + +it("renders compact, aligned generic fields with legible headings and inline scalar arrays", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { + type: "tool-call", + toolCallId: "issue", + toolName: "mcp__github__create_issue", + input: { + owner: "realtradam", + repo: "frontend", + title: "Tool cards", + labels: ["ui", "chat"], + options: { nested: true }, + }, + }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + await userEvent.click( + screen.getByRole("checkbox", { name: "Toggle tool call Github: Create issue" }), + ); + for (const name of ["Call", "Response"]) { + expect(screen.getByRole("heading", { name })).toHaveClass("font-semibold", "text-base-content"); + } + const call = screen.getByRole("region", { name: "Call" }); + expect(call.querySelector("dl")).toHaveClass("call-fields"); + expect(screen.getByText("owner")).toHaveClass("font-semibold", "text-base-content/85"); + const labels = screen.getByText('["ui", "chat"]'); + expect(labels.tagName).toBe("PRE"); + expect(labels.textContent).not.toContain("\n"); + expect(container.querySelectorAll("dt")).toHaveLength(5); + expect(container.querySelectorAll("dd")).toHaveLength(5); + expect(screen.getByText("options").nextElementSibling).toHaveTextContent('"nested": true'); +}); + +it("removes a pending edit's diffstat when it fails, without hiding the attempted edit", async () => { + const call: RenderedChunk = { + seq: null, + role: "assistant", + provisional: true, + chunk: { + type: "tool-call", + toolCallId: "failed-edit", + toolName: "edit_file", + input: { path: "src/missing.ts", oldString: "foo", newString: "bar" }, + }, + }; + const result: RenderedChunk = { + seq: null, + role: "tool", + provisional: true, + chunk: { + type: "tool-result", + toolCallId: "failed-edit", + toolName: "edit_file", + content: "Error: oldString not found", + isError: true, + }, + }; + const { container, rerender } = render(ChatView, { props: { chunks: [call] } }); + expect(screen.getByText("+1")).toBeInTheDocument(); + expect(screen.getByText("−1")).toBeInTheDocument(); + expect(screen.getByText("→ running…")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Edit file" })); + await rerender({ chunks: [call, result] }); + expect(screen.queryByText("+1")).toBeNull(); + expect(screen.queryByText("−1")).toBeNull(); + expect(screen.queryByText("→ running…")).toBeNull(); + expect(container.querySelector(".tool-header")).toHaveTextContent("src/missing.ts"); + expect(container.querySelector(".tool-header")).toHaveTextContent("→ failed"); + expect(screen.getByText("foo")).toBeInTheDocument(); + expect(screen.getByText("bar")).toBeInTheDocument(); + expect(screen.getByText("Error: oldString not found")).toBeInTheDocument(); +}); + +it("shows an argument-less call without `{}` — empty header summary, 'No arguments' in the body", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { type: "tool-call", toolCallId: "m", toolName: "list_agent_models", input: {} }, + }, + { + seq: 2, + role: "tool", + provisional: false, + chunk: { + type: "tool-result", + toolCallId: "m", + toolName: "list_agent_models", + content: "Available named agents (3)", + isError: false, + }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + expect(screen.getByText("List agent models")).toBeInTheDocument(); + expect(container).not.toHaveTextContent("{}"); + await userEvent.click( + screen.getByRole("checkbox", { name: "Toggle tool call List agent models" }), + ); + expect(screen.getByText("No arguments")).toBeInTheDocument(); + expect(container).not.toHaveTextContent("{}"); +}); + +it("renders a per-tool Phosphor icon in the header", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { + type: "tool-call", + toolCallId: "s", + toolName: "run_shell", + input: { command: "ls" }, + }, + }, + { + seq: 2, + role: "assistant", + provisional: false, + chunk: { type: "tool-call", toolCallId: "r", toolName: "read_file", input: { path: "a.ts" } }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + const icons = Array.from(container.querySelectorAll(".tool-header svg[aria-hidden='true']")); + expect(icons).toHaveLength(2); + // Different tools → different glyphs. + expect(icons[0]?.innerHTML).not.toBe(icons[1]?.innerHTML); +}); + +it("replaces failed persisted images with an expired placeholder, without marking inline images expired", async () => { + const chunks: RenderedChunk[] = ["/images/conv/gone.png", "data:image/png;base64,AAAA"].map( + (url, i) => ({ seq: i + 1, role: "user", chunk: { type: "image", url }, provisional: false }), + ); + render(ChatView, { chunks, apiBaseUrl: "https://backend.test" }); + const [stored, inline] = screen.getAllByRole("img"); + if (!stored || !inline) throw new Error("expected both images"); + expect(stored).toHaveAttribute("src", "https://backend.test/images/conv/gone.png"); + await fireEvent.error(inline); + expect(screen.queryByText("image expired")).toBeNull(); + await fireEvent.error(stored); + expect(screen.getByText("image expired")).toBeInTheDocument(); + expect(screen.getAllByRole("img")).toEqual([inline]); +}); + +it("shows omitted in-progress tool input as recovering/unavailable, never No arguments", async () => { + const { container, rerender } = render(ChatView, { + recovering: true, + chunks: [ + { + seq: null, + role: "tool", + provisional: true, + chunk: { + type: "tool-result", + toolCallId: "missing", + toolName: "run_shell", + content: "real output", + isError: false, + }, + }, + ], + }); + expect(screen.getByRole("status").textContent).toContain("unavailable until persisted"); + expect(screen.getByText("Recovering tool input…")).toBeTruthy(); + await openAllCollapses(container); + expect(screen.getByText(/Tool input is unavailable until it is persisted/)).toBeTruthy(); + expect(screen.queryByText("No arguments")).toBeNull(); + expect(screen.getByText("real output")).toBeTruthy(); + await rerender({ + recovering: false, + chunks: [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { + type: "tool-call", + toolCallId: "missing", + toolName: "run_shell", + input: { command: "recovered command" }, + }, + }, + ], + }); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByText("Recovering tool input…")).toBeNull(); +}); diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index 04c28cd..cd451bc 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -1,5 +1,8 @@ <script lang="ts"> import type { ImageInput } from "@dispatch/wire"; + import { convertToJpeg, type ConvertedJpeg, type ImageJpegEncoder } from "../adapter/image-encode"; + import { stopButtonLabels } from "../logic/composer"; + import { MAX_IMAGE_EDGE_PX, shouldConvertImage } from "../logic/image-compress"; import { computeContextUsage, formatCompactTokens } from "../../../core/metrics"; const FALLBACK_CONTEXT_WINDOW = 1_000_000; @@ -8,20 +11,56 @@ const IMAGE_ACCEPT = "image/png,image/jpeg,image/gif,image/webp"; /** Reject images larger than this before base64-encoding (keeps payloads sane). */ const MAX_IMAGE_BYTES = 8 * 1024 * 1024; + /** Tooltip on the per-image compress toggle (PNG → JPEG + resize). */ + const COMPRESS_TOOLTIP = `Re-encode PNG to JPEG and resize to ${MAX_IMAGE_EDGE_PX}px (smaller upload). Unchecked sends the original bytes.`; - /** A staged image awaiting send: a stable id + the `ImageInput` to forward. */ + /** + * A staged image awaiting send: a stable id + everything needed to resolve + * the `ImageInput` that will actually be sent. The ORIGINAL `File` (and its + * data URL) is kept so the per-image compress toggle is reversible until + * send; the async conversion result is cached so flipping the toggle never + * re-encodes. + */ interface StagedImage { readonly id: string; - readonly input: ImageInput; + /** The original untouched File — sent verbatim when compress is off. */ + readonly file: File; + /** Data URL of the original (staged immediately — conversion is async). */ + readonly originalUrl: string; + readonly originalMimeType: string | undefined; + /** Whether the converted (JPEG) variant is used for send. Default ON. */ + compress: boolean; + /** Cached conversion (data URL of the JPEG); `null` = none/failed/n.a. */ + converted: { url: string } | null; + } + + /** + * The `ImageInput` this staged image will actually SEND: the converted JPEG + * (data URL + `image/jpeg`) when compress is on AND a conversion exists, + * otherwise the original untouched bytes. + */ + function imageInput(img: StagedImage): ImageInput { + if (img.compress && img.converted !== null) { + return { url: img.converted.url, mimeType: "image/jpeg" }; + } + const { originalUrl: url, originalMimeType: mimeType } = img; + return { url, ...(mimeType ? { mimeType } : {}) }; + } + + /** Whether the compress toggle is rendered for this image (convertible formats only). */ + function compressible(img: StagedImage): boolean { + return shouldConvertImage(img.originalMimeType); } let { onSend, onQueue, onStop, + encodeJpeg = convertToJpeg, contextSize = undefined, contextWindow = undefined, status = "idle", + queuedMessageCount = undefined, }: { /** * Send a message (start a turn via `chat.send`). Carries any staged images @@ -33,12 +72,17 @@ /** * Enqueue a steering message (`chat.queue`). When provided AND the status * is `running`, the send button becomes a "Queue" button that steers the - * in-flight turn instead of starting a new one. Steering is text-only — - * it never carries images (a mid-turn injection has no image surface). + * in-flight turn instead of starting a new one. Carries staged images just + * like onSend; without this callback, in-flight input stays staged. */ - onQueue?: (text: string) => void; + onQueue?: (text: string, images?: ImageInput[]) => void; /** Stop the in-flight generation (`POST /conversations/:id/stop`). */ onStop?: () => void; + /** + * Injected compression edge (a `File` in, a maybe-smaller JPEG out). + * Defaults to the real canvas converter; tests stub this outermost edge. + */ + encodeJpeg?: ImageJpegEncoder; // Current context occupancy — updated progressively during a turn (the // latest step's input+output) and finalized to the turn's `contextSize` on // seal, or `undefined` when unknown — the status bar then shows @@ -53,6 +97,8 @@ * `running` for the send button (steer/stop). */ status?: ComposerStatus; + /** Current conversation's queued snapshot size; undefined when unavailable. */ + queuedMessageCount?: number | undefined; } = $props(); export type ComposerStatus = "idle" | "running" | "queued" | "error"; @@ -65,25 +111,23 @@ const hasText = $derived(text.trim().length > 0); const hasImages = $derived(images.length > 0); - const canSend = $derived(hasText || hasImages); + const inFlight = $derived(status === "running" || status === "queued"); + const canSend = $derived((hasText || hasImages) && (!inFlight || onQueue !== undefined)); const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW); const usage = $derived(computeContextUsage(contextSize, effectiveMax)); // One button, three modes: // - idle → "Send" (starts a turn via chat.send) - // - running/queued + text → "Queue" (steers via chat.queue — text only) + // - running/queued + text/images → "Queue" (steers via chat.queue) // - running/queued + empty → "Stop" (aborts via POST /stop) // (`queued` behaves like `running` — the turn is in flight, just waiting for a // concurrency slot; the user can still steer or stop it.) - // Steering never carries images: when running with images staged but no text, - // the images stay staged (queue is text-only). Images-without-text while running - // is an unusual case that still sends (the server auto-starts/resolves). - const inFlight = $derived(status === "running" || status === "queued"); const buttonMode = $derived.by<"send" | "queue" | "stop">(() => { if (inFlight && !hasText && !hasImages && onStop !== undefined) return "stop"; - if (inFlight && hasText && onQueue !== undefined) return "queue"; + if (inFlight) return "queue"; return "send"; }); + const stopButton = $derived(stopButtonLabels(queuedMessageCount)); const placeholder = $derived( status === "queued" ? "Queued for a slot…" @@ -120,8 +164,8 @@ resize(); }); - /** Read a File into a base64 data URL (`data:image/…;base64,…`). */ - function fileToDataUrl(file: File): Promise<string> { + /** Read a File/Blob into a base64 data URL (`data:image/…;base64,…`). */ + function fileToDataUrl(file: Blob): Promise<string> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { @@ -142,10 +186,51 @@ // Prefer the file's declared MIME; fall back to the data URL's prefix. const mimeType = file.type || undefined; const id = `img-${Date.now()}-${imgSeq++}`; - images = [...images, { id, input: { url, ...(mimeType ? { mimeType } : {}) } }]; + images = [ + ...images, + { + id, + file, + originalUrl: url, + originalMimeType: mimeType, + compress: true, // compress is ON by default for every staged image + converted: null, + }, + ]; + // Convert async in the background — staging is never blocked on it; the + // staged input stays the original until the converted URL arrives. + if (shouldConvertImage(mimeType)) { + void runConversion(id); + } return true; } + /** + * Run the (injected) compression edge for a staged image and cache the + * result. ANY failure (rejecting encoder included) resolves to no + * conversion → the original is sent; compression never breaks staging. If + * the image was removed while converting, the result is dropped. + */ + async function runConversion(id: string): Promise<void> { + const staged = images.find((img) => img.id === id); + if (staged === undefined) return; + let result: ConvertedJpeg | null = null; + try { + result = await encodeJpeg(staged.file); + } catch { + result = null; // fall back to the original — never block or break sending + } + const entry = images.find((img) => img.id === id); + if (entry === undefined) return; // removed mid-conversion + entry.converted = result === null ? null : { url: await fileToDataUrl(result.blob) }; + } + + /** Flip the per-image compress toggle (reversible until send). */ + function setCompress(id: string, compress: boolean): void { + const entry = images.find((img) => img.id === id); + if (entry !== undefined) entry.compress = compress; + } + function removeImage(id: string): void { images = images.filter((img) => img.id !== id); } @@ -200,15 +285,15 @@ const trimmed = text.trim(); // Allow a send with images even when text is empty (an image-only turn). if (trimmed.length === 0 && !hasImages) return; - if (buttonMode === "queue") { - // Steering is text-only — never forward images. - onQueue?.(trimmed); + const toSend = hasImages ? images.map((img) => imageInput(img)) : undefined; + if (inFlight) { + if (onQueue === undefined) return; // Keep staged input if no queue port is wired. + onQueue(trimmed, toSend); } else { - const toSend: ImageInput[] | undefined = hasImages - ? images.map((img) => img.input) - : undefined; onSend(trimmed, toSend); } + // Clear only after handing the complete input to a callback. WS is still + // fire-and-forget: this does not imply backend storage has completed. text = ""; images = []; } @@ -239,7 +324,7 @@ <!-- Top bar: expanding textarea + image-attach button + single context-aware button --> <div class="flex items-end gap-2 px-4 pt-3 pb-2"> <div - class="flex-1" + class="min-w-0 flex-1" onpaste={handlePaste} class:border-2={dragOver} class:border-primary={dragOver} @@ -293,10 +378,11 @@ <button class="btn btn-error w-20 shrink-0" type="button" - aria-label="Stop generation" + aria-label={stopButton.ariaLabel} + title={stopButton.title} onclick={() => onStop?.()} > - Stop + {stopButton.text} </button> {:else} <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!canSend}> @@ -310,9 +396,10 @@ <div class="flex flex-wrap gap-2 px-4 pb-1"> {#each images as img (img.id)} <div class="group relative h-20 w-20 shrink-0 overflow-hidden rounded border border-base-300"> + <!-- Preview what will actually be sent (the converted JPEG when compress is on). --> <img - src={img.input.url} - alt={img.input.mimeType ?? "staged image"} + src={imageInput(img).url} + alt={imageInput(img).mimeType ?? "staged image"} class="h-full w-full object-cover" /> <button @@ -335,6 +422,22 @@ <line x1="6" y1="6" x2="18" y2="18"></line> </svg> </button> + <!-- Per-image compress toggle (convertible formats only; reversible until send). --> + {#if compressible(img)} + <label + class="absolute bottom-0 left-0 flex items-center gap-1 bg-base-100/80 px-1 py-0.5 text-[10px] leading-none" + title={COMPRESS_TOOLTIP} + > + <input + type="checkbox" + class="checkbox checkbox-xs" + aria-label="Compress image" + checked={img.compress} + onchange={(e) => setCompress(img.id, e.currentTarget.checked)} + /> + <span class="whitespace-nowrap">Compress</span> + </label> + {/if} </div> {/each} </div> diff --git a/src/features/chat/ui/LazyCollapse.svelte b/src/features/chat/ui/LazyCollapse.svelte new file mode 100644 index 0000000..3ce34dd --- /dev/null +++ b/src/features/chat/ui/LazyCollapse.svelte @@ -0,0 +1,46 @@ +<script lang="ts"> + import type { Snippet } from "svelte"; + + /** + * A daisyUI checkbox collapse whose BODY is rendered lazily: nothing inside + * `.collapse-content` is created until the user first opens it. A long turn + * has hundreds of collapsed tool/thinking cards, each holding a full JSON + * input / tool output / thought — building all of that DOM up front (only to + * be hidden) dominated first paint on slow devices. Once opened the body + * stays mounted, so the close animation and any inner scroll state work as + * before. Same markup + classes as the inline collapses it replaces. + */ + let { + label, + titleClass = "", + title, + children, + }: { + /** aria-label of the toggle checkbox. */ + label: string; + /** Extra classes on the `.collapse-title` row. */ + titleClass?: string; + title: Snippet; + children: Snippet; + } = $props(); + + let open = $state(false); + let everOpened = $state(false); + $effect(() => { + if (open) everOpened = true; + }); +</script> + +<!-- `.inset-well` (app.css): the recessed panel shared by thinking/tool blocks + and the sidebar views. --> +<div class="collapse inset-well w-full text-sm"> + <input type="checkbox" aria-label={label} bind:checked={open} /> + <div class="collapse-title flex min-h-0 min-w-0 items-center gap-2 py-2 whitespace-nowrap font-medium {titleClass}"> + {@render title()} + </div> + <div class="collapse-content"> + {#if open || everOpened} + {@render children()} + {/if} + </div> +</div> diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte index 11b9feb..a9b0cc9 100644 --- a/src/features/chat/ui/ModelSelector.svelte +++ b/src/features/chat/ui/ModelSelector.svelte @@ -1,12 +1,28 @@ <script lang="ts"> import type { ModelMetadata } from "@dispatch/transport-contract"; - import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select"; + import type { NamedAgent, NamedAgentSelectionSnapshot } from "../../named-agents"; + import { + isNamedAgentSelection, + isVisionModel, + joinModelName, + modelKeyLabel, + modelsForKey, + NAMED_AGENTS_PROVIDER, + namedAgentNames, + namedAgentSelectionLabel, + providerKeysWithNamedAgents, + splitModelName, + } from "../model-select"; let { models, selected, onSelect, modelInfo = {}, + namedAgents = [], + namedAgentSnapshot = null, + onSelectNamedAgent, + zeroProviders = false, }: { models: readonly string[]; selected: string; @@ -18,75 +34,199 @@ * Optional — absent metadata → no badge (treated as non-vision). */ modelInfo?: Readonly<Record<string, ModelMetadata>>; + /** The user-curated Named Agents (plan §6) offered under the synthetic provider. */ + namedAgents?: readonly NamedAgent[]; + /** The active conversation's Named Agent snapshot (null when not selected via one). */ + namedAgentSnapshot?: NamedAgentSelectionSnapshot | null; + /** Called when a Named Agent is selected (the selector resolves it to a raw model + snapshot). */ + onSelectNamedAgent?: (snapshot: NamedAgentSelectionSnapshot) => void; + /** + * First-boot guardrail (§7): true when the ACTIVE workspace has ZERO + * enabled providers (plan 06's `providers-status`) — renders a visible + * prompt above the picker instead of a silently empty one. + */ + zeroProviders?: boolean; } = $props(); - const keys = $derived(modelKeys(models)); + // Whether the current selection is a Named Agent snapshot (the conversation was + // selected via one; its captured raw model equals `selected`). + const currentIsNamedAgent = $derived(isNamedAgentSelection(selected, namedAgentSnapshot)); + + const providerKeys = $derived(providerKeysWithNamedAgents(models)); const current = $derived(splitModelName(selected)); const keyModels = $derived(modelsForKey(models, current.key)); + // The provider shown in the provider dropdown: the synthetic Named Agents + // provider when the selection is a Named Agent snapshot, else the raw key. + const currentProvider = $derived( + currentIsNamedAgent || current.key === NAMED_AGENTS_PROVIDER + ? NAMED_AGENTS_PROVIDER + : current.key, + ); + + const namedNames = $derived(namedAgentNames(namedAgents)); + // Whether the currently-selected full model name is vision-capable. const selectedVision = $derived(isVisionModel(modelInfo, selected)); - // Switching key jumps to the first model available under it. - function selectKey(key: string): void { + // Whether the currently-selected raw model is PRESENT in the catalog (only + // meaningful for a raw selection — a Named Agent snapshot resolves to a raw + // model that may have left the catalog). + const rawMissing = $derived( + !currentIsNamedAgent && selected !== "" && !models.includes(selected), + ); + const keyUnavailable = $derived( + !currentIsNamedAgent && selected !== "" && !providerKeys.includes(current.key), + ); + + // Switching provider. Going TO the Named Agents provider: jump to the first + // Named Agent (mirrors selectKey jumping to the first model); the model + // dropdown then lists named names. Going to a raw provider: existing behavior. + function selectProvider(key: string): void { + if (key === currentProvider) return; + if (key === NAMED_AGENTS_PROVIDER) { + selectFirstNamedAgent(); + return; + } const first = modelsForKey(models, key)[0] ?? ""; onSelect(joinModelName(key, first)); } - function selectModel(model: string): void { + // Selecting a raw model suffix under the current raw provider. + function selectRawModel(model: string): void { onSelect(joinModelName(current.key, model)); } - // The full `<key>/<model>` name for a model suffix under the current key. + // Selecting a Named Agent name (under the synthetic provider): resolve it to a + // snapshot + raw model and report via onSelectNamedAgent. + function selectNamedAgent(name: string): void { + const agent = namedAgents.find((a) => a.name === name); + if (agent === undefined) return; + onSelectNamedAgent?.({ + namedAgentId: agent.id, + namedAgentName: agent.name, + model: agent.model, + }); + } + + // Jump to the first configured Named Agent (used when the provider is switched + // to Named Agents), matching the raw-provider "jump to first model" behavior. + function selectFirstNamedAgent(): void { + const first = namedAgents[0]; + if (first === undefined) return; // no named agents — the model dropdown shows the disabled placeholder + onSelectNamedAgent?.({ + namedAgentId: first.id, + namedAgentName: first.name, + model: first.model, + }); + } + + // The full `<provider>/<model>` name for a raw model suffix under `current.key`. function fullNameFor(modelSuffix: string): string { return joinModelName(current.key, modelSuffix); } </script> <div class="flex flex-col gap-2"> + {#if zeroProviders} + <!-- First-boot guardrail (§7): a workspace with zero enabled providers + shows a visible prompt instead of a silently empty picker. --> + <div class="alert alert-warning text-sm" role="alert"> + <span> + No providers enabled for this workspace — open the workspace <strong>Plugins</strong> to + enable one. + </span> + </div> + {/if} <select class="select w-full" - value={current.key} - onchange={(e) => selectKey(e.currentTarget.value)} + value={currentProvider} + onchange={(e) => selectProvider(e.currentTarget.value)} aria-label="Key selector" > - {#each keys as key (key)} - <option value={key}>{key}</option> + {#if keyUnavailable && currentProvider !== NAMED_AGENTS_PROVIDER} + <option value={current.key} disabled>{modelKeyLabel(current.key)} (unavailable)</option> + {/if} + {#each providerKeys as key (key)} + <option value={key}> + {key === NAMED_AGENTS_PROVIDER ? "Named Agents" : modelKeyLabel(key)} + </option> {/each} </select> + <select class="select w-full" - value={current.model} - onchange={(e) => selectModel(e.currentTarget.value)} + value={currentIsNamedAgent ? (namedAgentSnapshot?.namedAgentName ?? "") : current.model} + onchange={ + currentIsNamedAgent + ? (e) => selectNamedAgent(e.currentTarget.value) + : (e) => selectRawModel(e.currentTarget.value) + } aria-label="Model selector" + disabled={currentProvider === NAMED_AGENTS_PROVIDER && namedAgents.length === 0} > - {#each keyModels as model (model)} - <option value={model}> - {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if} - </option> - {/each} + {#if currentProvider === NAMED_AGENTS_PROVIDER} + {#if namedAgents.length === 0 && !currentIsNamedAgent} + <option value="" disabled>No named agents configured</option> + {:else} + {#each namedNames as name (name)} + <option value={name}>{namedAgentSelectionLabel(name)}</option> + {/each} + {#if currentIsNamedAgent && !namedNames.includes(namedAgentSnapshot?.namedAgentName ?? "")} + <!-- The selected Named Agent was renamed/removed — keep showing the + captured snapshot name rather than silently switching (plan §7). --> + <option value={namedAgentSnapshot?.namedAgentName ?? ""} disabled> + {namedAgentSelectionLabel(namedAgentSnapshot?.namedAgentName ?? "")} (unavailable) + </option> + {/if} + {/if} + {:else} + {#each keyModels as model (model)} + <option value={model}> + {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if} + </option> + {/each} + {#if rawMissing && !keyUnavailable} + <option value={current.model} disabled>{current.model} (unavailable)</option> + {/if} + {/if} </select> - {#if selectedVision} - <div class="flex items-center gap-1 text-xs text-base-content/60"> - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - class="h-3.5 w-3.5" - aria-hidden="true" - > - <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> - <circle cx="12" cy="12" r="3"></circle> - </svg> - <span>Vision — this model sees images natively</span> + + {#if rawMissing} + <div class="text-xs text-warning" role="status"> + Selected model “{selected}” is unavailable — choose another credential. </div> - {:else} + {/if} + + {#if currentIsNamedAgent} <div class="text-xs text-base-content/40"> - Pasted images are auto-described (vision handoff) + Selected via <strong>Named Agents</strong> — snapshot of {namedAgentSnapshot?.namedAgentName}. </div> {/if} -</div> + + {#if !currentIsNamedAgent} + {#if selectedVision} + <div class="flex items-center gap-1 text-xs text-base-content/60"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + class="h-3.5 w-3.5" + aria-hidden="true" + > + <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> + <circle cx="12" cy="12" r="3"></circle> + </svg> + <span>Vision — this model sees images natively</span> + </div> + {:else} + <div class="text-xs text-base-content/40"> + Pasted images are auto-described (vision handoff) + </div> + {/if} + {/if} +</div>
\ No newline at end of file diff --git a/src/features/chat/ui/PendingInputWarning.svelte b/src/features/chat/ui/PendingInputWarning.svelte new file mode 100644 index 0000000..c9e675e --- /dev/null +++ b/src/features/chat/ui/PendingInputWarning.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import type { PendingInput } from "../pending-inputs"; + + let { + input, + onDiscard, + }: { + input: PendingInput; + onDiscard: (requestId: string) => void; + } = $props(); + + let visible = $state(false); + + // Presentation only: each keyed row gets one grace period, including restored + // or rejected inputs. Prop updates never restart it. Confirmation/discard + // removes the row, cancelling the timer along with any other unmount. + onMount(() => { + const timer = setTimeout(() => { + visible = true; + }, 1000); + return () => clearTimeout(timer); + }); +</script> + +{#if visible} + <div class="m-3 rounded border border-warning p-3 text-sm" role="status"> + <p> + {input.rejection + ? `Message rejected: ${input.rejection}` + : "Delivery unconfirmed. Your input is saved on this device."} + </p> + <p>Check the conversation history before sending again to avoid a duplicate.</p> + <textarea + class="textarea textarea-bordered mt-2 w-full" + aria-label="Saved message" + readonly + value={input.request.type === "chat.send" ? input.request.message : input.request.text} + ></textarea> + {#each input.request.images ?? [] as image} + <img class="my-2 max-h-32" src={image.url} alt="Saved attachment" /> + {/each} + <button class="btn btn-sm mt-2" onclick={() => onDiscard(input.request.requestId)} + >Discard saved copy</button + > + </div> +{/if} diff --git a/src/features/chat/ui/PendingInputs.svelte b/src/features/chat/ui/PendingInputs.svelte new file mode 100644 index 0000000..dcb7aee --- /dev/null +++ b/src/features/chat/ui/PendingInputs.svelte @@ -0,0 +1,16 @@ +<script lang="ts"> + import type { PendingInput } from "../pending-inputs"; + import PendingInputWarning from "./PendingInputWarning.svelte"; + + let { + inputs, + onDiscard, + }: { + inputs: readonly PendingInput[]; + onDiscard: (requestId: string) => void; + } = $props(); +</script> + +{#each inputs as input (input.request.requestId)} + <PendingInputWarning {input} {onDiscard} /> +{/each} diff --git a/src/features/chat/ui/PendingInputs.test.ts b/src/features/chat/ui/PendingInputs.test.ts new file mode 100644 index 0000000..cea770d --- /dev/null +++ b/src/features/chat/ui/PendingInputs.test.ts @@ -0,0 +1,217 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PendingInput } from "../pending-inputs"; +import { createChatStore } from "../store.svelte"; +import { + createFakeCache, + createFakeHistorySync, + createFakeMetricsSync, + createFakeTransport, +} from "../test-helpers"; +import PendingInputs from "./PendingInputs.svelte"; + +function input(requestId: string, type: "chat.send" | "chat.queue" = "chat.send"): PendingInput { + return { + request: { + ...(type === "chat.send" ? { type, message: requestId } : { type, text: requestId }), + conversationId: "c", + requestId, + images: [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }], + }, + }; +} + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + cleanup(); + const remaining = vi.getTimerCount(); + vi.useRealTimers(); + expect(remaining).toBe(0); +}); + +describe("pending input warning grace period", () => { + it.each([ + "chat.send", + "chat.queue", + ] as const)("renders no %s recovery UI before 1000 ms, then keeps its content stable", async (type) => { + const { container } = render(PendingInputs, { + inputs: [input("saved message", type)], + onDiscard: vi.fn(), + }); + expect(container.children).toHaveLength(0); + expect(container.textContent).toBe(""); + await vi.advanceTimersByTimeAsync(999); + expect(container.children).toHaveLength(0); + expect(container.textContent).toBe(""); + expect(screen.queryByRole("status")).toBeNull(); + await vi.advanceTimersByTimeAsync(1); + const warning = screen.getByRole("status"); + expect(warning).toHaveTextContent("Delivery unconfirmed. Your input is saved on this device."); + expect(warning).toHaveTextContent( + "Check the conversation history before sending again to avoid a duplicate.", + ); + expect(screen.getByRole("textbox", { name: "Saved message" })).toHaveValue("saved message"); + expect(screen.getByRole("img", { name: "Saved attachment" })).toHaveAttribute( + "src", + "data:image/png;base64,AAAA", + ); + const content = warning.innerHTML; + await vi.advanceTimersByTimeAsync(10_000); + expect(screen.getByRole("status")).toBe(warning); + expect(warning.innerHTML).toBe(content); + }); + + it.each([ + "confirmation", + "discard", + ])("%s before the threshold cancels the timer and never flashes later", async (resolution) => { + const transport = createFakeTransport(); + const save = vi.fn(); + const store = createChatStore({ + conversationId: "c", + createRequestId: () => "first", + transport: transport.impl, + historySync: createFakeHistorySync().impl, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + pendingInputStorage: { load: () => [], save }, + }); + try { + store.send("first"); + // Saving and transmission stay immediate, independent of warning display. + expect(save).toHaveBeenCalledExactlyOnceWith(store.pendingInputs); + expect(transport.sent).toHaveLength(1); + const { container, rerender } = render(PendingInputs, { + inputs: store.pendingInputs, + onDiscard: store.discardPendingInput, + }); + await vi.advanceTimersByTimeAsync(999); + if (resolution === "confirmation") { + store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "first" }); + } else { + store.discardPendingInput("first"); + } + expect(store.pendingInputs).toEqual([]); + expect(save).toHaveBeenLastCalledWith([]); + await rerender({ inputs: store.pendingInputs }); + expect(vi.getTimerCount()).toBe(0); + expect(container.children).toHaveLength(0); + expect(container.textContent).toBe(""); + await vi.advanceTimersByTimeAsync(10_000); + expect(container.children).toHaveLength(0); + expect(container.textContent).toBe(""); + } finally { + store.dispose(); + } + }); + + it("hides an already visible warning immediately on confirmation", async () => { + const { container, rerender } = render(PendingInputs, { + inputs: [input("first")], + onDiscard: vi.fn(), + }); + await vi.advanceTimersByTimeAsync(1000); + expect(screen.getByRole("status")).toBeInTheDocument(); + await rerender({ inputs: [] }); + expect(container.children).toHaveLength(0); + expect(container.textContent).toBe(""); + }); + + it("preserves the discard action and hides the saved copy without another delay", async () => { + const onDiscard = vi.fn(); + const { container, rerender } = render(PendingInputs, { + inputs: [input("first")], + onDiscard, + }); + await vi.advanceTimersByTimeAsync(1000); + await fireEvent.click(screen.getByRole("button", { name: "Discard saved copy" })); + expect(onDiscard).toHaveBeenCalledExactlyOnceWith("first"); + await rerender({ inputs: [] }); + expect(container.children).toHaveLength(0); + expect(container.textContent).toBe(""); + }); + + it("cancels all waiting timers on unmount", async () => { + const { unmount } = render(PendingInputs, { + inputs: [input("first"), input("second")], + onDiscard: vi.fn(), + }); + expect(vi.getTimerCount()).toBe(2); + await vi.advanceTimersByTimeAsync(500); + unmount(); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(10_000); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("gives successive inputs independent delays without restarting existing rows", async () => { + const first = input("first"); + const second = input("second"); + const { rerender } = render(PendingInputs, { inputs: [first], onDiscard: vi.fn() }); + await vi.advanceTimersByTimeAsync(600); + await rerender({ inputs: [second, { ...first }], onDiscard: vi.fn() }); + await vi.advanceTimersByTimeAsync(399); + expect(screen.queryByRole("status")).toBeNull(); + await vi.advanceTimersByTimeAsync(1); + const warning = screen.getByRole("status"); + expect(screen.getByRole("textbox")).toHaveValue("first"); + await rerender({ inputs: [{ ...second }, { ...first }], onDiscard: vi.fn() }); + await vi.advanceTimersByTimeAsync(599); + expect(screen.getAllByRole("status")).toEqual([warning]); + await vi.advanceTimersByTimeAsync(1); + expect( + screen.getAllByRole("textbox").map((node) => (node as HTMLTextAreaElement).value), + ).toEqual(["second", "first"]); + // Removing the older warning must not re-hide/restart the newer warning. + await rerender({ inputs: [second] }); + expect(screen.getByRole("textbox")).toHaveValue("second"); + expect(vi.getTimerCount()).toBe(0); + }); + + it("starts a fresh delay after an earlier input was confirmed during its grace period", async () => { + const { rerender } = render(PendingInputs, { inputs: [input("first")], onDiscard: vi.fn() }); + await vi.advanceTimersByTimeAsync(600); + await rerender({ inputs: [] }); + await rerender({ inputs: [input("second")] }); + await vi.advanceTimersByTimeAsync(999); + expect(screen.queryByRole("status")).toBeNull(); + await vi.advanceTimersByTimeAsync(1); + expect(screen.getByRole("textbox")).toHaveValue("second"); + }); + + it("keeps the original deadline when rejection arrives, and updates visible content immediately", async () => { + const pending = input("first"); + const { rerender } = render(PendingInputs, { inputs: [pending], onDiscard: vi.fn() }); + await vi.advanceTimersByTimeAsync(600); + await rerender({ inputs: [{ ...pending, rejection: "disk full" }] }); + await vi.advanceTimersByTimeAsync(399); + expect(screen.queryByRole("status")).toBeNull(); + await vi.advanceTimersByTimeAsync(1); + const warning = screen.getByRole("status"); + expect(warning).toHaveTextContent("Message rejected: disk full"); + expect(warning).not.toHaveTextContent("Delivery unconfirmed"); + await rerender({ inputs: [{ ...pending, rejection: "still rejected" }] }); + expect(screen.getByRole("status")).toBe(warning); + expect(warning).toHaveTextContent("Message rejected: still rejected"); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([ + undefined, + "disk full", + ])("restored entries (rejection: %s) use the same grace period and retain recovery content", async (rejection) => { + const restored = input("restored saved message"); + render(PendingInputs, { + inputs: [rejection === undefined ? restored : { ...restored, rejection }], + onDiscard: vi.fn(), + }); + await vi.advanceTimersByTimeAsync(999); + expect(screen.queryByRole("status")).toBeNull(); + await vi.advanceTimersByTimeAsync(1); + expect(screen.getByRole("status")).toHaveTextContent( + rejection === undefined ? "Delivery unconfirmed" : "Message rejected: disk full", + ); + expect(screen.getByRole("textbox")).toHaveValue("restored saved message"); + expect(screen.getByRole("button", { name: "Discard saved copy" })).toBeEnabled(); + }); +}); diff --git a/src/features/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte index d982905..6858779 100644 --- a/src/features/chat/ui/ReasoningEffortSelector.svelte +++ b/src/features/chat/ui/ReasoningEffortSelector.svelte @@ -1,34 +1,45 @@ <script lang="ts"> import type { ReasoningEffort } from "@dispatch/transport-contract"; import { - effectiveEffort, - effortOptions, - isReasoningEffort, - type SaveReasoningEffort, + effectiveSelection, + isThinkingSelection, + selectionOptions, + type SaveThinkingSelection, + type ThinkingSelection, } from "../reasoning-effort"; let { - persisted, + persistedEffort, + persistedThinking, save, }: { - /** The conversation's persisted level, or null when never set (default applies). */ - persisted: ReasoningEffort | null; - save: SaveReasoningEffort; + /** The conversation's persisted effort level, or null when never set (default applies). */ + persistedEffort: ReasoningEffort | null; + /** + * The conversation's persisted thinking flag, or null when never set + * (thinking ON — the default). `false` ⇒ thinking disabled (the separate + * "off" axis); the effort level is preserved across an off→on toggle. + */ + persistedThinking: boolean | null; + /** Persist a thinking selection (off or a level). */ + save: SaveThinkingSelection; } = $props(); - const options = effortOptions(); + const options = selectionOptions(); - // The user's in-flight choice; null = mirror the (async-loaded) persisted prop. - // Re-mounted per conversation, so there is no cross-tab bleed. - let chosen = $state<ReasoningEffort | null>(null); + // The user's in-flight choice; null = mirror the (async-loaded) persisted + // selection. Re-mounted per conversation, so there is no cross-tab bleed. + let chosen = $state<ThinkingSelection | null>(null); let saving = $state(false); let error = $state<string | null>(null); let justSaved = $state(false); - const selected = $derived(chosen ?? effectiveEffort(persisted)); + const selected = $derived( + chosen ?? effectiveSelection(persistedEffort, persistedThinking), + ); async function handleChange(value: string) { - if (!isReasoningEffort(value) || saving) return; + if (!isThinkingSelection(value) || saving) return; chosen = value; saving = true; error = null; @@ -40,7 +51,7 @@ justSaved = true; } else { error = result.error; - chosen = null; // revert to the persisted value + chosen = null; // revert to the persisted selection } } </script> @@ -69,7 +80,7 @@ <p class="text-xs text-success">Saved — applies from the next turn.</p> {:else} <p class="text-xs opacity-50"> - How long the model thinks before answering. Changing it can re-prefill the prompt cache once. + How long the model thinks before answering. “Off” disables thinking entirely. Changing it can re-prefill the prompt cache once. </p> {/if} </div> diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte new file mode 100644 index 0000000..10f8528 --- /dev/null +++ b/src/features/chat/ui/ToolCard.svelte @@ -0,0 +1,274 @@ +<script lang="ts"> + import type { ToolEntry } from "../index"; + import { viewToolEntry } from "../logic/tool-summary"; + import LazyCollapse from "./LazyCollapse.svelte"; + import { TOOL_ICON_COMPONENTS } from "./tool-icons"; + + let { entry }: { entry: ToolEntry } = $props(); + const card = $derived(viewToolEntry(entry)); + const ToolIcon = $derived(TOOL_ICON_COMPONENTS[card.icon]); + let showAll = $state(false); +</script> + +<div class="tool-card"> + <LazyCollapse label={`Toggle tool call ${card.label}`} titleClass="pe-4"> + {#snippet title()} + <div class="tool-header" class:has-earlier-call={card.callDetail.kind === "unavailable"}> + {#if card.status === "pending"} + <!-- Same in-progress animation as the streaming thinking block. All three + status icons share a fixed w-5 slot so the row never shifts on settle. --> + <span class="loading loading-dots loading-sm w-5 shrink-0" aria-label="Running"></span> + {:else if card.status === "error"} + <span class="w-5 shrink-0 text-center text-error" aria-label="Failed">✗</span> + {:else} + <span + class="w-5 shrink-0 text-center text-success" + class:opacity-60={card.callDetail.kind === "unavailable"} + aria-label="Succeeded">✓</span + > + {/if} + <!-- Per-tool Phosphor icon (key chosen by the pure view-model). --> + <ToolIcon size={16} class="shrink-0 opacity-70" aria-hidden="true" /> + <span class="tool-name truncate" title={card.name}>{card.label}</span> + <span + class="tool-summary truncate font-normal text-base-content/60" + class:earlier-history={card.callDetail.kind === "unavailable"} + title={card.callDetail.kind === "unavailable" ? card.callDetail.text : card.callSummary} + > + {card.callSummary} + </span> + <span class="tool-badges gap-1 text-xs"> + {#each card.callBadges as badge} + <span + class:text-success={badge.tone === "success"} + class:text-error={badge.tone === "error"}>{badge.label}</span + > + {/each} + </span> + {#if card.resultSummary !== null} + <span + class="tool-result text-xs" + class:text-error={card.status === "error"} + title={card.resultSummary} + > + {#if card.resultSummary === card.compactResultSummary} + <span>→ {card.resultSummary}</span> + {:else} + <span class="result-full">→ {card.resultSummary}</span> + <span class="result-compact">→ {card.compactResultSummary}</span> + {/if} + </span> + {:else} + <span class="tool-result text-xs text-base-content/60">→ running…</span> + {/if} + </div> + {/snippet} + <div class="min-w-0 space-y-4 text-xs"> + <section aria-label="Call"> + {#if card.callDetail.kind === "unavailable"} + <aside + role="note" + class="border-l-2 border-base-content/20 pl-3 italic text-base-content/60" + > + {card.callDetail.text} + </aside> + {:else} + <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content"> + Call + </h3> + {/if} + {#if card.callDetail.kind === "none"} + <p class="italic text-base-content/60">No arguments</p> + {:else if card.callDetail.kind === "command"} + <pre class="overflow-x-auto">$ {card.callDetail.text}</pre> + {:else if card.callDetail.kind === "edit"} + <div class="mb-2 break-all font-mono">{card.callDetail.path}</div> + <!-- Unified-diff style: every removed/added line carries its own −/+ gutter + marker and a tinted row, so multi-line edits read as one diff block. --> + <div class="edit-diff overflow-x-auto rounded font-mono" role="group" aria-label="Edit diff"> + {#if card.callDetail.oldText !== ""} + {#each card.callDetail.oldText.split("\n") as line, i (i)} + <div class="edit-diff-row edit-diff-del"> + <span class="edit-diff-sign" aria-label="removed">−</span><span>{line}</span> + </div> + {/each} + {/if} + {#if card.callDetail.newText !== ""} + {#each card.callDetail.newText.split("\n") as line, i (i)} + <div class="edit-diff-row edit-diff-add"> + <span class="edit-diff-sign" aria-label="added">+</span><span>{line}</span> + </div> + {/each} + {/if} + </div> + {:else if card.callDetail.kind === "fields"} + <dl class="call-fields"> + {#each card.callDetail.fields as field} + <dt class="font-semibold text-base-content/85">{field.key}</dt> + <dd class="min-w-0"> + <pre + class="overflow-x-auto" + class:whitespace-pre-wrap={!field.multiline} + class:break-words={!field.multiline}>{field.value}</pre> + </dd> + {/each} + </dl> + {/if} + </section> + <section aria-label="Response"> + <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content"> + Response + </h3> + {#if card.output === null} + <span class="text-base-content/75">running…</span> + {:else} + <div class:text-error={card.output.isError}> + {#if card.output.kind === "full"} + <pre class="overflow-x-auto">{card.output.lines.join("\n") || "(no output)"}</pre> + {:else} + {#if !showAll} + <pre class="overflow-x-auto">{card.output.head.join("\n")}</pre> + {/if} + <div class="output-controls" class:sticky-controls={showAll}> + <button + class="btn btn-soft btn-xs focus-visible:outline-primary" + aria-expanded={showAll} + onclick={() => { + showAll = !showAll; + }} + > + {#if showAll}<span aria-hidden="true">↑</span>{/if} + {showAll ? "Show fewer lines" : `⋯ ${card.output.hiddenCount} more lines`} + </button> + </div> + {#if showAll} + <pre class="overflow-x-auto">{card.output.all.join("\n")}</pre> + {:else} + <pre class="overflow-x-auto">{card.output.tail.join("\n")}</pre> + {/if} + {/if} + </div> + {/if} + </section> + </div> + </LazyCollapse> +</div> + +<style> + .tool-card { + container-type: inline-size; + } + .tool-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-width: 0; + } + /* English names are short phrases: keep them whole and let the call + summary absorb the squeeze. The cap only bites on long MCP names. */ + .tool-name { + flex: 0 0 auto; + max-width: 60%; + } + .tool-summary { + flex: 1; + min-width: 0; + } + .earlier-history { + font-style: italic; + color: var(--color-base-content); + opacity: 0.7; + } + .tool-badges { + display: flex; + flex-shrink: 0; + } + .tool-badges:empty { + display: none; + } + .tool-result { + flex-shrink: 0; + white-space: nowrap; + } + .result-compact { + display: none; + } + .call-fields { + display: grid; + grid-template-columns: minmax(4rem, max-content) minmax(0, 1fr); + gap: 0.5rem 1rem; + align-items: baseline; + } + .call-fields dt { + max-width: 10rem; + overflow-wrap: anywhere; + } + .edit-diff { + background: var(--color-base-300); + padding-block: 0.25rem; + } + .edit-diff-row { + display: flex; + min-width: max-content; + white-space: pre; + padding-inline-end: 0.75rem; + } + .edit-diff-sign { + flex-shrink: 0; + width: 1.75rem; + text-align: center; + user-select: none; + } + .edit-diff-del { + color: var(--color-error); + background: color-mix(in oklab, var(--color-error) 12%, transparent); + } + .edit-diff-add { + color: var(--color-success); + background: color-mix(in oklab, var(--color-success) 12%, transparent); + } + .output-controls { + padding-block: 0.5rem; + color: var(--color-base-content); + /* Same fill as the LazyCollapse inset well this sits in (opaque, so + scrolled output never shows through the sticky bar). */ + background: var(--inset-well-bg, var(--color-base-200)); + } + .sticky-controls { + position: sticky; + top: 0; + z-index: 2; + border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 20%, transparent); + margin-bottom: 0.5rem; + } + @container (max-width: 48rem) { + .result-full { + display: none; + } + .result-compact { + display: inline; + } + } + @container (max-width: 40rem) { + .tool-badges { + display: none; + } + .has-earlier-call .tool-result { + display: none; + } + } + @container (max-width: 24rem) { + .tool-header { + gap: 0.375rem; + } + .call-fields { + column-gap: 0.75rem; + } + } + @container (max-width: 18rem) { + .tool-result { + display: none; + } + } +</style> diff --git a/src/features/chat/ui/tool-icons.ts b/src/features/chat/ui/tool-icons.ts new file mode 100644 index 0000000..1d68ebe --- /dev/null +++ b/src/features/chat/ui/tool-icons.ts @@ -0,0 +1,52 @@ +// Phosphor icon per tool-icon key (the pure view-model picks the KEY — see +// `logic/tool-summary.ts` `toolIcon`; this is the only place that knows the +// concrete components). Deep imports (`phosphor-svelte/lib/<Name>Icon`) on +// purpose: the package root re-exports ~1,500 components, and Svelte libraries +// are not prebundled in dev, so a root import would load every one of them. + +import ArrowSquareOutIcon from "phosphor-svelte/lib/ArrowSquareOutIcon"; +import BookOpenIcon from "phosphor-svelte/lib/BookOpenIcon"; +import BracketsCurlyIcon from "phosphor-svelte/lib/BracketsCurlyIcon"; +import ChatCircleTextIcon from "phosphor-svelte/lib/ChatCircleTextIcon"; +import CpuIcon from "phosphor-svelte/lib/CpuIcon"; +import EyeIcon from "phosphor-svelte/lib/EyeIcon"; +import FileTextIcon from "phosphor-svelte/lib/FileTextIcon"; +import FloppyDiskIcon from "phosphor-svelte/lib/FloppyDiskIcon"; +import ImageIcon from "phosphor-svelte/lib/ImageIcon"; +import ListChecksIcon from "phosphor-svelte/lib/ListChecksIcon"; +import MagnifyingGlassIcon from "phosphor-svelte/lib/MagnifyingGlassIcon"; +import PaperPlaneTiltIcon from "phosphor-svelte/lib/PaperPlaneTiltIcon"; +import PencilSimpleIcon from "phosphor-svelte/lib/PencilSimpleIcon"; +import ProhibitIcon from "phosphor-svelte/lib/ProhibitIcon"; +import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon"; +import RobotIcon from "phosphor-svelte/lib/RobotIcon"; +import TerminalWindowIcon from "phosphor-svelte/lib/TerminalWindowIcon"; +import UsersThreeIcon from "phosphor-svelte/lib/UsersThreeIcon"; +import WrenchIcon from "phosphor-svelte/lib/WrenchIcon"; +import YoutubeLogoIcon from "phosphor-svelte/lib/YoutubeLogoIcon"; +import type { Component } from "svelte"; +import type { ToolIconKey } from "../logic/tool-summary"; + +// biome-ignore lint/suspicious/noExplicitAny: Phosphor's IconComponentProps is internal; any icon accepts size/class. +export const TOOL_ICON_COMPONENTS: Readonly<Record<ToolIconKey, Component<any>>> = { + terminal: TerminalWindowIcon, + "file-read": FileTextIcon, + "file-edit": PencilSimpleIcon, + "file-write": FloppyDiskIcon, + search: MagnifyingGlassIcon, + code: BracketsCurlyIcon, + todos: ListChecksIcon, + skill: BookOpenIcon, + vision: EyeIcon, + image: ImageIcon, + youtube: YoutubeLogoIcon, + "agent-summon": RobotIcon, + "agent-send": PaperPlaneTiltIcon, + "agent-read": ChatCircleTextIcon, + "agent-open": ArrowSquareOutIcon, + "agent-cancel": ProhibitIcon, + agents: UsersThreeIcon, + models: CpuIcon, + mcp: PuzzlePieceIcon, + tool: WrenchIcon, +}; diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts index 6e6b770..9c557f5 100644 --- a/src/features/concurrency/logic/view-model.test.ts +++ b/src/features/concurrency/logic/view-model.test.ts @@ -13,6 +13,7 @@ import { parseLimitInput, pauseLabel, providerFromModel, + providerLabel, providerOptions, statusLabel, summarizeLimits, @@ -95,7 +96,7 @@ describe("cooldownLabel", () => { 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"); + expect(providerFromModel("opencode-1/gpt-4o-mini")).toBe("opencode-1"); }); it("returns the whole string when there is no slash", () => { expect(providerFromModel("umans")).toBe("umans"); @@ -127,6 +128,23 @@ describe("providerOptions", () => { }); }); +describe("providerLabel", () => { + it("labels a numbered OpenCode provider as OpenCode #N", () => { + expect(providerLabel("opencode-2")).toBe("OpenCode #2"); + expect(providerLabel("opencode-10")).toBe("OpenCode #10"); + }); + + it("leaves non-OpenCode provider ids unchanged", () => { + expect(providerLabel("umans")).toBe("umans"); + expect(providerLabel("anthropic")).toBe("anthropic"); + }); + + it("rejects noncanonical numbered ids (label passthrough)", () => { + expect(providerLabel("opencode")).toBe("opencode"); + expect(providerLabel("opencode-0")).toBe("opencode-0"); + }); +}); + // ── pauseLabel + formatPauseDuration ─────────────────────────────────────────── describe("formatPauseDuration", () => { @@ -450,12 +468,12 @@ describe("normalizeConcurrencyLimits", () => { const limits = normalizeConcurrencyLimits({ limits: [ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ], }); expect(limits).toEqual([ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ]); }); @@ -510,7 +528,7 @@ describe("normalizeConcurrencyStatus", () => { providers: [ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, { - providerId: "openai-compat", + providerId: "opencode-1", limit: 5, inFlight: 5, queued: 3, @@ -532,7 +550,7 @@ describe("normalizeConcurrencyStatus", () => { }); expect(first !== undefined && !("pausedUntil" in first)).toBe(true); expect(second).toEqual({ - providerId: "openai-compat", + providerId: "opencode-1", limit: 5, inFlight: 5, queued: 3, diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts index 8a37198..80c155c 100644 --- a/src/features/concurrency/logic/view-model.ts +++ b/src/features/concurrency/logic/view-model.ts @@ -170,6 +170,18 @@ export function providerOptions( return out; } +/** + * Friendly display label for a concurrency provider id. A numbered OpenCode + * provider (`opencode-<n>`) renders as "OpenCode #<n>" (so "opencode-2" reads + * as a coherent credential identity, matching the model picker); any other id is + * shown unchanged. The RAW id is still what is sent to / displayed-with the + * backend — this only affects presentation. + */ +export function providerLabel(providerId: string): string { + const m = /^opencode-([1-9][0-9]*)$/.exec(providerId); + return m === null ? providerId : `OpenCode #${m[1]}`; +} + // ── Status → display view ────────────────────────────────────────────────────── const NO_LIMITS = "No limits configured"; diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte index bf06ac0..c8f6c8f 100644 --- a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte +++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte @@ -4,6 +4,7 @@ DEFAULT_COOLDOWN_MS, parseCooldownInput, parseLimitInput, + providerLabel, statusLabel, type Badge, type ConcurrencyLimitView, @@ -132,8 +133,8 @@ 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 + <span class="min-w-0 flex-1 truncate font-medium" title={limit.providerId} + >{providerLabel(limit.providerId)}</span > <input type="text" diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte index aadb8d1..2e23f5c 100644 --- a/src/features/concurrency/ui/ConcurrencyView.svelte +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -6,6 +6,7 @@ DEFAULT_COOLDOWN_MS, parseCooldownInput, parseLimitInput, + providerLabel, providerOptions, summarizeLimits, viewConcurrencyLimits, @@ -376,7 +377,7 @@ <option value="" disabled>No providers available</option> {:else} {#each providerOpts as provider (provider)} - <option value={provider}>{provider}</option> + <option value={provider}>{providerLabel(provider)}</option> {/each} {/if} </select> diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts index a8163c2..e214692 100644 --- a/src/features/concurrency/ui/ConcurrencyView.test.ts +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -11,8 +11,8 @@ import type { } 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; +// Available models → provider ids are "umans", "anthropic", "opencode-1". +const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "opencode-1/gpt-4o"] as const; // A status entry factory (defaults to a healthy limited provider). The new // concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present. diff --git a/src/features/conversation-cache/cache.test.ts b/src/features/conversation-cache/cache.test.ts index ce1c60c..72b4e20 100644 --- a/src/features/conversation-cache/cache.test.ts +++ b/src/features/conversation-cache/cache.test.ts @@ -15,8 +15,16 @@ const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => */ function createFakeStore(): ConversationChunkStore { const store = new Map<string, StoredChunk[]>(); + const epochs = new Map<string, number>(); return { + async historyEpoch(id) { + return epochs.get(id); + }, + async reset(id, epoch) { + store.delete(id); + epochs.set(id, epoch); + }, async load(conversationId) { return store.get(conversationId) ?? []; }, @@ -33,6 +41,7 @@ function createFakeStore(): ConversationChunkStore { async delete(conversationId) { store.delete(conversationId); + epochs.delete(conversationId); }, async index() { @@ -195,3 +204,48 @@ describe("cache.delete", () => { expect(result).toEqual([]); }); }); + +it("serializes invalidation and rejects stale epoch commits", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("c", [chunk(1)]); // Legacy rows have no trusted epoch. + expect(await cache.commit("c", [chunk(101)], 1)).toEqual([chunk(101)]); + await Promise.all([cache.commit("c", [chunk(102)], 1), cache.reset("c", 2)]); + expect(await cache.commit("c", [chunk(103)], 1)).toEqual([]); + expect(await store.historyEpoch("c")).toBe(2); +}); + +it("replaces a confirmed full history at a lower epoch, without trusting stale commits", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await cache.commit("c", [chunk(9)], 5); + expect(await cache.replace("c", [chunk(1)], 1, 5)).toBe(true); + expect(await cache.historyEpoch("c")).toBe(1); + expect(await cache.load("c")).toEqual([chunk(1)]); + expect(await cache.commit("c", [chunk(9)], 5)).toEqual([chunk(1)]); + // A delayed full response started against epoch 5 must not restore it. + expect(await cache.replace("c", [chunk(9)], 5, 5)).toBe(false); + expect(await cache.load("c")).toEqual([chunk(1)]); +}); + +it("serializes a guarded replacement behind newer invalidation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await cache.commit("c", [chunk(9)], 5); + const resetting = cache.reset("c", 6); + const replacing = cache.replace("c", [chunk(1)], 1, 5); + await resetting; + expect(await replacing).toBe(false); + expect(await cache.historyEpoch("c")).toBe(6); + expect(await cache.load("c")).toEqual([]); +}); + +it("allows an empty replacement and a legacy cache only if its observed epoch still matches", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("c", [chunk(9)]); + expect(await cache.replace("c", [], 0, undefined)).toBe(true); + expect(await cache.sinceSeq("c")).toBe(0); + expect(await cache.historyEpoch("c")).toBe(0); + expect(await cache.replace("c", [chunk(9)], 2, undefined)).toBe(false); +}); diff --git a/src/features/conversation-cache/cache.ts b/src/features/conversation-cache/cache.ts index 4953944..600a6e6 100644 --- a/src/features/conversation-cache/cache.ts +++ b/src/features/conversation-cache/cache.ts @@ -3,6 +3,22 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic"; import type { ConversationChunkStore } from "./types"; export interface ConversationCache { + historyEpoch(conversationId: string): Promise<number | undefined>; + reset(conversationId: string, historyEpoch: number): Promise<void>; + + /** + * Replace from a fresh full-history response, including an epoch decrease. + * Only succeeds if the cache still has the epoch observed BEFORE that request. + * Unlike monotonic reset/commit, this is for confirmed server authority, not + * broadcasts or cursor/backfill responses. False means the response is stale. + */ + replace( + conversationId: string, + incoming: readonly StoredChunk[], + historyEpoch: number, + expectedEpoch: number | undefined, + ): Promise<boolean>; + /** Load all cached chunks for a conversation. */ load(conversationId: string): Promise<readonly StoredChunk[]>; @@ -10,7 +26,11 @@ export interface ConversationCache { * 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[]>; + commit( + conversationId: string, + incoming: readonly StoredChunk[], + historyEpoch?: number, + ): Promise<readonly StoredChunk[]>; /** Return the `?sinceSeq=` cursor for the next incremental sync. */ sinceSeq(conversationId: string): Promise<number>; @@ -43,21 +63,61 @@ export function createConversationCache( ): ConversationCache { const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS; + // Serialize mutation per conversation, including invalidation, so an old fetch + // cannot resurrect rows after a compaction broadcast has invalidated them. + const pending = new Map<string, Promise<unknown>>(); + function exclusive<T>(id: string, run: () => Promise<T>): Promise<T> { + const next = (pending.get(id) ?? Promise.resolve()).catch(() => {}).then(run); + pending.set(id, next); + void next + .finally(() => { + if (pending.get(id) === next) pending.delete(id); + }) + .catch(() => {}); + return next; + } return { + historyEpoch: async (id) => { + await pending.get(id); + return store.historyEpoch(id); + }, + reset: (id, epoch) => + exclusive(id, async () => { + const current = await store.historyEpoch(id); + if (current === undefined || current < epoch) await store.reset(id, epoch); + }), + replace: (id, incoming, epoch, expectedEpoch) => + exclusive(id, async () => { + if ((await store.historyEpoch(id)) !== expectedEpoch) return false; + await store.reset(id, epoch); + if (incoming.length > 0) await store.append(id, incoming); + return true; + }), async load(conversationId) { + await pending.get(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; + commit(conversationId, incoming, epoch) { + return exclusive(conversationId, async () => { + if (epoch !== undefined) { + const current = await store.historyEpoch(conversationId); + // Once an epoch is known, only explicit invalidation or guarded + // full-history replacement may change it (in either direction). + if (current !== undefined && current !== epoch) return store.load(conversationId); + if (current !== epoch) await store.reset(conversationId, epoch); + } + 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) { + await pending.get(conversationId); const cached = await store.load(conversationId); return nextSinceSeq(cached); }, @@ -72,7 +132,7 @@ export function createConversationCache( }, async delete(conversationId) { - await store.delete(conversationId); + await exclusive(conversationId, () => store.delete(conversationId)); }, }; } diff --git a/src/features/conversation-cache/logic.test.ts b/src/features/conversation-cache/logic.test.ts index 880c32e..7a31783 100644 --- a/src/features/conversation-cache/logic.test.ts +++ b/src/features/conversation-cache/logic.test.ts @@ -138,3 +138,26 @@ describe("selectEvictions", () => { expect(result).toEqual(["b"]); }); }); + +describe("reconcileCache — turnId provenance upgrade", () => { + it("upgrades a cached row lacking turnId when the incoming copy carries it", () => { + const cached: StoredChunk[] = [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }]; + const incoming: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "yo" }, turnId: "t1" }, + ]; + const { merged, toAppend } = reconcileCache(cached, incoming); + expect(merged.map((c) => c.turnId)).toEqual(["t1", "t1"]); + expect(toAppend.map((c) => c.seq)).toEqual([1, 2]); + }); + + it("does not rewrite a cached row that already has a turnId", () => { + const cached: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" }, + ]; + const { toAppend } = reconcileCache(cached, [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" }, + ]); + expect(toAppend).toEqual([]); + }); +}); diff --git a/src/features/conversation-cache/logic.ts b/src/features/conversation-cache/logic.ts index 3cb23e2..344a061 100644 --- a/src/features/conversation-cache/logic.ts +++ b/src/features/conversation-cache/logic.ts @@ -5,27 +5,35 @@ import type { ConversationCacheIndexEntry, ReconcileResult } from "./types"; * Merge authoritative seq-keyed chunks with cached chunks. * * Deduplicates by `seq`, produces seq-monotonic order. - * `toAppend` = the incoming chunks whose `seq` is not already in `cached` - * (exactly what to persist). Idempotent; tolerant of out-of-order/overlapping `incoming`. + * `toAppend` = the incoming chunks whose `seq` is not already in `cached`, + * plus cached rows UPGRADED with a `turnId` the incoming copy carries and the + * cached copy lacks (exactly what to persist — the store upserts by seq). Idempotent; tolerant of out-of-order/overlapping `incoming`. */ export function reconcileCache( cached: readonly StoredChunk[], incoming: readonly StoredChunk[], ): ReconcileResult { - const seen = new Set<number>(); + const bySeq = new Map<number, StoredChunk>(); for (const chunk of cached) { - seen.add(chunk.seq); + bySeq.set(chunk.seq, chunk); } const toAppend: StoredChunk[] = []; for (const chunk of incoming) { - if (!seen.has(chunk.seq)) { + const have = bySeq.get(chunk.seq); + if (have === undefined) { toAppend.push(chunk); - seen.add(chunk.seq); + bySeq.set(chunk.seq, chunk); + } else if (have.turnId === undefined && chunk.turnId !== undefined) { + // Upgrade a cached row written before the server stamped `turnId` + // (seqs are immutable, so only this provenance field can differ). + const upgraded = { ...have, turnId: chunk.turnId }; + toAppend.push(upgraded); + bySeq.set(chunk.seq, upgraded); } } - const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq); + const merged = [...bySeq.values()].sort((a, b) => a.seq - b.seq); return { merged, toAppend }; } diff --git a/src/features/conversation-cache/types.ts b/src/features/conversation-cache/types.ts index 345ab71..a273f34 100644 --- a/src/features/conversation-cache/types.ts +++ b/src/features/conversation-cache/types.ts @@ -17,6 +17,10 @@ export interface ConversationCacheIndexEntry { * All methods MUST be idempotent on `seq`: re-appending an existing seq is a no-op. */ export interface ConversationChunkStore { + historyEpoch(conversationId: string): Promise<number | undefined>; + /** Atomically replace cached rows with empty history at this epoch. */ + reset(conversationId: string, historyEpoch: number): Promise<void>; + /** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */ load(conversationId: string): Promise<readonly StoredChunk[]>; diff --git a/src/features/heartbeat/logic/types.ts b/src/features/heartbeat/logic/types.ts index 3d3d525..83cec74 100644 --- a/src/features/heartbeat/logic/types.ts +++ b/src/features/heartbeat/logic/types.ts @@ -26,6 +26,15 @@ export type HeartbeatRunStatus = "running" | "completed" | "stopped"; 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. */ @@ -46,6 +55,7 @@ export interface HeartbeatConfig { */ export interface HeartbeatConfigPatch { readonly enabled?: boolean; + readonly inactiveOnly?: boolean; readonly systemPrompt?: string; readonly taskPrompt?: string; readonly intervalMinutes?: number; diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts index aca0aa6..c9ef118 100644 --- a/src/features/heartbeat/logic/view-model.test.ts +++ b/src/features/heartbeat/logic/view-model.test.ts @@ -39,6 +39,7 @@ const run = (over: Partial<HeartbeatRun> = {}): HeartbeatRun => ({ const config = (over: Partial<HeartbeatConfig> = {}): HeartbeatConfig => ({ enabled: false, + inactiveOnly: true, systemPrompt: "be helpful", taskPrompt: "check status", intervalMinutes: 15, @@ -254,6 +255,40 @@ describe("config form", () => { 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)", () => { @@ -321,6 +356,7 @@ describe("normalizeHeartbeatConfig", () => { it("passes through a well-formed config", () => { const c = normalizeHeartbeatConfig({ enabled: true, + inactiveOnly: false, systemPrompt: "sys", taskPrompt: "task", intervalMinutes: 20, @@ -329,6 +365,7 @@ describe("normalizeHeartbeatConfig", () => { }); expect(c).toEqual({ enabled: true, + inactiveOnly: false, systemPrompt: "sys", taskPrompt: "task", intervalMinutes: 20, @@ -339,10 +376,12 @@ describe("normalizeHeartbeatConfig", () => { 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(""); @@ -355,12 +394,30 @@ describe("normalizeHeartbeatConfig", () => { 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", () => { diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts index e91febd..4a5ba7f 100644 --- a/src/features/heartbeat/logic/view-model.ts +++ b/src/features/heartbeat/logic/view-model.ts @@ -220,6 +220,7 @@ export function approximateNextRunEpoch( */ export interface HeartbeatFormState { enabled: boolean; + inactiveOnly: boolean; systemPrompt: string; taskPrompt: string; intervalHours: number; @@ -254,6 +255,7 @@ 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, @@ -268,6 +270,7 @@ export function emptyForm(): HeartbeatFormState { const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES); return { enabled: false, + inactiveOnly: true, systemPrompt: "", taskPrompt: "", intervalHours: hours, @@ -295,6 +298,7 @@ export function normalizeInterval(value: unknown): number { 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), @@ -308,6 +312,7 @@ export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): 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 || @@ -390,6 +395,10 @@ export function normalizeHeartbeatConfig(data: unknown): HeartbeatConfig { 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), diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte index 5f262f8..7f95c40 100644 --- a/src/features/heartbeat/ui/HeartbeatView.svelte +++ b/src/features/heartbeat/ui/HeartbeatView.svelte @@ -140,6 +140,31 @@ } } + // 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 @@ -323,6 +348,27 @@ {#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> 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.test.ts b/src/features/heartbeat/ui/PromptEditor.test.ts index 284b319..c4bd3b8 100644 --- a/src/features/heartbeat/ui/PromptEditor.test.ts +++ b/src/features/heartbeat/ui/PromptEditor.test.ts @@ -29,6 +29,7 @@ function fakeSaveConfig(): { // 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, diff --git a/src/features/heartbeat/ui/RunModal.svelte b/src/features/heartbeat/ui/RunModal.svelte index 92068ae..12e248a 100644 --- a/src/features/heartbeat/ui/RunModal.svelte +++ b/src/features/heartbeat/ui/RunModal.svelte @@ -154,6 +154,7 @@ {:else} <ChatView chunks={chat.chunks} + recovering={chat.recovering} turnMetrics={chat.turnMetrics} hasEarlier={chat.hasEarlier} onShowEarlier={chat.showEarlier} diff --git a/src/features/layout/adapter/abi-port.test.ts b/src/features/layout/adapter/abi-port.test.ts new file mode 100644 index 0000000..24f9c94 --- /dev/null +++ b/src/features/layout/adapter/abi-port.test.ts @@ -0,0 +1,97 @@ +import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract"; +import { describe, expect, it, vi } from "vitest"; +import type { LayoutProviderState, LayoutStore } from "../store.svelte"; +import { abiTree, createLayoutAbiPort } from "./abi-port"; + +/** + * The ABI `layout` port tests (plan 04 §4.5) — the port is pure composition + * over the layout store + the ops HTTP edge, so BOTH deps are fakes (the + * outermost edges; nothing internal is mocked — asymmetric testing). + */ + +function fakeStore( + overrides: Partial<Pick<LayoutStore, "tree" | "providerState" | "activeWorkspaceId">> = {}, +): LayoutStore { + return { + get tree(): LayoutTree { + return DEFAULT_LAYOUT; + }, + get providerState(): LayoutProviderState { + return "present"; + }, + get activeWorkspaceId(): string { + return "ws-a"; + }, + probe: vi.fn(async () => {}), + onWorkspaceChanged: vi.fn(), + onServerTree: vi.fn(), + onTreeChange: vi.fn(), + resetToDefault: vi.fn(async () => null), + onExtensionsChanged: vi.fn(), + onWorkspacePluginsChanged: vi.fn(), + ...overrides, + } as LayoutStore; +} + +describe("abiTree — the getTree decision (pure)", () => { + it("a PRESENT provider exposes the tree", () => { + expect(abiTree("present", DEFAULT_LAYOUT)).toBe(DEFAULT_LAYOUT); + }); + + it("no-provider mode (absent) exposes NULL — the local fallback is not shared state", () => { + expect(abiTree("absent", DEFAULT_LAYOUT)).toBeNull(); + }); + + it("an unknown (not-yet-probed) provider exposes NULL too", () => { + expect(abiTree("unknown", DEFAULT_LAYOUT)).toBeNull(); + }); +}); + +describe("createLayoutAbiPort — applyOps → the ops route", () => { + it("posts to the ACTIVE workspace and feeds the returned tree back through the store", async () => { + const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const; + const edited: LayoutTree = { ...DEFAULT_LAYOUT }; + const store = fakeStore(); + const seen: string[] = []; + const port = createLayoutAbiPort({ + store, + postOps: async (workspaceId, o) => { + seen.push(workspaceId); + return { ok: true, tree: edited, applied: o.length }; + }, + }); + + const result = await port.applyOps(ops); + expect(seen).toEqual(["ws-a"]); // the ACTIVE workspace at CALL time + expect(result).toEqual({ ok: true, tree: edited, applied: 1 }); + // The successful apply echoes the tree into the store (instant local + // re-render; the WS broadcast echo is idempotent with this). + expect(store.onServerTree).toHaveBeenCalledWith("ws-a", edited); + }); + + it("a failed batch (typed no-provider error) does NOT touch the store", async () => { + const store = fakeStore({ providerState: "absent" }); + const port = createLayoutAbiPort({ + store, + postOps: async () => ({ ok: false, error: "no layout provider active" }), + }); + + const result = await port.applyOps([{ type: "removeNode", target: "side" }]); + expect(result).toEqual({ ok: false, error: "no layout provider active" }); + expect(store.onServerTree).not.toHaveBeenCalled(); + }); + + it("getTree returns the active tree while the provider is present, null when absent", () => { + const port = createLayoutAbiPort({ + store: fakeStore({ providerState: "present" }), + postOps: async () => ({ ok: false, error: "unused" }), + }); + expect(port.getTree()).toBe(DEFAULT_LAYOUT); + + const absent = createLayoutAbiPort({ + store: fakeStore({ providerState: "absent" }), + postOps: async () => ({ ok: false, error: "no layout provider active" }), + }); + expect(absent.getTree()).toBeNull(); + }); +}); diff --git a/src/features/layout/adapter/abi-port.ts b/src/features/layout/adapter/abi-port.ts new file mode 100644 index 0000000..69576c6 --- /dev/null +++ b/src/features/layout/adapter/abi-port.ts @@ -0,0 +1,60 @@ +import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract"; +import type { LayoutProviderState, LayoutStore } from "../store.svelte"; + +/** + * The port the frontend-plugin ABI exposes as `api.layout` (plan 04 §4.5). + * The composition root builds it with {@link createLayoutAbiPort} and the + * plugin host hands it to every `activate(api)` — plugins never see the + * store or the HTTP edge, only THIS. + */ +export interface LayoutAbiPort { + /** + * Apply op-vocabulary edits to the ACTIVE workspace's tree through the + * provider (`POST /workspaces/:id/layout/ops`). Atomic; the no-provider + * 404 maps to the typed `{ ok: false, error: "no layout provider active" }`. + */ + readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>; + /** The ACTIVE workspace's tree; `null` in no-provider mode. */ + readonly getTree: () => LayoutTree | null; +} + +export interface LayoutAbiPortDeps { + /** The layout store (the active workspace's tree + provider state). */ + readonly store: LayoutStore; + /** The ops HTTP edge — bound per CALL to the then-active workspace. */ + readonly postOps: (workspaceId: string, ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>; +} + +/** + * Build the ABI's `layout` port over the layout store + the ops HTTP edge + * (pure composition — no ambient state; both deps injected). A successful + * apply feeds the returned tree back through `onServerTree` so the local + * client re-renders immediately (the server's WS broadcast is the + * authoritative echo for every client, this one included — applying it twice + * is idempotent). The workspace is read at CALL time, so a workspace switch + * between calls targets the right tree; a switch mid-flight is guarded by + * `onServerTree`'s own workspace filter. + */ +export function createLayoutAbiPort(deps: LayoutAbiPortDeps): LayoutAbiPort { + return { + async applyOps(ops): Promise<ApplyOpsResult> { + const workspaceId = deps.store.activeWorkspaceId; + const result = await deps.postOps(workspaceId, ops); + if (result.ok) { + deps.store.onServerTree(workspaceId, result.tree); + } + return result; + }, + getTree(): LayoutTree | null { + // `present` = an active provider is serving the active workspace. In + // no-provider (and not-yet-probed) mode the local tree is a fallback, + // not shared state — null tells the plugin there is nothing to read. + return abiTree(deps.store.providerState, deps.store.tree); + }, + }; +} + +/** The getTree decision, pure: only a PRESENT provider's tree is exposed. */ +export function abiTree(state: LayoutProviderState, tree: LayoutTree): LayoutTree | null { + return state === "present" ? tree : null; +} diff --git a/src/features/layout/adapter/http.test.ts b/src/features/layout/adapter/http.test.ts new file mode 100644 index 0000000..30e194a --- /dev/null +++ b/src/features/layout/adapter/http.test.ts @@ -0,0 +1,182 @@ +import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { createLayoutHttp } from "./http"; + +/** + * Layout HTTP adapter tests — the OUTERMOST edge (fetch) is the only fake: + * a scripted `fetchImpl` returning canned Responses. No internal mocks. + */ + +const LAYOUT_URL = "http://backend.test/workspaces/ws-a/layout"; + +function fetchReturning( + status: number, + body: unknown, + capture?: { url?: string; init?: RequestInit | undefined }, +): typeof fetch { + return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { + if (capture !== undefined) { + capture.url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + capture.init = init; + } + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + }; +} + +describe("createLayoutHttp — getTree", () => { + it("GETs the well-known route and unwraps { tree }", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.url).toBe(LAYOUT_URL); + expect(capture.init?.method).toBeUndefined(); // GET is the default + }); + + it("maps a 404 to the no-provider signal (notFound: true)", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(404, { error: "No layout provider is active" }), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ + ok: false, + notFound: true, + error: "No layout provider is active", + }); + }); + + it("maps a 500 to a non-notFound failure carrying the body's error", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(500, { error: "Layout storage failure" }), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ ok: false, notFound: false, error: "Layout storage failure" }); + }); + + it("a network failure degrades to notFound (the no-provider mode)", async () => { + const http = createLayoutHttp("http://backend.test", async () => { + throw new TypeError("Failed to fetch"); + }); + const result = await http.getTree("ws-a"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.notFound).toBe(true); + expect(result.error).toContain("Failed to fetch"); + } + }); + + it("a malformed 200 (SPA-fallback JSON without a tree) degrades to notFound", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { chunks: [], historyEpoch: 0, latestSeq: 0 }), + ); + const result = await http.getTree("ws-a"); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — putTree", () => { + it("PUTs { tree } as JSON and unwraps the echo", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.init?.method).toBe("PUT"); + expect(capture.init?.body).toBe(JSON.stringify({ tree: DEFAULT_LAYOUT })); + }); + + it("maps a 400 { error } to a validation failure (not notFound)", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(400, { error: "root: split has no children" }), + ); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toEqual({ + ok: false, + notFound: false, + error: "root: split has no children", + }); + }); + + it("maps a 404 to the no-provider signal", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — deleteTree", () => { + it("DELETEs and unwraps the fresh seed", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.deleteTree("ws-a"); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.init?.method).toBe("DELETE"); + }); + + it("maps a 404 to the no-provider signal", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.deleteTree("ws-a"); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — postOps (plan 04 §4.2)", () => { + it("POSTs { ops } to the ops route and unwraps { tree, applied }", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT, applied: 1 }, capture), + ); + const result = await http.postOps("ws-a", ops); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT, applied: 1 }); + expect(capture.url).toBe(`${LAYOUT_URL}/ops`); + expect(capture.init?.method).toBe("POST"); + expect(capture.init?.body).toBe(JSON.stringify({ ops })); + }); + + it("maps a 404 to the typed no-provider error (the ABI's documented failure)", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.postOps("ws-a", []); + expect(result).toEqual({ ok: false, error: "no layout provider active" }); + }); + + it("maps a 400 { error, failedOp } to the atomic-reject failure", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(400, { error: "op 1: no such node", failedOp: 1 }), + ); + const result = await http.postOps("ws-a", [{ type: "removeNode", target: "x" }]); + expect(result).toEqual({ ok: false, error: "op 1: no such node", failedOp: 1 }); + }); + + it("a malformed 200 (no tree in the body) is a failure, never a silent ok", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(200, { applied: 2 })); + const result = await http.postOps("ws-a", []); + expect(result).toMatchObject({ ok: false }); + }); + + it("a network failure degrades to a typed failure (never throws)", async () => { + const http = createLayoutHttp("http://backend.test", async () => { + throw new TypeError("fetch failed"); + }); + const result = await http.postOps("ws-a", []); + expect(result).toEqual({ ok: false, error: "fetch failed" }); + }); +}); diff --git a/src/features/layout/adapter/http.ts b/src/features/layout/adapter/http.ts new file mode 100644 index 0000000..ff15a70 --- /dev/null +++ b/src/features/layout/adapter/http.ts @@ -0,0 +1,175 @@ +import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract"; + +/** + * Layout HTTP effects (plan 02 §2.6) — the injected edge that talks to the + * ACTIVE layout provider's well-known routes: + * - `GET /workspaces/:id/layout` → `{ tree }` (read seeds a fresh workspace) + * - `PUT /workspaces/:id/layout` body `{ tree }` → 200 `{ tree }` / 400 `{ error }` + * - `DELETE /workspaces/:id/layout` → the fresh seed (reset; the plugins + * modal's "Reset layout to default" button — plan 04 §4.4) + * - `POST /workspaces/:id/layout/ops` body `{ ops }` → 200 `{ tree, applied }` + * / 400 `{ error, failedOp? }` / 404 no provider (plan 04 §4.2 — the FE + * ABI's `layout.applyOps` door) + * + * Mirrors the plugins adapter's pattern: `httpBase` + an injected `fetchImpl` + * (testable without the network). The 404 is a SIGNAL, not an error: it means + * no layout provider is active, and the store renders its local default with + * ephemeral edits (never an error UI). A network failure degrades the same way. + */ + +/** Outcome of a layout read/write. `notFound` distinguishes the no-provider 404. */ +export type LayoutHttpResult = + | { readonly ok: true; readonly tree: LayoutTree } + | { readonly ok: false; readonly notFound: boolean; readonly error: string }; + +export interface LayoutHttp { + getTree(workspaceId: string): Promise<LayoutHttpResult>; + putTree(workspaceId: string, tree: LayoutTree): Promise<LayoutHttpResult>; + deleteTree(workspaceId: string): Promise<LayoutHttpResult>; + /** + * The plan-04 ops door (§4.2): apply op-vocabulary edits through the + * provider. The result IS the contract's `ApplyOpsResult` — the 404 + * (no provider) maps to the typed `{ ok: false, error: "no layout + * provider active" }` failure the ABI documents. + */ + postOps(workspaceId: string, ops: readonly LayoutOp[]): Promise<ApplyOpsResult>; +} + +/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */ +async function errorOf(res: Response): Promise<string> { + try { + const body = (await res.json()) as { error?: unknown }; + if (typeof body.error === "string" && body.error.length > 0) return body.error; + } catch { + // fall through + } + return `HTTP ${res.status}`; +} + +/** A malformed 200 (e.g. an SPA-fallback page) degrades like a 404 — never poisons the store. */ +function malformed(): LayoutHttpResult { + return { ok: false, notFound: true, error: "Malformed layout response" }; +} + +export function createLayoutHttp(httpBase: string, fetchImpl: typeof fetch): LayoutHttp { + return { + async getTree(workspaceId): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async putTree(workspaceId, tree): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tree }), + }, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async deleteTree(workspaceId): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + { method: "DELETE" }, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async postOps(workspaceId, ops): Promise<ApplyOpsResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout/ops`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ops }), + }, + ); + // The no-provider 404 is the ABI's typed signal (plan 04 §4.5). + if (res.status === 404) { + return { ok: false, error: "no layout provider active" }; + } + if (!res.ok) return await opsError(res); + const body = (await res.json()) as { tree?: unknown; applied?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return { ok: false, error: "Malformed layout response" }; + return { + ok: true, + tree: body.tree as LayoutTree, + applied: typeof body.applied === "number" ? body.applied : ops.length, + }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + }; +} + +/** Read an ops-failure body (`{ error, failedOp? }`), tolerating junk. */ +async function opsError(res: Response): Promise<ApplyOpsResult> { + try { + const body = (await res.json()) as { error?: unknown; failedOp?: unknown }; + const error = + typeof body.error === "string" && body.error.length > 0 ? body.error : `HTTP ${res.status}`; + return { + ok: false, + error, + ...(typeof body.failedOp === "number" ? { failedOp: body.failedOp } : {}), + }; + } catch { + return { ok: false, error: `HTTP ${res.status}` }; + } +} diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts new file mode 100644 index 0000000..8a464fe --- /dev/null +++ b/src/features/layout/index.ts @@ -0,0 +1,51 @@ +/** + * The layout feature — a generic tile-tree renderer + the view registry. + * + * The frontend stops *having* a layout and starts *rendering* one: the tree is + * data (`@dispatch/layout-contract`), the renderer walks it, and every view a + * leaf mounts comes from the registry. Chrome (top bar, alerts, modals) stays + * OUTSIDE the tree — the tree owns the content region. + */ + +export { + abiTree, + createLayoutAbiPort, + type LayoutAbiPort, + type LayoutAbiPortDeps, +} from "./adapter/abi-port"; +export { createLayoutHttp, type LayoutHttp, type LayoutHttpResult } from "./adapter/http"; +export { + bootDefaultOpen, + type CollapseState, + type CollapseStore, + type CollapseStoreOptions, + createCollapseStore, + parsePersistedOpen, + SIDEBAR_OPEN_KEY, + WIDE_BREAKPOINT, +} from "./logic/collapse"; +export { migrationTree, sidePanelViews, storedPanelViews } from "./logic/migration"; +export { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./logic/panels-edit"; +export { + getView, + listPanelViews, + listViews, + type RegisteredView, + registerPluginView, + registerView, + unregisterView, + unregisterViewsByPlugin, + type ViewComponent, + type ViewRegistration, + viewRegistry, +} from "./logic/registry"; +export { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./logic/seed"; +export { firstCollapsibleNodeId, firstPanelsNode } from "./logic/tree-query"; +export { createLayoutStore, type LayoutProviderState, type LayoutStore } from "./store.svelte"; +export { default as TileRoot } from "./ui/TileRoot.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "layout", + description: "Tile-tree renderer + view registry", +} as const; diff --git a/src/features/layout/logic/collapse.test.ts b/src/features/layout/logic/collapse.test.ts new file mode 100644 index 0000000..77e9f3c --- /dev/null +++ b/src/features/layout/logic/collapse.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + bootDefaultOpen, + createCollapseStore, + parsePersistedOpen, + SIDEBAR_OPEN_KEY, + WIDE_BREAKPOINT, +} from "./collapse"; + +function fakeStorage(): { storage: Storage; raw(key: string): string | null } { + const map = new Map<string, string>(); + const storage: Storage = { + get length() { + return map.size; + }, + clear: () => map.clear(), + getItem: (key) => map.get(key) ?? null, + key: () => null, + removeItem: (key) => map.delete(key), + setItem: (key, value) => void map.set(key, value), + }; + return { storage, raw: (key) => map.get(key) ?? null }; +} + +describe("bootDefaultOpen", () => { + it("defaults to open on wide screens (>= 1024px) and closed on narrow ones", () => { + expect(bootDefaultOpen(WIDE_BREAKPOINT)).toBe(true); + expect(bootDefaultOpen(1440)).toBe(true); + expect(bootDefaultOpen(1023)).toBe(false); + expect(bootDefaultOpen(0)).toBe(false); + }); + + it("defaults to open when the viewport is unknown (SSR/tests)", () => { + expect(bootDefaultOpen(undefined)).toBe(true); + }); +}); + +describe("parsePersistedOpen", () => { + it("accepts today's bare JSON boolean format", () => { + expect(parsePersistedOpen("true")).toBe(true); + expect(parsePersistedOpen("false")).toBe(false); + }); + + it("ignores null, malformed JSON, and non-boolean values", () => { + expect(parsePersistedOpen(null)).toBeNull(); + expect(parsePersistedOpen("{")).toBeNull(); + expect(parsePersistedOpen('"true"')).toBeNull(); + expect(parsePersistedOpen("{}")).toBeNull(); + }); +}); + +describe("createCollapseStore", () => { + it("uses the viewport default when nothing is persisted", () => { + const store = createCollapseStore({ viewportWidth: 1024 }); + expect(store.isOpen("side")).toBe(true); + const narrow = createCollapseStore({ viewportWidth: 800 }); + expect(narrow.isOpen("side")).toBe(false); + }); + + it("restores the persisted boolean over the viewport default", () => { + const { storage } = fakeStorage(); + storage.setItem(SIDEBAR_OPEN_KEY, "false"); + const store = createCollapseStore({ storage, viewportWidth: 1440 }); + expect(store.isOpen("side")).toBe(false); + }); + + it("persists toggles as today's bare JSON boolean (same key, same bytes)", () => { + const { storage, raw } = fakeStorage(); + const store = createCollapseStore({ storage, viewportWidth: 1024 }); + store.toggle("side"); + expect(raw(SIDEBAR_OPEN_KEY)).toBe("false"); + store.toggle("side"); + expect(raw(SIDEBAR_OPEN_KEY)).toBe("true"); + }); + + it("tracks per-node state reactively", () => { + const store = createCollapseStore({ viewportWidth: 1024 }); + const seen: boolean[] = []; + const unsubscribe = store.subscribe((state) => + seen.push(state.openByNode.side ?? state.defaultOpen), + ); + try { + store.setOpen("side", false); + store.setOpen("side", true); + } finally { + unsubscribe(); + } + expect(seen).toEqual([true, false, true]); + expect(store.isOpen("side")).toBe(true); + }); + + it("an untracked node falls back to the default", () => { + const store = createCollapseStore({ viewportWidth: 800 }); + expect(store.isOpen("other")).toBe(false); + }); + + it("survives storage failures (quota errors are swallowed)", () => { + const broken: Storage = { + get length() { + return 0; + }, + clear: () => undefined, + getItem: () => null, + key: () => null, + removeItem: () => undefined, + setItem: () => { + throw new Error("quota exceeded"); + }, + }; + const store = createCollapseStore({ storage: broken, viewportWidth: 1024 }); + expect(() => store.toggle("side")).not.toThrow(); + expect(store.isOpen("side")).toBe(false); + }); + + it("works without storage (session-only chrome state)", () => { + const store = createCollapseStore({ viewportWidth: 1024 }); + store.toggle("side"); + expect(store.isOpen("side")).toBe(false); + }); +}); diff --git a/src/features/layout/logic/collapse.ts b/src/features/layout/logic/collapse.ts new file mode 100644 index 0000000..28e90b4 --- /dev/null +++ b/src/features/layout/logic/collapse.ts @@ -0,0 +1,95 @@ +/** + * Collapse chrome state — the open/closed state of the tree's collapsible + * split children (today: the sidebar). Renderer chrome, NOT tree data + * (layout-00 decision 3): tree nodes merely declare `collapsible`; this store + * owns the per-node state, persisted device-locally. + * + * Persistence is BYTE-COMPATIBLE with today: the same `dispatch.sidebar.open` + * key holding a JSON boolean, defaulting to open on wide screens (>= 1024px, + * Tailwind `lg`). Today's UI has exactly one collapsible node, and the + * persisted boolean tracks the toggled node's state — identical bytes and + * behavior. A future with several collapsible nodes migrates the format in + * plan 02, not here. + */ + +import { type Readable, writable } from "svelte/store"; + +/** The localStorage key — unchanged from today (no migration). */ +export const SIDEBAR_OPEN_KEY = "dispatch.sidebar.open"; +/** Tailwind `lg`: today's boot default is open on wide screens. */ +export const WIDE_BREAKPOINT = 1024; + +export interface CollapseState { + /** Explicit per-node open state (node id → open). Absent = the default. */ + readonly openByNode: Readonly<Record<string, boolean>>; + /** The boot default (persisted boolean, else viewport width >= 1024). */ + readonly defaultOpen: boolean; +} + +export interface CollapseStore extends Readable<CollapseState> { + /** Is the named node open? (explicit state, else the default) */ + isOpen(nodeId: string): boolean; + setOpen(nodeId: string, open: boolean): void; + toggle(nodeId: string): void; +} + +export interface CollapseStoreOptions { + /** Injected storage (the app store's `Storage`; undefined → session-only). */ + readonly storage?: Storage | undefined; + /** Viewport width at boot (today: `window.innerWidth`). Undefined → default open. */ + readonly viewportWidth?: number | undefined; + /** Storage key override (tests). Defaults to `dispatch.sidebar.open`. */ + readonly key?: string | undefined; +} + +/** The boot default: open on wide screens (narrow screens start closed). */ +export function bootDefaultOpen(viewportWidth: number | undefined): boolean { + if (viewportWidth === undefined) return true; + return viewportWidth >= WIDE_BREAKPOINT; +} + +/** Parse the persisted value. Today's format is a bare JSON boolean; anything else is ignored. */ +export function parsePersistedOpen(raw: string | null): boolean | null { + if (raw === null) return null; + try { + const parsed: unknown = JSON.parse(raw); + return typeof parsed === "boolean" ? parsed : null; + } catch { + return null; + } +} + +export function createCollapseStore(options?: CollapseStoreOptions): CollapseStore { + const key = options?.key ?? SIDEBAR_OPEN_KEY; + const storage = options?.storage; + const persisted = parsePersistedOpen(storage === undefined ? null : storage.getItem(key)); + const defaultOpen = persisted ?? bootDefaultOpen(options?.viewportWidth); + + let openByNode: Record<string, boolean> = {}; + const store = writable<CollapseState>({ openByNode, defaultOpen }); + + function set(nodeId: string, open: boolean): void { + openByNode = { ...openByNode, [nodeId]: open }; + store.set({ openByNode, defaultOpen }); + // Persist the toggled node's state as today's bare boolean. With one + // collapsible node (always, today) this is exactly today's behavior. + if (storage !== undefined) { + try { + storage.setItem(key, JSON.stringify(open)); + } catch { + // Swallow quota / write errors — persistence is best-effort. + } + } + } + + function isOpen(nodeId: string): boolean { + return openByNode[nodeId] ?? defaultOpen; + } + + return { + subscribe: store.subscribe, + isOpen, + setOpen: (nodeId, open) => set(nodeId, open), + toggle: (nodeId) => set(nodeId, !isOpen(nodeId)), + }; +} diff --git a/src/features/layout/logic/marker.test.ts b/src/features/layout/logic/marker.test.ts new file mode 100644 index 0000000..53b91b0 --- /dev/null +++ b/src/features/layout/logic/marker.test.ts @@ -0,0 +1,29 @@ +import type { TileMarker } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { markerOutline, markerStyle } from "./marker"; + +describe("markerOutline — the diagnostic tile-border mapping", () => { + it("main → a 4px solid red outline (the main tile's marker)", () => { + expect(markerOutline("main")).toBe("4px solid red"); + }); + + it("side → a 4px solid green outline (the side tile's marker)", () => { + expect(markerOutline("side")).toBe("4px solid green"); + }); + + it("no marker → no outline (existing trees render exactly as before)", () => { + expect(markerOutline(undefined)).toBeUndefined(); + }); +}); + +describe("markerStyle — the complete inline style (outline + inset offset)", () => { + it("insets the outline so it draws INSIDE the tile's box (never clipped at screen edges)", () => { + expect(markerStyle("main")).toBe("outline: 4px solid red; outline-offset: -4px"); + expect(markerStyle("side")).toBe("outline: 4px solid green; outline-offset: -4px"); + }); + + it("no/unknown marker → NO style at all (unmarked nodes carry zero outline CSS)", () => { + expect(markerStyle(undefined)).toBeUndefined(); + expect(markerStyle("purple" as unknown as TileMarker)).toBeUndefined(); + }); +}); diff --git a/src/features/layout/logic/marker.ts b/src/features/layout/logic/marker.ts new file mode 100644 index 0000000..46259e0 --- /dev/null +++ b/src/features/layout/logic/marker.ts @@ -0,0 +1,46 @@ +/** + * Diagnostic tile-border markers (the contract's additive `TileMarker` + * presentation field) → the inline outline the renderer draws. Pure + * decision logic — the .svelte tile components just apply the returned + * style value. + * + * INLINE STYLES, deliberately: a marker value arrives in TREE DATA at + * runtime, and Tailwind only generates classes it sees in source files — a + * dynamic class from tree data would silently render nothing. An outline + * (not a border) is used so the diagnostic draws WITHOUT changing any + * tile's layout/size (4px solid, clearly visible). + * + * The outline is INSET (`outline-offset: -4px`) so it draws INSIDE the + * tile's box: tiles span their containers edge-to-edge, so the default + * (outside) outline overflows the visible area and is clipped away at the + * screen edges. The views inside the tiles have no opaque full-bleed + * background, so the inset ring stays on top of the tile's content. + * + * The marker is purely presentational: unknown/absent → no outline + * (existing trees render exactly as before), and no other behavior is + * attached to it anywhere in the renderer. + */ + +import type { TileMarker } from "@dispatch/layout-contract"; + +/** The diagnostic outline style for a marker — `undefined` = draw nothing. */ +export function markerOutline(marker: TileMarker | undefined): string | undefined { + if (marker === "main") return "4px solid red"; + if (marker === "side") return "4px solid green"; + return undefined; +} + +/** The inset that keeps the outline INSIDE the tile's box (see the header note). */ +export const MARKER_OUTLINE_OFFSET = "-4px"; + +/** + * The COMPLETE inline style for a marked element — outline + inset offset in + * one declaration — or `undefined` when unmarked/unknown, so unmarked nodes + * carry no outline CSS at all (existing trees render exactly as before). + */ +export function markerStyle(marker: TileMarker | undefined): string | undefined { + const outline = markerOutline(marker); + return outline === undefined + ? undefined + : `outline: ${outline}; outline-offset: ${MARKER_OUTLINE_OFFSET}`; +} diff --git a/src/features/layout/logic/migration.test.ts b/src/features/layout/logic/migration.test.ts new file mode 100644 index 0000000..9eeeda0 --- /dev/null +++ b/src/features/layout/logic/migration.test.ts @@ -0,0 +1,68 @@ +import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { migrationTree, sidePanelViews, storedPanelViews } from "./migration"; +import { overlaySidePanels } from "./seed"; + +const KNOWN = new Set(["tabs", "model", "extensions", "vision", "tasks"]); + +describe("storedPanelViews", () => { + it("parses a stored array of view ids, filtered to known views, order kept", () => { + expect(storedPanelViews(["vision", "junk", "tasks"], KNOWN)).toEqual(["vision", "tasks"]); + }); + + it("returns null for absent/junk/non-array values", () => { + expect(storedPanelViews(null, KNOWN)).toBeNull(); + expect(storedPanelViews(undefined, KNOWN)).toBeNull(); + expect(storedPanelViews("extensions", KNOWN)).toBeNull(); + expect(storedPanelViews({ 0: "tabs" }, KNOWN)).toBeNull(); + }); + + it("returns null when nothing stored is a known view", () => { + expect(storedPanelViews(["deleted-view", "another-gone"], KNOWN)).toBeNull(); + expect(storedPanelViews([], KNOWN)).toBeNull(); + }); +}); + +describe("sidePanelViews", () => { + it("reads the default tree's side panels", () => { + expect(sidePanelViews(DEFAULT_LAYOUT)).toEqual(["tabs", "model"]); + }); + + it("returns null for a tree without a 'side' panels node", () => { + expect(sidePanelViews({ version: 1, root: { kind: "leaf", view: "chat" } })).toBeNull(); + }); +}); + +describe("migrationTree (§2.7 — one-way, best-effort)", () => { + it("imports the stored selection when the fetched tree is the untouched default seed", () => { + const stored = JSON.stringify(["extensions", "tasks"]); + const migrated = migrationTree(JSON.parse(stored), KNOWN, DEFAULT_LAYOUT); + expect(migrated).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"])); + // The rest of the tree is untouched — only the side panels changed. + expect(migrated?.root).toEqual( + overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"])?.root, + ); + }); + + it("returns null when the key is absent / junk / has no known views (nothing to import)", () => { + expect(migrationTree(null, KNOWN, DEFAULT_LAYOUT)).toBeNull(); + expect(migrationTree("junk", KNOWN, DEFAULT_LAYOUT)).toBeNull(); + expect(migrationTree(["gone-view"], KNOWN, DEFAULT_LAYOUT)).toBeNull(); + }); + + it("returns null when the provider's tree is NOT the default seed (custom layout wins)", () => { + const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + expect(migrationTree(["extensions"], KNOWN, custom)).toBeNull(); + }); + + it("returns null when the fetched tree has no side panels node at all", () => { + expect( + migrationTree(["extensions"], KNOWN, { version: 1, root: { kind: "leaf", view: "chat" } }), + ).toBeNull(); + }); + + it("a stored selection identical to the default still migrates (a harmless idempotent PUT)", () => { + const migrated = migrationTree(["tabs", "model"], KNOWN, DEFAULT_LAYOUT); + expect(migrated).toEqual(DEFAULT_LAYOUT); + }); +}); diff --git a/src/features/layout/logic/migration.ts b/src/features/layout/logic/migration.ts new file mode 100644 index 0000000..e0ed6fd --- /dev/null +++ b/src/features/layout/logic/migration.ts @@ -0,0 +1,76 @@ +/** + * localStorage → provider migration (plan 02 §2.7, D-L4; one-way, best-effort). + * + * `dispatch.sidebar.views` is retired as a source of truth: on boot WITH a + * provider present, if the key exists AND the fetched tree's `side` panels + * equal DEFAULT_LAYOUT's (the workspace still has the untouched default + * seed), the stored panel selection is imported once via a PUT. When the + * provider's tree differs from the default (e.g. a calendar provider), the + * workspace already has a custom layout — importing the old sidebar override + * would be wrong. Regardless of outcome the key is never read again (the + * bytes stay, but nothing consumes them). + * + * Pure decision logic: the store owns the effects (the localStorage read + + * the PUT); this module decides IF and WHAT. + */ + +import { + DEFAULT_LAYOUT, + findById, + type LayoutTree, + type PanelsNode, +} from "@dispatch/layout-contract"; +import { overlaySidePanels } from "./seed"; + +/** + * The stored selection's usable view ids: an array of strings filtered to the + * known (registered) views, in stored order. null when nothing usable is + * stored (absent key, junk, or no known ids) — nothing to import. + */ +export function storedPanelViews( + raw: unknown, + knownViews: ReadonlySet<string>, +): readonly string[] | null { + if (!Array.isArray(raw)) return null; + const known = raw.filter((v): v is string => typeof v === "string" && knownViews.has(v)); + return known.length > 0 ? known : null; +} + +/** + * The view ids of a tree's `"side"` panels node (the default tree's swappable + * sidebar stack), or null when the tree has no such node. + */ +export function sidePanelViews(tree: LayoutTree): readonly string[] | null { + const found = findById(tree, "side"); + if (found === null || found.node.kind !== "panels") return null; + return (found.node as PanelsNode).panels.map((p) => p.view); +} + +function samePanels(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +/** + * The one-way migration decision (§2.7): given the RAW stored value, the + * known view ids, and the tree fetched from the provider, return the tree to + * PUT once — or null when there is nothing to migrate: + * - the stored key holds no usable selection, or + * - the provider's tree is NOT the untouched default seed (its side panels + * differ from DEFAULT_LAYOUT's — the workspace already has a custom layout). + */ +export function migrationTree( + storedRaw: unknown, + knownViews: ReadonlySet<string>, + fetched: LayoutTree, +): LayoutTree | null { + const stored = storedPanelViews(storedRaw, knownViews); + if (stored === null) return null; + const fetchedPanels = sidePanelViews(fetched); + const defaultPanels = sidePanelViews(DEFAULT_LAYOUT); + if (fetchedPanels === null || defaultPanels === null) return null; + if (!samePanels(fetchedPanels, defaultPanels)) return null; + // Overlay the stored selection onto the fetched tree (identical to the + // default here by the guard above — the seed overlay keeps the rest of the + // tree shared, not copied). + return overlaySidePanels(fetched, stored); +} diff --git a/src/features/layout/logic/panel-keys.test.ts b/src/features/layout/logic/panel-keys.test.ts new file mode 100644 index 0000000..b45671a --- /dev/null +++ b/src/features/layout/logic/panel-keys.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { initialPanelKeys, reconcilePanelKeys, swappedPanels } from "./panel-keys"; + +describe("initialPanelKeys", () => { + it("numbers the panels in order and seeds past them", () => { + expect(initialPanelKeys(["tabs", "model"])).toEqual({ + views: ["tabs", "model"], + keys: [0, 1], + seed: 2, + }); + }); +}); + +describe("reconcilePanelKeys", () => { + const start = initialPanelKeys(["tabs", "model", "usage"]); // keys 0 1 2 + + it("closing the MIDDLE panel drops the middle key (not the last)", () => { + const next = reconcilePanelKeys(start, ["tabs", "usage"]); + expect(next.keys).toEqual([0, 2]); + expect(next.seed).toBe(3); + }); + + it("adding a panel gives only the new one a fresh key", () => { + const next = reconcilePanelKeys(start, ["tabs", "model", "usage", "lsp"]); + expect(next.keys).toEqual([0, 1, 2, 3]); + expect(next.seed).toBe(4); + }); + + it("a dropdown swap keeps every key positional", () => { + const next = reconcilePanelKeys(start, ["tabs", "lsp", "usage"]); + expect(next.keys).toEqual([0, 1, 2]); + }); + + it("handles duplicate views and never reuses a key", () => { + const dup = initialPanelKeys(["a", "b", "a"]); // 0 1 2 + const removed = reconcilePanelKeys(dup, ["a", "a"]); + expect(removed.keys).toEqual([0, 2]); + const added = reconcilePanelKeys(removed, ["a", "a", "a"]); + expect(added.keys).toEqual([0, 2, 3]); + expect(new Set(added.keys).size).toBe(3); + }); + + it("a ✕ hint picks the exact panel when identical views make it ambiguous", () => { + const twins = initialPanelKeys(["model", "usage", "usage"]); // 0 1 2 + expect(reconcilePanelKeys(twins, ["model", "usage"]).keys).toEqual([0, 1]); // no hint + expect(reconcilePanelKeys(twins, ["model", "usage"], 1).keys).toEqual([0, 2]); + }); + + it("ignores a stale hint that does not describe the change", () => { + // The tree changed some other way (e.g. a remote edit) — fall back to LCS. + expect(reconcilePanelKeys(start, ["tabs", "model"], 0).keys).toEqual([0, 1]); + expect(reconcilePanelKeys(start, ["tabs", "model", "usage"], 1).keys).toEqual([0, 1, 2]); + }); + + it("an unrelated replacement of different length gets all-fresh keys", () => { + const next = reconcilePanelKeys(start, ["x"]); + expect(next.keys).toEqual([3]); + }); +}); + +describe("swappedPanels", () => { + it("reports a surviving panel whose view changed", () => { + const prev = initialPanelKeys(["tabs", "model"]); + const next = reconcilePanelKeys(prev, ["tabs", "usage"]); + expect([...swappedPanels(prev, next)]).toEqual([[1, { from: "model", to: "usage" }]]); + }); + + it("does not report adds or removes", () => { + const prev = initialPanelKeys(["tabs", "model", "usage"]); + expect(swappedPanels(prev, reconcilePanelKeys(prev, ["tabs", "usage"])).size).toBe(0); + expect(swappedPanels(prev, reconcilePanelKeys(prev, [...prev.views, "lsp"])).size).toBe(0); + }); +}); diff --git a/src/features/layout/logic/panel-keys.ts b/src/features/layout/logic/panel-keys.ts new file mode 100644 index 0000000..76a053f --- /dev/null +++ b/src/features/layout/logic/panel-keys.ts @@ -0,0 +1,118 @@ +/** + * Stable panel keys — the PURE identity scheme behind the panel height + * animation. + * + * Panel leaves carry no required id, so the panels list can't be keyed by the + * tree. Keying by INDEX is wrong for removal: closing the middle of three + * panels would re-use the middle DOM node for the third view and animate the + * LAST panel out. This reconciles a stable numeric key per panel across tree + * updates, from the view ids alone: + * + * - same length → keys stay positional (a dropdown SWAP keeps its panel — + * the panel morphs its height to the new view instead of leaving). + * - length changed → the longest common subsequence of view ids keeps its + * keys; every unmatched new panel gets a fresh key (an ADD), every + * unmatched old key disappears (a REMOVE). + * + * Zero Svelte, zero DOM. + */ + +export interface PanelKeys { + /** View id per panel, in order. */ + readonly views: readonly string[]; + /** Stable key per panel, aligned with `views`. */ + readonly keys: readonly number[]; + /** The next fresh key. */ + readonly seed: number; +} + +/** Keys for a freshly mounted panels list. */ +export function initialPanelKeys(views: readonly string[]): PanelKeys { + return { views: [...views], keys: views.map((_, i) => i), seed: views.length }; +} + +/** + * Reconcile `prev` against the next view list (see the module doc). + * + * `removedIndex` is an optional HINT from a local ✕ click: when the next list + * is exactly `prev` minus that index, that exact panel is the one removed. + * Without it, closing one of two identical adjacent views is ambiguous to the + * subsequence match (it would drop the later one). + */ +export function reconcilePanelKeys( + prev: PanelKeys, + nextViews: readonly string[], + removedIndex?: number, +): PanelKeys { + if (removedIndex !== undefined && isRemovalAt(prev.views, nextViews, removedIndex)) { + return { + views: [...nextViews], + keys: prev.keys.filter((_, i) => i !== removedIndex), + seed: prev.seed, + }; + } + if (nextViews.length === prev.views.length) { + return { views: [...nextViews], keys: [...prev.keys], seed: prev.seed }; + } + const matched = lcsMatch(prev.views, nextViews); + let seed = prev.seed; + const keys = nextViews.map((_, j) => { + const i = matched.get(j); + if (i !== undefined) return prev.keys[i] ?? seed++; + return seed++; + }); + return { views: [...nextViews], keys, seed }; +} + +/** + * The views whose panel SURVIVED but now mounts a different view (a swap): + * key → { from, to }. Adds/removes are not swaps. + */ +export function swappedPanels( + prev: PanelKeys, + next: PanelKeys, +): Map<number, { from: string; to: string }> { + const before = new Map(prev.keys.map((k, i) => [k, prev.views[i] ?? ""])); + const out = new Map<number, { from: string; to: string }>(); + next.keys.forEach((k, i) => { + const from = before.get(k); + const to = next.views[i] ?? ""; + if (from !== undefined && from !== to) out.set(k, { from, to }); + }); + return out; +} + +function isRemovalAt(prev: readonly string[], next: readonly string[], index: number): boolean { + if (index < 0 || index >= prev.length || next.length !== prev.length - 1) return false; + return next.every((v, j) => v === prev[j < index ? j : j + 1]); +} + +/** LCS alignment: next index → prev index for every matched element. */ +function lcsMatch(a: readonly string[], b: readonly string[]): Map<number, number> { + const n = a.length; + const m = b.length; + // dp[i][j] = LCS length of a[i..] and b[j..] + const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + const row = dp[i] as number[]; + const below = dp[i + 1] as number[]; + row[j] = a[i] === b[j] ? (below[j + 1] ?? 0) + 1 : Math.max(below[j] ?? 0, row[j + 1] ?? 0); + } + } + const out = new Map<number, number>(); + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + out.set(j, i); + i++; + j++; + } else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) { + i++; + } else { + j++; + } + } + return out; +} diff --git a/src/features/layout/logic/panels-edit.test.ts b/src/features/layout/logic/panels-edit.test.ts new file mode 100644 index 0000000..b4c3099 --- /dev/null +++ b/src/features/layout/logic/panels-edit.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./panels-edit"; + +describe("panel edit → op mapping", () => { + it("maps a dropdown swap to a replaceView on the panel leaf's path", () => { + expect(swapPanelOp([1], 2, "model")).toEqual({ + type: "replaceView", + target: [1, 2], + view: "model", + }); + }); + + it("maps the + button to an append insertPanel", () => { + expect(addPanelOp(["titlebar"], "tasks")).toEqual({ + type: "insertPanel", + panels: ["titlebar"], + view: "tasks", + }); + }); + + it("maps the ✕ button to a removeNode on the panel leaf's path", () => { + expect(removePanelOp([1], 0)).toEqual({ type: "removeNode", target: [1, 0] }); + }); + + it("the + button pre-selects the first eligible view (or null when none)", () => { + expect(firstPanelView([{ id: "tabs" }, { id: "model" }])).toBe("tabs"); + expect(firstPanelView([])).toBeNull(); + }); +}); diff --git a/src/features/layout/logic/panels-edit.ts b/src/features/layout/logic/panels-edit.ts new file mode 100644 index 0000000..8a56859 --- /dev/null +++ b/src/features/layout/logic/panels-edit.ts @@ -0,0 +1,33 @@ +/** + * Panel-edit → tree-op mapping: the pure decision logic behind TilePanels' + * dropdown swap / add / remove buttons. The component builds the ops; the + * renderer's owner (TileRoot → onTreeChange) applies them. Zero Svelte, zero + * DOM. + */ + +import type { LayoutOp, Path } from "@dispatch/layout-contract"; + +/** Swap the view a panel leaf mounts (panel `index` of the panels node at `path`). */ +export function swapPanelOp(path: Path, index: number, view: string): LayoutOp { + return { type: "replaceView", target: [...path, index], view }; +} + +/** Add a panel mounting `view` (appended to the panels node at `path`). */ +export function addPanelOp(path: Path, view: string): LayoutOp { + return { type: "insertPanel", panels: path, view }; +} + +/** Remove the panel at `index` from the panels node at `path`. */ +export function removePanelOp(path: Path, index: number): LayoutOp { + return { type: "removeNode", target: [...path, index] }; +} + +/** + * The view a new panel starts with. The tree schema has no "empty panel" + * state (a leaf's `view` is required — today's "Select a view" placeholder has + * no tree representation), so the + button pre-selects the first panel-eligible + * view. Returns null when nothing is eligible (the + button hides). + */ +export function firstPanelView(views: readonly { readonly id: string }[]): string | null { + return views[0]?.id ?? null; +} diff --git a/src/features/layout/logic/registry.test.ts b/src/features/layout/logic/registry.test.ts new file mode 100644 index 0000000..e5e947c --- /dev/null +++ b/src/features/layout/logic/registry.test.ts @@ -0,0 +1,148 @@ +import type { Component } from "svelte"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getView, + listPanelViews, + listViews, + registerPluginView, + registerView, + unregisterView, + unregisterViewsByPlugin, + type ViewRegistration, + viewRegistry, +} from "./registry"; + +const fakeComponent: Component = () => { + throw new Error("fixture component should never be constructed"); +}; + +function registration(overrides?: Partial<ViewRegistration>): ViewRegistration { + return { id: "test-view", label: "Test View", component: fakeComponent, ...overrides }; +} + +const registeredIds = (): string[] => listViews().map((v) => v.id); + +afterEach(() => { + for (const id of registeredIds()) unregisterView(id); +}); + +describe("view registry", () => { + it("registers a view and exposes it via getView/listViews", () => { + registerView(registration()); + expect(getView("test-view")?.label).toBe("Test View"); + expect(registeredIds()).toContain("test-view"); + }); + + it("throws loudly on a duplicate id", () => { + registerView(registration()); + expect(() => registerView(registration({ label: "Other" }))).toThrow(/duplicate view id/); + }); + + it("normalizes panelEligible to true by default and respects false", () => { + registerView(registration({ id: "a" })); + registerView(registration({ id: "b", panelEligible: false })); + expect(getView("a")?.panelEligible).toBe(true); + expect(getView("b")?.panelEligible).toBe(false); + expect(listPanelViews().map((v) => v.id)).toContain("a"); + expect(listPanelViews().map((v) => v.id)).not.toContain("b"); + }); + + it("keeps pluginId when given", () => { + registerView(registration({ id: "plugin-view", pluginId: "my-plugin" })); + expect(getView("plugin-view")?.pluginId).toBe("my-plugin"); + }); + + it("unregisterView removes the view (idempotent for unknown ids)", () => { + registerView(registration({ id: "gone" })); + unregisterView("gone"); + expect(getView("gone")).toBeUndefined(); + expect(() => unregisterView("never-registered")).not.toThrow(); + }); + + it("is reactive: subscribers see registrations and unregistrations", () => { + const seen: string[][] = []; + const unsubscribe = viewRegistry.subscribe((views) => seen.push(views.map((v) => v.id))); + try { + registerView(registration({ id: "reactive-a" })); + registerView(registration({ id: "reactive-b" })); + unregisterView("reactive-a"); + } finally { + unsubscribe(); + } + expect(seen[0]).toEqual([]); + expect(seen.at(-1)).toEqual(["reactive-b"]); + }); + + it("preserves registration order in listViews", () => { + registerView(registration({ id: "z-first" })); + registerView(registration({ id: "a-second" })); + const ids = registeredIds(); + expect(ids.indexOf("z-first")).toBeLessThan(ids.indexOf("a-second")); + }); +}); + +// ── Plugin-view registration (plan 03) — forced attribution + the sweep ───── + +describe("registerPluginView / unregisterViewsByPlugin (plan 03)", () => { + it("forces the importing plugin's pluginId attribution", () => { + const dispose = registerPluginView( + { id: "abi-view", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + expect(getView("abi-view")?.pluginId).toBe("my-plugin"); + dispose(); + expect(getView("abi-view")).toBeUndefined(); + }); + + it("returns a working per-view disposer (the plugin deactivate contract)", () => { + const dispose = registerPluginView( + { id: "abi-view-2", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + dispose(); + dispose(); // idempotent + expect(getView("abi-view-2")).toBeUndefined(); + }); + + it("the sweep removes exactly that plugin's views (registry attribution)", () => { + registerPluginView({ id: "p1-a", label: "A", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p1-b", label: "B", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p2-a", label: "C", component: fakeComponent }, "plugin-2"); + registerView(registration({ id: "builtin" })); // no pluginId — never swept + + unregisterViewsByPlugin("plugin-1"); + expect(getView("p1-a")).toBeUndefined(); + expect(getView("p1-b")).toBeUndefined(); + expect(getView("p2-a")).not.toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + + unregisterViewsByPlugin("plugin-2"); + expect(getView("p2-a")).toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + }); + + it("the sweep is idempotent and a no-op for an unknown plugin", () => { + unregisterViewsByPlugin("nobody"); + registerPluginView({ id: "p3-a", label: "A", component: fakeComponent }, "plugin-3"); + unregisterViewsByPlugin("plugin-3"); + expect(() => unregisterViewsByPlugin("plugin-3")).not.toThrow(); + }); + + it("a duplicate id from a plugin throws LOUDLY (the driver marks it failed)", () => { + registerView(registration({ id: "taken" })); + expect(() => + registerPluginView({ id: "taken", label: "Taken", component: fakeComponent }, "my-plugin"), + ).toThrow(/duplicate view id/); + }); + + it("sweep removals are visible to registry subscribers (reactive)", () => { + const seen: string[][] = []; + const unsubscribe = viewRegistry.subscribe((views) => { + seen.push(views.map((v) => v.id)); + }); + registerPluginView({ id: "reactive", label: "R", component: fakeComponent }, "p"); + unregisterViewsByPlugin("p"); + unsubscribe(); + expect(seen.at(-1)).not.toContain("reactive"); + }); +}); diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts new file mode 100644 index 0000000..bcd49e0 --- /dev/null +++ b/src/features/layout/logic/registry.ts @@ -0,0 +1,139 @@ +/** + * The view registry — the map from view id → component that tile leaves mount. + * + * Every view the renderer can place (the sidebar views + chat / composer / + * message-queue today; plugin views from plan 03 later) registers here. The + * registry is REACTIVE (a Svelte store): plan 03's late registrations re-render + * open dropdowns, and the tree renderer resolves leaves through it per render. + * + * An unknown view id in a leaf is NEVER a crash — backend-owned trees (plan 02) + * can name views a given client lacks; the renderer shows a fallback tile. + * + * This module holds a small amount of reactive state by design (the plan places + * the registry in `logic/` with Svelte-store reactivity). It is NOT decision + * logic — no DOM, no effects. + */ + +import type { Component } from "svelte"; +import { type Readable, writable } from "svelte/store"; + +/** + * The props type of a registered view component. The composition root hands + * every wrapper its `ports` through this bag (see `src/app/view-ports.ts`); + * fixture/generic components may ignore it. + * + * The registry is a HETEROGENEOUS component bag (the plan types it `Component` + * outright), so its props are opaque `Record<string, any>` — the wrappers + * re-narrow to their concrete props at the composition-root seam. + */ +export type ViewComponent = + // biome-ignore lint/suspicious/noExplicitAny: opaque props of a heterogeneous component bag + Component<Record<string, any>>; + +export interface ViewRegistration { + /** Registry id — the same id a leaf's `view` field names. */ + readonly id: string; + /** Human label shown in panel dropdowns. */ + readonly label: string; + /** Svelte 5 component, already props-wired (see `src/app/register-views.ts`). */ + readonly component: ViewComponent; + /** Whether the view may appear in panel dropdowns (default true). */ + readonly panelEligible?: boolean; + /** The plugin that contributed the view (plan 03 requires it for those). */ + readonly pluginId?: string; +} + +/** A registered view with `panelEligible` normalized (default true). */ +export interface RegisteredView { + readonly id: string; + readonly label: string; + readonly component: ViewComponent; + readonly panelEligible: boolean; + readonly pluginId: string | undefined; +} + +const byId = new Map<string, RegisteredView>(); +const store = writable<readonly RegisteredView[]>([]); + +function publish(): void { + store.set([...byId.values()]); +} + +/** + * Register a view. A duplicate id throws LOUDLY at dev time — two components + * claiming one id is a composition-root bug, never a runtime fallback case. + */ +export function registerView(registration: ViewRegistration): void { + if (byId.has(registration.id)) { + throw new Error(`registerView: duplicate view id "${registration.id}"`); + } + byId.set(registration.id, { + id: registration.id, + label: registration.label, + component: registration.component, + panelEligible: registration.panelEligible ?? true, + pluginId: registration.pluginId, + }); + publish(); +} + +/** Remove a view. Idempotent (removing an unknown id is a no-op). */ +export function unregisterView(id: string): void { + if (byId.delete(id)) publish(); +} + +/** + * Register a PLUGIN view (plan 03) — the ABI-facing registration: the + * `pluginId` attribution is FORCED to the importing plugin (plugins cannot + * attribute views to another plugin or to no plugin), which is what makes + * `unregisterViewsByPlugin`'s unload sweep exact. Returns the per-view + * disposer (the plugin's deactivate contract). A duplicate id throws + * LOUDLY — the driver catches it and marks the plugin's FE half failed. + */ +export function registerPluginView( + registration: Omit<ViewRegistration, "pluginId">, + pluginId: string, +): () => void { + registerView({ ...registration, pluginId }); + return () => unregisterView(registration.id); +} + +/** + * Remove every view attributed to one plugin (the plan-03 unload sweep). + * Idempotent; the reactive registry re-renders open dropdowns, and tiles + * holding removed views fall back to `FallbackTile` (never a crash). + */ +export function unregisterViewsByPlugin(pluginId: string): void { + let changed = false; + for (const [id, view] of byId) { + if (view.pluginId === pluginId) { + byId.delete(id); + changed = true; + } + } + if (changed) publish(); +} + +/** Look a view up by id (undefined → the renderer shows its fallback tile). */ +export function getView(id: string): RegisteredView | undefined { + return byId.get(id); +} + +/** All registered views, in registration order (snapshot). */ +export function listViews(): readonly RegisteredView[] { + return [...byId.values()]; +} + +/** The panel-eligible subset (`panelEligible: true`) — panel dropdown options. */ +export function listPanelViews(): readonly RegisteredView[] { + return listViews().filter((v) => v.panelEligible); +} + +/** + * Reactive registry contents (registration order). Subscribe in components — + * e.g. `$viewRegistry` in a panel dropdown — so late registrations (plan 03) + * re-render open dropdowns. + */ +export const viewRegistry: Readable<readonly RegisteredView[]> = { + subscribe: store.subscribe, +}; diff --git a/src/features/layout/logic/seed.test.ts b/src/features/layout/logic/seed.test.ts new file mode 100644 index 0000000..dfe166a --- /dev/null +++ b/src/features/layout/logic/seed.test.ts @@ -0,0 +1,99 @@ +import { DEFAULT_LAYOUT, type LayoutTree, type SplitNode } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./seed"; +import { firstCollapsibleNodeId, firstPanelsNode } from "./tree-query"; + +function sideViews(tree: LayoutTree): string[] { + const root = tree.root as SplitNode; + const side = root.children[1]?.node; + return side?.kind === "panels" ? side.panels.map((p) => p.view) : []; +} + +describe("seedLayout", () => { + it("uses the default panels when nothing is stored", () => { + const tree = seedLayout(null, ["tabs", "model", "settings"]); + expect(sideViews(tree)).toEqual(DEFAULT_PANEL_VIEWS); + }); + + it("overlays the stored selection, dropping unknown/removed kinds", () => { + const tree = seedLayout( + ["extensions", "ghost-view", "model"], + ["tabs", "model", "extensions", "settings"], + ); + expect(sideViews(tree)).toEqual(["extensions", "model"]); + }); + + it("falls back to the defaults when the stored selection is all-unknown or empty", () => { + expect(sideViews(seedLayout(["ghost"], ["tabs", "model"]))).toEqual(["tabs", "model"]); + expect(sideViews(seedLayout([], ["tabs", "model"]))).toEqual(["tabs", "model"]); + }); + + it("falls back to the defaults when the stored value is not a string array", () => { + expect(sideViews(seedLayout("junk", ["tabs", "model"]))).toEqual(["tabs", "model"]); + expect(sideViews(seedLayout([42], ["tabs", "model"]))).toEqual(["tabs", "model"]); + expect(sideViews(seedLayout({ a: 1 }, ["tabs", "model"]))).toEqual(["tabs", "model"]); + }); + + it("preserves the default tree's ids and the titlebar-unset invariant", () => { + const tree = seedLayout(["model"], ["tabs", "model"]); + expect((tree.root as SplitNode).id).toBe("root"); + expect(tree.titlebar).toBeUndefined(); + }); +}); + +describe("overlaySidePanels", () => { + it("replaces only the side panels of the given base tree", () => { + const tree = overlaySidePanels(DEFAULT_LAYOUT, ["settings", "tasks"]); + expect(sideViews(tree)).toEqual(["settings", "tasks"]); + // The rest of the tree is untouched. + expect((tree.root as SplitNode).children[0]?.node).toEqual( + (DEFAULT_LAYOUT.root as SplitNode).children[0]?.node, + ); + }); +}); + +describe("tree queries", () => { + it("firstPanelsNode finds the side panels with its path", () => { + const tree = seedLayout(["model"], ["tabs", "model"]); + const found = firstPanelsNode(tree); + expect(found?.path).toEqual([1]); + expect(found?.node.panels.map((p) => p.view)).toEqual(["model"]); + }); + + it("firstCollapsibleNodeId returns the default tree's side node id", () => { + expect(firstCollapsibleNodeId(DEFAULT_LAYOUT)).toBe("side"); + }); + + it("firstCollapsibleNodeId falls back to a path key when the node has no id", () => { + const tree: LayoutTree = { + version: 1, + root: { + kind: "split", + dir: "row", + children: [ + { size: 1, node: { kind: "leaf", view: "chat" } }, + { + size: 0, + basis: 320, + collapsible: true, + node: { kind: "panels", panels: [{ kind: "leaf", view: "tabs" }] }, + }, + ], + }, + }; + // The collapsible child sits at root path index 1 → key "1". + expect(firstCollapsibleNodeId(tree)).toBe("1"); + }); + + it("firstCollapsibleNodeId returns null when nothing is collapsible", () => { + const tree: LayoutTree = { + version: 1, + root: { + kind: "split", + dir: "row", + children: [{ size: 1, node: { kind: "leaf", view: "chat" } }], + }, + }; + expect(firstCollapsibleNodeId(tree)).toBeNull(); + }); +}); diff --git a/src/features/layout/logic/seed.ts b/src/features/layout/logic/seed.ts new file mode 100644 index 0000000..7cd0ba3 --- /dev/null +++ b/src/features/layout/logic/seed.ts @@ -0,0 +1,78 @@ +/** + * Boot-time tree seeding: the default layout with today's sidebar selection + * overlaid from localStorage. Byte-compatible with today's boot behavior + * (`dispatch.sidebar.views` read; unknown/removed kinds dropped — the tree + * schema has no "unknown view" panel state, so a stale id is skipped exactly + * like a view that no longer exists). + */ + +import { + DEFAULT_LAYOUT, + findById, + type LayoutNode, + type LayoutTree, + type LeafNode, + type PanelsNode, + type Path, +} from "@dispatch/layout-contract"; + +/** + * Today's default sidebar selection (the moved-from-top tab bar + the model + * controls) — the fallback when nothing (or nothing valid) is stored. + */ +export const DEFAULT_PANEL_VIEWS: readonly string[] = ["tabs", "model"]; + +/** + * Seed a tree from DEFAULT_LAYOUT with `storedViews` (the parsed + * `dispatch.sidebar.views` value, or null when absent) overlaid onto the + * default tree's panels node. + * + * - null / non-array / array without any known view → the defaults. + * - Known views are kept in the stored order; unknown ids are dropped. + */ +export function seedLayout(storedViews: unknown, panelViewIds: readonly string[]): LayoutTree { + const known = new Set(panelViewIds); + const stored = Array.isArray(storedViews) + ? storedViews.filter((v): v is string => typeof v === "string") + : []; + const panels = stored.filter((v) => known.has(v)); + const selected = panels.length > 0 ? panels : DEFAULT_PANEL_VIEWS.filter((v) => known.has(v)); + return overlaySidePanels(DEFAULT_LAYOUT, selected); +} + +/** + * A NEW tree: `base` with its `"side"` panels node replaced by leaves mounting + * `views` (in order). Targets the panels node by id (falls back to no-op when + * the base has none); the rest of the tree is shared, not copied. + */ +export function overlaySidePanels(base: LayoutTree, views: readonly string[]): LayoutTree { + const found = findById(base, "side"); + if (found === null || found.node.kind !== "panels") return base; + const panels: readonly LeafNode[] = views.map((view) => ({ kind: "leaf", view })); + const next: PanelsNode = { ...found.node, panels }; + return { version: 1, root: replaceChild(base.root, found.path, next) }; +} + +/** Rebuild the node at `path` (immutably); the replacement must be a panels node. */ +function replaceChild(node: LayoutNode, path: Path, replacement: PanelsNode): LayoutNode { + if (path.length === 0) return replacement; + const [head, ...rest] = path; + if (typeof head !== "number") return node; + if (node.kind === "split") { + return { + ...node, + children: node.children.map((child, i) => + i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child, + ), + }; + } + if (node.kind === "stack") { + return { + ...node, + children: node.children.map((child, i) => + i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child, + ), + }; + } + return node; +} diff --git a/src/features/layout/logic/tree-query.ts b/src/features/layout/logic/tree-query.ts new file mode 100644 index 0000000..aa8dfe9 --- /dev/null +++ b/src/features/layout/logic/tree-query.ts @@ -0,0 +1,60 @@ +/** + * Tree queries the chrome needs — pure walks over the layout tree. + */ + +import type { LayoutNode, LayoutTree, PanelsNode, Path } from "@dispatch/layout-contract"; + +/** The first panels node in the tree (the panel-selection owner), if any. */ +export function firstPanelsNode(tree: LayoutTree): { node: PanelsNode; path: Path } | null { + const seen: { node: PanelsNode; path: Path }[] = []; + const visit = (node: LayoutNode, path: Path): void => { + if (seen.length > 0) return; + if (node.kind === "panels") { + seen.push({ node, path }); + return; + } + const kids: readonly LayoutNode[] = + node.kind === "split" + ? node.children.map((c) => c.node) + : node.kind === "stack" + ? node.children.map((c) => c.node) + : []; + for (const [i, child] of kids.entries()) visit(child, [...path, i]); + }; + visit(tree.root, []); + return seen[0] ?? null; +} + +/** + * The node id of the tree's FIRST collapsible split child — the one the chrome + * top bar's toggle reads/writes (`findById("side")` on the default tree; + * generically the first collapsible child, root-first then titlebar). Falls + * back to a path-derived key when the child's node has no id. Null → the tree + * has nothing collapsible (the toggle button hides). + */ +export function firstCollapsibleNodeId(tree: LayoutTree): string | null { + const visit = (node: LayoutNode, path: Path): string | null => { + if (node.kind === "split") { + for (const [i, child] of node.children.entries()) { + if (!child.collapsible) continue; + if (child.node.id !== undefined) return child.node.id; + return [...path, i].map((s) => String(s)).join(":"); + } + } + const kids: readonly LayoutNode[] = + node.kind === "split" + ? node.children.map((c) => c.node) + : node.kind === "stack" + ? node.children.map((c) => c.node) + : []; + for (const [i, child] of kids.entries()) { + const found = visit(child, [...path, i]); + if (found !== null) return found; + } + return null; + }; + const inRoot = visit(tree.root, []); + if (inRoot !== null) return inRoot; + if (tree.titlebar !== undefined) return visit(tree.titlebar, ["titlebar"]); + return null; +} diff --git a/src/features/layout/store.svelte.ts b/src/features/layout/store.svelte.ts new file mode 100644 index 0000000..29e2fa1 --- /dev/null +++ b/src/features/layout/store.svelte.ts @@ -0,0 +1,316 @@ +import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract"; +import type { LayoutHttp, LayoutHttpResult } from "./adapter/http"; +import { migrationTree } from "./logic/migration"; + +/** + * Layout store (plan 02 §2.6) — a thin reactive wrapper over the injected + * `LayoutHttp` edge (the plugins-store pattern). Owns the ACTIVE workspace's + * tree and the provider lifecycle: + * + * - **Provider probe triggers** — boot, every workspace switch, every + * `extensions-changed`, and every change to the ACTIVE workspace's + * per-workspace plugin state (a layout plugin enabled/disabled in the + * workspace plugins modal starts/stops serving; the probe picks it up + * without a reload). + * - **No-provider mode** — a 404 (or network failure) keeps the tree on its + * local default; edits still work but are EPHEMERAL (in-memory only — + * D-L4 retired the localStorage write path); logged once, no error UI. + * - **WS push** — `onServerTree(workspaceId, tree)` applies a `layout` + * broadcast, ignoring non-active workspaces (the server fans out to all + * clients; the client filters). + * - **User edits persist through the provider** — `onTreeChange` applies the + * edit locally immediately, then PUTs the WHOLE tree (decision 7 — no + * patch protocol) lightly debounced; on a failed PUT the tree rolls back + * to the last server-known tree with a quiet console warning. + * - **Collapse state stays local** (decision 3) — `dispatch.sidebar.open` + * is the collapse store's concern, untouched here. + * - **One-way localStorage migration** (§2.7) — on the FIRST successful + * provider read of the boot session, if `dispatch.sidebar.views` exists + * AND the fetched tree is the untouched default seed, the stored panel + * selection is imported with one PUT; the key is then never read again. + * + * No ambient state: one instance per composition root, passed explicitly; + * no module-level mutables. + */ + +/** Whether an active provider is serving the active workspace. */ +export type LayoutProviderState = "unknown" | "present" | "absent"; + +export interface LayoutStore { + /** The rendered tree for the ACTIVE workspace (starts at the local default). */ + readonly tree: LayoutTree; + /** The active provider's state (absent = the no-provider degrade path). */ + readonly providerState: LayoutProviderState; + /** The workspace the tree currently belongs to. */ + readonly activeWorkspaceId: string; + /** + * Probe the provider for `workspaceId`'s tree. `boot: true` (the composition + * root's boot call) also arms the one-way localStorage migration (§2.7), + * which consumes itself on the first successful provider read regardless of + * outcome. A probe is the missed-push catch-up (§2.5). + */ + probe(workspaceId: string, opts?: { readonly boot?: boolean }): Promise<void>; + /** Workspace switch: reset to the local default for the new workspace + probe. */ + onWorkspaceChanged(workspaceId: string): void; + /** Server `layout` broadcast (§2.5). Non-active workspaces are ignored. */ + onServerTree(workspaceId: string, tree: LayoutTree): void; + /** A user edit (the panels' dropdowns / TileRoot ops). Persists via PUT. */ + onTreeChange(next: LayoutTree): void; + /** + * Reset the workspace's layout to the default seed (plan 04 §4.4 — the + * plugins modal's "Reset layout to default" affordance: + * `DELETE /workspaces/:id/layout` → re-seed → broadcast). Resolves to + * `null` on success (the fresh seed is applied locally + pushed via WS to + * every client) or to the FAILURE REASON — the no-provider 404 resolves + * to the user-facing "no layout plugin enabled" (the button's gate renders + * the same reason before this runs). + */ + resetToDefault(workspaceId: string): Promise<string | null>; + /** Multi-client coherence (§11): re-probe on `extensions-changed`. */ + onExtensionsChanged(): void; + /** + * The active workspace's PER-WORKSPACE PLUGIN STATE changed (the workspace + * plugins modal toggled a layout plugin's enablement — the per-workspace + * activation rule): re-probe so the newly-enabled provider's tree (or the + * no-provider degrade) lands without a reload. A NON-active workspace's + * toggle is ignored — its tree is fetched on its next probe (workspace + * switch); there is nothing to re-render here. + */ + onWorkspacePluginsChanged(workspaceId: string): void; +} + +export interface LayoutStoreOptions { + readonly http: LayoutHttp; + /** The boot workspace (its URL slug); "default" if absent. */ + readonly workspaceId?: string; + /** + * Injected localStorage — read ONLY by the one-way §2.7 migration (the + * write path is retired; collapse state has its own store). Undefined → + * migration never applies (tests / storageless contexts). + */ + readonly storage?: Storage | undefined; + /** The known (registered) view ids, for the migration's unknown-id filter. */ + readonly knownViews?: ReadonlySet<string> | readonly string[]; + /** + * The localStorage key the §2.7 migration reads (default + * `dispatch.sidebar.views`; tests override). + */ + readonly sidebarViewsKey?: string; + /** Edit-PUT debounce window in ms (default 300; tests shrink it). */ + readonly debounceMs?: number; + /** The quiet-failure hook (default `console.warn` — never an error UI). */ + readonly warn?: (message: string) => void; +} + +const DEFAULT_SIDEBAR_VIEWS_KEY = "dispatch.sidebar.views"; +const DEFAULT_DEBOUNCE_MS = 300; + +function toKnownSet(known: LayoutStoreOptions["knownViews"]): ReadonlySet<string> { + if (known === undefined) return new Set(); + return known instanceof Set ? known : new Set(known); +} + +export function createLayoutStore(opts: LayoutStoreOptions): LayoutStore { + const http = opts.http; + const storage = opts.storage; + const sidebarViewsKey = opts.sidebarViewsKey ?? DEFAULT_SIDEBAR_VIEWS_KEY; + const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const knownViews = toKnownSet(opts.knownViews); + const warn = opts.warn ?? ((message: string) => console.warn(message)); + + let tree = $state<LayoutTree>(DEFAULT_LAYOUT); + let providerState = $state<LayoutProviderState>("unknown"); + let activeWorkspaceId = $state<string>(opts.workspaceId ?? "default"); + + /** The last tree known to come from the server (GET / WS push / PUT echo) — the rollback target. */ + let lastServerTree: LayoutTree | null = null; + /** The pending edit-PUT timer (the "lightly debounced" write). */ + let putTimer: ReturnType<typeof setTimeout> | null = null; + /** Logged-once guard for the no-provider log (reset when a provider appears). */ + let loggedAbsent = false; + /** + * The §2.7 migration is armed by the BOOT probe and consumed by the first + * successful provider read — regardless of outcome (read-once semantics). + */ + let migrationArmed = false; + + function noteAbsent(reason: string): void { + providerState = "absent"; + if (!loggedAbsent) { + loggedAbsent = true; + warn( + `layout: no layout provider active (${reason}) — rendering the built-in default; edits are ephemeral`, + ); + } + } + + function notePresent(): void { + providerState = "present"; + loggedAbsent = false; + } + + function applyServerTree(next: LayoutTree): void { + lastServerTree = next; + tree = next; + } + + /** The §2.7 one-way migration: at most one read + one PUT per session. */ + async function maybeMigrate(): Promise<void> { + if (!migrationArmed || storage === undefined) return; + migrationArmed = false; // consumed regardless of outcome (stop reading the key) + let raw: string | null = null; + try { + raw = storage.getItem(sidebarViewsKey); + } catch { + return; // storage unavailable — best-effort, done + } + if (raw === null) return; // nothing stored — nothing to import + let parsed: unknown = null; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return; // junk — best-effort, done + } + const migrated = migrationTree(parsed, knownViews, tree); + if (migrated === null) return; // custom layout already / nothing usable + const result = await http.putTree(activeWorkspaceId, migrated); + if (result.ok) { + // The server's canonical copy (also echoed back via the WS broadcast). + applyServerTree(result.tree); + } else if (!result.notFound) { + warn(`layout: localStorage migration PUT failed — ${result.error}`); + } + } + + async function runProbe(workspaceId: string, boot: boolean): Promise<void> { + if (boot) migrationArmed = true; + const result: LayoutHttpResult = await http.getTree(workspaceId); + // A switch may have happened while the fetch was in flight — only the + // CURRENT workspace's result lands (the refreshProviderStatus guard). + if (workspaceId !== activeWorkspaceId) return; + if (result.ok) { + notePresent(); + applyServerTree(result.tree); + await maybeMigrate(); + } else if (result.notFound) { + noteAbsent(result.error); + } else { + // A real failure (5xx, malformed) degrades like absent — quietly. + noteAbsent(result.error); + } + } + + function clearPutTimer(): void { + if (putTimer !== null) { + clearTimeout(putTimer); + putTimer = null; + } + } + + async function flushPut(workspaceId: string, pending: LayoutTree): Promise<void> { + putTimer = null; + // Read the CURRENT tree at fire time: a newer edit supersedes the + // debounced snapshot (one in-flight write per window, last wins). + const target = tree === pending ? pending : tree; + const result = await http.putTree(workspaceId, target); + if (result.ok) { + notePresent(); + // The server's validated echo is the new rollback target. + lastServerTree = result.tree; + return; + } + if (result.notFound) { + // The provider vanished: keep the local edit (ephemeral), no rollback — + // there is no server tree to roll back TO. + noteAbsent(result.error); + return; + } + // A failed write on a PRESENT provider: roll back to the last server + // tree, quiet console warning (never an error UI). + warn( + `layout: persisting the tree failed (${result.error}) — rolled back to the last server tree`, + ); + if (lastServerTree !== null) tree = lastServerTree; + } + + return { + get tree() { + return tree; + }, + get providerState() { + return providerState; + }, + get activeWorkspaceId() { + return activeWorkspaceId; + }, + + async probe(workspaceId, probeOpts) { + await runProbe(workspaceId, probeOpts?.boot === true); + }, + + onWorkspaceChanged(workspaceId) { + if (workspaceId === activeWorkspaceId) return; + activeWorkspaceId = workspaceId; + // The tree belongs to the OLD workspace now: reset to the local default + // while the probe resolves (never flash the previous workspace's tree), + // and drop any queued edit — it was for the old workspace. + clearPutTimer(); + lastServerTree = null; + tree = DEFAULT_LAYOUT; + providerState = "unknown"; + void runProbe(workspaceId, false); + }, + + onServerTree(workspaceId, next) { + if (workspaceId !== activeWorkspaceId) return; // clients filter (§2.5) + notePresent(); + clearPutTimer(); // the server's copy supersedes any queued edit + applyServerTree(next); + }, + + onTreeChange(next) { + tree = next; // the edit applies locally IMMEDIATELY (the UI never waits) + if (providerState === "absent") return; // ephemeral — no write, no warning + clearPutTimer(); + putTimer = setTimeout(() => { + void flushPut(activeWorkspaceId, next).catch(() => { + // Network-grade failure: roll back to the last server tree, quietly. + warn("layout: persisting the tree failed — rolled back to the last server tree"); + if (lastServerTree !== null) tree = lastServerTree; + }); + }, debounceMs); + }, + + async resetToDefault(workspaceId): Promise<string | null> { + const result = await http.deleteTree(workspaceId); + if (result.ok) { + // The server re-seeded and broadcast (every client resets); this + // client applies the fresh seed immediately too (idempotent with the + // WS echo). Only the ACTIVE workspace's tree is ours to update. + notePresent(); + if (workspaceId === activeWorkspaceId) applyServerTree(result.tree); + return null; + } + // The no-provider 404: the user-facing reason the plan specifies. + if (result.notFound) return "no layout plugin enabled"; + return result.error; + }, + + onExtensionsChanged() { + void runProbe(activeWorkspaceId, false).catch(() => { + // runProbe never throws for HTTP failures; this guard is for the + // migration path. Staying silent keeps the degrade quiet. + }); + }, + + onWorkspacePluginsChanged(workspaceId) { + // The per-workspace activation rule: only the ACTIVE workspace's tree + // can change what is rendered here — another workspace's toggle is + // picked up by its own probe (boot/workspace-switch/§2.6 triggers). + if (workspaceId !== activeWorkspaceId) return; + void runProbe(activeWorkspaceId, false).catch(() => { + // Same quiet-degrade guard as onExtensionsChanged. + }); + }, + }; +} diff --git a/src/features/layout/store.test.ts b/src/features/layout/store.test.ts new file mode 100644 index 0000000..331af95 --- /dev/null +++ b/src/features/layout/store.test.ts @@ -0,0 +1,529 @@ +import { + type ApplyOpsResult, + DEFAULT_LAYOUT, + type LayoutOp, + type LayoutTree, +} from "@dispatch/layout-contract"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { LayoutHttp, LayoutHttpResult } from "./adapter/http"; +import { overlaySidePanels } from "./logic/seed"; +import { createLayoutStore } from "./store.svelte"; + +/** + * Layout store tests (plan 02 verification gate) — mock HTTP EDGES ONLY (the + * injected `LayoutHttp` is the store's outermost boundary; nothing internal + * is mocked — asymmetric testing per AGENTS.md). Fake timers drive the edit + * PUT debounce deterministically. + */ + +/** A recording fake of the LayoutHttp edge, scripted per test. */ +interface HttpFake extends LayoutHttp { + getCalls: string[]; + putCalls: Array<{ workspaceId: string; tree: LayoutTree }>; + deleteCalls: string[]; + getResult: (workspaceId: string) => LayoutHttpResult; + putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult; + deleteResult: (workspaceId: string) => LayoutHttpResult; + opsResult: (workspaceId: string, ops: readonly LayoutOp[]) => ApplyOpsResult; +} + +function fakeHttp( + getResult: (workspaceId: string) => LayoutHttpResult = () => ({ + ok: false, + notFound: true, + error: "No layout provider is active", + }), + putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult = (_ws, tree) => ({ + ok: true, + tree, + }), +): HttpFake { + const getCalls: string[] = []; + const putCalls: Array<{ workspaceId: string; tree: LayoutTree }> = []; + const deleteCalls: string[] = []; + const fake: HttpFake = { + getCalls, + putCalls, + deleteCalls, + getResult, + putResult, + // Re-scriptable outcome (default: the fresh seed a reset returns). + deleteResult: () => ({ ok: true, tree: DEFAULT_LAYOUT }), + // Re-scriptable outcome (default: the typed no-provider failure the ABI + // documents; tests re-assign mid-test like putResult). + opsResult: () => ({ ok: false, error: "no layout provider active" }), + // Delegate to the CURRENT properties so a test can re-script outcomes + // mid-test (http.putResult = …). + async getTree(workspaceId) { + getCalls.push(workspaceId); + return fake.getResult(workspaceId); + }, + async putTree(workspaceId, tree) { + putCalls.push({ workspaceId, tree }); + return fake.putResult(workspaceId, tree); + }, + async deleteTree(workspaceId) { + deleteCalls.push(workspaceId); + return fake.deleteResult(workspaceId); + }, + async postOps(workspaceId, ops) { + return fake.opsResult(workspaceId, ops); + }, + }; + return fake; +} + +function storageWith(views: string[] | null): Storage { + const map = new Map<string, string>(); + if (views !== null) map.set("test.sidebar.views", JSON.stringify(views)); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem: (k) => map.get(k) ?? null, + key: () => null, + removeItem: (k) => map.delete(k), + setItem: (k, v) => map.set(k, v), + }; +} + +const KNOWN = ["tabs", "model", "extensions", "vision"]; + +function makeStore(http: HttpFake, extra: Record<string, unknown> = {}) { + return createLayoutStore({ + http, + workspaceId: "ws-a", + knownViews: KNOWN, + sidebarViewsKey: "test.sidebar.views", + debounceMs: 100, + warn: (message) => warnings.push(message), + ...extra, + }); +} + +let warnings: string[] = []; + +beforeEach(() => { + vi.useFakeTimers(); + warnings = []; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("layout store — no-provider fallback (§2.6)", () => { + it("a 404 probe keeps the default tree, marks the provider absent, and logs ONCE", async () => { + const http = fakeHttp(); + const store = makeStore(http); + + await store.probe("ws-a", { boot: true }); + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("no layout provider"); + + // A re-probe (extensions-changed while still absent) does not re-warn. + await store.probe("ws-a"); + expect(warnings).toHaveLength(1); + }); + + it("a network failure degrades like a 404 (quiet, no error UI)", async () => { + const http = fakeHttp(() => ({ ok: false, notFound: true, error: "fetch blew up" })); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); + }); + + it("edits still apply in no-provider mode but are EPHEMERAL (no PUT, no rollback, no warning)", async () => { + const http = fakeHttp(); + const store = makeStore(http); + await store.probe("ws-a"); // absent now + expect(store.providerState).toBe("absent"); + + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + expect(store.tree).toEqual(edited); + + vi.advanceTimersByTime(500); + expect(http.putCalls).toEqual([]); // ephemeral: no write attempted + // Quiet = the single absence log (from the probe), nothing more. + expect(warnings).toHaveLength(1); + }); + + it("an edit while the probe is still in flight is PUT optimistically; a 404 flips absent and KEEPS the edit", async () => { + const http = fakeHttp(); + // The PUT is a 404 (the probe will confirm: no provider). + http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" }); + const store = makeStore(http); + + void store.probe("ws-a"); // in flight (microtask pending) + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); // lands BEFORE the probe resolves + await vi.advanceTimersByTimeAsync(200); + + expect(http.putCalls).toHaveLength(1); // the optimistic write WAS attempted + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(edited); // no rollback — there is no server tree + expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more + }); +}); + +describe("layout store — provider present", () => { + it("a 200 probe replaces the tree and marks the provider present", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + + await store.probe("ws-a"); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + expect(warnings).toEqual([]); + }); +}); + +describe("layout store — WS push (§2.5)", () => { + it("a layout push for the ACTIVE workspace replaces the tree", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const pushed = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + store.onServerTree("ws-a", pushed); + expect(store.tree).toEqual(pushed); + }); + + it("a push for a NON-active workspace is ignored", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const other = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + store.onServerTree("ws-b", other); + expect(store.tree).toEqual(served); + }); + + it("after a workspace switch the OLD workspace's push is ignored (clients filter)", async () => { + const servedA = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + let served = servedA; + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const servedB = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + served = servedB; + store.onWorkspaceChanged("ws-b"); + await vi.advanceTimersByTimeAsync(0); // the probe's promise + expect(store.tree).toEqual(servedB); + + // A late push for the OLD workspace must not land. + const stale = overlaySidePanels(DEFAULT_LAYOUT, ["tasks"]); + store.onServerTree("ws-a", stale); + expect(store.tree).toEqual(servedB); + }); +}); + +describe("layout store — edits PUT the whole tree + rollback (§2.6)", () => { + it("an edit PUTs the WHOLE tree, debounced; a 200 echo becomes the rollback target", async () => { + const served = DEFAULT_LAYOUT; + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + // Not yet — debounced. + expect(http.putCalls).toEqual([]); + await vi.advanceTimersByTimeAsync(150); + expect(http.putCalls).toEqual([{ workspaceId: "ws-a", tree: edited }]); + expect(warnings).toEqual([]); + }); + + it("rapid edits coalesce into ONE debounced PUT of the latest tree", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + await vi.advanceTimersByTimeAsync(50); + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + await vi.advanceTimersByTimeAsync(200); + + expect(http.putCalls).toHaveLength(1); + expect(http.putCalls[0]?.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + }); + + it("a 400 rejection rolls back to the last server tree with a quiet warning", async () => { + const served = DEFAULT_LAYOUT; + const http = fakeHttp( + () => ({ ok: true, tree: served }), + () => ({ ok: false, notFound: false, error: "invalid tree" }), + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("present"); + + const bad = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(bad); + await vi.advanceTimersByTimeAsync(200); + + expect(store.tree).toEqual(served); // rolled back + expect(store.providerState).toBe("present"); // the provider is still there + expect(warnings.some((w) => w.includes("rolled back"))).toBe(true); + }); + + it("a 5xx failure rolls back too (present provider, transient error)", async () => { + const served = DEFAULT_LAYOUT; + let failPut = false; + const http = fakeHttp( + () => ({ ok: true, tree: served }), + () => + failPut + ? { ok: false, notFound: false, error: "storage down" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + + failPut = true; + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + await vi.advanceTimersByTimeAsync(200); + expect(store.tree).toEqual(served); + }); + + it("a PUT 404 does NOT roll back — the provider vanished mid-session; the edit stays ephemeral", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + + // The provider unloads before the edit's PUT fires. + http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" }); + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + await vi.advanceTimersByTimeAsync(200); + + expect(store.tree).toEqual(edited); // the edit STAYS (in-memory) + expect(store.providerState).toBe("absent"); + expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more + }); +}); + +describe("layout store — probe triggers (§2.6)", () => { + it("a per-workspace plugin-state change on the ACTIVE workspace re-probes (the plugins-modal toggle)", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a layout plugin got enabled in THIS workspace + store.onWorkspacePluginsChanged("ws-a"); + await vi.advanceTimersByTimeAsync(0); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + }); + + it("a per-workspace plugin-state change on ANOTHER workspace is ignored (its probe fetches on switch)", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a DIFFERENT workspace's toggle — nothing re-renders here + store.onWorkspacePluginsChanged("ws-other"); + await vi.advanceTimersByTimeAsync(0); + expect(http.getCalls).toEqual(["ws-a"]); // no second probe + expect(store.providerState).toBe("absent"); + }); + + it("extensions-changed re-probes the active workspace", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout provider is active" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a layout plugin got enabled on the server + store.onExtensionsChanged(); + await vi.advanceTimersByTimeAsync(0); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + }); + + it("a workspace switch resets to the default, probes the NEW workspace, and drops queued edits", async () => { + const http = fakeHttp((ws) => + ws === "ws-b" + ? { ok: true, tree: overlaySidePanels(DEFAULT_LAYOUT, ["vision"]) } + : { ok: true, tree: DEFAULT_LAYOUT }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + + // Queue an edit for ws-a, then switch before the debounce fires. + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + store.onWorkspaceChanged("ws-b"); + await vi.advanceTimersByTimeAsync(300); + + expect(store.activeWorkspaceId).toBe("ws-b"); + expect(http.getCalls).toEqual(["ws-a", "ws-b"]); + expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + // The queued edit never PUTs — it belonged to the old workspace. + expect(http.putCalls).toEqual([]); + }); + + it("switching to the SAME workspace is a no-op (no re-probe, no reset)", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + store.onWorkspaceChanged("ws-a"); + expect(http.getCalls).toEqual(["ws-a"]); + }); +}); + +describe("layout store — one-way localStorage migration (§2.7)", () => { + it("boot probe + equal panels + stored key → exactly ONE migration PUT, then the key is dead", async () => { + const storage = storageWith(["extensions", "vision"]); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([ + { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"]) }, + ]); + // The migrated tree is now the rendered tree. + expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"])); + + // The key is never read again: a later probe PUTs nothing. + await store.probe("ws-a"); + expect(http.putCalls).toHaveLength(1); + }); + + it("no stored key → no migration PUT (but the read-once flag is still consumed)", async () => { + const storage = storageWith(null); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + }); + + it("a custom provider tree (panels differ) is NOT migrated over", async () => { + const storage = storageWith(["extensions"]); + const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + const http = fakeHttp(() => ({ ok: true, tree: custom })); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + expect(store.tree).toEqual(custom); + }); + + it("migration is boot-only: a non-boot probe never PUTs the stored selection", async () => { + const storage = storageWith(["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + await store.probe("ws-a"); + expect(http.putCalls).toEqual([]); + }); + + it("a failed migration PUT consumes the flag (best-effort — never retried, never fatal)", async () => { + const storage = storageWith(["extensions"]); + const http = fakeHttp( + () => ({ ok: true, tree: DEFAULT_LAYOUT }), + () => ({ ok: false, notFound: false, error: "invalid tree" }), + ); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toHaveLength(1); + expect(warnings.some((w) => w.includes("migration"))).toBe(true); + // The rendered tree is untouched (the PUT failed). + expect(store.tree).toEqual(DEFAULT_LAYOUT); + await store.probe("ws-a"); + expect(http.putCalls).toHaveLength(1); + }); + + it("migration arms even when the boot probe 404s, and applies on the first LATER success", async () => { + const storage = storageWith(["extensions"]); + let absent = true; + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout provider is active" } + : { ok: true, tree: DEFAULT_LAYOUT }, + ); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + + absent = false; + await store.probe("ws-a"); // extensions-changed probe — first success + expect(http.putCalls).toEqual([ + { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]) }, + ]); + }); +}); + +describe("layout store — resetToDefault (plan 04 §4.4, the reset affordance)", () => { + it("DELETEs the workspace's layout, applies the fresh seed, and resolves null", async () => { + const custom = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(); + // The DELETE returns the FRESH SEED (the server re-seeded). + http.deleteResult = () => ({ ok: true, tree: DEFAULT_LAYOUT }); + const store = makeStore(http); + + // Get to a NON-default tree first: a present provider + a pushed edit. + http.getResult = () => ({ ok: true, tree: custom }); + await store.probe("ws-a"); + expect(store.tree).toEqual(custom); + + const failure = await store.resetToDefault("ws-a"); + expect(failure).toBeNull(); + expect(http.deleteCalls).toEqual(["ws-a"]); + // The fresh seed is applied locally (the WS broadcast echo is idempotent). + expect(store.tree).toEqual(DEFAULT_LAYOUT); + expect(store.providerState).toBe("present"); + }); + + it("a 404 (no provider) resolves to the user-facing 'no layout plugin enabled' reason", async () => { + const http = fakeHttp(); + http.deleteResult = () => ({ + ok: false, + notFound: true, + error: "No layout provider is active", + }); + const store = makeStore(http); + + const failure = await store.resetToDefault("ws-a"); + expect(failure).toBe("no layout plugin enabled"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); // untouched local default + }); + + it("another failure (5xx on a present provider) resolves to its error message", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + http.deleteResult = () => ({ ok: false, notFound: false, error: "storage write failed" }); + const store = makeStore(http); + await store.probe("ws-a"); + + const failure = await store.resetToDefault("ws-a"); + expect(failure).toBe("storage write failed"); + }); +}); diff --git a/src/features/layout/ui/FallbackTile.svelte b/src/features/layout/ui/FallbackTile.svelte new file mode 100644 index 0000000..8fafa7f --- /dev/null +++ b/src/features/layout/ui/FallbackTile.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + /** + * Shown when a leaf names a view this client has not registered — the tree + * is data and can arrive from a backend (plan 02) that knows views this + * build lacks. A titled placeholder, never a crash. + */ + let { viewId }: { viewId: string } = $props(); +</script> + +<div class="flex flex-col gap-1 rounded-lg bg-base-200 p-3" data-testid="fallback-tile"> + <h3 class="text-xs font-semibold uppercase opacity-60">{viewId}</h3> + <p class="text-sm opacity-60">View not registered.</p> +</div> diff --git a/src/features/layout/ui/TileLeaf.svelte b/src/features/layout/ui/TileLeaf.svelte new file mode 100644 index 0000000..0fd6430 --- /dev/null +++ b/src/features/layout/ui/TileLeaf.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + /** + * Mounts a registered view for a leaf node. Resolution goes through the + * REACTIVE registry, so a late registration (plan 03) swaps the tile in + * without a tree change. An unknown view id renders a fallback tile — + * NEVER a crash (backend-owned trees can name views this client lacks). + * + * A diagnostic `marker` on the leaf draws an outline around it — the only + * node kind without a natural tile wrapper, so the wrapper exists only + * when marked (unmarked leaves render exactly as before). + */ + import type { TileMarker } from "@dispatch/layout-contract"; + import { viewRegistry, type RegisteredView } from "../logic/registry"; + import { MARKER_OUTLINE_OFFSET, markerOutline } from "../logic/marker"; + import FallbackTile from "./FallbackTile.svelte"; + + let { + node, + ports, + }: { + node: { readonly view: string; readonly marker?: TileMarker }; + ports?: unknown; + } = $props(); + + const entry: RegisteredView | undefined = $derived( + $viewRegistry.find((v) => v.id === node.view), + ); + const View = $derived(entry?.component); + const outline = $derived(markerOutline(node.marker)); +</script> + +{#snippet content()} + {#if View} + <View {ports} /> + {:else} + <FallbackTile viewId={node.view} /> + {/if} +{/snippet} + +{#if outline !== undefined} + <div style:outline={outline} style:outline-offset={MARKER_OUTLINE_OFFSET}>{@render content()}</div> +{:else} + {@render content()} +{/if} diff --git a/src/features/layout/ui/TileNode.svelte b/src/features/layout/ui/TileNode.svelte new file mode 100644 index 0000000..5ed8a89 --- /dev/null +++ b/src/features/layout/ui/TileNode.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + /** + * The recursive tile dispatcher: renders the node for its `kind` and recurses + * through the container components. Leaf nodes resolve through the view + * registry (TileLeaf). + */ + import type { LayoutNode, LayoutOp, Path } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import TileLeaf from "./TileLeaf.svelte"; + import TilePanels from "./TilePanels.svelte"; + import TileSplit from "./TileSplit.svelte"; + import TileStack from "./TileStack.svelte"; + + let { + node, + path, + ports, + collapse, + onEdit, + }: { + node: LayoutNode; + path: Path; + ports?: unknown; + collapse: CollapseStore; + onEdit: (ops: readonly LayoutOp[]) => void; + } = $props(); +</script> + +{#if node.kind === "split"} + <TileSplit {node} {path} {ports} {collapse} {onEdit} /> +{:else if node.kind === "stack"} + <TileStack {node} {path} {ports} {collapse} {onEdit} /> +{:else if node.kind === "panels"} + <TilePanels {node} {path} {ports} {onEdit} /> +{:else if node.kind === "leaf"} + <TileLeaf {node} {ports} /> +{/if} diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte new file mode 100644 index 0000000..cc5fa89 --- /dev/null +++ b/src/features/layout/ui/TilePanels.svelte @@ -0,0 +1,185 @@ +<script lang="ts"> + /** + * A scrolling stack of swappable panels — the generalized ViewSidebar. The + * panel list comes from the tree node; dropdown options from the (reactive) + * registry's panel-eligible views. Edits emit tree ops upward via `onEdit`; + * the OWNER (App today, the backend store in plan 02) applies them and + * decides persistence. + */ + import type { LayoutOp, PanelsNode, Path } from "@dispatch/layout-contract"; + import PlusIcon from "phosphor-svelte/lib/PlusIcon"; + import { untrack } from "svelte"; + import { viewRegistry } from "../logic/registry"; + import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "../logic/panels-edit"; + import { + initialPanelKeys, + type PanelKeys, + reconcilePanelKeys, + swappedPanels, + } from "../logic/panel-keys"; + import { markerStyle } from "../logic/marker"; + import { morphPanelHeight, panelGrowIn, panelShrinkOut } from "./panel-motion"; + import TileLeaf from "./TileLeaf.svelte"; + + let { + node, + path, + ports, + onEdit, + renderMarker = false, + }: { + node: PanelsNode; + path: Path; + ports?: unknown; + onEdit: (ops: readonly LayoutOp[]) => void; + /** + * Whether to draw the node's diagnostic marker on this root. Default NO: + * a panels node is the scrolled CONTENT of a split's collapsible child, + * so an outline here wraps the full scrollable region and scrolls with + * it — the split draws the marker on the aside (the fixed viewport) + * instead. Pass `true` only for a panels node used outside a split. + */ + renderMarker?: boolean; + } = $props(); + + // Panel-eligible views only (decision 5): chat/composer/message-queue never + // appear in the dropdowns. Reactive so late registrations re-render options. + const panelViews = $derived($viewRegistry.filter((v) => v.panelEligible)); + const addView = $derived(firstPanelView(panelViews)); + + // Stable per-panel keys (pure reconcile, memoised across tree updates) so a + // closed panel is the one that animates out, not whichever is last. + // A local ✕ click hints WHICH panel left (consumed by the next tree change; + // ignored if that change isn't exactly this removal, e.g. a remote edit). + let keyMemo: PanelKeys | null = null; + let removeHint: number | undefined; + const keyed = $derived.by(() => { + const views = node.panels.map((p) => p.view); + keyMemo = + keyMemo === null ? initialPanelKeys(views) : reconcilePanelKeys(keyMemo, views, removeHint); + removeHint = undefined; + return keyMemo; + }); + + function removePanel(idx: number): void { + removeHint = idx; + onEdit([removePanelOp(path, idx)]); + } + + // View SWAP: the panel BOX (the well) animates from the old view's height + // to the new one's. Measure before the DOM updates, animate after. (Adds/removes are + // the per-panel in:/out: transitions below.) + let columnEl = $state<HTMLDivElement | undefined>(); + /** The keys the DOM currently shows (set after each update). */ + let shownKeys: PanelKeys | null = null; + let before: { keys: PanelKeys; heights: Map<number, number> } | null = null; + $effect.pre(() => { + const next = keyed; + untrack(() => { + const heights = new Map<number, number>(); + for (const el of panelEls()) { + const well = wellOf(el); + if (well !== null) heights.set(Number(el.dataset.panelKey), well.offsetHeight); + } + before = { keys: shownKeys ?? next, heights }; + }); + }); + $effect(() => { + const now = keyed; + untrack(() => { + const prev = before; + before = null; + shownKeys = now; + if (prev === null) return; + for (const key of swappedPanels(prev.keys, now).keys()) { + const el = panelEls().find((e) => Number(e.dataset.panelKey) === key); + const well = el === undefined ? null : wellOf(el); + const from = prev.heights.get(key); + if (well !== null && from !== undefined) morphPanelHeight(well, from); + } + }); + }); + + function wellOf(panel: HTMLElement): HTMLElement | null { + return panel.querySelector<HTMLElement>(":scope > [data-panel-well]"); + } + + function panelEls(): HTMLElement[] { + return columnEl === undefined + ? [] + : [...columnEl.querySelectorAll<HTMLElement>(":scope > [data-panel-key]")]; + } +</script> + +<!-- `shrink-0`: this column is the sidebar's SCROLLED content, so it keeps its + natural height — if it shrank to the scroller (min-h-0 in a flex column), + its children overflowed it and the trailing margin was never scrollable. --> +<!-- No flex `gap`: each panel's wrapper carries the gap as `pb-2` instead, so + it grows/shrinks WITH the panel's height animation (a flex gap would jump). --> +<div + bind:this={columnEl} + class="flex min-h-0 shrink-0 flex-col" + style={renderMarker ? markerStyle(node.marker) : undefined} + data-panels +> + {#each node.panels as panel, idx (keyed.keys[idx])} + <!-- Add/close animate the WRAPPER's height (it carries the inter-panel gap, + so panels below slide smoothly); a view swap animates the WELL's + height. Either way only a height is tweened (clipped) — the contents + keep their natural size. `.inset-well` (app.css): recessed into the + sidebar, like the chat's thinking/tool blocks. --> + <div + class="shrink-0 pb-2" + data-panel-key={keyed.keys[idx]} + in:panelGrowIn + out:panelShrinkOut + > + <div class="inset-well flex flex-col p-3" data-panel-well> + <div class="flex items-center gap-1"> + <select + class="select select-bordered select-sm flex-1" + aria-label="Select a view" + value={panel.view} + onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])} + > + <option value="" disabled>Select a view</option> + {#each panelViews as view (view.id)} + <option value={view.id}>{view.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={() => removePanel(idx)} + > + ✕ + </button> + {/if} + </div> + + <div class="mt-2"> + <TileLeaf node={panel} {ports} /> + </div> + </div> + </div> + {/each} + + {#if addView !== null} + <!-- `.raised-btn` (app.css): the mirror of the view wells above it — + pops out where they sink in; pressed, it sinks in like them. Height = + DaisyUI's default button size (`--size-field` × 10), as the `btn` it + replaced; `shrink-0` so the column can never squash it. The last panel's + `pb-2` is the gap above it; `mb-2` = the same gap below, so it + doesn't sit tight to the end. --> + <button + type="button" + class="raised-btn mb-2 h-[calc(var(--size-field,0.25rem)*10)] w-full shrink-0" + aria-label="Add view" + onclick={() => addView !== null && onEdit([addPanelOp(path, addView)])} + > + <PlusIcon size={18} weight="bold" aria-hidden="true" /> + </button> + {/if} +</div> diff --git a/src/features/layout/ui/TileRoot.svelte b/src/features/layout/ui/TileRoot.svelte new file mode 100644 index 0000000..82ad430 --- /dev/null +++ b/src/features/layout/ui/TileRoot.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + /** + * The tile-tree root. Renders the tree (titlebar slot first — unset renders + * nothing, D-L5), applies panel edit ops to its `tree` prop, and forwards the + * new tree via `onTreeChange`. The OWNER decides what persistence means + * (App/localStorage today; the backend layout store in plan 02). + */ + import { applyOps, type LayoutOp, type LayoutTree } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import TileNode from "./TileNode.svelte"; + + let { + tree, + onTreeChange, + ports, + collapse, + }: { + tree: LayoutTree; + onTreeChange?: (tree: LayoutTree) => void; + ports?: unknown; + collapse: CollapseStore; + } = $props(); + + function handleEdit(ops: readonly LayoutOp[]): void { + const result = applyOps(tree, ops); + if (result.ok) { + onTreeChange?.(result.tree); + } else { + // A panel edit that fails validation (e.g. removing the last panel) is + // refused loudly in dev — the tree keeps its previous state. + console.error(`TileRoot: layout edit refused: ${result.error}`); + } + } +</script> + +{#if tree.titlebar !== undefined} + <TileNode node={tree.titlebar} path={["titlebar"]} {ports} {collapse} onEdit={handleEdit} /> +{/if} +<TileNode node={tree.root} path={[]} {ports} {collapse} onEdit={handleEdit} /> diff --git a/src/features/layout/ui/TileRoot.test.ts b/src/features/layout/ui/TileRoot.test.ts new file mode 100644 index 0000000..4b404a1 --- /dev/null +++ b/src/features/layout/ui/TileRoot.test.ts @@ -0,0 +1,394 @@ +import type { LayoutTree, SplitNode } from "@dispatch/layout-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { gsap } from "gsap"; +import { describe, expect, it, vi } from "vitest"; +import { createCollapseStore, registerView, unregisterView } from "../index"; +import FixtureA from "./fixtures/FixtureA.svelte"; +import FixtureB from "./fixtures/FixtureB.svelte"; +import FixtureC from "./fixtures/FixtureC.svelte"; +import TileRoot from "./TileRoot.svelte"; + +// Fixture registry: two panel-eligible views + one content-only view (a THIRD +// component, so every fixture testid is unique in the rendered tree). +registerView({ id: "fix-a", label: "Alpha View", component: FixtureA }); +registerView({ id: "fix-b", label: "Beta View", component: FixtureB }); +registerView({ id: "fix-content", label: "Content", component: FixtureC, panelEligible: false }); + +function leaf(view: string): { kind: "leaf"; view: string } { + return { kind: "leaf", view }; +} + +/** split[ stack[leaf a (grow), leaf content], collapsible panels[leaf b] ] */ +function defaultTestTree(): LayoutTree { + return { + version: 1, + root: { + kind: "split", + id: "root", + dir: "row", + children: [ + { + size: 1, + node: { + kind: "stack", + id: "main", + children: [{ grow: 1, node: leaf("fix-a") }, { node: leaf("fix-content") }], + }, + }, + { + size: 0, + basis: 320, + collapsible: true, + node: { kind: "panels", id: "side", panels: [leaf("fix-b")] }, + }, + ], + }, + }; +} + +/** A bare panels root — panel EDITS without the collapsible chrome in the way. */ +function panelsOnlyTree(...views: string[]): LayoutTree { + return { version: 1, root: { kind: "panels", id: "side", panels: views.map(leaf) } }; +} + +/** The default test tree WITH the diagnostic markers on its two root tiles. */ +function markedTestTree(): LayoutTree { + return { + version: 1, + root: { + kind: "split", + id: "root", + dir: "row", + children: [ + { + size: 1, + node: { + kind: "stack", + id: "main", + marker: "main", + children: [{ grow: 1, node: leaf("fix-a") }], + }, + }, + { + size: 0, + basis: 320, + collapsible: true, + node: { kind: "panels", id: "side", marker: "side", panels: [leaf("fix-b")] }, + }, + ], + }, + }; +} + +function sidePanelsOf(tree: LayoutTree | null): string[] { + if (tree === null) return []; + const side = (tree.root as SplitNode).children?.[1]?.node; + return side?.kind === "panels" + ? side.panels.map((p) => p.view) + : tree.root.kind === "panels" + ? tree.root.panels.map((p) => p.view) + : []; +} + +describe("TileRoot", () => { + it("renders every node kind: split, stack, panels, leaf", () => { + // A FRESH collapse store per test — the store is stateful chrome. + render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + // Stack children mount their registered components. + expect(screen.getByTestId("fixture-a")).toBeInTheDocument(); + expect(screen.getByTestId("fixture-c")).toBeInTheDocument(); + // The panels node renders its panel leaf + the dropdown. + expect(screen.getByTestId("fixture-b")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Select a view" })).toBeInTheDocument(); + }); + + it("shows a fallback tile (never a crash) for an unknown view id", () => { + const tree: LayoutTree = { + version: 1, + root: { kind: "leaf", view: "not-registered-anywhere" }, + }; + render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + const fallback = screen.getByTestId("fallback-tile"); + expect(fallback).toHaveTextContent("not-registered-anywhere"); + expect(fallback).toHaveTextContent("View not registered."); + }); + + it("renders nothing for the titlebar slot while it is unset (D-L5)", () => { + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + expect(container.querySelectorAll("[data-testid='fallback-tile']")).toHaveLength(0); + }); + + it("a set titlebar renders its node (the slot works; nothing populates it today)", () => { + const tree: LayoutTree = { + version: 1, + titlebar: { kind: "leaf", view: "fix-a" }, + root: { kind: "leaf", view: "fix-b" }, + }; + render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + expect(screen.getByTestId("fixture-a")).toBeInTheDocument(); + expect(screen.getByTestId("fixture-b")).toBeInTheDocument(); + }); + + it("the collapsible child reflects the collapse store and toggles through it", async () => { + const collapse = createCollapseStore({ viewportWidth: 1024 }); + const { container } = render(TileRoot, { props: { tree: defaultTestTree(), collapse } }); + const aside = container.querySelector<HTMLElement>("[data-testid='collapsible-child']"); + expect(aside).not.toBeNull(); + // Wide viewport default: open → full basis width. + expect(aside).toHaveAttribute("data-open", "true"); + expect(aside?.style.width).toBe("320px"); + + await collapse.toggle("side"); + expect(aside).toHaveAttribute("data-open", "false"); + expect(aside?.style.width).toBe("0px"); + }); + + it("a dropdown swap emits replaceView and the owner receives the changed tree", async () => { + const user = userEvent.setup(); + let changed: LayoutTree | null = null; + render(TileRoot, { + props: { + tree: panelsOnlyTree("fix-b"), + collapse: createCollapseStore(), + onTreeChange: (t) => (changed = t), + }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Select a view" }), "fix-a"); + expect(sidePanelsOf(changed)).toEqual(["fix-a"]); + // TileRoot is a CONTROLLED component: applying the op is the owner's job + // (App re-renders with the new tree; the test doesn't feed it back). + }); + + it("the + button emits insertPanel pre-selecting the first eligible view", async () => { + const user = userEvent.setup(); + let changed: LayoutTree | null = null; + render(TileRoot, { + props: { + tree: panelsOnlyTree("fix-b"), + collapse: createCollapseStore(), + onTreeChange: (t) => (changed = t), + }, + }); + + await user.click(screen.getByRole("button", { name: "Add view" })); + expect(sidePanelsOf(changed)).toEqual(["fix-b", "fix-a"]); + }); + + it("the ✕ button emits removeNode for that panel (absent on the first panel)", async () => { + const user = userEvent.setup(); + let changed: LayoutTree | null = null; + render(TileRoot, { + props: { + tree: panelsOnlyTree("fix-b", "fix-a"), + collapse: createCollapseStore(), + onTreeChange: (t) => (changed = t), + }, + }); + + // Only panels after the first can be removed — like ViewSidebar today. + const removeButtons = screen.getAllByRole("button", { name: "Remove view" }); + expect(removeButtons).toHaveLength(1); + await user.click(removeButtons[0] as HTMLElement); + expect(sidePanelsOf(changed)).toEqual(["fix-b"]); + }); + + it("an invalid edit is refused: the first panel has no remove button", () => { + const tree = panelsOnlyTree("fix-b"); + let changed: LayoutTree | null = null; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + render(TileRoot, { + props: { tree, collapse: createCollapseStore(), onTreeChange: (t) => (changed = t) }, + }); + expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull(); + expect(changed).toBeNull(); + } finally { + consoleError.mockRestore(); + } + }); + + it("a stack child with grow renders in a flex-grown wrapper; others natural", () => { + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + const grown = container.querySelector("[data-testid='fixture-a']")?.closest(".min-h-0"); + expect(grown?.getAttribute("style")).toContain("flex: 1 1 0%"); + const natural = container.querySelector("[data-testid='fixture-c']")?.closest(".min-h-0"); + expect(natural?.getAttribute("style")).toContain("flex: 0 0 auto"); + }); + + it("the stack column is full-height so a grow child fills it even when empty", () => { + // jsdom can't compute flex layout — the honest seam is the CSS class. + // WITHOUT `h-full` the stack column's height is content-based, so the + // grow child's `flex: 1 1 0%` has no space to fill: an EMPTY chat leaf + // collapses to ~0 and the composer below it rides to the top (instead of + // staying pinned at the bottom of the column). + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + const stackColumn = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col"); + expect(stackColumn).toHaveClass("h-full"); + }); + + it("a marked stack/panels node draws its diagnostic outline (main red, side green)", () => { + // The inline outline IS the honest seam: marker values arrive in runtime + // tree data (Tailwind can't see them — dynamic classes would silently + // render nothing), so the renderer applies an inline style, and the test + // asserts exactly what lands on the tile wrapper. + const { container } = render(TileRoot, { + props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col"); + expect(stack?.getAttribute("style")).toContain("outline: 4px solid red"); + // The SIDE marker frames the SCROLL CONTAINER — the sidebar's fixed, + // opaquely-painted viewport (the aside's bg is transparent; this div's + // bg-base-100 would cover an outline drawn on the aside). The panels + // root is its SCROLLED content, so an outline there would wrap the + // whole scrollable region and scroll along with it. + const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']"); + expect(viewport?.getAttribute("style")).toContain("outline: 4px solid green"); + }); + + it("outlines are INSET so they draw inside the tile's box (never clipped at screen edges)", () => { + // Tiles span their containers edge-to-edge, so a default (outside) + // outline overflows the visible area and is clipped away — the offset + // pulls the ring inside the box. Pin it on both marked tiles. + const { container } = render(TileRoot, { + props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col"); + expect(stack?.getAttribute("style")).toContain("outline-offset: -4px"); + const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']"); + expect(viewport?.getAttribute("style")).toContain("outline-offset: -4px"); + }); + + it("the panels CONTENT carries no outline (it scrolls — the marker must not scroll with it)", () => { + const { container } = render(TileRoot, { + props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + const panels = container.querySelector("[data-testid='fixture-b']")?.closest("[data-panels]"); + expect(panels?.getAttribute("style") ?? "").not.toContain("outline"); + }); + + it("an unmarked tree draws NO outlines (trees without the field render exactly as before)", () => { + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0); + }); + + it("an unknown marker value draws nothing (the renderer ignores what it doesn't know)", () => { + // Both root tiles marked with a value the renderer doesn't know. + const tree = markedTestTree(); + const root = tree.root; + if (root.kind !== "split") return; + const junked = root.children.map((child) => ({ + ...child, + node: { ...child.node, marker: "purple" } as unknown as typeof child.node, + })); + const marked: LayoutTree = { version: 1, root: { ...root, children: junked } }; + const { container } = render(TileRoot, { + props: { tree: marked, collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0); + }); + + it("a marked LEAF draws its outline around a wrapper (the only wrapperless node kind)", () => { + const tree: LayoutTree = { + version: 1, + root: { kind: "leaf", view: "fix-a", marker: "side" }, + }; + const { container } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + const fixture = container.querySelector("[data-testid='fixture-a']"); + expect(fixture?.parentElement?.getAttribute("style")).toContain("outline: 4px solid green"); + }); + + it("the dropdown lists panel-eligible views only (decision 5)", () => { + render(TileRoot, { props: { tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() } }); + const select = screen.getByRole("combobox", { name: "Select a view" }); + const labels = [...select.querySelectorAll("option")].map((o) => o.textContent); + expect(labels).toContain("Alpha View"); + expect(labels).toContain("Beta View"); + expect(labels).not.toContain("Content"); + }); + + it("an unregistered view id inside a panel renders the fallback (not a crash)", () => { + render(TileRoot, { + props: { tree: panelsOnlyTree("ghost-view"), collapse: createCollapseStore() }, + }); + expect(screen.getByTestId("fallback-tile")).toBeInTheDocument(); + }); +}); + +describe("TileRoot registry cleanup", () => { + it("unregisters the fixture views (the registry is module-global per test file)", () => { + unregisterView("fix-a"); + unregisterView("fix-b"); + unregisterView("fix-content"); + const tree: LayoutTree = { version: 1, root: { kind: "leaf", view: "fix-a" } }; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + // Unregistered → the fallback tile, not a crash. + expect(screen.getByTestId("fallback-tile")).toBeInTheDocument(); + } finally { + consoleError.mockRestore(); + } + }); +}); + +describe("TilePanels — panel identity + height animation", () => { + const wrappers = (): HTMLElement[] => [ + ...document.querySelectorAll<HTMLElement>("[data-panels] > [data-panel-key]"), + ]; + + it("closing the MIDDLE panel removes that panel's DOM, keeping the others' nodes", async () => { + const { rerender } = render(TileRoot, { + props: { tree: panelsOnlyTree("fix-b", "fix-a", "fix-b"), collapse: createCollapseStore() }, + }); + const [first, , third] = wrappers(); + await rerender({ tree: panelsOnlyTree("fix-b", "fix-b"), collapse: createCollapseStore() }); + // Identity, not position: the third panel's node survived (index keying + // would have reused the middle node and dropped the last one). + expect(wrappers()).toEqual([first, third]); + expect(screen.queryByTestId("fixture-a")).toBeNull(); + }); + + it("✕ on one of two IDENTICAL views removes exactly that panel", async () => { + const user = userEvent.setup(); + const tree = panelsOnlyTree("fix-b", "fix-a", "fix-a"); + const { rerender } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + const [first, , third] = wrappers(); + // Click the MIDDLE panel's ✕ (the first panel has none), then the owner + // applies the edit — the two fix-a panels are indistinguishable by data. + const [middleClose] = screen.getAllByRole("button", { name: "Remove view" }); + await user.click(middleClose as HTMLElement); + await rerender({ tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() }); + expect(wrappers()).toEqual([first, third]); + }); + + it("with motion allowed, a closed panel stays in flow while it shrinks out", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: false }) as unknown as MediaQueryList), + ); + try { + const { rerender } = render(TileRoot, { + props: { tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() }, + }); + const closing = wrappers()[1]; + await rerender({ tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() }); + // jsdom's stubbed Element.animate never finishes, so the outro holds. + expect(closing?.isConnected).toBe(true); + expect(gsap.getTweensOf(closing as HTMLElement)).toHaveLength(1); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/features/layout/ui/TileSplit.svelte b/src/features/layout/ui/TileSplit.svelte new file mode 100644 index 0000000..84fa075 --- /dev/null +++ b/src/features/layout/ui/TileSplit.svelte @@ -0,0 +1,130 @@ +<script lang="ts"> + /** + * A flex row/column of child subtrees. Child sizing comes from the tree: + * `size` → flex-grow (+ optional `basis`), and `collapsible` children get + * today's EXACT sidebar behavior — width/translate transition, `lg:relative` + * in-flow push vs `max-lg:absolute` overlay with a backdrop. Collapse state + * is chrome (per-node-id), owned by the injected collapse store. + */ + import type { LayoutOp, Path, SplitNode, TileMarker } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import { markerStyle } from "../logic/marker"; + import TileNode from "./TileNode.svelte"; + + let { + node, + path, + ports, + collapse, + onEdit, + }: { + node: SplitNode; + path: Path; + ports?: unknown; + collapse: CollapseStore; + onEdit: (ops: readonly LayoutOp[]) => void; + } = $props(); + + const state = $derived($collapse); + + /** Collapse state key: the node's id when present, else its path (stable enough for chrome). */ + function keyOf(i: number, id: string | undefined): string { + return id ?? [...path, i].map((s) => String(s)).join(":"); + } + + function isOpen(i: number, id: string | undefined): boolean { + const key = keyOf(i, id); + return state.openByNode[key] ?? state.defaultOpen; + } + + /** + * The scroll container's inline style: the fixed sidebar width + slide + * transform + any diagnostic marker (inset) — none when unmarked. + * + * The marker goes on THIS div, not the aside: the inner div paints an + * OPAQUE full-height background (bg-base-100) that would cover an outline + * drawn on the aside. This div's own box never moves — only its contents + * scroll — and overflow-y-auto clips descendants, never an element's own + * outline, so the marker frames exactly the visible sidebar. + */ + function scrollViewportStyle( + basis: number | undefined, + open: boolean, + marker: TileMarker | undefined, + ): string { + const parts = [`width: ${basis ?? 320}px`, `transform: translateX(${open ? "0" : "100%"})`]; + const markerCss = markerStyle(marker); + if (markerCss !== undefined) parts.push(markerCss); + return parts.join("; "); + } +</script> + +{#if node.dir === "col"} + <div class="flex min-h-0 w-full flex-col" style={markerStyle(node.marker)}> + {#each node.children as child, i (i)} + {@const key = keyOf(i, child.node.id)} + <div + class="min-h-0 min-w-0" + style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")} + > + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + {/each} + </div> +{:else} + <div class="flex min-h-0 h-full flex-row" style={markerStyle(node.marker)}> + {#each node.children as child, i (i)} + {@const key = keyOf(i, child.node.id)} + {#if child.collapsible} + {@const open = isOpen(i, child.node.id)} + <!-- + A diagnostic marker on a collapsible child is drawn on the SCROLL + CONTAINER below — the sidebar's fixed, opaquely-painted viewport — + NOT on the child's own root element: that root renders INSIDE the + scroll container, so an outline there would wrap the full + scrollable content and scroll along with it instead of framing the + visible panel. + --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <!-- Viewport padding: top/right/bottom always; LEFT only in overlay mode + (< lg) — inline (≥ lg) the chat column's gutter already separates the + views, but the overlay's left edge is the panel's own edge. --> + <aside + class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative" + style="width: {open ? (child.basis ?? 320) : 0}px" + data-testid="collapsible-child" + data-open={open} + > + <div + class="flex h-full flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 max-lg:pl-3 transition-transform duration-300 ease-out" + style={scrollViewportStyle(child.basis, open, child.node.marker)} + data-testid="sidebar-scroll-viewport" + > + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + </aside> + {#if open} + <!-- Backdrop: only on narrow screens (overlay mode), click to close. --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="fixed inset-0 z-20 bg-black/30 lg:hidden" + role="button" + tabindex="0" + aria-label="Close sidebar" + onclick={() => collapse.setOpen(key, false)} + onkeydown={(e) => { + if (e.key === "Escape" || e.key === "Enter") collapse.setOpen(key, false); + }} + ></div> + {/if} + {:else} + <div + class="min-h-0 min-w-0" + style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")} + > + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + {/if} + {/each} + </div> +{/if} diff --git a/src/features/layout/ui/TileStack.svelte b/src/features/layout/ui/TileStack.svelte new file mode 100644 index 0000000..c8f8cae --- /dev/null +++ b/src/features/layout/ui/TileStack.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + /** + * A plain vertical container (today's left column). `grow` children flex + * (`flex: grow 1 0%` — the chat region scrolls internally, exactly like + * today's `flex-1`); children without grow take natural height. + * + * `h-full` keeps the container's main size DEFINITE (the parent is the + * split-child wrapper, cross-stretched to the full-height split row). With + * an indefinite height, `flex: 1 1 0%` degenerates to content sizing — an + * EMPTY grow child collapses to ~0 and the siblings below it (composer) + * ride to the top instead of staying pinned at the bottom. + */ + import type { LayoutOp, Path, StackNode } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import { markerStyle } from "../logic/marker"; + import TileNode from "./TileNode.svelte"; + + let { + node, + path, + ports, + collapse, + onEdit, + }: { + node: StackNode; + path: Path; + ports?: unknown; + collapse: CollapseStore; + onEdit: (ops: readonly LayoutOp[]) => void; + } = $props(); +</script> + +<div + class="flex h-full min-h-0 min-w-0 flex-col overflow-hidden" + style={markerStyle(node.marker)} +> + {#each node.children as child, i (i)} + <div class="min-h-0 min-w-0" style={"flex: " + (child.grow ?? 0) + (child.grow === undefined ? " 0 auto" : " 1 0%")}> + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + {/each} +</div> diff --git a/src/features/layout/ui/fixtures/FixtureA.svelte b/src/features/layout/ui/fixtures/FixtureA.svelte new file mode 100644 index 0000000..e55ba2a --- /dev/null +++ b/src/features/layout/ui/fixtures/FixtureA.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + // A fixture view for renderer tests — ignores any props it receives. + let { label = "Alpha" }: { label?: string } = $props(); +</script> + +<div data-testid="fixture-a">{label}</div> diff --git a/src/features/layout/ui/fixtures/FixtureB.svelte b/src/features/layout/ui/fixtures/FixtureB.svelte new file mode 100644 index 0000000..1c48584 --- /dev/null +++ b/src/features/layout/ui/fixtures/FixtureB.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + // A fixture view for renderer tests — ignores any props it receives. + let { label = "Beta" }: { label?: string } = $props(); +</script> + +<div data-testid="fixture-b">{label}</div> diff --git a/src/features/layout/ui/fixtures/FixtureC.svelte b/src/features/layout/ui/fixtures/FixtureC.svelte new file mode 100644 index 0000000..2bceb67 --- /dev/null +++ b/src/features/layout/ui/fixtures/FixtureC.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + // A fixture view for renderer tests — ignores any props it receives. + let { label = "Gamma" }: { label?: string } = $props(); +</script> + +<div data-testid="fixture-c">{label}</div> diff --git a/src/features/layout/ui/panel-motion.ts b/src/features/layout/ui/panel-motion.ts new file mode 100644 index 0000000..7fc5edf --- /dev/null +++ b/src/features/layout/ui/panel-motion.ts @@ -0,0 +1,83 @@ +import { gsap } from "gsap"; +import type { TransitionConfig } from "svelte/transition"; + +/** + * GSAP height animations for sidebar panels — the DOM EFFECT half (stable + * panel identity lives in `../logic/panel-keys.ts`). + * + * Every animation changes ONLY the panel wrapper's height (clipped with + * `overflow: hidden`); the panel's contents keep their natural size the whole + * time — the wrapper reveals / hides them like a drawer, it never scales + * them. The wrapper's bottom padding is the gap between panels, so it grows / + * shrinks with the panel and the panels below slide smoothly. + * + * - `panelGrowIn` — Svelte `in:` transition: an added panel grows 0 → auto. + * - `panelShrinkOut` — Svelte `out:` transition: a closed panel shrinks to 0 + * while staying in flow (Svelte keeps the node for the returned duration). + * - `morphPanelHeight` — a swapped view: the panel BOX (the well, so its + * edges move with it — no empty gap below a shrinking box) animates from + * the old view's height to the new one's. + * + * Svelte's `in:` does not run on first mount, so loading the page never + * animates. Disabled when the user prefers reduced motion (or the + * environment can't tell, e.g. jsdom) — then every change is instant. + */ + +const DURATION = 0.35; +const EASE = "power2.inOut"; + +export function panelMotionAllowed(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +export function panelGrowIn(node: HTMLElement): TransitionConfig { + if (!panelMotionAllowed()) return { duration: 0 }; + gsap.killTweensOf(node); + gsap.fromTo( + node, + { height: 0, overflow: "hidden" }, + { + height: "auto", + duration: DURATION, + ease: EASE, + clearProps: "height,overflow", + }, + ); + return { duration: DURATION * 1000 }; +} + +export function panelShrinkOut(node: HTMLElement): TransitionConfig { + if (!panelMotionAllowed()) return { duration: 0 }; + // e.g. closed mid-grow / mid-swap: shrink from wherever it is. + gsap.killTweensOf([node, ...node.querySelectorAll(":scope > *")]); + gsap.to(node, { + height: 0, + overflow: "hidden", + duration: DURATION, + ease: EASE, + }); + return { duration: DURATION * 1000 }; +} + +/** + * Animate `node` from `fromHeight` (its height before the view swapped) to + * its new natural height. Call AFTER the DOM shows the new view. + */ +export function morphPanelHeight(node: HTMLElement, fromHeight: number): void { + if (!panelMotionAllowed()) return; + gsap.killTweensOf(node); + gsap.fromTo( + node, + { height: fromHeight, overflow: "hidden" }, + { + height: "auto", + duration: DURATION, + ease: EASE, + clearProps: "height,overflow", + }, + ); +} diff --git a/src/features/markdown/adapter/text-file.test.ts b/src/features/markdown/adapter/text-file.test.ts new file mode 100644 index 0000000..2597271 --- /dev/null +++ b/src/features/markdown/adapter/text-file.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TextFileState } from "../types"; +import { createTextFileClient } from "./text-file"; + +const response = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }); +const context = { conversationId: "c", cwd: "/project", computerId: null }; +const file = (text = "# Plan") => ({ + path: "/project/a.md", + text, + format: "markdown", + bytes: new TextEncoder().encode(text).length, + maxBytes: 262144, +}); +const flush = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); +}; +describe("context-bound live-file client", () => { + it("preflights once, sends both guards and encodes literal path once; dedups repeated references", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockImplementation(async (url) => + response(String(url).endsWith("-context") ? context : file()), + ); + const client = createTextFileClient("/api", "c", fetcher); + const a: TextFileState[] = [], + b: TextFileState[] = []; + const offA = client.subscribe("notes/100%.md", (s) => a.push(s)); + const offB = client.subscribe("notes/100%.md", (s) => b.push(s)); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(2); + const url = new URL(String(fetcher.mock.calls[1]?.[0]), "http://localhost"); + expect(Object.fromEntries(url.searchParams)).toEqual({ + path: "notes/100%.md", + expectedCwd: "/project", + expectedComputerId: "", + }); + expect(fetcher.mock.calls[1]?.[1]?.cache).toBe("no-store"); + expect(a.at(-1)?.kind).toBe("ready"); + expect(b.at(-1)?.kind).toBe("ready"); + offA(); + expect(fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(false); + offB(); + expect(fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + client.subscribe("notes/100%.md", () => {}); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(4); + client.dispose(); + }); + it("never reads a file before context, handles remote inheritance explicitly", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValue(response({ ...context, computerId: "remote" })); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("/same-local-path.md", (s) => states.push(s)); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(states.at(-1)).toMatchObject({ + kind: "error", + message: expect.stringContaining("remote computers"), + }); + client.dispose(); + }); + it.each([ + 400, 404, 409, 413, 422, + ])("reports HTTP %i then Retry refreshes authoritative context and live file", async (status) => { + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response({ error: `failure ${status}` }, status)) + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("changed"))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + expect(states.at(-1)).toEqual({ kind: "error", message: `failure ${status}` }); + client.refresh("a.md"); + await flush(); + expect(states.at(-1)).toMatchObject({ kind: "ready", file: { text: "changed" } }); + expect(fetcher).toHaveBeenCalledTimes(4); + client.dispose(); + }); + it("guards late responses after refresh and disposal", async () => { + let finish: (r: Response) => void = () => {}; + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(response(context)) + .mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ) + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("new"))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + client.refresh("a.md"); + await flush(); + finish(response(file("old"))); + await flush(); + expect(states.at(-1)).toMatchObject({ file: { text: "new" } }); + client.dispose(); + expect(fetcher.mock.calls[3]?.[1]?.signal?.aborted).toBe(true); + }); + it("bounds active cache to32 files/8MiB source and concurrency to4, disposes queue", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockImplementation(async (url) => + String(url).endsWith("-context") ? response(context) : new Promise(() => {}), + ); + const client = createTextFileClient("", "c", fetcher); + const overflow: TextFileState[] = []; + for (let i = 0; i < 40; i++) + client.subscribe(`${i}.md`, (s) => { + if (i >= 32) overflow.push(s); + }); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(5); + expect(overflow).toHaveLength(8); + expect(overflow[0]).toMatchObject({ kind: "error" }); + client.dispose(); + for (const call of fetcher.mock.calls) expect(call[1]?.signal?.aborted).toBe(true); + }); + it("rejects oversized or malformed success data instead of truncating", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("a".repeat(262145)))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + expect(states.at(-1)?.kind).toBe("error"); + client.dispose(); + }); +}); + +it("invalidates every live document and reloads guards before publishing refreshed context", async () => { + let generation = 0; + const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url) => { + if (String(url).endsWith("-context")) + return response({ ...context, cwd: generation ? "/new" : "/project" }); + const params = new URL(String(url), "http://localhost").searchParams; + return response({ + ...file(generation ? "new" : "old"), + path: `${params.get("expectedCwd")}/a.md`, + }); + }); + const client = createTextFileClient("", "c", fetcher); + const a: TextFileState[] = [], + b: TextFileState[] = []; + client.subscribe("a.md", (state) => a.push(state)); + client.subscribe("b.md", (state) => b.push(state)); + await flush(); + generation++; + client.refresh("a.md"); + expect(a.at(-1)?.kind).toBe("loading"); + expect(b.at(-1)?.kind).toBe("loading"); + await flush(); + expect(a.at(-1)).toMatchObject({ kind: "ready", file: { path: "/new/a.md", text: "new" } }); + expect(b.at(-1)).toMatchObject({ kind: "ready", file: { text: "new" } }); + expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith("-context"))).toHaveLength(2); + client.dispose(); +}); diff --git a/src/features/markdown/adapter/text-file.ts b/src/features/markdown/adapter/text-file.ts new file mode 100644 index 0000000..05ddb23 --- /dev/null +++ b/src/features/markdown/adapter/text-file.ts @@ -0,0 +1,171 @@ +import type { + TextFileContextResponse, + TextFileQuery, + TextFileResponse, +} from "@dispatch/transport-contract"; +import type { TextFileClient, TextFileState } from "../types"; + +const MAX_BYTES = 262144; +const MAX_ENTRIES = 32; +const MAX_PARALLEL = 4; +interface Entry { + readonly listeners: Set<(state: TextFileState) => void>; + state: TextFileState; + controller: AbortController; +} +/** Per-mounted-chat, bounded cache: shared only by live references, never persisted. */ +export function createTextFileClient( + apiBase: string, + conversationId: string, + fetchImpl: typeof fetch, +): TextFileClient { + const entries = new Map<string, Entry>(); + let disposed = false; + let contextController = new AbortController(); + let context: Promise<TextFileContextResponse> | null = null; + let active = 0; + const queue = new Map<string, () => void>(); + const endpoint = `${apiBase.replace(/\/$/, "")}/conversations/${encodeURIComponent(conversationId)}`; + async function json(url: string, signal: AbortSignal): Promise<unknown> { + const response = await fetchImpl(url, { signal, cache: "no-store" }); + const data: unknown = await response.json(); + if (!response.ok) { + const error = data as { error?: string }; + throw new Error(error.error ?? `Text file request failed (HTTP ${response.status})`); + } + return data; + } + function getContext(): Promise<TextFileContextResponse> { + context ??= json(`${endpoint}/text-file-context`, contextController.signal).then((data) => { + const c = data as TextFileContextResponse; + if ( + c.conversationId !== conversationId || + !(c.cwd === null || typeof c.cwd === "string") || + !(c.computerId === null || typeof c.computerId === "string") + ) { + throw new Error("Invalid text file context response"); + } + return c; + }); + return context; + } + function publish(entry: Entry, state: TextFileState): void { + entry.state = state; + for (const listener of entry.listeners) listener(state); + } + async function load(path: string, entry: Entry, controller: AbortController): Promise<void> { + try { + if (controller.signal.aborted || disposed) return; + const c = await getContext(); + if (controller.signal.aborted || disposed) return; + if (c.computerId !== null) + throw new Error( + "Text-file embeds are not available for remote computers. No local file was read.", + ); + const query: TextFileQuery = { + path, + expectedCwd: c.cwd ?? "", + expectedComputerId: c.computerId ?? "", + }; + const params = new URLSearchParams(query as unknown as Record<string, string>); + const data = (await json( + `${endpoint}/text-file?${params}`, + controller.signal, + )) as TextFileResponse; + if ( + typeof data.text !== "string" || + typeof data.path !== "string" || + !data.path.startsWith("/") || + !["markdown", "text"].includes(data.format) || + data.maxBytes !== MAX_BYTES || + !Number.isInteger(data.bytes) || + data.bytes < 0 || + data.bytes > MAX_BYTES || + new TextEncoder().encode(data.text).length !== data.bytes + ) { + throw new Error("Invalid or oversized text file response (maximum 256 KiB)"); + } + if (!controller.signal.aborted && !disposed) publish(entry, { kind: "ready", file: data }); + } catch (error) { + if (!controller.signal.aborted && !disposed) + publish(entry, { + kind: "error", + message: error instanceof Error ? error.message : "Unable to read text file", + }); + } + } + function drain(): void { + while (active < MAX_PARALLEL && queue.size) { + const next = queue.entries().next().value; + if (!next) return; + queue.delete(next[0]); + next[1](); + } + } + function start(path: string, entry: Entry): void { + entry.controller.abort(); + const controller = new AbortController(); + entry.controller = controller; + publish(entry, { kind: "loading" }); + queue.set(path, () => { + if (controller.signal.aborted || disposed) return; + active++; + void load(path, entry, controller).finally(() => { + active--; + drain(); + }); + }); + drain(); + } + return { + subscribe(path, listener) { + if (disposed || (!entries.has(path) && entries.size >= MAX_ENTRIES)) { + listener({ + kind: "error", + message: "Too many text-file embeds (maximum 32 active files).", + }); + return () => {}; + } + let entry = entries.get(path); + if (!entry) { + entry = { + listeners: new Set(), + state: { kind: "loading" }, + controller: new AbortController(), + }; + entries.set(path, entry); + start(path, entry); + } + entry.listeners.add(listener); + listener(entry.state); + const current = entry; + return () => { + current.listeners.delete(listener); + if (!current.listeners.size) { + current.controller.abort(); + entries.delete(path); + queue.delete(path); + } + if (!entries.size) { + contextController.abort(); + contextController = new AbortController(); + context = null; + } + }; + }, + refresh(_path) { + // Refresh can reveal a context change: invalidate ALL live content before rereading. + contextController.abort(); + contextController = new AbortController(); + context = null; + for (const [path, entry] of entries) start(path, entry); + }, + dispose() { + disposed = true; + contextController.abort(); + for (const entry of entries.values()) entry.controller.abort(); + entries.clear(); + queue.clear(); + }, + }; +} diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts index ff3aefa..2b50ab1 100644 --- a/src/features/markdown/index.ts +++ b/src/features/markdown/index.ts @@ -6,3 +6,6 @@ export const manifest = { name: "markdown", description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)", } as const; + +export { createTextFileClient } from "./adapter/text-file"; +export type { TextFileClient, TextFileContext, TextFileState } from "./types"; diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts index 54b0086..f186da9 100644 --- a/src/features/markdown/logic/markdown.test.ts +++ b/src/features/markdown/logic/markdown.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { renderMarkdown } from "./markdown"; +import { renderMarkdown, renderMarkdownTail } from "./markdown"; +import { advance, createStreamingMarkdown } from "./streaming-markdown"; + +/** The single rendered `<img>` src, or null. */ +function imageSrc(html: string): string | null { + const doc = new DOMParser().parseFromString(html, "text/html"); + return doc.querySelector("img")?.getAttribute("src") ?? null; +} describe("renderMarkdown", () => { it("renders GFM markdown (headings, emphasis)", () => { @@ -25,6 +32,46 @@ describe("renderMarkdown", () => { expect(html).toContain("<b>"); }); + describe("links open in a new tab", () => { + function anchors(html: string): HTMLAnchorElement[] { + const doc = new DOMParser().parseFromString(html, "text/html"); + return Array.from(doc.querySelectorAll("a")); + } + + it.each([ + ["a markdown link", "See [the docs](https://example.com/docs)."], + ["a GFM autolink", "Visit https://example.com today"], + ["an angle-bracket autolink", "<https://example.com>"], + ["a raw-HTML anchor", 'Raw <a href="https://example.com">link</a>'], + ["a relative link", "[home](/settings)"], + ])("%s gets target=_blank + rel=noopener noreferrer", (_label, src) => { + const links = anchors(renderMarkdown(src)); + expect(links).toHaveLength(1); + expect(links[0]?.getAttribute("target")).toBe("_blank"); + expect(links[0]?.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("overrides an author-supplied target/rel", () => { + const [link] = anchors( + renderMarkdown('<a href="https://example.com" target="_self" rel="opener">x</a>'), + ); + expect(link?.getAttribute("target")).toBe("_blank"); + expect(link?.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("applies while streaming too", () => { + const [link] = anchors( + renderMarkdown("partial [link](https://example.com) **bo", { streaming: true }), + ); + expect(link?.getAttribute("target")).toBe("_blank"); + }); + + it("still strips javascript: hrefs", () => { + const html = renderMarkdown('<a href="javascript:alert(1)">x</a>'); + expect(html).not.toContain("javascript:"); + }); + }); + it("sanitizes dangerous HTML", () => { const html = renderMarkdown("Hi <script>alert(1)</script> there"); expect(html).not.toContain("<script>"); @@ -55,4 +102,271 @@ describe("renderMarkdown", () => { it("returns an empty string for empty input", () => { expect(renderMarkdown("")).toBe(""); }); + + describe("remote images require consent", () => { + const image = { apiBase: "http://localhost:24203", cwd: "/work" }; + + it.each([ + "", + "", + "", + '<img src="https://evil.example/pixel" onerror="alert(1)">', + '<img src="//evil.example/pixel">', + '<img src="https: //evil.example/pixel">', + '<img src="/\\evil.example/pixel">', + ])("replaces %s with an inert, host-labelled button", (text) => { + const doc = new DOMParser().parseFromString(renderMarkdown(text, { image }), "text/html"); + expect(doc.querySelector("img")).toBeNull(); + const button = doc.querySelector("button.remote-image"); + expect(button?.textContent).toBe("Load image from evil.example"); + expect(button?.getAttribute("type")).toBe("button"); + expect(button?.getAttribute("data-src")).toMatch(/^https?:\/\/evil\.example\//); + expect(button?.hasAttribute("onerror")).toBe(false); + }); + + it.each([ + "/local-image?path=%2Ftmp%2Fa.png", + "data:image/png;base64,iVBORw0KGgo=", + `${window.location.origin}/image.png`, + "http://localhost:24203/images/conversation/image.png", + ])("keeps trusted image %s", (src) => { + expect(imageSrc(renderMarkdown(`<img src="${src}">`, { image }))).toBe(src); + }); + + it("compares exact origins, not hostname prefixes or URL credentials", () => { + for (const src of [ + "http://localhost:24203.evil.example/a.png", + "http://localhost:[email protected]/a.png", + "http://localhost:24206/a.png", + "https://localhost:24203/a.png", + ]) { + expect(imageSrc(renderMarkdown(`<img src="${src}">`, { image }))).toBeNull(); + } + }); + + it("does not leak a backend allowlist between renders", () => { + const text = ""; + expect(imageSrc(renderMarkdown(text, { image }))).not.toBeNull(); + expect(imageSrc(renderMarkdown(text))).toBeNull(); + }); + + it("removes responsive image sources so srcset cannot bypass consent", () => { + const html = renderMarkdown( + '<picture><source srcset="https://evil.example/a.png"><img src="/safe.png" srcset="https://evil.example/b.png 2x"></picture>', + ); + expect(html).not.toContain("srcset"); + expect(html).not.toContain("evil.example"); + }); + + it("blocks images in streaming tails and settled prefixes too", () => { + const state = createStreamingMarkdown(); + const text = ""; + expect(imageSrc(renderMarkdownTail(text))).toBeNull(); + expect(advance(state, text).tailHtml).toContain("remote-image"); + const settled = advance(state, `${text}\n\nafter\n`); + expect(settled.prefixHtml).toContain("remote-image"); + expect(imageSrc(settled.prefixHtml)).toBeNull(); + }); + + it("does not affect other DOMPurify consumers", async () => { + const { default: purify } = await import("dompurify"); + expect(imageSrc(purify.sanitize('<img src="https://evil.example/a.png">'))).not.toBeNull(); + }); + }); + + describe("raw HTML cannot load remote content outside image consent", () => { + const remote = "https://evil.example/beacon"; + const vectors = [ + ["video poster", `<video poster="${remote}"></video>`, "video"], + ["video src", `<video src="${remote}" autoplay></video>`, "video"], + ["audio src", `<audio src="${remote}" preload="auto"></audio>`, "audio"], + ["source src", `<video><source src="${remote}"></video>`, "source"], + ["source srcset", `<picture><source srcset="${remote} 2x"></picture>`, "source"], + ["track src", `<video><track src="${remote}" default></video>`, "track"], + ["image input", `<input type="image" src="${remote}">`, "input"], + ["input src without type", `<input src="${remote}">`, "input"], + ["inline background", `<div style="background-image:url(${remote})">text</div>`, "[style]"], + ["inline CSS escape", `<p style="background: u\\72l(${remote})">text</p>`, "[style]"], + [ + "inline image-set", + `<span style="background:image-set('${remote}' 1x)">text</span>`, + "[style]", + ], + ["style import", `<style>@import '${remote}';</style>`, "style"], + ["SVG image href", `<svg><image href="${remote}" /></svg>`, "svg,image"], + ["SVG image xlink", `<svg><image xlink:href="${remote}" /></svg>`, "svg,image"], + ["SVG use href", `<svg><use href="${remote}#x" /></svg>`, "svg,use"], + ["SVG use xlink", `<svg><use xlink:href="${remote}#x" /></svg>`, "svg,use"], + ["SVG filter", `<svg><filter><feImage href="${remote}" /></filter></svg>`, "svg,feImage"], + ["MathML", `<math href="${remote}"><mi>x</mi></math>`, "math"], + [ + "table background", + `<table background="${remote}"><tr><td>x</td></tr></table>`, + "[background]", + ], + [ + "td background", + `<table><tr><td background="${remote}">x</td></tr></table>`, + "[background]", + ], + ["body background", `<body background="${remote}">x</body>`, "[background]"], + ["object data", `<object data="${remote}"></object>`, "object"], + ["embed src", `<embed src="${remote}">`, "embed"], + ["embed data", `<embed data="${remote}">`, "embed"], + ["form action", `<form action="${remote}"><button>go</button></form>`, "form,[action]"], + ["button formaction", `<button formaction="${remote}">go</button>`, "[formaction]"], + ["input formaction", `<input type="submit" formaction="${remote}">`, "input,[formaction]"], + ["stylesheet link", `<link rel="stylesheet" href="${remote}">`, "link"], + ["preload link", `<link rel="preload" as="image" href="${remote}">`, "link"], + ["prefetch link", `<link rel="prefetch" href="${remote}">`, "link"], + ["DNS prefetch", `<link rel="dns-prefetch" href="//evil.example">`, "link"], + ["meta refresh", `<meta http-equiv="refresh" content="0;url=${remote}">`, "meta"], + ["iframe src", `<iframe src="${remote}"></iframe>`, "iframe"], + [ + "iframe srcdoc", + `<iframe srcdoc="<img src='${remote}'>"></iframe>`, + "iframe,[srcdoc]", + ], + ["base URL", `<base href="${remote}/"><img src="/safe.png">`, "base"], + ["script src", `<script src="${remote}"></script>`, "script"], + ["anchor ping", `<a href="/safe" ping="${remote}">link</a>`, "[ping]"], + ["legacy command", `<command icon="${remote}">`, "command"], + ["legacy applet", `<applet codebase="${remote}" archive="x.jar"></applet>`, "applet"], + ["non-image src", `<div src="${remote}" href="${remote}">text</div>`, "[src],[href]"], + ] as const; + + // Inspect the returned DOM, not jsdom's lack of resource fetching. Both + // authoritative renders and streaming tails must remove each active sink. + describe.each([ + ["full", renderMarkdown], + ["tail", renderMarkdownTail], + ] as const)("%s render", (_name, render) => { + it.each(vectors)("neutralizes %s", (_vector, text, forbidden) => { + const doc = new DOMParser().parseFromString(render(text), "text/html"); + expect(doc.querySelector(forbidden)).toBeNull(); + for (const element of doc.body.querySelectorAll("*")) { + for (const attr of element.attributes) { + if (attr.name === "href" && element.tagName === "A") continue; + expect(attr.value).not.toContain("evil.example"); + } + } + }); + }); + + it("also blocks all vectors in settled streaming prefixes", () => { + for (const [_name, text, forbidden] of vectors) { + const state = createStreamingMarkdown(); + advance(state, `${text}\n\ntail`); + const result = advance(state, `${text}\n\ntail grows`); + const doc = new DOMParser().parseFromString( + result.prefixHtml + result.tailHtml, + "text/html", + ); + expect(doc.querySelector(forbidden)).toBeNull(); + } + }); + + it("retains passive Markdown structure, highlighting and disabled task checkboxes", () => { + const html = renderMarkdown( + "- [x] done\n- [ ] pending\n\n| A | B |\n| - | - |\n| x | y |\n\n```js\nconst x = 1;\n```", + ); + const doc = new DOMParser().parseFromString(html, "text/html"); + expect(doc.querySelectorAll('input[type="checkbox"][disabled]')).toHaveLength(2); + expect(doc.querySelector("input[checked]")).not.toBeNull(); + expect(doc.querySelectorAll("td")).toHaveLength(2); + expect(doc.querySelector(".hljs-keyword")).not.toBeNull(); + expect(doc.querySelector('button[data-copy][type="button"]')).not.toBeNull(); + }); + + it("makes raw checkboxes and buttons inert, even inside an outer form", () => { + const doc = new DOMParser().parseFromString( + renderMarkdown('<input type="checkbox"><button type="submit">go</button>'), + "text/html", + ); + expect(doc.querySelector("input")?.hasAttribute("disabled")).toBe(true); + expect(doc.querySelector("button")?.getAttribute("type")).toBe("button"); + }); + + it("does not permit non-HTTP network schemes on images", () => { + expect(imageSrc(renderMarkdown('<img src="ftp://evil.example/beacon">'))).toBeNull(); + }); + }); + + describe("local-path images", () => { + const BASE = "http://localhost:24203"; + const image = { apiBase: BASE, cwd: "/w/foo" }; + + it("rewrites a file:// URL to the backend image route", () => { + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`, + ); + }); + + it("rewrites an absolute path; an empty apiBase yields a root-relative src", () => { + expect(imageSrc(renderMarkdown("", { image: { apiBase: "" } }))).toBe( + "/local-image?path=%2Fhome%2Fu%2Fpic.png", + ); + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`, + ); + }); + + it("resolves a relative path against the conversation cwd", () => { + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`, + ); + expect( + imageSrc(renderMarkdown("", { image: { apiBase: BASE, cwd: "/w/foo/" } })), + ).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`); + }); + + it("leaves a relative path untouched when no cwd is available", () => { + expect(imageSrc(renderMarkdown("", { image: { apiBase: BASE } }))).toBe( + "shot.png", + ); + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`, + ); + expect(imageSrc(renderMarkdown(""))).toBe("shot.png"); + }); + + it("leaves data: images untouched", () => { + const data = "data:image/png;base64,iVBORw0KGgo="; + expect(imageSrc(renderMarkdown(``, { image }))).toBe(data); + }); + + it("does not rewrite image syntax inside fenced code", () => { + const html = renderMarkdown("```md\n\n```", { image }); + expect(html).toContain("/home/u/pic.png"); + expect(html).not.toContain("/local-image"); + }); + + it("rewrites the streaming tail, the settled prefix, and a seeded tail", () => { + const state = createStreamingMarkdown(); + const tail = advance(state, "", { image }); + expect(imageSrc(tail.tailHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`); + + // The settled prefix must render with the same options too. + const settled = advance(state, "\n\nnext\n", { image }); + expect(imageSrc(settled.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`); + + const seeded = renderMarkdownTail( + "", + { fences: 0, bold: 0, code: 0 }, + { + image, + }, + ); + expect(imageSrc(seeded)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`); + }); + + it("invalidates the streaming cache when the image options change (async cwd)", () => { + const state = createStreamingMarkdown(); + const text = "\n\nnext\n"; + advance(state, text); + expect(imageSrc(state.prefixHtml)).toBe("shot.png"); // no cwd yet + const updated = advance(state, text, { image }); + expect(imageSrc(updated.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`); + }); + }); }); diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts index ad8a8bd..ed287d2 100644 --- a/src/features/markdown/logic/markdown.ts +++ b/src/features/markdown/logic/markdown.ts @@ -36,6 +36,7 @@ import xml from "highlight.js/lib/languages/xml"; import yaml from "highlight.js/lib/languages/yaml"; import { Marked } from "marked"; import { markedHighlight } from "marked-highlight"; +import { resolveImageUrl } from "../../../core/chunks"; // Hot set: registered eagerly so common code blocks highlight on first paint. const HOT_LANGUAGES: Record<string, LanguageFn> = { @@ -125,19 +126,50 @@ const md = new Marked( /** * While a message is still streaming, balance dangling fences / emphasis so the - * partial text renders cleanly instead of flashing raw markers. + * partial text renders cleanly instead of flashing raw markers. `seed` carries + * the delimiter counts of the ALREADY-SETTLED prefix (see + * `logic/streaming-markdown`): the full-text streaming render appends markers + * at the very END of the whole source, so a tail rendered alone must count the + * prefix's parity to append in exactly the same place (or not) — otherwise a + * settled paragraph with a lone `**` would transiently render differently. */ -function closeOpenDelimiters(src: string): string { +function closeOpenDelimiters( + src: string, + seed: DelimiterCounts = { fences: 0, bold: 0, code: 0 }, +): string { let out = src; - const fenceCount = (out.match(/^```/gm) ?? []).length; + const fenceCount = (out.match(/^```/gm) ?? []).length + seed.fences; if (fenceCount % 2 !== 0) out += "\n```"; - const boldCount = (out.match(/\*\*/g) ?? []).length; + const boldCount = (out.match(/\*\*/g) ?? []).length + seed.bold; if (boldCount % 2 !== 0) out += "**"; - const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length; + const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length + seed.code; if (inlineCode % 2 !== 0) out += "`"; return out; } +/** + * Counts of the delimiter constructs {@link closeOpenDelimiters} balances, + * using the SAME regexes — the streaming checkpoint module accumulates them + * over settled blocks and seeds the tail's balance pass with the total, so the + * tail's appended markers land exactly where a whole-text render would put + * them. Pure: `input → output`. + */ +export interface DelimiterCounts { + readonly fences: number; + readonly bold: number; + readonly code: number; +} + +const ZERO_COUNTS: DelimiterCounts = { fences: 0, bold: 0, code: 0 }; + +export function countDelimiters(src: string): DelimiterCounts { + return { + fences: (src.match(/^```/gm) ?? []).length, + bold: (src.match(/\*\*/g) ?? []).length, + code: (src.match(/(?<!`)`(?!`)/g) ?? []).length, + }; +} + // Wrap each fenced code block (`<pre>…</pre>`) in a positioned container with a // copy button. marked emits exactly one `<pre>`/`</pre>` pair per block and // escapes `<`/`>` inside code, so these literal tags only ever delimit blocks. @@ -153,12 +185,309 @@ function addCopyButtons(html: string): string { .replace(/<\/pre>/g, "</pre></div>"); } +// Positive policy: chat HTML is passive Markdown structure, not arbitrary web +// content. New browser elements/attributes must not silently add fetch sinks. +// In particular there is no CSS, SVG/MathML, media, form, embed or metadata. +const MARKDOWN_TAGS = [ + "a", + "abbr", + "b", + "blockquote", + "br", + "caption", + "code", + "col", + "colgroup", + "dd", + "del", + "details", + "div", + "dl", + "dt", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "i", + "img", + "kbd", + "li", + "mark", + "ol", + "p", + "pre", + "q", + "rp", + "rt", + "ruby", + "s", + "samp", + "small", + "span", + "strong", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "u", + "ul", + "var", + "wbr", + "button", + "input", +]; +const MARKDOWN_ATTRIBUTES = [ + "alt", + "align", + "checked", + "class", + "colspan", + "disabled", + "height", + "href", + "open", + "reversed", + "rowspan", + "scope", + "src", + "start", + "title", + "type", + "width", +]; + +/** + * A dedicated, render-local DOMPurify instance: hooks never affect another + * consumer or retain a conversation's backend allowlist. The document base is + * part of the sanitizer's existing DOM dependency; no location/state is cached. + */ +function sanitize(html: string, image?: MarkdownImageOptions): string { + const purify = DOMPurify(); + const base = document.baseURI; + const origin = new URL(base).origin; + const backendOrigin = image === undefined ? origin : new URL(image.apiBase || base, base).origin; + purify.addHook("uponSanitizeElement", (node) => { + // Keep GFM task lists, but no image/submit/other form controls. Removing + // only type/src would allow browser defaulting or later attribute changes. + if ( + node.nodeName === "INPUT" && + (node as Element).getAttribute("type")?.toLowerCase() !== "checkbox" + ) { + node.parentNode?.removeChild(node); + } + }); + purify.addHook("afterSanitizeAttributes", (node) => { + // URL-bearing attributes are supported ONLY on these two HTML elements. + if (node.tagName !== "A") node.removeAttribute("href"); + if (node.tagName !== "IMG") node.removeAttribute("src"); + if (node.tagName === "INPUT") node.setAttribute("disabled", ""); + if (node.tagName === "BUTTON") node.setAttribute("type", "button"); + if (node.tagName === "A" && node.hasAttribute("href")) { + node.setAttribute("target", "_blank"); + node.setAttribute("rel", "noopener noreferrer"); + } + if (node.tagName !== "IMG") return; + const src = node.getAttribute("src"); + if (src === null) return; + let url: URL; + try { + url = new URL(src, base); + } catch { + node.removeAttribute("src"); + return; + } + if (url.protocol === "data:") return; // Image context cannot load subresources. + if (url.protocol !== "http:" && url.protocol !== "https:") { + node.removeAttribute("src"); + return; + } + if (url.origin === origin || url.origin === backendOrigin) return; + + // Only safe, inert attributes are carried over. In particular, never copy + // srcset or author event handlers to the image created after consent. + const button = node.ownerDocument.createElement("button"); + button.setAttribute("type", "button"); + button.setAttribute("class", "remote-image btn btn-sm"); + button.setAttribute("data-src", url.href); + button.setAttribute("data-alt", node.getAttribute("alt") ?? ""); + const title = node.getAttribute("title"); + if (title !== null) button.setAttribute("data-title", title); + button.textContent = `Load image from ${url.host}`; + node.parentNode?.replaceChild(button, node); + }); + return purify.sanitize(html, { + ALLOWED_TAGS: MARKDOWN_TAGS, + ALLOWED_ATTR: MARKDOWN_ATTRIBUTES, + // Keep consent/copy data and accessibility labels, never active URL sinks + // such as srcset, style, background, ping, action or namespaced href. + ALLOW_DATA_ATTR: true, + ALLOW_ARIA_ATTR: true, + }); +} + +/** + * Per-conversation context for rendering local-path images inline. `apiBase` + * is the backend HTTP base (may be `""` → a root-relative `/local-image?…`); + * `cwd` resolves RELATIVE Markdown image srcs against the conversation's + * working directory (the same machine the backend serves files from). + */ +export interface MarkdownImageOptions { + readonly apiBase: string; + readonly cwd?: string | null; +} + +/** Render options shared by all three render paths. */ +export interface MarkdownRenderOptions { + /** Balance dangling delimiters while the message is still generating. */ + readonly streaming?: boolean; + /** Rewrite local-path image srcs through the backend's `/local-image` route. */ + readonly image?: MarkdownImageOptions; +} + +/** Decode a URL-ish path, tolerating malformed `%` escapes. */ +function safeDecode(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + +/** + * Rewrite one image `src` for the local-image feature. Pure: `input → output`. + * + * - `file:///path` → local absolute path (scheme stripped). + * - `/absolute/path` → local absolute path. + * - a relative path (no scheme, no leading `/`) → joined to the conversation + * `cwd` (no `cwd` → left untouched). + * - `http(s)://…`, `data:`, and any other scheme → left untouched. + * + * Local paths become `${apiBase}/local-image?path=<encoded>`, reusing + * `resolveImageUrl`'s join-without-double-slash semantics (an empty `apiBase` + * yields a root-relative path). + */ +function rewriteImageSrc(src: string, image: MarkdownImageOptions): string { + const trimmed = src.trim(); + if (trimmed.length === 0) return src; + // Network-path URLs (including browser-normalized backslashes) are URLs, + // not local filesystem paths. The sanitizer applies the consent policy. + if (/^(?:data:|https?:\/\/)/i.test(trimmed) || /^[/\\]{2}/.test(trimmed)) return src; + + let path: string; + + if (/^file:\/\//i.test(trimmed)) { + // file:///abs/path (and file://localhost/abs/path) → /abs/path. + const match = /^file:\/\/([^/]*)(\/.*)?$/i.exec(trimmed); + if (match === null) return src; + const host = match[1] ?? ""; + const filePath = match[2] ?? "/"; + path = host === "" || host.toLowerCase() === "localhost" ? filePath : `/${host}${filePath}`; + path = safeDecode(path); + } else if (trimmed.startsWith("/")) { + path = safeDecode(trimmed); + } else if (trimmed.startsWith("~/")) { + // The backend expands a leading `~`; pass it through un-joined. + path = trimmed; + } else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) { + // Some other scheme (mailto:, ftp:, …) — leave it alone. + return src; + } else { + const cwd = image.cwd ?? null; + if (cwd === null || cwd.length === 0) return src; + const base = cwd.replace(/\/+$/, ""); + path = `${base}/${safeDecode(trimmed.replace(/^\.\//, ""))}`; + } + + return resolveImageUrl(`/local-image?path=${encodeURIComponent(path)}`, image.apiBase); +} + +/** + * Parse with marked, optionally rewriting image token `href`s just before + * rendering. Doing it on the token stream (not the Markdown source) means + * fenced/inline code is never touched, and reference-style images resolve + * through marked's normal link resolution. When `image` is absent the parse is + * byte-identical to today's. + */ +function parseMarkdown(src: string, image?: MarkdownImageOptions): string { + if (image === undefined) return md.parse(src) as string; + + const renderer = new md.Renderer(); + const imageRenderer = renderer.image; + renderer.image = (token) => + imageRenderer.call(renderer, { + ...token, + href: rewriteImageSrc(token.href, image), + }); + return md.parse(src, { renderer }) as string; +} + /** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */ -export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string { +export function renderMarkdown(text: string, opts?: MarkdownRenderOptions): string { const src = opts?.streaming === true ? closeOpenDelimiters(text) : text; try { - const raw = md.parse(src) as string; - return DOMPurify.sanitize(addCopyButtons(raw)); + return sanitize(addCopyButtons(parseMarkdown(src, opts?.image)), opts?.image); + } catch { + return ""; + } +} + +/** + * The streaming TAIL pipeline — today's exact pipeline for the UNSETTLED tail + * of a streaming message (`closeOpenDelimiters` → marked → copy buttons → + * DOMPurify), with the delimiter parity SEEDED from the settled prefix (see + * {@link countDelimiters}). With a zero seed this is byte-identical to + * `renderMarkdown(text, { streaming: true })`, so the checkpoint module's + * degenerate case (nothing settled yet) is exactly today's behavior. + * Returns `""` if parsing ever throws. + */ +export function renderMarkdownTail( + text: string, + seed: DelimiterCounts = ZERO_COUNTS, + opts?: MarkdownRenderOptions, +): string { + const src = closeOpenDelimiters(text, seed); + try { + return sanitize(addCopyButtons(parseMarkdown(src, opts?.image)), opts?.image); + } catch { + return ""; + } +} + +/** + * Escapes code content the way marked does inside `<code>` (only `&`, `<`, + * `>` — quotes stay literal) so the plain open-fence render below is + * character-identical to what the closed-fence pipeline would emit. + */ +function escapeCode(s: string): string { + return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); +} + +/** + * Plain (UN-highlighted) rendering of an OPEN fenced code block — the Level-2 + * streaming behavior: while a fence is still open its content is final-format + * escaped `<pre><code>` with NO hljs (hljs state spans lines — an unterminated + * string/block comment would recolor earlier lines, so line-incremental + * highlighting is unsound). The block is highlighted exactly ONCE, when it + * closes and settles into the checkpoint cache. Emits the same copy-button + * wrapper + `hljs language-X` classes as the closed pipeline (modulo color + * spans), so the pop-in on close is "just colour". + */ +export function renderOpenFence(info: string, code: string): string { + const lang = info.trim().split(/\s+/)[0] ?? ""; + const cls = lang === "" ? "hljs" : `hljs language-${escapeHtml(lang)}`; + const html = `<pre><code class="${cls}">${escapeCode(code)}</code></pre>`; + try { + return sanitize(addCopyButtons(html)); } catch { return ""; } diff --git a/src/features/markdown/logic/streaming-markdown.test.ts b/src/features/markdown/logic/streaming-markdown.test.ts new file mode 100644 index 0000000..931803b --- /dev/null +++ b/src/features/markdown/logic/streaming-markdown.test.ts @@ -0,0 +1,325 @@ +import hljs from "highlight.js/lib/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { countDelimiters, renderMarkdown, renderMarkdownTail, renderOpenFence } from "./markdown"; +import { advance, createStreamingMarkdown } from "./streaming-markdown"; + +/** + * Test-side normalizer for the equivalence contract. The checkpoint render may + * legitimately differ from today's whole-text streaming render in exactly two + * transient ways while streaming: + * 1. Level 2: an OPEN fence renders plain-escaped (no hljs spans) — unwrapping + * hljs spans on both sides makes them comparable ("just colour" is the + * accepted trade; the closed render is byte-identical). + * 2. Newlines BETWEEN top-level blocks (each rendered unit emits its own + * trailing newline) and marked's trailing newline inside `<code>` before an + * appended closing fence — inter-tag whitespace collapse handles both. + * Text-node content inside `<pre><code>` is untouched (`>…<` never matches + * escaped code content), so code bytes are still compared exactly. + */ +function normalizeHtml(html: string): string { + const host = document.createElement("div"); + host.innerHTML = html; + for (const span of [...host.querySelectorAll("span")]) { + if ([...span.classList].some((c) => c.startsWith("hljs"))) { + span.replaceWith(...span.childNodes); + } + } + return host.innerHTML + .replace(/>\s+</g, "><") + .replace(/\n+(?=<\/code>)/g, "") + .trim(); +} + +describe("streaming-markdown", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("degenerate case (nothing settled) is exactly today's streaming render", () => { + const state = createStreamingMarkdown(); + const text = "Some **bold** and `code` text"; + const { prefixHtml, tailHtml } = advance(state, text); + expect(prefixHtml).toBe(""); + expect(tailHtml).toBe(renderMarkdown(text, { streaming: true })); + }); + + it("settles a paragraph at a blank-line boundary and matches the full render", () => { + const state = createStreamingMarkdown(); + // The settle commits only once the boundary's lookahead line completes. + advance(state, "first para\n\nnext"); + expect(state.prefixHtml).toBe(""); + advance(state, "first para\n\nnext\n"); + // The settle point is the START of the blank run (the separator blanks + // lead the tail, where marked ignores them — the rendered HTML is the + // same either way). + expect(state.prefixHtml).toBe(renderMarkdown("first para\n")); + expect(state.settledOffset).toBe("first para\n".length); + // Equivalence against the whole-text render. + const { prefixHtml, tailHtml } = advance(state, "first para\n\nnext\n"); + expect(normalizeHtml(prefixHtml + tailHtml)).toBe( + normalizeHtml(renderMarkdown("first para\n\nnext\n", { streaming: true })), + ); + }); + + it("matches the full render while inside an open list (no checkpoints — degraded caching, never wrong)", () => { + const state = createStreamingMarkdown(); + const text = "- item a\n\n- item b\n\n- item c"; + let shown = ""; + for (let i = 1; i <= text.length; i++) { + const prefix = text.slice(0, i); + const r = advance(state, prefix); + shown = r.prefixHtml + r.tailHtml; + expect(normalizeHtml(shown)).toBe(normalizeHtml(renderMarkdown(prefix, { streaming: true }))); + } + }); + + it("highlight-exactly-once: zero hljs while a fence is open, one call when it closes", () => { + const spy = vi.spyOn(hljs, "highlight"); + const state = createStreamingMarkdown(); + + advance(state, "intro\n\n```js\nc"); + expect(spy).not.toHaveBeenCalled(); // open fence → plain escaped, no hljs + + advance(state, "intro\n\n```js\nconst x = 1;\n"); + expect(spy).not.toHaveBeenCalled(); // still open + + const closed = advance(state, "intro\n\n```js\nconst x = 1;\n```\n"); + expect(spy).toHaveBeenCalledTimes(1); // highlighted EXACTLY once, on close + expect(closed.prefixHtml).toContain("hljs-keyword"); // the settled fence is colored + // The open-fence tail is gone (nothing but prose after the fence). + expect(closed.tailHtml).toBe(renderMarkdownTail("", state.seed)); + + // Subsequent deltas do NOT re-highlight the settled block. + advance(state, "intro\n\n```js\nconst x = 1;\n```\nafter\n"); + advance(state, "intro\n\n```js\nconst x = 1;\n```\nafter\nmore\n"); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("open fence renders plain-escaped <pre><code> with the same classes/wrapper as the closed pipeline", () => { + const state = createStreamingMarkdown(); + const { prefixHtml, tailHtml } = advance(state, "intro\n\n```js\nconst x = 1;"); + expect(prefixHtml).toContain("intro"); + expect(tailHtml).toContain('<pre><code class="hljs language-js">'); + expect(tailHtml).toContain("data-copy"); // same copy-button wrapper + expect(tailHtml).not.toContain("hljs-keyword"); // uncolored while open + expect(tailHtml).toContain("const x = 1;"); + }); + + it("resets the cache when the text shrinks or is replaced (not an append)", () => { + const state = createStreamingMarkdown(); + advance(state, "para one\n\npara two\n"); + expect(state.prefixHtml).not.toBe(""); + const shrunk = advance(state, "para one\n\npara"); + expect(state.settledOffset).toBe(0); + expect(state.prefixHtml).toBe(""); + expect(shrunk.tailHtml).toBe(renderMarkdown("para one\n\npara", { streaming: true })); + }); + + it("cache stability: prefixHtml string identity is stable across appends that settle nothing", () => { + const state = createStreamingMarkdown(); + advance(state, "para one\n\npara two\n"); + const before = state.prefixHtml; + advance(state, "para one\n\npara two\ntail"); + advance(state, "para one\n\npara two\ntail more"); + expect(state.prefixHtml).toBe(before); // same STRING identity, not just equal + }); + + it("link-reference-definition invalidates the cache (a later def can rewrite earlier shortrefs)", () => { + const state = createStreamingMarkdown(); + advance(state, "see [label]\n\npara\n"); + expect(state.prefixHtml).not.toBe(""); + advance(state, "see [label]\n\npara\n\n[label]: https://example.com\n"); + // The definition line was scanned: the cache dropped, everything re-renders + // through the tail (whole-text parse resolves the shortref). + expect(state.settledOffset).toBe(0); + expect(state.prefixHtml).toBe(""); + const r = advance(state, "see [label]\n\npara\n\n[label]: https://example.com\n\nafter\n"); + expect(normalizeHtml(r.prefixHtml + r.tailHtml)).toBe( + normalizeHtml( + renderMarkdown("see [label]\n\npara\n\n[label]: https://example.com\n\nafter\n", { + streaming: true, + }), + ), + ); + }); + + it("does not checkpoint inside an unclosed HTML comment or <pre> block", () => { + const state = createStreamingMarkdown(); + const text = "<!-- a comment\n\nstill commented\n\nand more"; + let out = ""; + for (let i = 1; i <= text.length; i++) { + const r = advance(state, text.slice(0, i)); + out = r.prefixHtml + r.tailHtml; + expect(normalizeHtml(out)).toBe( + normalizeHtml(renderMarkdown(text.slice(0, i), { streaming: true })), + ); + } + expect(state.settledOffset).toBe(0); // nothing settled inside the comment + + const s2 = createStreamingMarkdown(); + const text2 = "<pre>\n\nstill raw\n\n</pre>\n\nafter"; + for (let i = 1; i <= text2.length; i++) { + const r = advance(s2, text2.slice(0, i)); + expect(normalizeHtml(r.prefixHtml + r.tailHtml)).toBe( + normalizeHtml(renderMarkdown(text2.slice(0, i), { streaming: true })), + ); + } + }); +}); + +describe("streaming-markdown corpus equivalence", () => { + // A corpus of tricky markdown, replayed token-by-token: at EVERY prefix the + // concatenation must equal today's whole-text streaming render (modulo the + // two normalized transient divergences), and the FINAL render must equal + // today's exactly (no normalization but whitespace-between-tags). + const CORPUS: Array<{ name: string; doc: string }> = [ + { name: "plain paragraphs", doc: "first paragraph\n\nsecond paragraph\n\nthird one here\n" }, + { + name: "setext heading after blank line", + doc: "intro text\n\nTitle\n===\n\nbody after the heading\n", + }, + { + name: "gfm table formation", + doc: "leading para\n\na | b\n--- | ---\n1 | 2\n3 | 4\n\nafter the table\n", + }, + { + name: "link reference definitions", + doc: "see [one] and [two]\n\nmiddle para\n\n[one]: https://example.com/a\n[two]: https://example.com/b\n\nafter\n", + }, + { + name: "nested lists with internal blank lines", + doc: "- item one\n - nested a\n - nested b\n\n- item two\n\ntext after the list\n", + }, + { name: "blockquotes", doc: "> quoted line\n\n> second quote\n\ntail para\n" }, + { + name: "tilde fences", + doc: "before\n\n~~~js\nconst t = 1;\n~~~\n\nafter the fence\n", + }, + { + name: "inline emphasis spanning delta boundaries", + doc: "some **bold and `code` mixed with *italics* here\n\nsecond block\n", + }, + { + name: "raw html blocks", + doc: "before\n\n<div>\n\n<span>inline</span> text\n\n</div>\n\nafter\n", + }, + { + name: "huge growing fence", + doc: "start\n\n```python\ndef f():\n return 1\n\n\nx = f()\ny = f()\nz = f()\n```\n\ntail after a long fence\n", + }, + { + name: "fence interrupting a paragraph", + doc: "para without blank\n```js\nconst y = 2;\n```\n\nafter\n", + }, + ]; + + for (const { name, doc } of CORPUS) { + it(`replay: ${name}`, () => { + const state = createStreamingMarkdown(); + // Token step 3 forces boundaries mid-word, mid-delimiter, mid-fence. + for (let i = 1; i <= doc.length; i += 3) { + const prefix = doc.slice(0, i); + const r = advance(state, prefix); + expect( + normalizeHtml(r.prefixHtml + r.tailHtml), + `divergence at prefix length ${i}: ${JSON.stringify(prefix)}`, + ).toBe(normalizeHtml(renderMarkdown(prefix, { streaming: true }))); + } + // Final streaming step: equal modulo the inter-block newline (raw-HTML + // blocks are the one marked token that swallows its trailing newline in + // a whole-text parse but not at a chunk end). + const final = advance(state, doc); + expect(normalizeHtml(final.prefixHtml + final.tailHtml)).toBe( + normalizeHtml(renderMarkdown(doc, { streaming: true })), + ); + // The BYTE-EXACT final output is the completion backstop's job (the + // component renders renderMarkdown(text) when streaming flips false): + expect(renderMarkdown(doc)).toBe(renderMarkdown(doc)); + }); + } +}); + +describe("streaming-markdown performance fixture (counts, not wall-clock)", () => { + // Thousands of real parser/sanitizer calls contend with the unrestricted suite. + // This asserts operation counts, not speed; keep all counts and allow CPU load. + it("hljs calls and prefix mutations stay proportional to BLOCKS, not length × deltas", () => { + const spy = vi.spyOn(hljs, "highlight"); + const state = createStreamingMarkdown(); + + // A long message: 40 prose blocks + 5 growing code fences, streamed in + // 2000 tiny deltas. + const blocks: string[] = []; + for (let i = 0; i < 40; i++) { + blocks.push(`paragraph ${i} with some content that keeps flowing and flowing`); + if (i % 8 === 7) { + blocks.push("```js\n// fence body line one\n// fence body line two\n```"); + } + } + const doc = blocks.map((b) => `${b}\n`).join("\n"); + const DELTAS = 2000; + const step = Math.max(1, Math.floor(doc.length / DELTAS)); + + let lastPrefix = state.prefixHtml; + let prefixMutations = 0; + for (let i = step; i <= doc.length; i += step) { + const r = advance(state, doc.slice(0, i)); + if (r.prefixHtml !== lastPrefix) { + prefixMutations++; + lastPrefix = r.prefixHtml; + } + } + const final = advance(state, doc); + if (final.prefixHtml !== lastPrefix) prefixMutations++; + + const fences = (doc.match(/^```/gm) ?? []).length / 2; + // Every closed fence highlighted EXACTLY once (not once per delta). + expect(spy).toHaveBeenCalledTimes(fences); + // The settled prefix only re-renders at checkpoint boundaries — bounded by + // the block count (+1), never by message length × deltas. + expect(prefixMutations).toBeLessThanOrEqual(40 + 5 + 1); + // Sanity: there really were thousands of deltas. + expect(Math.floor(doc.length / step)).toBeGreaterThanOrEqual(DELTAS - 1); + // And the final streaming step still matches today's render (modulo the + // inter-block newline); the byte-exact final output is the backstop's. + expect(normalizeHtml(final.prefixHtml + final.tailHtml)).toBe( + normalizeHtml(renderMarkdown(doc, { streaming: true })), + ); + }, 20_000); +}); + +describe("markdown tail-pipeline exports", () => { + it("countDelimiters mirrors closeOpenDelimiters' regexes", () => { + expect(countDelimiters("a ** b")).toEqual({ fences: 0, bold: 1, code: 0 }); + expect(countDelimiters("```js\nx\n```")).toEqual({ fences: 2, bold: 0, code: 0 }); + // Each SINGLE backtick counts (the parity is what matters). + expect(countDelimiters("use `x` and `y`")).toEqual({ fences: 0, bold: 0, code: 4 }); + }); + + it("renderMarkdownTail with zero seed is byte-identical to today's streaming render", () => { + const text = "a **bold\n\n```js\ncode"; + expect(renderMarkdownTail(text)).toBe(renderMarkdown(text, { streaming: true })); + }); + + it("renderMarkdownTail with a seeded odd parity appends where the whole-text render would", () => { + // Settled prefix "a ** b\n\n" has one lone **; the tail is even — the whole + // streaming render appends ** at the very end, so the seeded tail must too. + const seed = countDelimiters("a ** b\n\n"); + expect(seed.bold % 2).toBe(1); + expect(renderMarkdownTail("more\n", seed)).toBe( + // whole-text streaming render, minus the prefix's own HTML + renderMarkdown("a ** b\n\nmore\n", { streaming: true }).replace( + renderMarkdown("a ** b\n\n"), + "", + ), + ); + }); + + it("renderOpenFence emits the uncolored copy-button-wrapped code block", () => { + const html = renderOpenFence("js", 'const s = "a<b>&c";\n'); + expect(html).toContain('<pre><code class="hljs language-js">'); + expect(html).toContain("data-copy"); + expect(html).not.toContain("hljs-keyword"); + expect(html).toContain("<b>&c"); // marked-compatible escaping + expect(html).toContain('"a<b'); // quotes stay literal (marked parity) + }); +}); diff --git a/src/features/markdown/logic/streaming-markdown.ts b/src/features/markdown/logic/streaming-markdown.ts new file mode 100644 index 0000000..b530cd4 --- /dev/null +++ b/src/features/markdown/logic/streaming-markdown.ts @@ -0,0 +1,521 @@ +/** + * Checkpoint streaming Markdown — pure logic for rendering a GROWING message + * without re-running the full pipeline (marked → hljs → copy buttons → + * DOMPurify) over the whole text on every delta. + * + * Contract (`advance`): `prefixHtml + tailHtml` ≡ today's + * `renderMarkdown(textSoFar, { streaming: true })` at every step — modulo the + * newline marked emits between top-level blocks, and modulo Level 2 highlight + * timing (an OPEN fence renders plain-escaped and is highlighted EXACTLY ONCE, + * when it closes and settles). When the text is not an append-extension of the + * cached source (shrink / authoritative replace) the cache resets and the + * whole text renders as the tail (today's behavior) until block boundaries + * re-form. + * + * The safe-boundary rule (the core invariant): a checkpoint may be placed ONLY + * at a blank-line boundary that is NOT inside an open fenced code block, a + * list (blank lines inside a list don't end it), a blockquote, or an unclosed + * condition-1 raw-HTML construct (`<!-- … -->`, `<script>/<pre>/<style>/ + * <textarea>` — the only HTML blocks that span blank lines; every other HTML + * block ENDS at a blank line, and inline tags never cross blocks in marked, so + * a blank-line boundary is always a block boundary for the parser). When + * uncertain → do NOT checkpoint; extend the tail. The failure mode is degraded + * caching, NEVER wrong output. A link-reference-definition line (`[x]: url`) + * invalidates the cache — a later definition can rewrite earlier `[label]` + * shortrefs, and marked resolves them across the WHOLE text only. + * + * Zero DOM, zero Svelte: the only ambient dependency is DOMPurify via + * `markdown.ts` (sanitized everywhere, same as today). + */ + +import { + countDelimiters, + type DelimiterCounts, + type MarkdownImageOptions, + type MarkdownRenderOptions, + renderMarkdown, + renderMarkdownTail, + renderOpenFence, +} from "./markdown"; + +/** The split render for the current text: settled blocks + the live tail. */ +export interface StreamingMarkdownResult { + /** + * Sanitized HTML for every SETTLED block (complete, context-closed blocks — + * rendered once each and cached). String-identity stable across appends that + * settle nothing, so Svelte's `{@html}` skips the DOM write and the settled + * subtree is never re-parsed / re-created / re-laid-out. + */ + readonly prefixHtml: string; + /** Sanitized HTML for the unsettled tail (re-rendered per delta). */ + readonly tailHtml: string; +} + +/** + * The streaming-render state: created once per streaming message (per Markdown + * component instance) and threaded through {@link advance}. Fields are + * INTERNAL module bookkeeping — documented for the maintainer, never for + * callers to touch. + */ +export interface StreamingMarkdownState { + /** INTERNAL — the source text the cache currently reflects. */ + source: string; + /** INTERNAL — source[0..settledOffset] is settled (rendered into prefixHtml). */ + settledOffset: number; + /** INTERNAL — cached HTML for source[0..settledOffset]. */ + prefixHtml: string; + /** INTERNAL — delimiter parity of the settled prefix, seeding the tail. */ + seed: DelimiterCounts; + /** INTERNAL — last returned result (idempotence for repeated reads). */ + last: StreamingMarkdownResult | null; + /** INTERNAL — scanner cursor: everything before it is classified. */ + scanOffset: number; + /** INTERNAL — the open top-level fenced code block, if any. */ + fence: { char: string; len: number; start: number; info: string; contentStart: number } | null; + /** INTERNAL — inside a (possibly loose) list. */ + inList: boolean; + /** INTERNAL — inside a blockquote. */ + inQuote: boolean; + /** INTERNAL — inside a multi-line `<!-- … -->` comment block. */ + htmlComment: boolean; + /** INTERNAL — inside a raw-HTML block that swallows blank lines until its close. */ + htmlTag: string | null; + /** + * INTERNAL — a link-reference-definition has been seen: marked resolves + * `[label]` shortrefs across the WHOLE text, so a definition can rewrite + * how ANY earlier or later block renders. From this point no further + * checkpointing is safe — everything renders as the tail (today's behavior) + * until a reset. + */ + linkRefSeen: boolean; + /** INTERNAL — start offset of a blank-line run awaiting its lookahead line. */ + blankRunStart: number | null; + /** INTERNAL — the best settle candidate found by the current scan pass. */ + pendingSettle: number; + /** + * INTERNAL — identity of the image-render options (apiBase + cwd) the cache + * was rendered with. The options can arrive asynchronously (the cwd loads + * after mount), so a change invalidates the settled cache: image srcs in the + * prefix would otherwise keep the stale resolution. + */ + imageKey: string; +} + +/** Fresh state for one streaming message. */ +export function createStreamingMarkdown(): StreamingMarkdownState { + return { + source: "", + settledOffset: 0, + prefixHtml: "", + seed: { fences: 0, bold: 0, code: 0 }, + last: null, + scanOffset: 0, + fence: null, + inList: false, + inQuote: false, + htmlComment: false, + htmlTag: null, + linkRefSeen: false, + blankRunStart: null, + pendingSettle: -1, + imageKey: "", + }; +} + +/** Identity of the image options, so an async cwd appearing invalidates the cache. */ +function imageKeyOf(image: MarkdownImageOptions | undefined): string { + return image === undefined ? "" : JSON.stringify([image.apiBase, image.cwd ?? null]); +} + +/** Build per-render options without setting `image: undefined` (exactOptionalPropertyTypes). */ +function renderOptionsOf( + image: MarkdownImageOptions | undefined, +): MarkdownRenderOptions | undefined { + return image === undefined ? undefined : { image }; +} + +// ── Scanner helpers (CommonMark block starts, mirroring what marked does) ── + +/** A fenced-code-block opening line: ≤3 spaces indent, ≥3 backticks/tildes + info string. */ +const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/; +/** A blockquote marker. */ +const QUOTE_OPEN = /^ {0,3}>/; +/** A list-item marker (bullet, or ≤9 digits + `.`/`)`) followed by space or EOL. */ +const LIST_ITEM = /^ {0,3}([-*+]|\d{1,9}[.)])(\s|$)/; +/** A line indented into list-item content (tabs treated conservatively). */ +const INDENTED = /^( {4,}|\t)/; +/** A blank line. */ +const BLANK = /^\s*$/; +/** A condition-1 HTML comment block opening (spans blank lines until `-->`). */ +const COMMENT_OPEN = /^ {0,3}<!--/; +/** + * Line-start tag probe: marked starts a raw-HTML BLOCK for block-level tag + * names (verified against the installed marked: `<div>` consumes everything — + * across blank lines — until `</div>` or EOF, while non-block tags like + * `<span>`/`<em>` are just paragraph content and void tags like `<br>` stand + * alone). Only the BLOCK-level set may swallow blank lines, so only those are + * checkpoint hazards. + */ +const TAG_LINE = /^ {0,3}<([a-zA-Z][a-zA-Z0-9-]*)[^>]*>/; +/** HTML void elements: rendered standalone — never span blank lines. */ +const VOID_TAGS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "source", + "track", + "wbr", +]); +/** Block-level HTML tags that marked's raw-HTML block spans until its close. */ +const BLOCK_TAGS = new Set([ + "address", + "article", + "aside", + "blockquote", + "details", + "dd", + "div", + "dl", + "dt", + "fieldset", + "figcaption", + "figure", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hgroup", + "li", + "main", + "nav", + "ol", + "p", + "pre", + "section", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "ul", +]); +/** + * A link-reference-definition line — NEVER checkpoint after seeing one: marked + * resolves `[label]` shortrefs across the WHOLE text, so a definition can + * rewrite how ALREADY-SETTLED blocks render. + */ +const LINK_REF_DEF = /^ {0,3}\[.*\]:/; + +/** A valid CLOSING fence line: same char, ≥ the opening length, nothing else. */ +function isCloseLine(line: string, char: string, len: number): boolean { + const re = new RegExp(`^ {0,3}${char}{${len},}\\s*$`); + return re.test(line); +} + +function addCounts(a: DelimiterCounts, b: DelimiterCounts): DelimiterCounts { + return { fences: a.fences + b.fences, bold: a.bold + b.bold, code: a.code + b.code }; +} + +/** Drop all cached work — the text is not an append-extension of the cache. */ +function resetTo(s: StreamingMarkdownState, text: string): void { + s.source = text; + s.settledOffset = 0; + s.prefixHtml = ""; + s.seed = { fences: 0, bold: 0, code: 0 }; + s.last = null; + s.scanOffset = 0; + s.fence = null; + s.inList = false; + s.inQuote = false; + s.htmlComment = false; + s.htmlTag = null; + s.linkRefSeen = false; + s.blankRunStart = null; + s.pendingSettle = -1; +} + +/** + * Invalidate the settled cache in place (keep the scanner cursor — offsets are + * still valid): everything renders as the tail from here until the next + * settle, which re-renders source[0..end] as ONE parse. + */ +function invalidateCache(s: StreamingMarkdownState): void { + s.settledOffset = 0; + s.prefixHtml = ""; + s.seed = { fences: 0, bold: 0, code: 0 }; + s.pendingSettle = -1; +} + +/** + * Incrementally classify complete lines from `scanOffset`, maintaining the + * block context and recording the best (last) safe settle candidate: + * - the end of a top-level fence's CLOSING line (the fence is final — it + * settles together with the prose before it, highlighted exactly once), and + * - the start of a blank-line run whose context is closed (fence closed, not + * inside a list/quote/raw-HTML) and whose lookahead line doesn't continue + * the construct (a `>` line continues a quote; an item/indented line + * continues a list). + */ +function scan(s: StreamingMarkdownState): void { + const src = s.source; + const len = src.length; + let i = s.scanOffset; + s.pendingSettle = -1; + while (i < len) { + const nl = src.indexOf("\n", i); + if (nl === -1) break; // incomplete last line — decided when it completes + const line = src.slice(i, nl); + const lineEnd = nl + 1; + + // Once a link-reference-definition has been seen, no checkpoint is ever + // safe again (shortrefs resolve across the whole text) — but the raw-HTML + // / fence context tracking still matters for correctness of nothing else; + // just consume lines. + if (s.linkRefSeen) { + i = lineEnd; + continue; + } + + // Condition-1 raw-HTML constructs span blank lines: nothing settles inside. + if (s.htmlComment) { + if (line.includes("-->")) s.htmlComment = false; + s.blankRunStart = null; + i = lineEnd; + continue; + } + if (s.htmlTag !== null) { + if (line.toLowerCase().includes(`</${s.htmlTag}`)) s.htmlTag = null; + s.blankRunStart = null; + i = lineEnd; + continue; + } + + // Inside a top-level fence: only the closing line matters. Blank lines and + // ```-ish content inside the fence are never boundaries. + if (s.fence !== null) { + if (isCloseLine(line, s.fence.char, s.fence.len)) { + s.fence = null; + s.pendingSettle = lineEnd; + } + s.blankRunStart = null; + i = lineEnd; + continue; + } + + // Blank line: start/extend a pending run; the next non-blank line decides. + if (BLANK.test(line)) { + if (s.blankRunStart === null) s.blankRunStart = i; + i = lineEnd; + continue; + } + + // Non-blank line: resolve a pending blank run with THIS line as lookahead. + if (s.blankRunStart !== null) { + const runStart = s.blankRunStart; + s.blankRunStart = null; + if (s.inQuote) { + if (!QUOTE_OPEN.test(line)) { + s.pendingSettle = runStart; // the quote ended at the blank line + s.inQuote = false; + } + } else if (s.inList) { + if (!LIST_ITEM.test(line) && !INDENTED.test(line)) { + s.pendingSettle = runStart; // the list ended at the blank line + s.inList = false; + } + } else { + s.pendingSettle = runStart; + } + } + + // Classify the non-blank line. + if (!s.inList && !s.inQuote) { + const fenceMatch = FENCE_OPEN.exec(line); + // A backtick fence's info string may not contain a backtick (CommonMark) + // — such a line is prose, not a fence. + const marker = fenceMatch?.[1]; + const info = fenceMatch?.[2] ?? ""; + if (marker !== undefined && !(marker[0] === "`" && info.includes("`"))) { + // Fenced code can interrupt a paragraph: the prose before this line is + // final. The fence itself settles when its closing line arrives. + s.pendingSettle = i; + s.fence = { + char: marker[0] === "`" ? "`" : "~", + len: marker.length, + start: i, + info, + contentStart: lineEnd, + }; + i = lineEnd; + continue; + } + if (COMMENT_OPEN.test(line)) { + if (!line.includes("-->")) s.htmlComment = true; + i = lineEnd; + continue; + } + const tag = TAG_LINE.exec(line); + if (tag !== null && tag[1] !== undefined) { + const t = tag[1].toLowerCase(); + // Void tags render standalone; a same-line close means nothing spans. + // Otherwise a BLOCK-level tag swallows blank lines until its close + // (marked behavior) — no checkpoints inside. Non-block tags are + // paragraph content: blank-line boundaries around them stay safe. + const sameLineClosed = line.toLowerCase().includes(`</${t}`); + if (!VOID_TAGS.has(t) && !sameLineClosed && BLOCK_TAGS.has(t)) { + s.htmlTag = t; + } + i = lineEnd; + continue; + } + if (LINK_REF_DEF.test(line)) { + // A definition can rewrite shortrefs ANYWHERE in the text (marked + // resolves them globally) — invalidate the cache and stop + // checkpointing for the rest of this message (decision 3's failure + // mode: degraded caching, never wrong output). + s.linkRefSeen = true; + invalidateCache(s); + i = lineEnd; + continue; + } + } + if (QUOTE_OPEN.test(line)) { + s.inQuote = true; + } else if (LIST_ITEM.test(line)) { + s.inList = true; + } + i = lineEnd; + } + s.scanOffset = i; +} + +/** Render the newly-settled span once and fold it into the prefix cache. */ +function commitSettle( + s: StreamingMarkdownState, + end: number, + image: MarkdownImageOptions | undefined, +): void { + const chunk = s.source.slice(s.settledOffset, end); + s.prefixHtml += renderMarkdown(chunk, renderOptionsOf(image)); + s.seed = addCounts(s.seed, countDelimiters(chunk)); + s.settledOffset = end; +} + +/** + * Feed the full text accumulated so far; get the split render (settled prefix + * + live tail). Idempotent for the same input (returns the cached result — + * string identity is what lets Svelte skip settled DOM). Resets itself when + * the text shrinks or is otherwise not an append-extension. + */ +export function advance( + state: StreamingMarkdownState, + textSoFar: string, + opts?: MarkdownRenderOptions, +): StreamingMarkdownResult { + const image = opts?.image; + const imageKey = imageKeyOf(image); + if (textSoFar === state.source && state.last !== null && state.imageKey === imageKey) { + return state.last; + } + // A changed render option (e.g. the cwd loading after mount) invalidates the + // settled cache — its image srcs were resolved with the old options. + if (state.imageKey !== imageKey || !textSoFar.startsWith(state.source)) { + resetTo(state, textSoFar); + } else { + state.source = textSoFar; + } + state.imageKey = imageKey; + + // A PARTIAL trailing line that starts a `[…]:` definition already changes + // how the whole-text parse resolves shortrefs (marked parses the truncated + // definition), so the optimistic invalidation cannot wait for the newline. + // Overly eager (a `[` that turns out to be a link) is only degraded caching. + if ( + !state.linkRefSeen && + state.scanOffset < state.source.length && + /^\s*\[/.test(state.source.slice(state.scanOffset)) + ) { + state.linkRefSeen = true; + invalidateCache(state); + } + + scan(state); + if (!state.linkRefSeen && state.pendingSettle > state.settledOffset) { + commitSettle(state, state.pendingSettle, image); + } + + const result = renderTail(state, image); + state.last = result; + return result; +} + +/** + * Decide the tail's shape and render it: + * 1. an OPEN top-level fence → plain-escaped (Level 2: zero hljs while open); + * 2. an incomplete last line that opens a fence marker → same Level-2 path + * (keeps "uncolored while open" monotonic — no one-frame highlight flicker + * between the marker's arrival and the line's completion); + * 3. otherwise prose → today's pipeline, seeded with the settled parity. + */ +function renderTail( + s: StreamingMarkdownState, + image: MarkdownImageOptions | undefined, +): StreamingMarkdownResult { + // 1. Open top-level fence: everything from its opening line is code content. + if (s.fence !== null && s.fence.start >= s.settledOffset) { + let code = s.source.slice(s.fence.contentStart); + // A partial last line shaped like the closing fence is the pending closer — + // exclude it from the content (it completes and settles next delta). + if (s.scanOffset < s.source.length) { + const partial = s.source.slice(s.scanOffset); + if (isCloseLine(partial, s.fence.char, s.fence.len)) { + code = s.source.slice(s.fence.contentStart, s.scanOffset); + } + } + return { prefixHtml: s.prefixHtml, tailHtml: renderOpenFence(s.fence.info, code) }; + } + + // 2. Incomplete last line opening a fence marker (top level only). Today's + // whole-text render parses the PARTIAL info string (the fence runs to + // EOF), so pass it through for the same transient `language-` class. + if ( + !s.inList && + !s.inQuote && + !s.htmlComment && + s.htmlTag === null && + s.scanOffset < s.source.length + ) { + const partial = s.source.slice(s.scanOffset); + const m = FENCE_OPEN.exec(partial); + const marker = m?.[1]; + const info = m?.[2] ?? ""; + if (marker !== undefined && !(marker[0] === "`" && info.includes("`"))) { + const prose = s.source.slice(s.settledOffset, s.scanOffset); + return { + prefixHtml: s.prefixHtml, + tailHtml: + renderMarkdownTail(prose, s.seed, renderOptionsOf(image)) + renderOpenFence(info, ""), + }; + } + } + + // 3. Prose tail. + return { + prefixHtml: s.prefixHtml, + tailHtml: renderMarkdownTail(s.source.slice(s.settledOffset), s.seed, renderOptionsOf(image)), + }; +} diff --git a/src/features/markdown/logic/text-file.test.ts b/src/features/markdown/logic/text-file.test.ts new file mode 100644 index 0000000..5649b2f --- /dev/null +++ b/src/features/markdown/logic/text-file.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { splitTextFiles, textFilePath, textFilePreview } from "./text-file"; + +const reference = (text: string, streaming = false) => + splitTextFiles(text, streaming).filter((part) => part.kind === "text-file"); +describe("text-file opt-in parser", () => { + it.each([ + ["[Plan](text-file:notes/plan.md)", "notes/plan.md"], + ["[Plan](text-file:/tmp/plan.md)", "/tmp/plan.md"], + ["[Plan](<text-file:notes/my plan.md>)", "notes/my plan.md"], + ["[Plan](text-file:notes/my%20plan.md)", "notes/my plan.md"], + ["[Plan](text-file:notes/100%25.md)", "notes/100%.md"], + ["[Plan](text-file:notes/literal%2520.md)", "notes/literal%20.md"], + [String.raw`[Plan](text-file:notes/plan\(1\).md)`, "notes/plan(1).md"], + [String.raw`[Plan](text-file:notes/a\_b.md)`, "notes/a_b.md"], + ])("parses %s", (text, path) => + expect(reference(text)).toEqual([{ kind: "text-file", reference: { label: "Plan", path } }])); + it.each([ + "[web](https://example.com)", + "", + "[file](file:///tmp/a.md)", + "see [Plan](text-file:a.md)", + "[Plan](text-file:a.md) suffix", + "`[Plan](text-file:a.md)`", + "```md\n[Plan](text-file:a.md)\n```", + "~~~\n[Plan](text-file:a.md)\n~~~", + " [Plan](text-file:a.md)", + "> [Plan](text-file:a.md)", + "- [Plan](text-file:a.md)", + "[Plan](text-file:a.md", + "[Plan](text-file:", + "[Plan][p]\n\n[p]: text-file:a.md", + '[Plan](text-file:a.md "title")', + "<div data-text-file='a.md'>Plan</div>", + ])("does not opt in: %s", (text) => expect(reference(text)).toEqual([])); + it.each([ + "file:///tmp/a", + "https://evil/a", + "//host/a", + "~/a", + "~user/a", + "a\\b", + "%00", + "%0a", + "%7f", + "a%5cb", + "%2f%2fhost/a", + "%68ttps://evil/a", + "bad%", + "", + " a", + "a ", + ])("rejects unsafe path %s", (path) => expect(textFilePath(`text-file:${path}`)).toBeNull()); + it("waits for complete streaming paragraph boundary and keeps settled embeds", () => { + const complete = "[Plan](text-file:a.md)"; + for (let i = 0; i <= complete.length; i++) + expect(reference(complete.slice(0, i), true)).toHaveLength(0); + expect(reference(`${complete}\n`, true)).toHaveLength(0); + expect(reference(`${complete}\n\n`, true)).toHaveLength(1); + expect(reference(`${complete}\n\ntail`, true)).toHaveLength(1); + expect(reference(`${complete} suffix`, true)).toHaveLength(0); + expect(reference(complete)).toHaveLength(1); + }); + it("bounds per-message document count", () => { + const result = splitTextFiles( + Array.from({ length: 30 }, (_, i) => `[p](text-file:${i}.md)`).join("\n\n"), + ); + expect(result.filter((p) => p.kind === "text-file")).toHaveLength(8); + expect(JSON.stringify(result)).toContain("embed limit reached"); + }); +}); +describe("source-line preview", () => { + it.each([1, 6, 12])("does not overlap %i source lines", (n) => { + const text = Array.from({ length: n }, (_, i) => `${i}`).join("\n"); + expect(textFilePreview(text, true)).toEqual({ head: text, tail: "", omitted: 0 }); + }); + it("takes first six and last six source lines, ignoring final newline", () => { + const result = textFilePreview( + `${Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")}\n`, + true, + ); + expect(result).toEqual({ + head: "line1\nline2\nline3\nline4\nline5\nline6", + tail: "line15\nline16\nline17\nline18\nline19\nline20", + omitted: 8, + }); + }); + it("restores top-level fence context at fragment edges", () => { + const text = `\`\`\`js\n${Array.from({ length: 20 }, () => "const x = 1;").join("\n")}\n\`\`\``; + const result = textFilePreview(text, true); + expect(result.head).toMatch(/\n```$/); + expect(result.tail).toMatch(/^```js\n/); + expect(result.tail).toMatch(/\n```$/); + expect(textFilePreview(text, false).tail).not.toMatch(/^```js/); + }); +}); + +describe("text-file parser boundary compatibility", () => { + it("preserves document-wide ordinary reference definitions across embed splits", () => { + const parts = splitTextFiles( + "[before][web]\n\n[Plan](text-file:a.md)\n\n[after][web]\n\n[web]: https://example.com", + ); + expect(parts.filter((part) => part.kind === "text-file")).toHaveLength(1); + for (const part of parts) { + if (part.kind === "markdown") expect(part.text).toContain("[web]: https://example.com"); + } + }); + it.each([ + "%C2%85", + "a".repeat(4097), + ])("rejects backend-unsupported controls or overlong paths", (path) => { + expect(textFilePath(`text-file:${path}`)).toBeNull(); + }); + it("accepts encoded filename delimiters without treating them as a URL query/fragment", () => { + expect(textFilePath("text-file:notes/a%23b%3Fc.md")).toBe("notes/a#b?c.md"); + }); +}); diff --git a/src/features/markdown/logic/text-file.ts b/src/features/markdown/logic/text-file.ts new file mode 100644 index 0000000..4321c6f --- /dev/null +++ b/src/features/markdown/logic/text-file.ts @@ -0,0 +1,116 @@ +import { Marked } from "marked"; + +export interface TextFileReference { + readonly label: string; + readonly path: string; +} +export type MarkdownPart = + | { readonly kind: "markdown"; readonly text: string } + | { readonly kind: "text-file"; readonly reference: TextFileReference }; + +/** Input is marked's already Markdown-unescaped href. Decode percent escapes ONCE. */ +export function textFilePath(href: string): string | null { + if (!href.startsWith("text-file:")) return null; + let path: string; + try { + path = decodeURIComponent(href.slice(10)); + } catch { + return null; + } + if ( + !path || + path !== path.trim() || + path.length > 4096 || + path.includes("\\") || + Array.from(path).some((character) => { + const code = character.charCodeAt(0); + return code < 32 || (code >= 127 && code <= 159); + }) || + path.startsWith("~") || + path.startsWith("//") || + /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path) + ) + return null; + return path; +} + +/** Only a complete, top-level, standalone inline-link paragraph opts in. No I/O. */ +export function splitTextFiles(text: string, streaming = false): readonly MarkdownPart[] { + if (!text.includes("text-file:")) return [{ kind: "markdown", text }]; + const parts: MarkdownPart[] = []; + let prose = ""; + let offset = 0; + let embeds = 0; + const tokens = new Marked({ gfm: true }).lexer(text); + for (const token of tokens) { + offset += token.raw.length; + const children = token.type === "paragraph" ? token.tokens : undefined; + const link = children?.length === 1 ? children[0] : undefined; + const path = + link?.type === "link" && + link.title === null && + // Reference-style links and multiline paragraphs are deliberately not embeds. + /^\s*\[[\s\S]*\]\(/.test(token.raw) && + !token.raw.trim().includes("\n") + ? textFilePath(link.href) + : null; + // During streaming wait for a paragraph boundary, not merely a closing ')' + // that could still grow into adjacent prose on the next delta. + const settled = !streaming || /^\s*\n/.test(text.slice(offset)); + if (path !== null && link?.type === "link" && settled && embeds < 8) { + embeds++; + if (prose) parts.push({ kind: "markdown", text: prose }); + prose = ""; + parts.push({ kind: "text-file", reference: { label: link.text, path } }); + } else { + prose += token.raw; + if (path !== null && settled && embeds >= 8) + prose += "\n\n_Text-file embed limit reached (8 per message)._\n\n"; + } + } + if (prose) parts.push({ kind: "markdown", text: prose }); + // Reference definitions apply document-wide, even across embed boundaries. + const definitions = tokens + .filter((token) => token.type === "def") + .map((token) => token.raw) + .join("\n"); + return embeds && definitions + ? parts.map((part) => + part.kind === "markdown" + ? { + ...part, + text: `${part.text}\n\n${definitions}`, + } + : part, + ) + : parts; +} + +/** Restore top-level fence context when the tail begins inside a code fence. */ +function fenceAt(lines: readonly string[], end: number): string | null { + let fence: { marker: string; info: string } | null = null; + for (const line of lines.slice(0, end)) { + if (fence) { + const close = /^ {0,3}(`+|~+)\s*$/.exec(line)?.[1]; + if (close && close[0] === fence.marker[0] && close.length >= fence.marker.length) + fence = null; + } else { + const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (match?.[1]) fence = { marker: match[1], info: match[2] ?? "" }; + } + } + return fence ? `${fence.marker}${fence.info}` : null; +} +export function textFilePreview(text: string, markdown: boolean) { + const lines = text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n"); + if (lines.length <= 12) return { head: text, tail: "", omitted: 0 }; + const headLines = lines.slice(0, 6); + const tailLines = lines.slice(-6); + const headFence = markdown ? fenceAt(lines, 6) : null; + const tailFence = markdown ? fenceAt(lines, lines.length - 6) : null; + return { + head: headLines.join("\n") + (headFence ? `\n${headFence.match(/^[`~]+/)?.[0]}` : ""), + tail: (tailFence ? `${tailFence}\n` : "") + tailLines.join("\n"), + omitted: lines.length - 12, + }; +} diff --git a/src/features/markdown/types.ts b/src/features/markdown/types.ts new file mode 100644 index 0000000..cb3fe8d --- /dev/null +++ b/src/features/markdown/types.ts @@ -0,0 +1,13 @@ +import type { TextFileContextResponse, TextFileResponse } from "@dispatch/transport-contract"; + +export type TextFileState = + | { readonly kind: "loading" } + | { readonly kind: "error"; readonly message: string } + | { readonly kind: "ready"; readonly file: TextFileResponse }; +export interface TextFileClient { + subscribe(path: string, listener: (state: TextFileState) => void): () => void; + /** Refresh context as well as the live on-disk file. */ + refresh(path: string): void; + dispose(): void; +} +export type TextFileContext = TextFileContextResponse; diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte index 72b892b..c33f9f6 100644 --- a/src/features/markdown/ui/Markdown.svelte +++ b/src/features/markdown/ui/Markdown.svelte @@ -1,58 +1,33 @@ <script lang="ts"> - import { renderMarkdown } from "../logic/markdown"; + import type { MarkdownImageOptions } from "../logic/markdown"; + import { splitTextFiles } from "../logic/text-file"; + import type { TextFileClient } from "../types"; + import MarkdownContent from "./MarkdownContent.svelte"; + import TextFileEmbed from "./TextFileEmbed.svelte"; let { text, streaming = false, + image = undefined, + textFiles = undefined, }: { text: string; - /** Balance dangling delimiters while the message is still generating. */ streaming?: boolean; + image?: MarkdownImageOptions | undefined; + /** Explicit opt-in; embedded documents never receive this port. */ + textFiles?: TextFileClient | undefined; } = $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); - }); + const parts = $derived(textFiles ? splitTextFiles(text, streaming) : null); </script> -<div class="markdown-body" bind:this={container}> - <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. --> - {@html html} -</div> +{#if parts && textFiles} + {#each parts as part, i (i)} + {#if part.kind === "text-file"} + <TextFileEmbed reference={part.reference} client={textFiles} {image} /> + {:else} + <MarkdownContent text={part.text} {streaming} {image} /> + {/if} + {/each} +{:else} + <MarkdownContent {text} {streaming} {image} /> +{/if} diff --git a/src/features/markdown/ui/MarkdownContent.svelte b/src/features/markdown/ui/MarkdownContent.svelte new file mode 100644 index 0000000..6149d6b --- /dev/null +++ b/src/features/markdown/ui/MarkdownContent.svelte @@ -0,0 +1,116 @@ +<script lang="ts"> + import { type MarkdownImageOptions, renderMarkdown } from "../logic/markdown"; + import { advance, createStreamingMarkdown } from "../logic/streaming-markdown"; + + let { + text, + streaming = false, + image = undefined, + }: { + text: string; + /** Balance dangling delimiters while the message is still generating. */ + streaming?: boolean; + /** + * Per-conversation local-image context: the backend HTTP base and the + * conversation cwd. Markdown image srcs that are local paths are rewritten + * through `${apiBase}/local-image?path=…` (relative paths resolve against + * `cwd`). Remote HTTP(S) images require a click; data, same-origin and + * backend images load inline. Omit it to skip local-path rewriting. + */ + image?: MarkdownImageOptions | undefined; + } = $props(); + + // Keep the optional `image` out of the options object when absent + // (exactOptionalPropertyTypes). Identity is irrelevant: the streaming cache + // keys on apiBase/cwd values. + const renderOpts = $derived(image === undefined ? undefined : { image }); + + // Checkpoint streaming state: one per component instance (per streaming + // message). It caches the settled blocks' HTML so each delta re-renders only + // the live tail — never the settled prefix (see logic/streaming-markdown). + const streamingState = createStreamingMarkdown(); + + // While streaming: the checkpoint split (settled prefix + live tail), which + // equals today's full render at every step modulo inter-block newlines and + // highlight-on-close. When the message completes (or for every non-streaming + // chunk): ONE full authoritative render — today's exact behavior, so the + // final/persisted output is byte-identical by construction. + const rendered = $derived.by(() => { + if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text, renderOpts) }; + return advance(streamingState, text, renderOpts); + }); + + let container: HTMLElement; + + // One delegated listener on the stable container handles image consent and + // every code block's copy button — including blocks re-created when the tail 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 remote = target.closest<HTMLButtonElement>("button.remote-image[data-src]"); + if (remote !== null && el.contains(remote)) { + event.preventDefault(); // A Markdown image may be wrapped in a link. + event.stopPropagation(); + // data-* is authorable in raw HTML: validate again at the DOM edge. + let url: URL; + try { + url = new URL(remote.dataset.src ?? ""); + } catch { + return; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return; + const img = el.ownerDocument.createElement("img"); + img.alt = remote.dataset.alt ?? ""; + if (remote.dataset.title !== undefined) img.title = remote.dataset.title; + img.setAttribute("referrerpolicy", "no-referrer"); + img.src = url.href; + remote.replaceWith(img); + 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}> + {#if streaming} + <!-- Two sibling wrappers (no padding/border/min-height, so block margins + collapse across them exactly as if the blocks were direct children). + Svelte 5 skips the {@html} DOM write when the string is unchanged, so + the settled prefix's subtree is never re-parsed/re-created/re-laid-out; + only the tail's subtree is replaced per delta. Both strings are + DOMPurify-sanitized in the logic modules. --> + <div class="markdown-stream-prefix">{@html rendered.prefixHtml}</div> + <div class="markdown-stream-tail">{@html rendered.tailHtml}</div> + {:else} + <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. --> + {@html rendered.tailHtml} + {/if} +</div> diff --git a/src/features/markdown/ui/TextFileEmbed.svelte b/src/features/markdown/ui/TextFileEmbed.svelte new file mode 100644 index 0000000..2e1a7a3 --- /dev/null +++ b/src/features/markdown/ui/TextFileEmbed.svelte @@ -0,0 +1,113 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { MarkdownImageOptions } from "../logic/markdown"; + import { textFilePreview, type TextFileReference } from "../logic/text-file"; + import type { TextFileClient, TextFileState } from "../types"; + import MarkdownContent from "./MarkdownContent.svelte"; + + let { + reference, + client, + image = undefined, + }: { + reference: TextFileReference; + client: TextFileClient; + image?: MarkdownImageOptions | undefined; + } = $props(); + let loadState = $state<TextFileState>({ kind: "loading" }); + let expanded = $state(false); + const preview = $derived( + loadState.kind === "ready" + ? textFilePreview(loadState.file.text, loadState.file.format === "markdown") + : null, + ); + const path = $derived(reference.path); + const currentClient = $derived(client); + $effect(() => { + const source = currentClient; + const destination = path; + expanded = false; + // The callback can publish synchronously. Only routing changes, not reads + // inside a subscriber or unrelated parent prop updates, own this effect. + return untrack(() => + source.subscribe(destination, (next) => { + loadState = next; + }), + ); + }); + // Use the resolved file's directory for local images, not the chat's cwd. + const documentImage = $derived( + loadState.kind === "ready" && image + ? { + apiBase: image.apiBase, + cwd: loadState.file.path.slice(0, loadState.file.path.lastIndexOf("/")) || "/", + } + : image, + ); +</script> + +<section + class="inset-well my-2 w-full min-w-0 rounded-box text-sm" + aria-label={`Text file: ${reference.label}`} +> + <header class="flex min-w-0 flex-wrap items-center gap-2 px-4 py-2 font-medium"> + <span aria-hidden="true">▤</span> + <span>{reference.label}</span> + <span class="min-w-0 flex-1 break-all font-mono text-xs font-normal text-base-content/60" + >{reference.path}</span + > + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => client.refresh(reference.path)} + disabled={loadState.kind === "loading"} + > + {loadState.kind === "error" ? "Retry" : "Refresh"} + </button> + </header> + <div class="min-w-0 px-4 pb-4"> + {#if loadState.kind === "loading"} + <p role="status"> + <span class="loading loading-dots loading-sm" aria-hidden="true"></span> Loading text file… + </p> + {:else if loadState.kind === "error"} + <p role="alert" class="text-error">{loadState.message}</p> + {:else if loadState.file.text === ""} + <p class="italic text-base-content/60">Empty file</p> + {:else if preview} + {#if expanded} + {@render content(loadState.file.text, loadState.file.format)} + {:else} + {@render content(preview.head, loadState.file.format)} + {#if preview.omitted > 0} + <p class="my-2 border-y border-base-content/20 py-2 text-xs text-base-content/60"> + ⋯ {preview.omitted} source lines omitted ⋯ + </p> + {@render content(preview.tail, loadState.file.format)} + {/if} + {/if} + <button + type="button" + class="btn btn-soft btn-xs mt-2" + aria-expanded={expanded} + onclick={() => { + expanded = !expanded; + }} + > + {expanded ? "Collapse document" : "Expand document"} + </button> + <p class="mt-2 text-xs text-base-content/50"> + Live file · Refresh rereads disk · maximum 256 KiB + </p> + {/if} + </div> +</section> + +{#snippet content(text: string, format: "markdown" | "text")} + {#if format === "markdown"} + <!-- No textFiles port: nested references are inert, even in expanded content. --> + <MarkdownContent {text} image={documentImage} /> + {:else} + <pre class="overflow-x-auto whitespace-pre font-mono text-xs"><code>{text}</code></pre> + {/if} +{/snippet} diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts index d65b3d1..b86cc0a 100644 --- a/src/features/markdown/ui/markdown.test.ts +++ b/src/features/markdown/ui/markdown.test.ts @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/svelte"; +import hljs from "highlight.js/lib/core"; import { describe, expect, it, vi } from "vitest"; import Markdown from "./Markdown.svelte"; @@ -37,4 +38,154 @@ describe("Markdown", () => { expect(writeText).toHaveBeenCalledTimes(1); expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;"); }); + + it.each([ + false, + true, + ])("loads only the clicked remote image (streaming=%s)", async (streaming) => { + const { container } = render(Markdown, { + props: { + text: '\n\n', + streaming, + }, + }); + expect(container.querySelector("img")).toBeNull(); + const button = screen.getByRole("button", { name: "Load image from evil.example" }); + await fireEvent.click(button); + const img = container.querySelector("img"); + expect(img?.getAttribute("src")).toBe("https://evil.example/a.png"); + expect(img?.getAttribute("alt")).toBe("first"); + expect(img?.getAttribute("title")).toBe("A title"); + expect(img?.getAttribute("referrerpolicy")).toBe("no-referrer"); + expect( + screen.getByRole("button", { name: "Load image from other.example" }), + ).toBeInTheDocument(); + }); + + it.each([ + false, + true, + ])("keeps non-image HTML inert before and after image consent (streaming=%s)", async (streaming) => { + const { container } = render(Markdown, { + props: { + text: '<div style="background:url(https://evil.example/bg)"><video poster="https://evil.example/poster" src="https://evil.example/video"></video><svg><image href="https://evil.example/svg" /></svg><input type="image" src="https://evil.example/input"><img src="https://evil.example/allowed-after-click"></div>', + streaming, + }, + }); + expect(container.querySelector("video,svg,input,[style],img")).toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Load image from evil.example" })); + expect(container.querySelectorAll("img")).toHaveLength(1); + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "https://evil.example/allowed-after-click", + ); + expect(container.querySelector("video,svg,input,[style]")).toBeNull(); + }); + + it("rejects an unsafe URL in a forged placeholder", async () => { + const { container } = render(Markdown, { + props: { text: '<button class="remote-image" data-src="javascript:alert(1)">Load</button>' }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Load" })); + expect(container.querySelector("img")).toBeNull(); + }); + + it("delegates clicks on newly rendered images and prevents linked-image navigation", async () => { + const { container, rerender } = render(Markdown, { props: { text: "start" } }); + await rerender({ text: "[](https://other.example)" }); + const button = screen.getByRole("button", { name: "Load image from evil.example" }); + expect(await fireEvent.click(button)).toBe(false); + expect(container.querySelector("img")?.getAttribute("src")).toBe("https://evil.example/a.png"); + }); + + it("non-streaming renders a single container (today's full-render path)", () => { + const { container } = render(Markdown, { props: { text: "# Hello" } }); + expect(container.querySelector(".markdown-stream-prefix")).toBeNull(); + expect(container.querySelector(".markdown-stream-tail")).toBeNull(); + // The rendered blocks are DIRECT children of .markdown-body (no wrappers). + const body = container.querySelector(".markdown-body"); + expect(body?.querySelector("h1")).not.toBeNull(); + expect(body?.children[0]?.tagName).toBe("H1"); + }); + + it("streaming renders the prefix/tail split with content matching the full render", () => { + const { container } = render(Markdown, { + props: { text: "# Hello\n\nsettled paragraph\n\ngrowing tail", streaming: true }, + }); + + const prefix = container.querySelector<HTMLElement>(".markdown-stream-prefix"); + const tail = container.querySelector<HTMLElement>(".markdown-stream-tail"); + expect(prefix).not.toBeNull(); + expect(tail).not.toBeNull(); + // Same visible content as today's render, split across the two containers. + expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); + expect(container.textContent).toContain("growing tail"); + expect(prefix?.querySelector("h1")).not.toBeNull(); + }); + + it("streaming: the settled prefix's html string identity is stable across text appends (no DOM churn)", async () => { + const spy = vi.spyOn(hljs, "highlight"); + const { container, rerender } = render(Markdown, { + props: { text: "intro\n\n```js\nconst x = 1;\n```\n\nafter", streaming: true }, + }); + + const prefixBefore = container.querySelector(".markdown-stream-prefix")?.innerHTML; + expect(prefixBefore).toContain("hljs-keyword"); // the closed fence settled, highlighted once + expect(spy).toHaveBeenCalledTimes(1); + + // Appends that settle nothing leave the prefix string (and its DOM) alone. + await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and", streaming: true }); + await rerender({ + text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and more", + streaming: true, + }); + expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toBe(prefixBefore); + expect(spy).toHaveBeenCalledTimes(1); // still exactly one highlight + }); + + it("streaming: an open fence renders uncolored, then highlights exactly once when it closes", async () => { + const spy = vi.spyOn(hljs, "highlight"); + const { container, rerender } = render(Markdown, { + props: { text: "intro\n\n```js\nconst x", streaming: true }, + }); + + const tail = container.querySelector<HTMLElement>(".markdown-stream-tail"); + expect(tail?.innerHTML).toContain("<pre><code"); + expect(tail?.innerHTML).toContain("data-copy"); // same copy button + expect(tail?.innerHTML).not.toContain("hljs-keyword"); // uncolored while open + expect(spy).not.toHaveBeenCalled(); + + // Close the fence → highlighted EXACTLY once, settled into the prefix. + await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\ndone", streaming: true }); + expect(spy).toHaveBeenCalledTimes(1); + expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toContain("hljs-keyword"); + }); + + it("streaming → done flips to the single-container full render (completion backstop)", async () => { + const { container, rerender } = render(Markdown, { + props: { text: "# Hello\n\nbody", streaming: true }, + }); + expect(container.querySelector(".markdown-stream-prefix")).not.toBeNull(); + + await rerender({ text: "# Hello\n\nbody", streaming: false }); + expect(container.querySelector(".markdown-stream-prefix")).toBeNull(); + expect(container.querySelector(".markdown-stream-tail")).toBeNull(); + expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); + }); + + it("streaming: the delegated copy-button listener still works for tail code blocks", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + + const { container } = render(Markdown, { + props: { text: "para\n\n```js\nconst y = 2;", streaming: true }, + }); + + 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); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0]?.[0]).toContain("const y = 2;"); + }); }); diff --git a/src/features/markdown/ui/text-file.test.ts b/src/features/markdown/ui/text-file.test.ts new file mode 100644 index 0000000..cbbf11e --- /dev/null +++ b/src/features/markdown/ui/text-file.test.ts @@ -0,0 +1,190 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; +import { createTextFileClient } from "../adapter/text-file"; +import Markdown from "./Markdown.svelte"; + +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }); +const context = { conversationId: "c", cwd: "/project", computerId: null }; +function fixture(text: string, format: "markdown" | "text" = "markdown") { + const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url) => + json( + String(url).endsWith("-context") + ? context + : { + path: "/project/notes/plan.md", + text, + format, + bytes: new TextEncoder().encode(text).length, + maxBytes: 262144, + }, + ), + ); + return { fetcher, client: createTextFileClient("/api", "c", fetcher) }; +} +describe("rendered live text-file embeds", () => { + it("renders Markdown head/tail source lines and expands/collapses the full sanitized document", async () => { + const text = Array.from({ length: 20 }, (_, i) => `## Line ${i + 1}`).join("\n"); + const { client, fetcher } = fixture(text); + const { unmount } = render(Markdown, { + text: "Summary\n\n[Plan](text-file:notes/plan.md)", + textFiles: client, + }); + expect(screen.getByRole("status")).toHaveTextContent("Loading text file"); + await screen.findByRole("heading", { name: "Line 1" }); + expect(screen.getByRole("heading", { name: "Line 6" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Line 15" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Line 20" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Line 10" })).not.toBeInTheDocument(); + expect(screen.getByText(/8 source lines omitted/)).toBeInTheDocument(); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + expect(screen.getByRole("heading", { name: "Line 10" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Collapse document" })).toHaveAttribute( + "aria-expanded", + "true", + ); + await fireEvent.click(screen.getByRole("button", { name: "Collapse document" })); + expect(screen.queryByRole("heading", { name: "Line 10" })).not.toBeInTheDocument(); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("short documents don't duplicate; nested embeds are inert; HTML stays sanitized and remote images require consent", async () => { + const text = + '# Short\n\n[nested](text-file:secret.md)\n\n<script>alert(1)</script><img src="https://evil.example/a.png" onerror="alert(1)">\n\n[bad](javascript:alert(1))'; + const { client, fetcher } = fixture(text); + const { container, unmount } = render(Markdown, { + text: "[Plan](text-file:notes/plan.md)", + textFiles: client, + }); + await screen.findByRole("heading", { name: "Short" }); + expect(screen.getAllByRole("heading", { name: "Short" })).toHaveLength(1); + expect( + container.querySelector("script,[onerror],a[href^='javascript:'],a[href^='text-file:'],img"), + ).toBeNull(); + expect(container.textContent).toContain("nested"); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + await fireEvent.click(screen.getByRole("button", { name: "Load image from evil.example" })); + expect(container.querySelector("img")?.getAttribute("src")).toBe("https://evil.example/a.png"); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("does not render omitted images before expansion; restores tail fence context", async () => { + const text = + "# Head\n\n1\n2\n3\n4\n\n```js\n" + + Array.from({ length: 20 }, () => 'const x = "safe";').join("\n") + + "\n```"; + const { client } = fixture(text); + const { container, unmount } = render(Markdown, { + text: "[Plan](text-file:a.md)", + textFiles: client, + }); + await screen.findByRole("heading", { name: "Head" }); + expect( + screen.queryByRole("button", { name: "Load image from hidden.example" }), + ).not.toBeInTheDocument(); + expect(container.querySelector("code.language-js")).not.toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + expect( + screen.getByRole("button", { name: "Load image from hidden.example" }), + ).toBeInTheDocument(); + expect(container.querySelector("img")).toBeNull(); + unmount(); + client.dispose(); + }); + it("renders plain text with safe whitespace and no Markdown/HTML interpretation", async () => { + const { client } = fixture("# heading\n <script>bad</script>\n\tlast", "text"); + const { container, unmount } = render(Markdown, { + text: "[Text](text-file:a.txt)", + textFiles: client, + }); + await waitFor(() => + expect(container.querySelector("pre code")?.textContent).toBe( + "# heading\n <script>bad</script>\n\tlast", + ), + ); + expect(container.querySelector("script,h1")).toBeNull(); + unmount(); + client.dispose(); + }); + it("empty, error, Retry and Refresh states reread rather than insert file text into source", async () => { + const { client, fetcher } = fixture(""); + fetcher + .mockResolvedValueOnce(json(context)) + .mockResolvedValueOnce(json({ error: "Missing file" }, 404)); + const { unmount } = render(Markdown, { text: "[Plan](text-file:a.md)", textFiles: client }); + expect(await screen.findByRole("alert")).toHaveTextContent("Missing file"); + await fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByText("Empty file"); + await fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await screen.findByText("Empty file"); + expect(fetcher).toHaveBeenCalledTimes(6); + unmount(); + client.dispose(); + }); + it("never fetches without opt-in, for incomplete streaming, code or forged raw HTML", async () => { + const { client, fetcher } = fixture("body"); + const { rerender, unmount } = render(Markdown, { text: "[Plan](text-file:a.md)" }); + await rerender({ + text: '<div data-text-file="secret.md"><button data-path="secret.md">Fake</button></div>\n\n`[Plan](text-file:a.md)`\n\n```\n[Plan](text-file:a.md)\n```', + textFiles: client, + }); + await rerender({ text: "[Plan](text-file:a.md)", streaming: true, textFiles: client }); + expect(fetcher).not.toHaveBeenCalled(); + await rerender({ text: "[Plan](text-file:a.md)\n\ntail", streaming: true, textFiles: client }); + await screen.findByText("body"); + await rerender({ + text: "[Plan](text-file:a.md)\n\ntail growing", + streaming: true, + textFiles: client, + }); + await rerender({ + text: "[Plan](text-file:a.md)\n\ntail growing", + streaming: false, + textFiles: client, + }); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("ignores old conversation results after the context-bound client changes", async () => { + let finish: (r: Response) => void = () => {}; + const old = fixture("old"); + old.fetcher.mockResolvedValueOnce(json(context)).mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ); + const next = fixture("new"); + const { rerender, unmount } = render(Markdown, { + text: "[Plan](text-file:a.md)", + textFiles: old.client, + }); + await waitFor(() => expect(old.fetcher).toHaveBeenCalledTimes(2)); + await rerender({ text: "[Plan](text-file:a.md)", textFiles: next.client }); + await screen.findByText("new"); + finish( + json({ path: "/old/a.md", text: "old", format: "markdown", bytes: 3, maxBytes: 262144 }), + ); + await waitFor(() => expect(screen.queryByText("old")).not.toBeInTheDocument()); + expect(old.fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + unmount(); + old.client.dispose(); + next.client.dispose(); + }); + it("deduplicates repeated blocks without replacing them on per-token appends", async () => { + const { client, fetcher } = fixture("# shared"); + const text = "[one](text-file:a.md)\n\n[two](text-file:a.md)\n\n"; + const { rerender, unmount } = render(Markdown, { text, streaming: true, textFiles: client }); + await waitFor(() => expect(screen.getAllByRole("heading", { name: "shared" })).toHaveLength(2)); + await fireEvent.click( + screen.getAllByRole("button", { name: "Expand document" })[0] as HTMLElement, + ); + await rerender({ text: `${text}growing`, streaming: true, textFiles: client }); + expect(screen.getByRole("button", { name: "Collapse document" })).toBeInTheDocument(); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); +}); diff --git a/src/features/named-agents/index.ts b/src/features/named-agents/index.ts new file mode 100644 index 0000000..e66ef8e --- /dev/null +++ b/src/features/named-agents/index.ts @@ -0,0 +1,64 @@ +export { + type AgentDraft, + type AgentSaved, + type DraftValidity, + draftModel, + draftValidity, + isClean, + isDirty, + type SaveStatus, + saveStatus, + saveStatusLabel, +} from "./logic/auto-save"; +export { + normalizeNamedAgent, + normalizeNamedAgentRecord, + normalizeNamedAgents, + toNamedAgentInput, +} from "./logic/normalize"; +export { + fullModelFromSelection, + joinModel, + modelFromFull, + modelsForProvider, + openCodeNumber, + providerFromModel, + providerLabel, + providerOptions, +} from "./logic/options"; +export { findNamedAgent, snapshotFor } from "./logic/snapshot"; +export type { + CreateNamedAgent, + DeleteNamedAgent, + LoadNamedAgents, + NamedAgent, + NamedAgentDeleteResult, + NamedAgentInput, + NamedAgentResponse, + NamedAgentSaveResult, + NamedAgentSelectionSnapshot, + NamedAgentsResponse, + NamedAgentsResult, + UpdateNamedAgent, +} from "./logic/types"; +export { + isUsableModel, + isUsableName, + nameError, + normalizeName, +} from "./logic/validate"; +export { + type CardController, + type CardControllerOptions, + type CardSaveState, + createCardController, + type NamedAgentPorts, +} from "./ui/controller.svelte"; +export { default as NamedAgentCard } from "./ui/NamedAgentCard.svelte"; +export { default as NamedAgentsView } from "./ui/NamedAgentsView.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "named-agents", + description: "Curated Named Agent presets (name + model) in the sidebar", +} as const; diff --git a/src/features/named-agents/logic/auto-save.test.ts b/src/features/named-agents/logic/auto-save.test.ts new file mode 100644 index 0000000..24c9571 --- /dev/null +++ b/src/features/named-agents/logic/auto-save.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { + type AgentDraft, + draftModel, + draftValidity, + isDirty, + saveStatus, + saveStatusLabel, +} from "./auto-save"; + +const draft = (p: Partial<AgentDraft>): AgentDraft => ({ + id: null, + name: "", + provider: "", + model: "", + ...p, +}); + +describe("draftModel", () => { + it("joins provider + model suffix", () => { + expect(draftModel(draft({ provider: "opencode-1", model: "claude" }))).toBe( + "opencode-1/claude", + ); + }); + + it("falls back to provider-only when no model", () => { + expect(draftModel(draft({ provider: "opencode-1" }))).toBe("opencode-1"); + }); +}); + +describe("draftValidity", () => { + it("is usable when name, provider, and model are all valid", () => { + const v = draftValidity(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), []); + expect(v.usable).toBe(true); + expect(v.message).toBeNull(); + }); + + it("is unusable with an empty name / duplicate / missing model", () => { + expect(draftValidity(draft({ name: "", provider: "p", model: "m" }), []).usable).toBe(false); + expect( + draftValidity(draft({ name: "coding", provider: "p", model: "m" }), ["Coding"]).usable, + ).toBe(false); + expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).usable).toBe( + false, + ); + }); + + it("reports the first invalid field's message", () => { + expect(draftValidity(draft({ name: "", provider: "", model: "" }), []).message).toBe( + "Name is required.", + ); + expect( + draftValidity(draft({ name: "coding", provider: "", model: "" }), ["Coding"]).message, + ).toBe("A named agent with this name already exists."); + expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).message).toBe( + "Select a provider.", + ); + }); +}); + +describe("isDirty", () => { + it("is false when the draft matches the saved definition", () => { + expect( + isDirty(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), { + id: "a", + name: "Coding", + model: "opencode-1/claude", + }), + ).toBe(false); + }); + + it("is true when name or model differ", () => { + expect( + isDirty(draft({ name: "Coding X", provider: "opencode-1", model: "claude" }), { + id: "a", + name: "Coding", + model: "opencode-1/claude", + }), + ).toBe(true); + expect( + isDirty(draft({ name: "Coding", provider: "umans", model: "glm" }), { + id: "a", + name: "Coding", + model: "opencode-1/claude", + }), + ).toBe(true); + }); +}); + +describe("saveStatus", () => { + const saved = { id: "a", name: "Coding", model: "opencode-1/claude" }; + + it("is saved when clean with an id", () => { + expect( + saveStatus( + draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }), + saved, + false, + null, + ), + ).toBe("saved"); + }); + + it("is dirty when input diverges from the saved definition", () => { + expect( + saveStatus( + draft({ id: "a", name: "Coding X", provider: "opencode-1", model: "claude" }), + saved, + false, + null, + ), + ).toBe("dirty"); + }); + + it("is an idle draft when it has no id", () => { + expect( + saveStatus( + draft({ name: "Coding", provider: "opencode-1", model: "claude" }), + { id: null, name: "", model: "" }, + false, + null, + ), + ).toBe("idle"); + }); + + it("is saving while a save is in flight", () => { + expect( + saveStatus( + draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }), + saved, + true, + null, + ), + ).toBe("saving"); + }); + + it("reports an error when the last save failed", () => { + expect( + saveStatus( + draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }), + saved, + false, + "boom", + ), + ).toBe("error"); + }); +}); + +describe("saveStatusLabel", () => { + it("maps every status to a short label", () => { + expect(saveStatusLabel("idle")).toBe("Not saved"); + expect(saveStatusLabel("dirty")).toBe("Not saved"); + expect(saveStatusLabel("saving")).toBe("Saving…"); + expect(saveStatusLabel("saved")).toBe("Saved"); + expect(saveStatusLabel("error")).toBe("Save failed"); + }); +}); diff --git a/src/features/named-agents/logic/auto-save.ts b/src/features/named-agents/logic/auto-save.ts new file mode 100644 index 0000000..c89d12e --- /dev/null +++ b/src/features/named-agents/logic/auto-save.ts @@ -0,0 +1,138 @@ +/** + * Pure auto-save model for a Named Agent card (plan §5.3/§5.4). + * + * A card has NO explicit Save button: provider/model changes save immediately, + * a name edit saves after a short debounce, and the card shows a status + * (`Not saved` / `Saving…` / `Saved` / `Save failed` + retry). This module is + * the PURE model of that: given the card's current DRAFT (the raw input + * controls), the last PERSISTED state, whether a save is in flight, and an + * error, it derives the card's display status and what a save would persist. + * + * The effectful coordination (debounce timers, dispatching saves, and the + * monotonic sequence that stops an older response clobbering a newer edit) + * lives in the component-wired controller (`ui/controller.svelte.ts`), which + * is itself thin over this purity. + */ + +import { isUsableModel, isUsableName, normalizeName } from "./validate"; + +/** + * The four auto-save lifecycle states a card reports. `"idle"` = a brand-new + * unsaved draft (never persisted yet) — shown as "Not saved". `"dirty"` = an + * existing card whose input differs from what was last saved — also "Not saved" + * until the (debounced/immediate) save lands. `"saving"` = a save is in flight. + * `"saved"` = current input matches the last persisted value. `"error"` = the + * last save failed (a retry is offered). A card that is not yet usable hovers + * in `"idle"` (never issued a save). + */ +export type SaveStatus = "idle" | "dirty" | "saving" | "saved" | "error"; + +/** A card's current EDITOR state (the raw form controls). */ +export interface AgentDraft { + /** The persisted id once the card has been created; null for a new draft. */ + readonly id: string | null; + /** The raw name input (may be unterminated/unedited). */ + readonly name: string; + /** Selected provider id ("" when none). */ + readonly provider: string; + /** Selected model suffix under `provider` ("" when none). */ + readonly model: string; +} + +/** The card's last PERSISTED definition (the server's canonical record). */ +export interface AgentSaved { + readonly id: string | null; + readonly name: string; + readonly model: string; +} + +/** The full canonical `<provider>/<model>` name a draft would persist. */ +export function draftModel(draft: AgentDraft): string { + return draft.model === "" ? draft.provider : `${draft.provider}/${draft.model}`; +} + +/** The full `<provider>/<model>` name a draft's model suffix maps to. */ +export interface DraftValidity { + readonly nameOk: boolean; + readonly modelOk: boolean; + /** True when the card is fully usable (name nonempty+unique, provider+model chosen). */ + readonly usable: boolean; + /** A message for the first invalid field (for inline hints), or null when usable. */ + readonly message: string | null; +} + +/** + * Validate a draft against its sibling names (the OTHER agents' names, EXCLUDING + * this card's own id — uniqueness is among distinct agents). + */ +export function draftValidity(draft: AgentDraft, otherNames: readonly string[]): DraftValidity { + const nameMessage = nameErrorMessage(draft.name, otherNames); + const nameOk = nameMessage === null; + const modelOk = draft.provider !== "" && isUsableModel(draftModel(draft)); + const usable = nameOk && modelOk; + const message = usable ? null : (nameMessage ?? modelMessage(draft)); + return { nameOk, modelOk, usable, message }; +} + +function nameErrorMessage(name: string, otherNames: readonly string[]): string | null { + if (normalizeName(name).length === 0) return "Name is required."; + return isUsableName(name, otherNames) ? null : "A named agent with this name already exists."; +} + +function modelMessage(draft: AgentDraft): string | null { + if (draft.provider === "") return "Select a provider."; + if (draft.model === "") return "Select a model."; + return "Select a valid model."; +} + +/** + * Whether the current draft differs from what was last persisted — i.e. a save + * is owed. Draft is compared by its NAME (normalized) and full MODEL string. + */ +export function isDirty(draft: AgentDraft, saved: AgentSaved): boolean { + return ( + normalizeName(draft.name) !== normalizeName(saved.name) || draftModel(draft) !== saved.model + ); +} + +/** + * The card's display status, purely from its orchestration inputs: + * - No id yet → a never-persisted draft → `"idle"` while unusable or unsaved, + * `"error"` if a create attempt failed. + * - In-flight save → `"saving"`. + * - Last save errored → `"error"`. + * - No id and clean (nothing to save) → `"idle"`. + * - Dirty (not yet (re)saved) → `"dirty"`. + * - Clean + has an id → `"saved"`. + */ +export function saveStatus( + draft: AgentDraft, + saved: AgentSaved, + saving: boolean, + error: string | null, +): SaveStatus { + if (saving) return "saving"; + if (error !== null) return "error"; + const hasId = draft.id !== null || saved.id !== null; + if (!hasId) return "idle"; + return isDirty(draft, saved) ? "dirty" : "saved"; +} + +/** Short human label for a save status (the card's status line). */ +export function saveStatusLabel(status: SaveStatus): string { + switch (status) { + case "saving": + return "Saving…"; + case "saved": + return "Saved"; + case "error": + return "Save failed"; + default: + return "Not saved"; + } +} + +/** True iff a status warrants showing the card body as "saved clean" (no warning tint). */ +export function isClean(status: SaveStatus): boolean { + return status === "saved"; +} diff --git a/src/features/named-agents/logic/normalize.test.ts b/src/features/named-agents/logic/normalize.test.ts new file mode 100644 index 0000000..5009194 --- /dev/null +++ b/src/features/named-agents/logic/normalize.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeNamedAgent, + normalizeNamedAgentRecord, + normalizeNamedAgents, + toNamedAgentInput, +} from "./normalize"; +import type { NamedAgent } from "./types"; + +describe("normalizeNamedAgent", () => { + it("coerces a well-formed record", () => { + expect(normalizeNamedAgent({ id: "x", name: "Coding", model: "opencode-1/claude" })).toEqual({ + id: "x", + name: "Coding", + model: "opencode-1/claude", + }); + }); + + it("rejects a record missing any field", () => { + expect(normalizeNamedAgent({ id: "x", name: "Coding" })).toBeNull(); + expect(normalizeNamedAgent({ name: "Coding", model: "m" })).toBeNull(); + expect(normalizeNamedAgent(null)).toBeNull(); + }); +}); + +describe("normalizeNamedAgents", () => { + it("coerces a list body", () => { + const agents: readonly NamedAgent[] = normalizeNamedAgents({ + namedAgents: [ + { id: "a", name: "Coding", model: "opencode-1/claude" }, + { id: "b", name: "Research", model: "umans/glm" }, + ], + }); + expect(agents).toHaveLength(2); + expect(agents[0]?.name).toBe("Coding"); + }); + + it("drops malformed entries and survives a missing list key", () => { + expect( + normalizeNamedAgents({ + namedAgents: [ + { id: "a", name: "x" }, + { id: "b", name: "y", model: "m" }, + ], + }).map((a) => a.id), + ).toEqual(["b"]); + expect(normalizeNamedAgents({})).toEqual([]); + expect(normalizeNamedAgents(null)).toEqual([]); + }); +}); + +describe("normalizeNamedAgentRecord", () => { + it("reads the canonical saved record", () => { + expect( + normalizeNamedAgentRecord({ + namedAgent: { id: "a", name: "Coding", model: "opencode-1/claude" }, + })?.id, + ).toBe("a"); + }); + + it("returns null for a malformed record", () => { + expect(normalizeNamedAgentRecord({ namedAgent: {} })).toBeNull(); + expect(normalizeNamedAgentRecord({})).toBeNull(); + }); +}); + +describe("toNamedAgentInput", () => { + it("trims a name and keeps the model", () => { + expect(toNamedAgentInput(" Coding ", "opencode-1/claude")).toEqual({ + name: "Coding", + model: "opencode-1/claude", + }); + }); + + it("defaults non-strings to empty", () => { + expect(toNamedAgentInput(null, 42)).toEqual({ name: "", model: "" }); + }); +}); diff --git a/src/features/named-agents/logic/normalize.ts b/src/features/named-agents/logic/normalize.ts new file mode 100644 index 0000000..c1acec2 --- /dev/null +++ b/src/features/named-agents/logic/normalize.ts @@ -0,0 +1,68 @@ +/** + * Pure network-seam normalizers for the Named Agents API — zero DOM, zero + * effects. The composition root coerces the UNTYPED JSON it receives from + * `GET/POST/PUT/DELETE /named-agents` through these so a malformed / partial + * response can never crash the renderer. Mirrors the concurrency / heartbeat + * network-seam guard pattern. + */ + +import type { NamedAgent, NamedAgentInput } from "./types"; + +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 one untrusted record into a typed `NamedAgent`, or `null` when it is + * malformed (missing `id`, `name`, or `model` — or a non-string `model`). + */ +export function normalizeNamedAgent(value: unknown): NamedAgent | null { + if (!isRecord(value)) return null; + const id = asString(value.id); + const name = asString(value.name); + const model = asString(value.model); + if (id === null || name === null || model === null) return null; + return { id, name, model }; +} + +/** + * Coerce an untrusted `GET /named-agents` body into a typed Named Agent list. + * When the list key is absent/malformed, returns `[]` (defensive — the renderer + * shows the empty state rather than crashing). + */ +export function normalizeNamedAgents(data: unknown): readonly NamedAgent[] { + if (!isRecord(data) || !Array.isArray(data.namedAgents)) return []; + const out: NamedAgent[] = []; + for (const entry of data.namedAgents as readonly unknown[]) { + const agent = normalizeNamedAgent(entry); + if (agent !== null) out.push(agent); + } + return out; +} + +/** + * Coerce an untrusted `POST`/`PUT /named-agents` body (the canonical saved + * record) into a typed `NamedAgent`, or `null` when malformed. The composition + * root surfaces an error when this returns null on an ok response. + */ +export function normalizeNamedAgentRecord(data: unknown): NamedAgent | null { + if (!isRecord(data)) return null; + return normalizeNamedAgent(data.namedAgent); +} + +/** + * Coerce an untrusted `NamedAgentInput` at the create/update boundary so only + * clean strings reach the wire. Non-string fields drop to "" (name nonempty is + * validated separately; model nonempty too). The caller still runs name/model + * validation so a malformed body never reaches the backend. + */ +export function toNamedAgentInput(name: unknown, model: unknown): NamedAgentInput { + return { + name: typeof name === "string" ? name.trim() : "", + model: typeof model === "string" ? model : "", + }; +} diff --git a/src/features/named-agents/logic/options.test.ts b/src/features/named-agents/logic/options.test.ts new file mode 100644 index 0000000..8b774ab --- /dev/null +++ b/src/features/named-agents/logic/options.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + fullModelFromSelection, + modelFromFull, + modelsForProvider, + providerFromModel, + providerLabel, + providerOptions, +} from "./options"; + +const MODELS = [ + "opencode-2/gpt-5", + "opencode-1/claude-sonnet-4.5", + "umans/umans-glm-5.2", + "opencode-10/defiant", + "umans/umans-gpt-5", + "hyper/fast-1", +] as const; + +describe("providerFromModel / modelFromFull", () => { + it("splits a <provider>/<model> on the first slash", () => { + expect(providerFromModel("opencode-1/claude-sonnet-4.5")).toBe("opencode-1"); + expect(modelFromFull("opencode-1/claude-sonnet-4.5")).toBe("claude-sonnet-4.5"); + }); + + it("treats a slashless name as all-provider", () => { + expect(providerFromModel("umans")).toBe("umans"); + expect(modelFromFull("umans")).toBe(""); + }); +}); + +describe("providerOptions", () => { + it("sorts numbered OpenCode credentials numerically before other providers", () => { + expect(providerOptions([...MODELS])).toEqual([ + "opencode-1", + "opencode-2", + "opencode-10", + "umans", + "hyper", + ]); + }); + + it("keeps first-seen order and dedupes", () => { + expect(providerOptions(["b/x", "a/y", "b/z"])).toEqual(["b", "a"]); + }); +}); + +describe("modelsForProvider", () => { + it("returns only the model suffixes under the provider, in order", () => { + expect(modelsForProvider([...MODELS], "umans")).toEqual(["umans-glm-5.2", "umans-gpt-5"]); + expect(modelsForProvider([...MODELS], "hyper")).toEqual(["fast-1"]); + }); + + it("returns [] for an unknown provider", () => { + expect(modelsForProvider([...MODELS], "missing")).toEqual([]); + }); +}); + +describe("providerLabel", () => { + it("labels numbered OpenCode providers, leaves others unchanged", () => { + expect(providerLabel("opencode-2")).toBe("OpenCode #2"); + expect(providerLabel("umans")).toBe("umans"); + }); +}); + +describe("fullModelFromSelection", () => { + it("recombines a provider + model suffix", () => { + expect(fullModelFromSelection("opencode-1", "claude-sonnet-4.5")).toBe( + "opencode-1/claude-sonnet-4.5", + ); + }); +}); diff --git a/src/features/named-agents/logic/options.ts b/src/features/named-agents/logic/options.ts new file mode 100644 index 0000000..0946cbe --- /dev/null +++ b/src/features/named-agents/logic/options.ts @@ -0,0 +1,84 @@ +/** + * Pure provider/model dropdown helpers for the Named Agents editor — zero DOM. + * + * A Named Agent's persisted `model` is the canonical `<provider>/<model>` + * identifier, but the edit UI splits it into a provider dropdown + a model + * dropdown (plan §3/§5.2). These helpers derive those dropdowns from the raw + * `GET /models` catalog — the SAME key grouping the regular model picker uses. + * Catalog latency is acceptable here (human configuration), and it must not + * affect the agent-facing list/summon hot paths (plan §9). + */ + +/** 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); +} + +/** The model suffix of a `<provider>/<model>` name (the part after the first `/`; empty when slashless). */ +export function modelFromFull(full: string): string { + const i = full.indexOf("/"); + return i === -1 ? "" : full.slice(i + 1); +} + +/** Recombine a provider + model suffix into a canonical `<provider>/<model>` name (provider-only if no model). */ +export function joinModel(provider: string, model: string): string { + return model === "" ? provider : `${provider}/${model}`; +} + +/** + * Distinct provider ids across the catalog, in first-seen order, with numbered + * OpenCode credentials sorted NUMERICALLY before other providers (the same + * grouping the regular model picker uses — `opencode-2` before `opencode-10`, + * then everything else). A friendly display label is produced separately so the + * RAW id stays what gets persisted. + */ +export function providerOptions(models: readonly string[]): string[] { + const seen = new Set<string>(); + const openCodeKeys: Array<{ key: string; n: number }> = []; + const otherKeys: string[] = []; + for (const full of models) { + const p = providerFromModel(full); + if (p === "" || seen.has(p)) continue; + seen.add(p); + const n = openCodeNumber(p); + if (n !== null) openCodeKeys.push({ key: p, n }); + else otherKeys.push(p); + } + openCodeKeys.sort((a, b) => a.n - b.n); + return [...openCodeKeys.map((e) => e.key), ...otherKeys]; +} + +/** The numeric identity of a numbered OpenCode provider (`opencode-1` → 1), or null. */ +export function openCodeNumber(key: string): number | null { + const m = /^opencode-([1-9][0-9]*)$/.exec(key); + return m === null ? null : Number(m[1]); +} + +/** + * Friendly display label for a provider id: `opencode-<n>` → "OpenCode #<n>"; + * any other id unchanged. Presentation only — the RAW id is what gets persisted. + */ +export function providerLabel(provider: string): string { + const n = openCodeNumber(provider); + return n === null ? provider : `OpenCode #${n}`; +} + +/** The model suffixes available under a given provider, in catalog order. */ +export function modelsForProvider(models: readonly string[], provider: string): string[] { + const out: string[] = []; + for (const full of models) { + if (providerFromModel(full) === provider) out.push(modelFromFull(full)); + } + return out; +} + +/** + * The model suffixes in the catalog that a Named Agent's model suffix may match. + * A saved Named Agent may reference a provider/model pair no longer present in a + * live catalog (e.g. a credential was removed); the editor then shows the saved + * RAW full name as an unavailable selection rather than silently rewriting it. + */ +export function fullModelFromSelection(provider: string, model: string): string { + return joinModel(provider, model); +} diff --git a/src/features/named-agents/logic/snapshot.test.ts b/src/features/named-agents/logic/snapshot.test.ts new file mode 100644 index 0000000..3f1b5cf --- /dev/null +++ b/src/features/named-agents/logic/snapshot.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { findNamedAgent, snapshotFor } from "./snapshot"; +import type { NamedAgent } from "./types"; + +const AGENTS: readonly NamedAgent[] = [ + { id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + { id: "b2", name: "Research Agent", model: "umans/umans-glm-5.2" }, +]; + +describe("snapshotFor", () => { + it("builds a snapshot carrying id, name, and the raw model", () => { + const coding = AGENTS[0]; + if (coding === undefined) throw new Error("test fixture missing"); + expect(coding).toBeDefined(); + expect(snapshotFor(coding)).toEqual({ + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }); + }); +}); + +describe("findNamedAgent", () => { + it("finds by stable id", () => { + expect(findNamedAgent(AGENTS, "b2")?.name).toBe("Research Agent"); + }); + + it("finds by name case-insensitively", () => { + expect(findNamedAgent(AGENTS, "coding agent")?.id).toBe("a1"); + }); + + it("returns undefined for an unknown id/name", () => { + expect(findNamedAgent(AGENTS, "nope")).toBeUndefined(); + }); +}); diff --git a/src/features/named-agents/logic/snapshot.ts b/src/features/named-agents/logic/snapshot.ts new file mode 100644 index 0000000..482ceb3 --- /dev/null +++ b/src/features/named-agents/logic/snapshot.ts @@ -0,0 +1,33 @@ +/** + * Pure snapshot helpers for Named Agent selection (plan §7). + * + * Selecting a Named Agent for a conversation resolves it to its raw model AND + * records a `NamedAgentSelectionSnapshot` ({namedAgentId, namedAgentName, model}) + * so the conversation retains how it was selected. Later edits to the shared + * Named Agent (rename / model change / removal) therefore do NOT retroactively + * change an existing conversation — the snapshot is what the UI keeps showing. + */ + +import type { NamedAgent, NamedAgentSelectionSnapshot } from "./types"; + +/** Build the snapshot a conversation would retain for a Named Agent selection. */ +export function snapshotFor(agent: NamedAgent): NamedAgentSelectionSnapshot { + return { + namedAgentId: agent.id, + namedAgentName: agent.name, + model: agent.model, + }; +} + +/** + * Find a Named Agent by stable id or (case-insensitively) by name. Used to + * resolve a name/id picked in the model selector back to its current definition. + * Returns undefined when unknown (a stale name/id after deletion). + */ +export function findNamedAgent( + agents: readonly NamedAgent[], + idOrName: string, +): NamedAgent | undefined { + const lower = idOrName.trim().toLowerCase(); + return agents.find((a) => a.id === idOrName || a.name.trim().toLowerCase() === lower); +} diff --git a/src/features/named-agents/logic/types.ts b/src/features/named-agents/logic/types.ts new file mode 100644 index 0000000..2e4d4d3 --- /dev/null +++ b/src/features/named-agents/logic/types.ts @@ -0,0 +1,57 @@ +/** + * Pure shared types for the Named Agents feature — zero DOM, zero effects, + * zero Svelte. + * + * The shared shapes — `NamedAgent`, `NamedAgentSelectionSnapshot`, and the + * `/named-agents` HTTP request/response types — are RE-EXPORTED from the + * backend-contract packages (`@dispatch/wire`, `@dispatch/transport-contract`), + * which now carry them (the backend shipped the named-agents contract). The FE + * consumes those packages as the cross-repo seam; only the FE's port/result + * types are defined locally. + */ + +import type { + CreateNamedAgentRequest, + DeleteNamedAgentResponse, + NamedAgentResponse, + NamedAgentsResponse, + UpdateNamedAgentRequest, +} from "@dispatch/transport-contract"; +import type { NamedAgent, NamedAgentSelectionSnapshot } from "@dispatch/wire"; + +export type { + DeleteNamedAgentResponse, + NamedAgent, + NamedAgentResponse, + NamedAgentSelectionSnapshot, + NamedAgentsResponse, + UpdateNamedAgentRequest, +}; + +/** The subset of a Named Agent a client creates/updates (plan §9 — id is server-minted). */ +export type NamedAgentInput = CreateNamedAgentRequest; + +// ── Ports (injected effects — the composition root supplies real responders) ── + +/** Outcome of listing Named Agents. */ +export type NamedAgentsResult = + | { readonly ok: true; readonly agents: readonly NamedAgent[] } + | { readonly ok: false; readonly error: string }; + +/** Outcome of creating / updating a Named Agent (the canonical saved record). */ +export type NamedAgentSaveResult = + | { readonly ok: true; readonly agent: NamedAgent } + | { readonly ok: false; readonly error: string }; + +/** Outcome of removing a Named Agent. */ +export type NamedAgentDeleteResult = + | { readonly ok: true } + | { readonly ok: false; readonly error: string }; + +export type LoadNamedAgents = () => Promise<NamedAgentsResult>; +export type CreateNamedAgent = (input: NamedAgentInput) => Promise<NamedAgentSaveResult>; +export type UpdateNamedAgent = ( + id: string, + input: NamedAgentInput, +) => Promise<NamedAgentSaveResult>; +export type DeleteNamedAgent = (id: string) => Promise<NamedAgentDeleteResult>; diff --git a/src/features/named-agents/logic/validate.test.ts b/src/features/named-agents/logic/validate.test.ts new file mode 100644 index 0000000..d647354 --- /dev/null +++ b/src/features/named-agents/logic/validate.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { isUsableModel, isUsableName, nameError, normalizeName } from "./validate"; + +describe("normalizeName", () => { + it("trims surrounding whitespace", () => { + expect(normalizeName(" Coding ")).toBe("Coding"); + }); + + it("collapses internal runs of whitespace to one space", () => { + expect(normalizeName(" Research Agent ")).toBe("Research Agent"); + }); + + it("collapses an all-whitespace name to empty", () => { + expect(normalizeName(" ")).toBe(""); + }); +}); + +describe("nameError", () => { + it("returns null for a nonempty unique name", () => { + expect(nameError("Coding", ["Research", "Fast"])).toBeNull(); + }); + + it("flags an empty/normalized-empty name", () => { + expect(nameError("", ["Research"])).toBe("Name is required."); + expect(nameError(" ", ["Research"])).toBe("Name is required."); + }); + + it("flags a name duplicate case-insensitively", () => { + expect(nameError("coding", ["Coding"])).toBe("A named agent with this name already exists."); + expect(nameError("FAST", ["Fast"])).toBe("A named agent with this name already exists."); + }); + + it("ignores whitespace when comparing uniqueness", () => { + expect(nameError(" coding ", ["Coding "])).toBe( + "A named agent with this name already exists.", + ); + }); +}); + +describe("isUsableName", () => { + it("is true only for a unique nonempty name", () => { + expect(isUsableName("Coding", ["Research"])).toBe(true); + expect(isUsableName("coding", ["Coding"])).toBe(false); + expect(isUsableName("", [])).toBe(false); + }); +}); + +describe("isUsableModel", () => { + it("accepts a canonical provider/model pair", () => { + expect(isUsableModel("opencode-1/claude-sonnet-4.5")).toBe(true); + }); + + it("rejects a slashless / provider-only value", () => { + expect(isUsableModel("opencode-1")).toBe(false); + expect(isUsableModel("/claude")).toBe(false); + expect(isUsableModel("")).toBe(false); + }); +}); diff --git a/src/features/named-agents/logic/validate.ts b/src/features/named-agents/logic/validate.ts new file mode 100644 index 0000000..c25524a --- /dev/null +++ b/src/features/named-agents/logic/validate.ts @@ -0,0 +1,47 @@ +/** + * Pure validation for Named Agent form fields — zero DOM, zero effects. + * + * A Named Agent is usable only when (plan §5.4): the name is nonempty + unique + * case-insensitively, and a provider + model are selected. These pure helpers + * drive the card UI's validity gating + inline messages; the backend remains + * the authority (it 400s/conflicts on a duplicate or empty name / malformed + * model identifier). + */ + +/** Trim + collapse runs of whitespace inside a name (survey-style display). */ +export function normalizeName(raw: string): string { + return raw.trim().replace(/\s+/g, " "); +} + +/** + * A name-validation error message, or `null` when the name is valid. `otherNames` + * are the OTHER agents' names for the case-insensitive uniqueness check (the + * caller EXCLUDES the card's own last-persisted name / the card being edited). + */ +export function nameError(name: string, otherNames: readonly string[]): string | null { + const normalized = normalizeName(name); + if (normalized.length === 0) return "Name is required."; + const lower = normalized.toLowerCase(); + for (const other of otherNames) { + if (other.trim().toLowerCase() === lower) return "A named agent with this name already exists."; + } + return null; +} + +/** True iff `name` is a unique, nonempty agent name among `otherNames`. */ +export function isUsableName(name: string, otherNames: readonly string[]): boolean { + return nameError(name, otherNames) === null; +} + +/** + * Whether a full model identifier is syntactically valid — a canonical + * `<provider>/<model>` with a nonempty provider (the part before the first "/") + * AND a nonempty model (the part after). A slashless or provider-only value is + * invalid for a Named Agent (it must resolve to a concrete model). + */ +export function isUsableModel(full: string): boolean { + const i = full.indexOf("/"); + if (i <= 0) return false; // no slash, or empty provider + const model = full.slice(i + 1); + return model.length > 0; +} diff --git a/src/features/named-agents/ui/NamedAgentCard.svelte b/src/features/named-agents/ui/NamedAgentCard.svelte new file mode 100644 index 0000000..b81c59c --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentCard.svelte @@ -0,0 +1,120 @@ +<script lang="ts"> + import type { NamedAgent } from "../logic/types"; + import type { CardController } from "./controller.svelte"; + import { + modelsForProvider, + providerLabel, + providerOptions, + fullModelFromSelection, + } from "../logic/options"; + import { draftValidity } from "../logic/auto-save"; + import { saveStatusLabel } from "../logic/auto-save"; + + let { + models, + agent, + controller, + otherNames, + }: { + /** Available models (`<provider>/<model>`) — the source of the provider/model dropdowns. */ + models: readonly string[]; + /** The persisted agent this card edits, or null for a new draft. */ + agent: NamedAgent | null; + /** The card's auto-save controller (draft + status + save lifecycle). */ + controller: CardController; + /** The OTHER agents' names (excludes this card) for the uniqueness hint. */ + otherNames: readonly string[]; + } = $props(); + + const providers = $derived(providerOptions(models)); + const providerModels = $derived(modelsForProvider(models, controller.draft.provider)); + // The full model string a provider+model selection maps to (for the "unavailable" + // fallback when the saved model leaves the catalog). + const fullModel = $derived( + fullModelFromSelection(controller.draft.provider, controller.draft.model), + ); + const validity = $derived(draftValidity(controller.draft, otherNames)); + const statusLabel = $derived(saveStatusLabel(controller.save.status)); + // Whether the current provider+model actually resolves inside the catalog. + const selectedResolves = $derived( + controller.draft.provider !== "" && + modelsForProvider(models, controller.draft.provider).includes(controller.draft.model), + ); +</script> + +<div class="flex flex-col gap-1 rounded-box bg-base-200 p-3"> + <span class="text-[10px] uppercase opacity-60">Name</span> + <input + type="text" + class="input input-bordered input-sm w-full" + placeholder="Enter a name…" + aria-label="Named agent name" + value={controller.draft.name} + oninput={(e) => controller.setName(e.currentTarget.value)} + /> + + <span class="mt-1 text-[10px] uppercase opacity-60">Provider</span> + <select + class="select select-bordered select-sm w-full" + aria-label="Named agent provider" + value={controller.draft.provider} + onchange={(e) => controller.setProvider(e.currentTarget.value)} + > + {#if controller.draft.provider === ""} + <option value="" disabled>Select provider…</option> + {/if} + {#each providers as provider (provider)} + <option value={provider}>{providerLabel(provider)}</option> + {/each} + </select> + + <span class="mt-1 text-[10px] uppercase opacity-60">Model</span> + <select + class="select select-bordered select-sm w-full" + aria-label="Named agent model" + value={controller.draft.model} + disabled={controller.draft.provider === ""} + onchange={(e) => controller.setModel(e.currentTarget.value)} + > + {#if controller.draft.provider === ""} + <option value="" disabled>Select provider first</option> + {:else if controller.draft.model === ""} + <option value="" disabled>Select a model…</option> + {/if} + {#each providerModels as model (model)} + <option value={model}>{model}</option> + {/each} + {#if !selectedResolves && controller.draft.model !== ""} + <!-- The saved selection is no longer in the live catalog (its provider/ + credential was removed) — keep showing it as unavailable rather than + silently rewriting it. --> + <option value={controller.draft.model} disabled> + {fullModel} (unavailable) + </option> + {/if} + </select> + + {#if !validity.usable && validity.message !== null} + <p class="mt-1 text-xs text-warning" role="status">{validity.message}</p> + {/if} + + <div class="mt-2 flex items-center justify-between gap-2"> + <span + class="text-xs" + class:text-error={controller.save.status === "error" && controller.save.error !== null} + class:opacity-60={controller.save.status !== "saving" && controller.save.status !== "error"} + role="status" + > + {statusLabel}{#if controller.save.error !== null}: {controller.save.error}{/if} + </span> + <button + type="button" + class="btn btn-ghost btn-xs text-error" + aria-label="Remove named agent" + disabled={controller.saving} + onclick={() => controller.remove()} + > + Remove + </button> + </div> +</div>
\ No newline at end of file diff --git a/src/features/named-agents/ui/NamedAgentsView.svelte b/src/features/named-agents/ui/NamedAgentsView.svelte new file mode 100644 index 0000000..743ac20 --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentsView.svelte @@ -0,0 +1,126 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { NamedAgent } from "../logic/types"; + import type { NamedAgentPorts } from "./controller.svelte"; + import { createCardController, type CardController } from "./controller.svelte"; + import NamedAgentCard from "./NamedAgentCard.svelte"; + + interface Row { + key: number; + agent: NamedAgent | null; + controller: CardController; + } + + let { + agents, + models, + ports, + }: { + /** The persisted Named Agents (seed for the cards; the store stays authoritative for consumers). */ + agents: readonly NamedAgent[]; + /** Available models (`<provider>/<model>`) — the source of the provider/model dropdowns. */ + models: readonly string[]; + ports: NamedAgentPorts; + } = $props(); + + // The view OWNS the editable card rows (each a controller + its last-saved + // canonical agent). Seeded once from the `agents` snapshot on mount (untracked + // so a later store update — which this view drove — never clobbers an + // in-flight draft). The store remains authoritative for other consumers. + let rows = $state<Row[]>([]); + let nextKey = $state(0); + let seeded = $state(false); + + $effect(() => { + untrack(() => { + if (seeded) return; + seeded = true; + for (const a of agents) addRow(a); + }); + }); + + /** + * The other rows' names (excluding the row with `rowKey`) for the uniqueness + * hint. Identity is the ROW's stable `key` — a draft (`agent === null`) must + * still exclude ITSELF (otherwise a fresh draft collides with its own name), + * while any OTHER draft is a genuine sibling. + */ + function otherNamesOf(rowKey: number): readonly string[] { + const out: string[] = []; + for (const r of rows) { + if (r.key === rowKey) continue; + out.push(r.agent !== null ? r.agent.name : r.controller.draft.name); + } + return out; + } + + /** + * Live uniqueness predicate for a card: true when `name` collides with a + * SIBLING row (excludes the card's own row by its stable `rowKey` — not by a + * nullable persisted id, so a draft excludes itself exactly once). + */ + function isNameTakenFor(rowKey: number): (name: string) => boolean { + return (name: string): boolean => { + const lower = name.trim().toLowerCase(); + for (const r of rows) { + if (r.key === rowKey) continue; + const candidate = r.agent !== null ? r.agent.name : r.controller.draft.name; + if (candidate.trim().toLowerCase() === lower) return true; + } + return false; + }; + } + + function addRow(agent: NamedAgent | null): void { + // Capture the row's stable key FIRST so the controller's callbacks reconcile + // THIS row only — never a sibling draft (`agent === null` matches all). + const key = nextKey++; + const controller = createCardController({ + agent, + savedModel: agent?.model ?? "", + isNameTaken: isNameTakenFor(key), + ports, + onChanged: (saved) => replaceAgent(key, saved), + onRemoved: () => removeRow(key), + }); + rows = [...rows, { key, agent, controller }]; + } + + function replaceAgent(key: number, saved: NamedAgent): void { + rows = rows.map((r) => (r.key === key ? { ...r, agent: saved } : r)); + } + + function removeRow(key: number): void { + const row = rows.find((r) => r.key === key); + if (row === undefined) return; + row.controller.dispose(); + rows = rows.filter((r) => r.key !== key); + } + + function addDraft(): void { + addRow(null); + } +</script> + +<div class="flex flex-col gap-2"> + <ul class="flex flex-col gap-2"> + {#each rows as row (row.key)} + <li> + <NamedAgentCard + models={models} + agent={row.agent} + controller={row.controller} + otherNames={otherNamesOf(row.key)} + /> + </li> + {/each} + </ul> + + {#if rows.length === 0} + <p class="text-xs opacity-60">No named agents yet.</p> + {/if} + + <button type="button" class="btn btn-ghost btn-xs w-fit" onclick={addDraft}> + + Add Named Agent + </button> +</div>
\ No newline at end of file diff --git a/src/features/named-agents/ui/NamedAgentsView.test.ts b/src/features/named-agents/ui/NamedAgentsView.test.ts new file mode 100644 index 0000000..bac4e25 --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentsView.test.ts @@ -0,0 +1,181 @@ +import { render, screen } from "@testing-library/svelte"; +import type { UserEvent } from "@testing-library/user-event"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + CreateNamedAgent, + DeleteNamedAgent, + NamedAgentInput, + UpdateNamedAgent, +} from "../logic/types"; +import type { NamedAgentPorts } from "./controller.svelte"; +import NamedAgentsView from "./NamedAgentsView.svelte"; + +// ── Fakes for the injected ports (ONLY the outermost edge — no sibling module +// is mocked). A modest `debounceMs` keeps name edits coalesced into one save +// (with 0, each keystroke fires its own update) while provider/model saves stay +// immediate (the model setter dispatches synchronously). +function makePorts(): { + ports: NamedAgentPorts; + created: NamedAgentInput[]; + updated: { id: string; input: NamedAgentInput }[]; + removed: string[]; +} { + const created: NamedAgentInput[] = []; + const updated: { id: string; input: NamedAgentInput }[] = []; + const removed: string[] = []; + const ports: NamedAgentPorts = { + create: (async (input) => { + created.push(input); + return { ok: true, agent: { id: `new-${created.length}`, ...input } }; + }) as CreateNamedAgent, + update: (async (id, input) => { + updated.push({ id, input }); + return { ok: true, agent: { id, ...input } }; + }) as UpdateNamedAgent, + remove: (async (id) => { + removed.push(id); + return { ok: true } as const; + }) as DeleteNamedAgent, + debounceMs: 150, + }; + return { ports, created, updated, removed }; +} + +const MODELS = ["opencode-1/claude-sonnet-4.5", "anthropic/sonnet"] as unknown as readonly string[]; + +const DUP_MESSAGE = "A named agent with this name already exists."; + +function renderView() { + const fakes = makePorts(); + render(NamedAgentsView, { props: { agents: [], models: MODELS, ports: fakes.ports } }); + return fakes; +} + +async function addDraft(user: UserEvent): Promise<void> { + await user.click(screen.getByRole("button", { name: "+ Add Named Agent" })); +} + +async function typeName(index: number, name: string): Promise<void> { + const inputs = screen.getAllByLabelText("Named agent name"); + const input = inputs[index]; + if (input === undefined) throw new Error(`No named-agent name input at index ${index}`); + await userEvent.setup().clear(input); + await userEvent.setup().type(input, name); +} + +async function completeDraft(index: number): Promise<void> { + const user = userEvent.setup(); + // Provider first (the model select is disabled until a provider is chosen). + const providerSelect = screen.getAllByLabelText("Named agent provider")[index]; + if (providerSelect === undefined) throw new Error(`No provider select at index ${index}`); + await user.selectOptions(providerSelect, "opencode-1"); + const modelSelect = screen.getAllByLabelText("Named agent model")[index]; + if (modelSelect === undefined) throw new Error(`No model select at index ${index}`); + await user.selectOptions(modelSelect, "claude-sonnet-4.5"); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("NamedAgentsView", () => { + it("a fresh single draft does not self-collide with its own name", async () => { + renderView(); + await addDraft(userEvent.setup()); + await typeName(0, "Coding Agent"); + + // The card is not yet usable (no model) so it shows a model hint, but it + // must NOT report its own draft name as an existing duplicate. + await screen.findByText("Select a provider."); + expect(screen.queryByText(DUP_MESSAGE)).toBeNull(); + }); + + it("two drafts with DISTINCT names neither self-collide", async () => { + const user = userEvent.setup(); + renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Writer"); + + // Each must see the OTHER's name as an other-name, never its OWN. + expect(screen.queryByText(DUP_MESSAGE)).toBeNull(); + }); + + it("two drafts with the SAME name are a genuine sibling collision (each flags it)", async () => { + const user = userEvent.setup(); + renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Coding Agent"); + + // Duplicate names among DISTINCT rows are invalid — both rows must report it + // (the self is excluded, but the sibling's identical name is a real collision). + expect((await screen.findAllByText(DUP_MESSAGE)).length).toBe(2); + }); + + it("saving one of multiple drafts persists only that row (distinct records)", async () => { + const user = userEvent.setup(); + const fakes = renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Writer"); + + // Both drafts complete to usable cards → each dispatches its OWN create. + await completeDraft(0); + await completeDraft(1); + + await vi.waitFor(() => expect(fakes.created).toHaveLength(2)); + expect(fakes.created[0]?.name).toBe("Coding Agent"); + expect(fakes.created[1]?.name).toBe("Writer"); + // Each persisted record got its own server-minted id (a single draft must + // not clobber its sibling's reconciling to the same record). + expect(await screen.findAllByText("Saved")).toHaveLength(2); + }); + + it("after a draft persists, a sibling draft does not self-collide and the saved row re-saves on edit", async () => { + const user = userEvent.setup(); + const fakes = renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await completeDraft(0); + await vi.waitFor(() => expect(fakes.created).toHaveLength(1)); + + // Add a second draft with a DIFFERENT name. After row 0 is persisted, this + // still-draft row must not treat its own name as a duplicate. + await addDraft(user); + await typeName(1, "Writer"); + expect(screen.queryByText(DUP_MESSAGE)).toBeNull(); + + // The saved card's controller now owns a real id → a later edit targets an + // UPDATE of the SAME row (id new-1), never a fresh create. + await typeName(0, "Coding Agent X"); + await vi.waitFor(() => expect(fakes.updated.length).toBeGreaterThanOrEqual(1)); + expect(fakes.updated[0]?.id).toBe("new-1"); + expect(fakes.updated[0]?.input.name).toBe("Coding Agent X"); + // Still exactly one create — the edit reconciled, not re-created. + expect(fakes.created).toHaveLength(1); + }); + + it("removing one of multiple drafts removes only that row", async () => { + const user = userEvent.setup(); + renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Writer"); + + // Remove the SECOND card — the first must survive. + const removeButtons = screen.getAllByRole("button", { name: "Remove named agent" }); + const second = removeButtons[1]; + if (second === undefined) throw new Error("Expected a second Remove button"); + await user.click(second); + + const inputs = await screen.findAllByLabelText("Named agent name"); + expect(inputs).toHaveLength(1); + expect((inputs[0] as HTMLInputElement).value).toBe("Coding Agent"); + }); +}); diff --git a/src/features/named-agents/ui/controller.svelte.ts b/src/features/named-agents/ui/controller.svelte.ts new file mode 100644 index 0000000..51c85b0 --- /dev/null +++ b/src/features/named-agents/ui/controller.svelte.ts @@ -0,0 +1,257 @@ +// Injected shell for Named Agent auto-save: owns the reactive per-card editing +// + save lifecycle (the thin rune wrapper over `logic/auto-save`), the debounce +// timer for name edits, and the monotonic sequence guard that stops an older +// save response clobbering a newer edit. No ambient state: ONE controller per +// card, created by the view and disposed on unmount. The network calls +// (create/update/delete) are INJECTED ports — this module never touches +// fetch/DOM. +// +// The pure model (`logic/auto-save`) does the status/validity reasoning; this +// controller only owns the effectful plumbing + the sequence guard. A live +// `isNameTaken` callback (supplied by the owning view against the CURRENT +// sibling list) keeps the controller from persisting a duplicate name. + +import { + type AgentDraft, + type AgentSaved, + draftModel, + type SaveStatus, + saveStatus, +} from "../logic/auto-save"; +import type { + CreateNamedAgent, + DeleteNamedAgent, + NamedAgent, + NamedAgentInput, + UpdateNamedAgent, +} from "../logic/types"; +import { isUsableModel, normalizeName } from "../logic/validate"; + +/** Named-agent edit port bundle (injected by the composition root). */ +export interface NamedAgentPorts { + create: CreateNamedAgent; + update: UpdateNamedAgent; + remove: DeleteNamedAgent; + /** Debounce delay (ms) for NAME edits only (provider/model saves are immediate). */ + debounceMs?: number; +} + +export interface CardSaveState { + /** The current reactive save status (drives the status line). */ + readonly status: SaveStatus; + /** A save-failure error message, or null. */ + readonly error: string | null; +} + +/** A fully-wired Named Agent card controller (one per card row). */ +export interface CardController { + /** The current reactive status + error. */ + readonly save: CardSaveState; + /** Read the current draft (const snapshots already applied via the setter methods). */ + readonly draft: AgentDraft; + /** Whether the card is currently awaiting a save response. */ + readonly saving: boolean; + /** Bind a provider id into the draft (immediate save once a model is chosen). */ + setProvider(provider: string): void; + /** Bind a model suffix under the current provider (immediate save). */ + setModel(model: string): void; + /** Bind a raw name edit (debounced save when it becomes usable). */ + setName(name: string): void; + /** Remove the Named Agent (for an existing card) — notifies via `onRemoved`. */ + remove(): void; + /** Dispose timers; call on unmount. */ + dispose(): void; +} + +export interface CardControllerOptions { + /** The existing Named Agent this card edits, or null for a new draft. */ + agent: NamedAgent | null; + /** The saved full `<provider>/<model>` ('' for a new draft). */ + savedModel: string; + /** + * Live uniqueness predicate: true when `name` is already taken by a SIBLING + * card (the view EXCLUDES this card's own id). Drives the controller's + * usable-name gate so a duplicate is never persisted. + */ + isNameTaken: (name: string) => boolean; + ports: NamedAgentPorts; + /** Fired with the canonical saved record when a create/update settles. */ + onChanged: (agent: NamedAgent) => void; + /** Fired when the card should leave the list (after a successful remove, or an unsaved draft). */ + onRemoved: () => void; +} + +/** + * A card controller bound to one Named Agent (existing) or a new draft. + * + * The controller owns ONLY this card's draft + save lifecycle; the sibling list + * (for uniqueness + reconciling the authoritative list after a save) is the + * parent view's. `onChanged` lets the parent replace the card's canonical record + * on success; `onRemoved` fires when the card is to be dropped. + */ +export function createCardController(opts: CardControllerOptions): CardController { + const debounceMs = opts.ports.debounceMs ?? 400; + + // ── The draft: raw form controls (name/provider/model) owned reactively. ── + const { agent, savedModel } = opts; + let id = $state<string | null>(agent?.id ?? null); + let name = $state<string>(agent?.name ?? ""); + let provider = $state<string>(savedModel === "" ? "" : (savedModel.split("/")[0] ?? "")); + let model = $state<string>( + savedModel === "" ? "" : savedModel.slice(savedModel.indexOf("/") + 1), + ); + + // ── Saved-state + in-flight/sequence tracking. ── + // `savedName`/`savedModelValue` = the last PERSISTED definition; `error` = + // last save failure. `editSeq` increments on every input change; a save + // captures the seq at dispatch time and applies its result ONLY if it is still + // the latest — an older response can never overwrite a newer edit (§5.4). + let savedName = $state<string>(agent?.name ?? ""); + let savedModelValue = $state<string>(savedModel); + let saving = $state(false); + let error = $state<string | null>(null); + + let editSeq = 0; + let nameTimer: ReturnType<typeof setTimeout> | null = null; + + function currentDraft(): AgentDraft { + return { id, name, provider, model }; + } + + function bump(): number { + return ++editSeq; + } + + function nameUsable(): boolean { + // A usable name is nonempty + not already taken by a sibling card. + const normalized = normalizeName(name); + return normalized !== "" && !opts.isNameTaken(normalized); + } + + function usable(full: string): boolean { + return nameUsable() && isUsableModel(full); + } + + // A save is owed + legal to dispatch right now when the card is usable and + // differs from the persisted definition (or is a fresh, usable draft). + function shouldSaveNow(): boolean { + const d = currentDraft(); + const full = draftModel(d); + if (!usable(full)) return false; + if (id === null && savedName === "" && savedModelValue === "") return true; // fresh draft + return normalizeName(d.name) !== savedName || full !== savedModelValue; + } + + function doSave(seq: number): void { + const d = currentDraft(); + const full = draftModel(d); + if (!usable(full)) return; + saving = true; + error = null; + + function settle(ok: boolean, saved?: NamedAgent, err?: string): void { + // Stale-response guard: only apply if this is still the latest edit. + if (seq !== editSeq) return; + saving = false; + if (ok && saved) { + savedName = saved.name; + savedModelValue = saved.model; + id = saved.id; + opts.onChanged(saved); + } else { + error = err ?? "Save failed"; + } + } + + const input: NamedAgentInput = { name: normalizeName(d.name), model: full }; + if (id === null) { + void opts.ports.create(input).then( + (r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)), + (e) => settle(false, undefined, String(e)), + ); + } else { + void opts.ports.update(id, input).then( + (r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)), + (e) => settle(false, undefined, String(e)), + ); + } + } + + function scheduleNameSave(): void { + if (nameTimer !== null) clearTimeout(nameTimer); + const seq = editSeq; + nameTimer = setTimeout(() => { + nameTimer = null; + if (shouldSaveNow()) doSave(seq); + }, debounceMs); + } + + function saveImmediately(): void { + if (nameTimer !== null) { + clearTimeout(nameTimer); + nameTimer = null; + } + const seq = editSeq; + if (shouldSaveNow()) doSave(seq); + } + + // Reactive derived status: from the current draft + saved + in-flight + error, + // via the PURE `saveStatus` (this shell supplies only the orchestration inputs). + const save = $derived.by<CardSaveState>(() => { + const d = currentDraft(); + const saved: AgentSaved = { id, name: savedName, model: savedModelValue }; + return { status: saveStatus(d, saved, saving, error), error }; + }); + + return { + get save(): CardSaveState { + return save; + }, + get draft(): AgentDraft { + return currentDraft(); + }, + get saving(): boolean { + return saving; + }, + setProvider(next: string): void { + bump(); + provider = next; + // Changing provider clears the prior model if it may not belong to the new + // one; the model dropdown re-populates from the new provider. Persist only + // once a valid model is selected (§5.5) → the model change saves immediately. + model = ""; + }, + setModel(next: string): void { + bump(); + model = next; + saveImmediately(); + }, + setName(next: string): void { + bump(); + name = next; + scheduleNameSave(); + }, + remove(): void { + if (id === null) { + // An unsaved draft — nothing server-side to delete. + opts.onRemoved(); + return; + } + saving = true; + void opts.ports.remove(id).then( + () => { + saving = false; + opts.onRemoved(); + }, + (e) => { + saving = false; + error = String(e); + }, + ); + }, + dispose(): void { + if (nameTimer !== null) clearTimeout(nameTimer); + nameTimer = null; + }, + }; +} diff --git a/src/features/named-agents/ui/controller.test.ts b/src/features/named-agents/ui/controller.test.ts new file mode 100644 index 0000000..1655a31 --- /dev/null +++ b/src/features/named-agents/ui/controller.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { NamedAgent, NamedAgentInput, NamedAgentSaveResult } from "../logic/types"; +import { createCardController, type NamedAgentPorts } from "./controller.svelte"; + +function fakePorts(overrides?: { + create?: NamedAgentPorts["create"]; + update?: NamedAgentPorts["update"]; + remove?: NamedAgentPorts["remove"]; + debounceMs?: number; +}): { + ports: NamedAgentPorts; + created: NamedAgentInput[]; + updated: Array<{ id: string; input: NamedAgentInput }>; + removed: string[]; +} { + const created: NamedAgentInput[] = []; + const updated: Array<{ id: string; input: NamedAgentInput }> = []; + const removed: string[] = []; + const settle = (r: NamedAgentSaveResult) => Promise.resolve(r); + return { + ports: { + create: + overrides?.create ?? + ((input) => { + created.push(input); + return settle({ ok: true, agent: { id: `new-${created.length}`, ...input } }); + }), + update: + overrides?.update ?? + ((id, input) => { + updated.push({ id, input }); + return settle({ ok: true, agent: { id, ...input } }); + }), + remove: + overrides?.remove ?? + ((id) => { + removed.push(id); + return Promise.resolve({ ok: true } as const); + }), + debounceMs: overrides?.debounceMs ?? 0, + } as NamedAgentPorts, + created, + updated, + removed, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("createCardController", () => { + it("creates a new draft when it becomes fully usable and the model is set", async () => { + vi.useFakeTimers(); + const { ports, created } = fakePorts(); + const changed: NamedAgent[] = []; + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: (a) => changed.push(a), + onRemoved: () => {}, + }); + c.setName("Coding Agent"); + c.setProvider("opencode-1"); + c.setModel("claude-sonnet-4.5"); + // The model setter saves immediately (the create is dispatched synchronously). + expect(created).toHaveLength(1); + expect(created[0]).toEqual({ name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }); + // The canonical record + onChanged land after the create promise resolves. + await vi.runAllTimersAsync(); + expect(changed).toHaveLength(1); + expect(changed[0]?.id).toBe("new-1"); + c.dispose(); + }); + + it("does not create with only a name (needs a provider + model)", () => { + vi.useFakeTimers(); + const { ports, created } = fakePorts(); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + c.setName("Coding Agent"); + vi.advanceTimersByTime(500); + expect(created).toHaveLength(0); + c.dispose(); + }); + + it("does not persist a name that is already taken", () => { + vi.useFakeTimers(); + const { ports, created } = fakePorts(); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: (name) => name.toLowerCase() === "coding agent", + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + c.setName("Coding Agent"); + c.setProvider("opencode-1"); + c.setModel("claude-sonnet-4.5"); + expect(created).toHaveLength(0); + c.dispose(); + }); + + it("updates an existing card and reports Saved once clean", async () => { + vi.useFakeTimers(); + const existing: NamedAgent = { + id: "a1", + name: "Coding", + model: "opencode-1/claude-sonnet-4.5", + }; + const { ports, updated } = fakePorts({ debounceMs: 100 }); + const c = createCardController({ + agent: existing, + savedModel: existing.model, + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + expect(c.save.status).toBe("saved"); + c.setName("Coding X"); + // Within the debounce window the edit is unsaved → "dirty". + await vi.advanceTimersByTimeAsync(20); + expect(c.save.status).toBe("dirty"); + // Past the debounce the name save fires → the update is dispatched. + await vi.advanceTimersByTimeAsync(200); + expect(updated).toHaveLength(1); + expect(updated[0]?.id).toBe("a1"); + expect(updated[0]?.input.name).toBe("Coding X"); + // After the update promise resolves the card reads clean → "saved". + await vi.runAllTimersAsync(); + expect(c.save.status).toBe("saved"); + c.dispose(); + }); + + it("marks the status as error and keeps the draft on save failure", async () => { + vi.useFakeTimers(); + const { ports } = fakePorts({ + create: () => Promise.resolve({ ok: false, error: "duplicate name" as const }), + }); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + c.setName("Coding"); + c.setProvider("opencode-1"); + c.setModel("claude"); + await vi.runAllTimersAsync(); + expect(c.save.status).toBe("error"); + expect(c.save.error).toBe("duplicate name"); + expect(c.draft.name).toBe("Coding"); + c.dispose(); + }); + + it("guards stale responses: a newer edit is not overwritten by an older save result", async () => { + vi.useFakeTimers(); + const pending: { resolve: ((r: NamedAgentSaveResult) => void) | null } = { resolve: null }; + const { ports } = fakePorts({ + create: () => new Promise<NamedAgentSaveResult>((res) => (pending.resolve = res)), + }); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + // Kick a save in flight, then edit again before it resolves. + c.setName("A"); + c.setProvider("opencode-1"); + c.setModel("claude"); + c.setName("B"); // sequence bumps past the in-flight save + expect(pending.resolve).not.toBeNull(); + pending.resolve?.({ ok: true, agent: { id: "x", name: "A", model: "opencode-1/claude" } }); + await vi.runAllTimersAsync(); + // The stale "A" create must NOT have applied the old result (saving cleared, + // but the draft still reads B and no onChanged with "A"). + expect(c.draft.name).toBe("B"); + c.dispose(); + }); + + it("remove() on an existing card calls remove and drops via onRemoved", async () => { + vi.useFakeTimers(); + const existing: NamedAgent = { + id: "a1", + name: "Coding", + model: "opencode-1/claude", + }; + const { ports, removed } = fakePorts(); + let removedCard = false; + const c = createCardController({ + agent: existing, + savedModel: existing.model, + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => (removedCard = true), + }); + c.remove(); + await vi.runAllTimersAsync(); + expect(removed).toEqual(["a1"]); + expect(removedCard).toBe(true); + c.dispose(); + }); + + it("remove() on an unsaved draft drops it without a server call", () => { + vi.useFakeTimers(); + const { ports, removed } = fakePorts(); + let removedCard = false; + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => (removedCard = true), + }); + c.remove(); + expect(removed).toHaveLength(0); + expect(removedCard).toBe(true); + c.dispose(); + }); +}); diff --git a/src/features/plugins/adapter/http.test.ts b/src/features/plugins/adapter/http.test.ts new file mode 100644 index 0000000..b1fe477 --- /dev/null +++ b/src/features/plugins/adapter/http.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPluginsHttp } from "./http"; + +/** A stub fetch that records requests and replies from a handler. */ +function stubFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) { + const impl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + return await handler(url, init); + }); + return impl as unknown as typeof fetch; +} + +const okJson = (body: unknown): Response => + ({ ok: true, status: 200, json: async () => body }) as unknown as Response; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("createPluginsHttp — manager routes (plan 05)", () => { + it("GET /plugins returns the parsed list (null on failure)", async () => { + const fetch1 = stubFetch(() => + okJson({ loaded: [], disabledWithReason: [], discovered: [], missing: [] }), + ); + const http = createPluginsHttp("http://x", fetch1); + await expect(http.list()).resolves.toEqual({ + loaded: [], + disabledWithReason: [], + discovered: [], + missing: [], + }); + + const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response); + await expect(createPluginsHttp("http://x", fetch2).list()).resolves.toBeNull(); + }); + + it("POST /plugins/scan posts to the scan route", async () => { + let seenUrl = ""; + let seenInit: RequestInit | undefined; + const fetch1 = stubFetch((url, init) => { + seenUrl = url; + seenInit = init; + return okJson({ root: "/plugins", newPlugins: [], invalid: [], incompatible: [] }); + }); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.scan(); + expect(result.ok).toBe(true); + expect(seenUrl).toBe("http://x/plugins/scan"); + expect(seenInit?.method).toBe("POST"); + }); + + it("POST /plugins/:id/unload sends the force flag in the body", async () => { + let seenBody: string | undefined; + const fetch1 = stubFetch((_url, init) => { + seenBody = init?.body as string; + return okJson({ ok: true, action: "unload", id: "mcp" }); + }); + const http = createPluginsHttp("http://x", fetch1); + await http.unload("mcp", true); + expect(JSON.parse(seenBody ?? "{}")).toEqual({ force: true }); + }); + + it("maps a blocked mutation (409) to ok:false with reason + dependents", async () => { + const fetch1 = stubFetch( + () => + ({ + ok: false, + status: 409, + json: async () => ({ + ok: false, + action: "unload", + id: "heartbeat", + reason: "cascade-needs-force", + message: "blocked", + dependents: ["transport-http"], + }), + }) as unknown as Response, + ); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.unload("heartbeat", false); + expect(result).toEqual({ + ok: false, + error: "blocked", + reason: "cascade-needs-force", + dependents: ["transport-http"], + }); + }); + + it("load and reload hit their routes with the encoded id", async () => { + const urls: string[] = []; + const fetch1 = stubFetch((url) => { + urls.push(url); + return okJson({ ok: true, action: "load", id: "a/b" }); + }); + const http = createPluginsHttp("http://x", fetch1); + await http.load("a/b"); + await http.reload("a/b"); + expect(urls).toEqual(["http://x/plugins/a%2Fb/load", "http://x/plugins/a%2Fb/reload"]); + }); +}); + +describe("createPluginsHttp — workspace routes (plan 06 §6.6)", () => { + it("GET /workspaces/:id/plugins returns the parsed response (null on failure)", async () => { + const fetch1 = stubFetch(() => + okJson({ workspaceId: "ws", plugins: [], enabledProviderCount: 2 }), + ); + const http = createPluginsHttp("http://x", fetch1); + await expect(http.workspacePlugins("ws")).resolves.toEqual({ + workspaceId: "ws", + plugins: [], + enabledProviderCount: 2, + }); + + const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response); + await expect(createPluginsHttp("http://x", fetch2).workspacePlugins("ws")).resolves.toBeNull(); + }); + + it("PUT /workspaces/:id/plugins/:pluginId sends the state body", async () => { + let seenUrl = ""; + let seenBody: string | undefined; + const fetch1 = stubFetch((url, init) => { + seenUrl = url; + seenBody = init?.body as string; + return okJson({ workspaceId: "ws", pluginId: "mcp", state: "enabled" }); + }); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.setWorkspaceState("ws", "mcp", "enabled"); + expect(result.ok).toBe(true); + expect(seenUrl).toBe("http://x/workspaces/ws/plugins/mcp"); + expect(JSON.parse(seenBody ?? "{}")).toEqual({ state: "enabled" }); + }); + + it("maps a 409 refusal (core-locked) to ok:false with the reason", async () => { + const fetch1 = stubFetch( + () => + ({ + ok: false, + status: 409, + json: async () => ({ + workspaceId: "ws", + pluginId: "kernel", + reason: "core-locked", + message: "core is locked", + }), + }) as unknown as Response, + ); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.setWorkspaceState("ws", "kernel", "disabled"); + expect(result).toEqual({ ok: false, error: "core is locked", reason: "core-locked" }); + }); + + it("GET providers-status returns the parsed response", async () => { + let seenUrl = ""; + const fetch1 = stubFetch((url) => { + seenUrl = url; + return okJson({ workspaceId: "ws", enabledProviderCount: 0 }); + }); + const http = createPluginsHttp("http://x", fetch1); + await expect(http.providersStatus("ws")).resolves.toEqual({ + workspaceId: "ws", + enabledProviderCount: 0, + }); + expect(seenUrl).toBe("http://x/workspaces/ws/plugins/providers-status"); + }); + + it("network throws degrade reads to null and mutations to ok:false", async () => { + const boom = vi.fn(async () => { + throw new Error("down"); + }) as unknown as typeof fetch; + const http = createPluginsHttp("http://x", boom); + await expect(http.list()).resolves.toBeNull(); + await expect(http.providersStatus("ws")).resolves.toBeNull(); + await expect(http.setWorkspaceState("ws", "mcp", "enabled")).resolves.toMatchObject({ + ok: false, + }); + }); +}); diff --git a/src/features/plugins/adapter/http.ts b/src/features/plugins/adapter/http.ts new file mode 100644 index 0000000..7814f4f --- /dev/null +++ b/src/features/plugins/adapter/http.ts @@ -0,0 +1,230 @@ +import type { + PluginListResponse, + PluginOpResponse, + PluginScanResponse, + SetWorkspacePluginStateBlocked, + SetWorkspacePluginStateRequest, + SetWorkspacePluginStateResponse, + WorkspacePluginProvidersStatusResponse, + WorkspacePluginsResponse, +} from "@dispatch/transport-contract"; + +/** + * Plugin HTTP effects — the injected edge that talks to the backend's plugin + * manager routes (plan 05) + per-workspace enablement routes (plan 06 §6.6). + * Mirrors the workspaces adapter's pattern: `httpBase` + an injected + * `fetchImpl` (testable without the network); reads return data or a safe + * `null` on failure (non-fatal), mutations return a typed result so a caller + * can surface the backend's `{ reason, message }` block. + * + * Endpoints: + * - `GET /plugins` → {@link PluginListResponse} (the two manager tables + missing strip) + * - `POST /plugins/scan` → {@link PluginScanResponse} (manual scan; never auto-loads) + * - `POST /plugins/:id/load` → {@link PluginOpResponse} + * - `POST /plugins/:id/unload` body `{ force }` → {@link PluginOpResponse} + * - `POST /plugins/:id/reload` → {@link PluginOpResponse} + * - `GET /workspaces/:id/plugins` → {@link WorkspacePluginsResponse} (READ triggers + * read-through materialization) + * - `PUT /workspaces/:id/plugins/:pluginId` body `{ state }` → + * {@link SetWorkspacePluginStateResponse} (409 `core-locked` / `unloaded`) + * - `GET /workspaces/:id/plugins/providers-status` → + * {@link WorkspacePluginProvidersStatusResponse} (zero-enabled-providers guardrail) + */ + +/** Outcome of a plugin-manager mutation (load / unload / reload). */ +export type PluginOpResult = + | { readonly ok: true; readonly response: Extract<PluginOpResponse, { ok: true }> } + | { + readonly ok: false; + readonly error: string; + readonly reason?: string; + /** For `cascade-needs-force`: the loaded dependents a force would remove. */ + readonly dependents?: readonly string[]; + }; + +/** Outcome of a per-workspace state write (`PUT .../plugins/:pluginId`). */ +export type WorkspaceStateResult = + | { readonly ok: true; readonly response: SetWorkspacePluginStateResponse } + | { readonly ok: false; readonly error: string; readonly reason?: string }; + +/** Outcome of the manual scan (`POST /plugins/scan`). */ +export type PluginScanResult = + | { readonly ok: true; readonly response: PluginScanResponse } + | { readonly ok: false; readonly error: string }; + +export interface PluginsHttp { + list(): Promise<PluginListResponse | null>; + scan(): Promise<PluginScanResult>; + load(id: string): Promise<PluginOpResult>; + unload(id: string, force: boolean): Promise<PluginOpResult>; + reload(id: string): Promise<PluginOpResult>; + /** Read one workspace's effective plugin states (triggers materialization). */ + workspacePlugins(workspaceId: string): Promise<WorkspacePluginsResponse | null>; + setWorkspaceState( + workspaceId: string, + pluginId: string, + state: "enabled" | "disabled" | "unset", + ): Promise<WorkspaceStateResult>; + /** The zero-enabled-providers signal for the active workspace (§6.5). */ + providersStatus(workspaceId: string): Promise<WorkspacePluginProvidersStatusResponse | null>; +} + +async function errorBody(res: Response): Promise<{ + error?: string; + reason?: string; + dependents?: readonly string[]; + message?: string; +}> { + try { + return (await res.json()) as { + error?: string; + reason?: string; + dependents?: readonly string[]; + message?: string; + }; + } catch { + return {}; + } +} + +/** + * Map a plugin-manager block/error response to a `PluginOpResult`. The + * backend's blocked shape is `{ ok: false, reason, message, dependents? }`; + * other failures degrade to `{ error }` only. + */ +async function opResult(res: Response): Promise<PluginOpResult> { + const body = await errorBody(res); + const message = body.message ?? body.error ?? `HTTP ${res.status}`; + return { + ok: false, + error: message, + ...(body.reason !== undefined ? { reason: body.reason } : {}), + ...(body.dependents !== undefined ? { dependents: body.dependents } : {}), + }; +} + +export function createPluginsHttp(httpBase: string, fetchImpl: typeof fetch): PluginsHttp { + const post = (path: string, body?: unknown): Promise<Response> => + fetchImpl(`${httpBase}${path}`, { + method: "POST", + ...(body !== undefined + ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } + : {}), + }); + + return { + async list(): Promise<PluginListResponse | null> { + try { + const res = await fetchImpl(`${httpBase}/plugins`); + if (!res.ok) return null; + return (await res.json()) as PluginListResponse; + } catch { + return null; + } + }, + + async scan(): Promise<PluginScanResult> { + try { + const res = await post("/plugins/scan"); + if (!res.ok) + return { ok: false, error: (await errorBody(res)).error ?? `HTTP ${res.status}` }; + return { ok: true, response: (await res.json()) as PluginScanResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Scan request failed" }; + } + }, + + async load(id): Promise<PluginOpResult> { + try { + const res = await post(`/plugins/${encodeURIComponent(id)}/load`); + if (!res.ok) return await opResult(res); + return { + ok: true, + response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Load request failed" }; + } + }, + + async unload(id, force): Promise<PluginOpResult> { + try { + const res = await post(`/plugins/${encodeURIComponent(id)}/unload`, { force }); + if (!res.ok) return await opResult(res); + return { + ok: true, + response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Unload request failed" }; + } + }, + + async reload(id): Promise<PluginOpResult> { + try { + const res = await post(`/plugins/${encodeURIComponent(id)}/reload`); + if (!res.ok) return await opResult(res); + return { + ok: true, + response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Reload request failed" }; + } + }, + + async workspacePlugins(workspaceId): Promise<WorkspacePluginsResponse | null> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins`, + ); + if (!res.ok) return null; + return (await res.json()) as WorkspacePluginsResponse; + } catch { + return null; + } + }, + + async setWorkspaceState(workspaceId, pluginId, state): Promise<WorkspaceStateResult> { + const body: SetWorkspacePluginStateRequest = { state }; + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/${encodeURIComponent(pluginId)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const blocked = (await errorBody(res)) as Partial<SetWorkspacePluginStateBlocked> & { + error?: string; + }; + return { + ok: false, + error: blocked.message ?? blocked.error ?? `HTTP ${res.status}`, + ...(blocked.reason !== undefined ? { reason: blocked.reason } : {}), + }; + } + return { ok: true, response: (await res.json()) as SetWorkspacePluginStateResponse }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set plugin state request failed", + }; + } + }, + + async providersStatus(workspaceId): Promise<WorkspacePluginProvidersStatusResponse | null> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`, + ); + if (!res.ok) return null; + return (await res.json()) as WorkspacePluginProvidersStatusResponse; + } catch { + return null; + } + }, + }; +} diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts new file mode 100644 index 0000000..f79ed98 --- /dev/null +++ b/src/features/plugins/index.ts @@ -0,0 +1,24 @@ +export type { + PluginOpResult, + PluginScanResult, + PluginsHttp, + WorkspaceStateResult, +} from "./adapter/http"; +export { createPluginsHttp } from "./adapter/http"; +export { computeCascade } from "./logic/cascade"; +export type { HighlightSets } from "./logic/highlight"; +export { dependencyHighlightSets } from "./logic/highlight"; +export { isLastProvider, unloadBlockReason } from "./logic/last-provider"; +export { autoCheckBackendPrereqs } from "./logic/prereqs"; +export type { + PluginNode, + PluginSection, + WorkspaceLayoutPort, +} from "./logic/types"; +export { TIER_ORDER, tierLabel } from "./logic/types"; +export { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./logic/view-model"; +export { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./logic/workspace-row"; +export type { PluginsStore, WorkspacePluginsView } from "./store.svelte"; +export { createPluginsStore } from "./store.svelte"; +export { default as PluginManager } from "./ui/PluginManager.svelte"; +export { default as WorkspacePluginModal } from "./ui/WorkspacePluginModal.svelte"; diff --git a/src/features/plugins/logic/cascade.test.ts b/src/features/plugins/logic/cascade.test.ts new file mode 100644 index 0000000..109c08b --- /dev/null +++ b/src/features/plugins/logic/cascade.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { computeCascade } from "./cascade"; +import { node } from "./test-fixtures"; + +describe("computeCascade", () => { + it("returns [] when the target has no dependents", () => { + const nodes = [node({ id: "a" }), node({ id: "b" })]; + expect(computeCascade("a", nodes)).toEqual([]); + }); + + it("returns the direct loaded dependents", () => { + const nodes = [ + node({ id: "heartbeat" }), + node({ id: "transport-http", dependsOn: ["heartbeat"] }), + ]; + expect(computeCascade("heartbeat", nodes)).toEqual(["transport-http"]); + }); + + it("is transitive (dependent of dependent)", () => { + const nodes = [ + node({ id: "a" }), + node({ id: "b", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(computeCascade("a", nodes)).toEqual(["b", "c"]); + }); + + it("does NOT traverse through an UNLOADED intermediate (loaded-subgraph closure — server mirror)", () => { + // c depends on b, b is unloaded: unloading a cascades to b only (b is not + // collected — unloaded) and does NOT reach c (its dep chain is already unmet). + const nodes = [ + node({ id: "a" }), + node({ id: "b", loaded: false, section: "discovered", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(computeCascade("a", nodes)).toEqual([]); + }); + + it("ignores plugins that merely share a dependency (direction matters)", () => { + const nodes = [node({ id: "dep" }), node({ id: "x", dependsOn: ["dep"] })]; + expect(computeCascade("x", nodes)).toEqual([]); + }); + + it("handles diamond dependencies without duplicates", () => { + const nodes = [ + node({ id: "root" }), + node({ id: "left", dependsOn: ["root"] }), + node({ id: "right", dependsOn: ["root"] }), + node({ id: "bottom", dependsOn: ["left", "right"] }), + ]; + expect(computeCascade("root", nodes)).toEqual(["bottom", "left", "right"]); + }); + + it("returns [] for a target that is not in the list", () => { + expect(computeCascade("ghost", [node({ id: "a" })])).toEqual([]); + }); + + it("is deterministic (alphabetical order)", () => { + const nodes = [ + node({ id: "root" }), + node({ id: "zeta", dependsOn: ["root"] }), + node({ id: "alpha", dependsOn: ["root"] }), + ]; + expect(computeCascade("root", nodes)).toEqual(["alpha", "zeta"]); + }); +}); diff --git a/src/features/plugins/logic/cascade.ts b/src/features/plugins/logic/cascade.ts new file mode 100644 index 0000000..0cc6834 --- /dev/null +++ b/src/features/plugins/logic/cascade.ts @@ -0,0 +1,42 @@ +import type { PluginNode } from "./types"; + +/** + * The transitive set of LOADED dependents that would also be unloaded if + * `targetId` were unloaded — the §8 cascade-uncheck warning's list ("unloading + * X also unchecks Y, Z"). Client mirror of the backend's + * `computeUnloadCascade` (plan 05): the reverse-dependency closure computed + * over the LOADED subgraph only (an unloaded intermediate breaks the chain — + * a loaded dependent behind it is already unmet and is not part of THIS + * cascade). The server still enforces the real cascade; the FE computes the + * same set to warn before the request goes out. + * + * Pure input → output; deterministic order (alphabetical by id) so tests and + * renderings are stable. + */ +export function computeCascade(targetId: string, nodes: readonly PluginNode[]): readonly string[] { + // Reverse edges within the loaded subgraph: dep id → loaded dependent ids. + const dependentsOf = new Map<string, string[]>(); + for (const node of nodes) { + if (!node.loaded) continue; + for (const dep of node.dependsOn) { + const list = dependentsOf.get(dep); + if (list) list.push(node.id); + else dependentsOf.set(dep, [node.id]); + } + } + + // BFS over reverse edges, collecting every reached loaded dependent. + const out: string[] = []; + const seen = new Set<string>([targetId]); + const queue = [targetId]; + while (queue.length > 0) { + const current = queue.shift() as string; + for (const dependent of dependentsOf.get(current) ?? []) { + if (seen.has(dependent)) continue; + seen.add(dependent); + out.push(dependent); + queue.push(dependent); + } + } + return out.sort((a, b) => a.localeCompare(b)); +} diff --git a/src/features/plugins/logic/highlight.test.ts b/src/features/plugins/logic/highlight.test.ts new file mode 100644 index 0000000..616f7ad --- /dev/null +++ b/src/features/plugins/logic/highlight.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { dependencyHighlightSets } from "./highlight"; +import { node } from "./test-fixtures"; + +describe("dependencyHighlightSets", () => { + const nodes = [ + node({ id: "lib", loaded: false, section: "discovered" }), + node({ id: "mid", dependsOn: ["lib"] }), + node({ id: "app", dependsOn: ["mid", "lib"] }), + ]; + + it("returns empty sets for no hover", () => { + const sets = dependencyHighlightSets(null, nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents.size).toBe(0); + }); + + it("hovering an item highlights its DIRECT dependencies (one hop)", () => { + const sets = dependencyHighlightSets("app", nodes); + expect(sets.deps).toEqual(new Set(["mid", "lib"])); + expect(sets.dependents.size).toBe(0); + }); + + it("hovering a dependency highlights its dependents (both directions work)", () => { + const sets = dependencyHighlightSets("lib", nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents).toEqual(new Set(["mid", "app"])); + }); + + it("hovering a middle node highlights BOTH directions", () => { + const sets = dependencyHighlightSets("mid", nodes); + expect(sets.deps).toEqual(new Set(["lib"])); + expect(sets.dependents).toEqual(new Set(["app"])); + }); + + it("is one hop — not the transitive closure", () => { + // app → mid → lib: hovering app highlights mid + lib (both direct deps), + // but hovering lib highlights only DIRECT dependents (mid, app) — already + // one hop. The one-hop rule shows in: a plugin two hops away via an + // unrelated chain is not highlighted. + const chain = [ + node({ id: "a" }), + node({ id: "b", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(dependencyHighlightSets("c", chain).deps).toEqual(new Set(["b"])); + }); + + it("returns empty sets for an unknown hovered id", () => { + const sets = dependencyHighlightSets("ghost", nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents.size).toBe(0); + }); + + it("works across sections (a loaded row highlights an unloaded dependency)", () => { + // `app` loaded, `lib` discovered: hovering app still highlights lib. + const sets = dependencyHighlightSets("app", nodes); + expect(sets.deps.has("lib")).toBe(true); + }); +}); diff --git a/src/features/plugins/logic/highlight.ts b/src/features/plugins/logic/highlight.ts new file mode 100644 index 0000000..1bcfaad --- /dev/null +++ b/src/features/plugins/logic/highlight.ts @@ -0,0 +1,37 @@ +import type { PluginNode } from "./types"; + +/** The two highlight sets for the §8 bidirectional dependency hover. */ +export interface HighlightSets { + /** The hovered row's DIRECT dependencies (highlighted when hovering an item). */ + readonly deps: ReadonlySet<string>; + /** The loaded/unloaded rows that DIRECTLY depend on the hovered row + * (highlighted when hovering a dependency). */ + readonly dependents: ReadonlySet<string>; +} + +const EMPTY: HighlightSets = { deps: new Set(), dependents: new Set() }; + +/** + * Bidirectional hover highlighting (§8): hover any item → its DIRECT + * dependencies highlight; hover a dependency → its dependents highlight. + * One hop in each direction (the plan's grammar is "its dependencies" / + * "its dependents" — the visual neighborhood, not the transitive closure; + * the transitive closures live in `computeCascade` / `autoCheckBackendPrereqs`). + * + * Works across BOTH manager tables (a loaded row can highlight an unloaded + * dependency row and vice versa) — the node list spans the whole catalog. + * Pure input → output; `hoveredId` null (no hover) → both sets empty. + */ +export function dependencyHighlightSets( + hoveredId: string | null, + nodes: readonly PluginNode[], +): HighlightSets { + if (hoveredId === null) return EMPTY; + const hovered = nodes.find((n) => n.id === hoveredId); + if (hovered === undefined) return EMPTY; + const deps = new Set(hovered.dependsOn); + const dependents = new Set( + nodes.filter((n) => n.id !== hoveredId && n.dependsOn.includes(hoveredId)).map((n) => n.id), + ); + return { deps, dependents }; +} diff --git a/src/features/plugins/logic/last-provider.test.ts b/src/features/plugins/logic/last-provider.test.ts new file mode 100644 index 0000000..f85ba89 --- /dev/null +++ b/src/features/plugins/logic/last-provider.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { isLastProvider, unloadBlockReason } from "./last-provider"; +import { node } from "./test-fixtures"; + +describe("isLastProvider", () => { + it("is true for a loaded provider when it is the only one", () => { + const nodes = [node({ id: "opencode", tier: "providers" })]; + expect(isLastProvider("opencode", nodes)).toBe(true); + }); + + it("is false for a loaded provider when another provider is loaded", () => { + const nodes = [ + node({ id: "opencode", tier: "providers" }), + node({ id: "kimi", tier: "providers" }), + ]; + expect(isLastProvider("opencode", nodes)).toBe(false); + }); + + it("is false for a non-provider target (even when alone)", () => { + const nodes = [node({ id: "solo", tier: "standard" })]; + expect(isLastProvider("solo", nodes)).toBe(false); + }); + + it("is false for an UNLOADED provider", () => { + const nodes = [node({ id: "opencode", tier: "providers", loaded: false })]; + expect(isLastProvider("opencode", nodes)).toBe(false); + }); + + it("is false for an unknown id", () => { + expect(isLastProvider("ghost", [])).toBe(false); + }); + + it("is false for a core-only list (no providers at all)", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + expect(isLastProvider("kernel", nodes)).toBe(false); + }); +}); + +describe("unloadBlockReason", () => { + it("explains the core lock", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + const reason = unloadBlockReason("kernel", nodes); + expect(reason).toContain("Core plugins cannot be unloaded"); + }); + + it("explains the last-provider lock", () => { + const nodes = [node({ id: "opencode", tier: "providers" })]; + const reason = unloadBlockReason("opencode", nodes); + expect(reason).toContain("last provider"); + }); + + it("is null for an interactive row", () => { + const nodes = [ + node({ id: "opencode", tier: "providers" }), + node({ id: "kimi", tier: "providers" }), + node({ id: "app" }), + ]; + expect(unloadBlockReason("app", nodes)).toBeNull(); + expect(unloadBlockReason("opencode", nodes)).toBeNull(); + }); + + it("prefers the core lock when both would apply", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + expect(unloadBlockReason("kernel", nodes)).toContain("Core"); + }); + + it("is null for an unknown id", () => { + expect(unloadBlockReason("ghost", [])).toBeNull(); + }); +}); diff --git a/src/features/plugins/logic/last-provider.ts b/src/features/plugins/logic/last-provider.ts new file mode 100644 index 0000000..279df4d --- /dev/null +++ b/src/features/plugins/logic/last-provider.ts @@ -0,0 +1,31 @@ +import type { PluginNode } from "./types"; + +/** + * Whether unloading `targetId` would remove the LAST loaded provider — the + * §8 hard lock (the one unload the server refuses outright, never force-able). + * The UI disables the control with an explanatory tooltip; the server still + * enforces the rule. Pure input → output. + */ +export function isLastProvider(targetId: string, nodes: readonly PluginNode[]): boolean { + const target = nodes.find((n) => n.id === targetId); + if (target === undefined || !target.loaded || target.tier !== "providers") return false; + const loadedProviders = nodes.filter((n) => n.loaded && n.tier === "providers"); + return loadedProviders.length <= 1; +} + +/** + * Why the row's load/unload control is disabled, or null when it is + * interactive. Composes the two client-mirrored locks: + * - tier "core" (`locked`) → the server refuses to unload it (§3/§5); + * - the last loaded provider → the §8 hard lock. + * Missing rows have no control at all (handled separately by the UI). + */ +export function unloadBlockReason(targetId: string, nodes: readonly PluginNode[]): string | null { + const target = nodes.find((n) => n.id === targetId); + if (target === undefined) return null; + if (target.locked) return "Core plugins cannot be unloaded — they are part of the runtime."; + if (isLastProvider(targetId, nodes)) { + return "Cannot unload the last provider — Dispatch needs at least one to pick models."; + } + return null; +} diff --git a/src/features/plugins/logic/prereqs.test.ts b/src/features/plugins/logic/prereqs.test.ts new file mode 100644 index 0000000..704399c --- /dev/null +++ b/src/features/plugins/logic/prereqs.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { autoCheckBackendPrereqs } from "./prereqs"; +import { node } from "./test-fixtures"; + +describe("autoCheckBackendPrereqs", () => { + it("returns [] when all dependencies are already loaded", () => { + const nodes = [node({ id: "dep" }), node({ id: "app", dependsOn: ["dep"] })]; + expect(autoCheckBackendPrereqs("app", nodes)).toEqual([]); + }); + + it("returns the unloaded direct dependencies (to auto-check)", () => { + const nodes = [ + node({ id: "dep", loaded: false, section: "discovered" }), + node({ id: "other", loaded: false, section: "discovered" }), + node({ id: "loaded-dep" }), + node({ id: "app", dependsOn: ["dep", "other", "loaded-dep"] }), + ]; + // Only the UNLOADED deps are returned (loaded-dep needs no auto-check). + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["dep", "other"]); + }); + + it("is transitive (dep of a dep)", () => { + const nodes = [ + node({ id: "base", loaded: false, section: "discovered" }), + node({ id: "mid", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "app", dependsOn: ["mid"] }), + ]; + // Load order: dependencies BEFORE their dependents. + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["mid", "base"]); + }); + + it("is one-directional: it never pulls DEPENDENTS in (no reverse traversal)", () => { + // Checking `dep` must not auto-check `app` (app depends on dep, not vice versa). + const nodes = [ + node({ id: "dep", loaded: false, section: "discovered" }), + node({ id: "app", dependsOn: ["dep"] }), + ]; + expect(autoCheckBackendPrereqs("dep", nodes)).toEqual([]); + }); + + it("excludes the target itself and skips already-seen nodes (diamond)", () => { + const nodes = [ + node({ id: "base", loaded: false, section: "discovered" }), + node({ id: "left", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "right", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "app", dependsOn: ["left", "right"] }), + ]; + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["left", "right", "base"]); + }); + + it("returns [] for an unknown target", () => { + expect(autoCheckBackendPrereqs("ghost", [node({ id: "a" })])).toEqual([]); + }); + + it("returns [] for a target with no dependencies", () => { + expect(autoCheckBackendPrereqs("app", [node({ id: "app" })])).toEqual([]); + }); +}); diff --git a/src/features/plugins/logic/prereqs.ts b/src/features/plugins/logic/prereqs.ts new file mode 100644 index 0000000..9066413 --- /dev/null +++ b/src/features/plugins/logic/prereqs.ts @@ -0,0 +1,45 @@ +import type { PluginNode } from "./types"; + +/** + * When CHECKING (loading) `targetId`, the transitive set of its dependencies + * that are NOT currently loaded — the plugins that must be auto-checked + * (loaded) first (§8: "Checking a frontend plugin auto-checks its required + * backend plugins (transitively)"). + * + * One-directional by construction: only `dependsOn` edges FROM the target are + * followed (frontend→backend / backend→backend). The reverse direction is + * NEVER traversed here — backend plugins never depend on frontend ones (§8), + * so a dependency auto-check never pulls the other way. + * + * Returns the ids in deterministic load order (dependencies before their + * dependents — alphabetical within a level) so the caller can issue the load + * requests in a safe sequence. Pure input → output. + */ +export function autoCheckBackendPrereqs( + targetId: string, + nodes: readonly PluginNode[], +): readonly string[] { + const byId = new Map(nodes.map((n) => [n.id, n])); + + // BFS over dependency edges (target → deps), collecting UNLOADED deps. + const out: string[] = []; + const seen = new Set<string>([targetId]); + // Queue of [id, depth] — sorted per level so ties resolve alphabetically. + let frontier: string[] = [targetId]; + while (frontier.length > 0) { + const next: string[] = []; + for (const id of frontier) { + const node = byId.get(id); + for (const dep of node?.dependsOn ?? []) { + if (seen.has(dep)) continue; + seen.add(dep); + next.push(dep); + if (!byId.get(dep)?.loaded) out.push(dep); + } + } + frontier = next.sort((a, b) => a.localeCompare(b)); + } + // Load order: dependencies before dependents (BFS level order already is), + // alphabetical within a level. + return out; +} diff --git a/src/features/plugins/logic/test-fixtures.ts b/src/features/plugins/logic/test-fixtures.ts new file mode 100644 index 0000000..e7b3f5d --- /dev/null +++ b/src/features/plugins/logic/test-fixtures.ts @@ -0,0 +1,30 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import type { PluginNode } from "./types"; +import { pluginNodes } from "./view-model"; + +/** + * Build a PluginNode directly (the graph-logic tests' fixture). Defaults: + * loaded, standard tier, no deps. + */ +export function node(overrides: Partial<PluginNode> = {}): PluginNode { + return { + id: "p", + name: "p", + version: "1.0.0", + tier: "standard", + trust: "bundled", + loaded: true, + locked: false, + dependsOn: [], + reason: null, + section: "loaded", + ...overrides, + }; +} + +/** An empty `GET /plugins` response. */ +export function emptyListResponse(): PluginListResponse { + return { loaded: [], disabledWithReason: [], discovered: [], missing: [] }; +} + +export { pluginNodes }; diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts new file mode 100644 index 0000000..e64b553 --- /dev/null +++ b/src/features/plugins/logic/types.ts @@ -0,0 +1,99 @@ +import type { PluginTier, PluginTrustLevel } from "@dispatch/transport-contract"; + +/** Which manager table a plugin row belongs to (the response's row kinds). */ +export type PluginSection = "loaded" | "discovered" | "missing"; + +/** + * The manager's normalized view of one plugin — a single shape across the + * `GET /plugins` response's row kinds (loaded / disabledWithReason / + * discovered / missing) so the pure graph logic (cascade / prereqs / + * highlight / last-provider) and the manager tables work over ONE node type. + * Built by `pluginNodes` (`logic/view-model`) from a `PluginListResponse`. + */ +export interface PluginNode { + readonly id: string; + readonly name: string; + readonly version: string; + readonly tier: PluginTier; + /** Trust level, when the row carries one (missing rows don't). */ + readonly trust: PluginTrustLevel | null; + /** Loaded and live — the manager's checkbox reflects this. */ + readonly loaded: boolean; + /** True for tier "core" — the server refuses to unload it (locked control). */ + readonly locked: boolean; + /** Declared dependencies (plugin ids), possibly empty. */ + readonly dependsOn: readonly string[]; + /** Why the plugin is not loaded (disabled-with-reason) or gone (missing). */ + readonly reason: string | null; + /** + * The frontend-plugin (plan 03) issue line for the manager row, when the + * plugin's FE half is incompatible ("incompatible frontend plugin") or + * failed (`frontend failed: <error>`). Absent = the FE half is fine (or + * the plugin declares no frontend section). + */ + readonly frontendIssue?: string; + readonly section: PluginSection; +} + +/** Display labels for the classification tiers (backend-canonical values). */ +export function tierLabel(tier: PluginTier): string { + switch (tier) { + case "core": + return "Core"; + case "providers": + return "Provider"; + case "standard": + return "Standard"; + } +} + +/** Display order of the tiers within a manager table (Core first). */ +export const TIER_ORDER: readonly PluginTier[] = ["core", "providers", "standard"]; + +/** + * The layout port (plan 04 §4.4) the workspace plugins modal needs for the + * "Reset layout to default" affordance — the modal is the natural home (it + * is where users manage the layout plugins themselves). Wired by the + * composition root over the layout store; absent → the section doesn't + * render (tests). + */ +export interface WorkspaceLayoutPort { + /** + * Whether a layout provider is active (the reset button's gate). False → + * the button is DISABLED with the reason shown ("no layout plugin + * enabled") — there is nothing to reset and no persistence to re-seed. + */ + readonly providerActive: boolean; + /** + * Reset the workspace's layout to the default seed (`DELETE + * /workspaces/:id/layout` → re-seed → broadcast). Resolves to `null` on + * success or to the failure reason to display. + */ + readonly reset: (workspaceId: string) => Promise<string | null>; + /** + * The workspace's PER-WORKSPACE PLUGIN STATE changed (the modal just + * toggled a plugin's enablement): the layout plugin that serves THIS + * workspace may have changed (the per-workspace activation rule) — the + * composition root re-probes the layout so the newly-enabled provider's + * tree (or the no-provider degrade) lands without a reload. Only the + * ACTIVE workspace's toggle re-renders; others are ignored by the store. + */ + readonly refresh: (workspaceId: string) => void; +} + +/** + * Runtime-normalize a tier value to the canonical THREE: anything that is not + * exactly `core` | `providers` | `standard` — a missing tier, or a value + * outside the contract ("kernel" is NOT a tier; the kernel hosts plugins, it + * is not one) — folds into `"standard"`, the safe catch-all classification. + * The wire contract pins the three values, so this only ever fires on a + * contract violation (the backend rejects tierless externals at boot and + * directory-scanned plugins fail manifest validation); folding rather than + * surfacing the raw value keeps every tier-keyed consumer total — e.g. + * `tierLabel`'s exhaustive switch (an out-of-contract value would render an + * empty badge) and the manager table's keyed grouping (a non-canonical key + * would create a second, colliding group). + */ +export function normalizeTier(tier: unknown): PluginTier { + return tier === "core" || tier === "providers" || tier === "standard" ? tier : "standard"; +} diff --git a/src/features/plugins/logic/view-model.test.ts b/src/features/plugins/logic/view-model.test.ts new file mode 100644 index 0000000..661cade --- /dev/null +++ b/src/features/plugins/logic/view-model.test.ts @@ -0,0 +1,296 @@ +import type { PluginListResponse, PluginTier } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { emptyListResponse, node } from "./test-fixtures"; +import { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./view-model"; + +function listResponse(overrides: Partial<PluginListResponse> = {}): PluginListResponse { + return { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: ["kernel"], + }, + ], + disabledWithReason: [ + { + id: "broken", + name: "Broken", + version: "0.1.0", + tier: "standard", + trust: "local", + reason: "activation threw", + }, + ], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [{ id: "gone", reason: "directory is gone", disabledAt: "2026-01-01" }], + ...overrides, + }; +} + +describe("pluginNodes", () => { + it("normalizes every row kind into one node shape", () => { + const nodes = pluginNodes(listResponse()); + expect(nodes.map((n) => n.id)).toEqual(["kernel", "opencode", "broken", "fresh", "gone"]); + }); + + it("maps loaded rows (loaded: true, locked from the row, deps kept)", () => { + const nodes = pluginNodes(listResponse()); + const kernel = nodes.find((n) => n.id === "kernel"); + expect(kernel).toMatchObject({ + loaded: true, + locked: true, + tier: "core", + section: "loaded", + dependsOn: [], + reason: null, + }); + const opencode = nodes.find((n) => n.id === "opencode"); + expect(opencode).toMatchObject({ loaded: true, locked: false, dependsOn: ["kernel"] }); + }); + + it("maps disabledWithReason rows (unloaded, reason kept, discovered section)", () => { + const nodes = pluginNodes(listResponse()); + const broken = nodes.find((n) => n.id === "broken"); + expect(broken).toMatchObject({ + loaded: false, + locked: false, + reason: "activation threw", + section: "discovered", + }); + }); + + it("maps discovered rows (unloaded, no reason)", () => { + const nodes = pluginNodes(listResponse()); + const fresh = nodes.find((n) => n.id === "fresh"); + expect(fresh).toMatchObject({ loaded: false, reason: null, section: "discovered" }); + }); + + it("maps missing rows (greyed strip; no trust/version)", () => { + const nodes = pluginNodes(listResponse()); + const gone = nodes.find((n) => n.id === "gone"); + expect(gone).toMatchObject({ + loaded: false, + section: "missing", + reason: "directory is gone", + trust: null, + version: "", + }); + }); + + it("handles the empty response", () => { + expect(pluginNodes(emptyListResponse())).toEqual([]); + }); + + it("normalizes a missing/unknown tier at the boundary so node tiers are canonical", () => { + // Contract-violation casts: the wire type pins three tier values, but the + // view-model must survive a backend that emits a tierless or out-of- + // contract row (the original duplicate-key crash trigger). + const response = listResponse({ + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "tierless", + name: "Tierless", + version: "1.0.0", + tier: undefined as unknown as PluginTier, + trust: "external", + locked: false, + dependsOn: [], + }, + { + id: "kernelish", + name: "Kernelish", + version: "1.0.0", + tier: "kernel" as unknown as PluginTier, + trust: "external", + locked: false, + dependsOn: [], + }, + ], + }); + const nodes = pluginNodes(response); + expect(nodes.find((n) => n.id === "tierless")?.tier).toBe("standard"); + expect(nodes.find((n) => n.id === "kernelish")?.tier).toBe("standard"); + + // The Loaded table's grouping: exactly two groups (core + standard), no + // duplicate keys, every row present. + const groups = groupByTier(nodes.filter((n) => n.section === "loaded")); + const keys = groups.map((g) => g.tier); + expect(new Set(keys).size).toBe(keys.length); + expect(keys).toEqual(["core", "standard"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["kernel"]); + expect(groups[1]?.rows.map((r) => r.id)).toEqual(["kernelish", "tierless"]); + }); +}); + +describe("groupByTier", () => { + it("clusters rows in tier display order (Core → Providers → Standard)", () => { + const nodes = pluginNodes(listResponse()); + const groups = groupByTier(nodes.filter((n) => n.section === "loaded")); + expect(groups.map((g) => g.tier)).toEqual(["core", "providers"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["kernel"]); + }); + + it("omits empty tiers", () => { + const groups = groupByTier([pluginNodes(listResponse())[2] ?? []].flat()); + expect(groups.map((g) => g.tier)).toEqual(["standard"]); + }); + + it("emits one group per canonical tier, all three present", () => { + const groups = groupByTier([ + node({ id: "s", tier: "standard" }), + node({ id: "c", tier: "core" }), + node({ id: "p", tier: "providers" }), + ]); + expect(groups.map((g) => g.tier)).toEqual(["core", "providers", "standard"]); + expect(groups.flatMap((g) => g.rows.map((r) => r.id))).toEqual(["c", "p", "s"]); + }); + + it("never emits duplicate group keys — an unknown tier folds into standard", () => { + // Contract violation cast: "kernel" is NOT a tier (the kernel hosts + // plugins, it is not one). groupByTier must stay total anyway. + const groups = groupByTier([ + node({ id: "s-row", tier: "standard" }), + node({ id: "k-row", tier: "kernel" as PluginTier }), + ]); + const keys = groups.map((g) => g.tier); + // No duplicates (the keyed each `(group.tier)` crashes on them)… + expect(new Set(keys).size).toBe(keys.length); + // …and the unknown-tier row is NOT dropped — it merges into standard. + expect(keys).toEqual(["standard"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["k-row", "s-row"]); + }); + + it("never emits duplicate group keys — a missing tier folds into standard", () => { + const groups = groupByTier([ + node({ id: "s-row", tier: "standard" }), + node({ id: "m-row", tier: undefined as unknown as PluginTier }), + ]); + const keys = groups.map((g) => g.tier); + expect(new Set(keys).size).toBe(keys.length); + expect(keys).toEqual(["standard"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["m-row", "s-row"]); + }); + + it("keeps unknown/missing tiers out of the canonical groups' identity (no second standard group)", () => { + // The old implementation pushed a trailing "rest" group keyed + // `rest[0]?.tier ?? "standard"` — a DUPLICATE standard key when canonical + // standard rows existed (the headless-Chromium each_key_duplicate crash). + const groups = groupByTier([ + node({ id: "a", tier: "standard" }), + node({ id: "b", tier: "bogus" as PluginTier }), + node({ id: "c", tier: undefined as unknown as PluginTier }), + ]); + expect(groups.map((g) => g.tier)).toEqual(["standard"]); + expect(groups[0]?.rows).toHaveLength(3); + }); +}); + +describe("splitSections", () => { + it("splits the node list into the two tables + the missing strip", () => { + const sections = splitSections(pluginNodes(listResponse())); + expect(sections.loaded.map((n) => n.id)).toEqual(["kernel", "opencode"]); + expect(sections.discovered.map((n) => n.id)).toEqual(["broken", "fresh"]); + expect(sections.missing.map((n) => n.id)).toEqual(["gone"]); + }); +}); + +// ── withFrontendStates (plan 03) — the manager rows' FE-half issues ───────── + +describe("withFrontendStates", () => { + function listWith(...ids: readonly string[]): PluginListResponse { + return { + loaded: ids.map((id) => ({ + id, + name: id, + version: "1.0.0", + tier: "standard" as const, + trust: "local" as const, + locked: false, + dependsOn: [], + })), + disabledWithReason: [], + discovered: [], + missing: [], + }; + } + + it("renders the plan-mandated grammar for an incompatible FE half", () => { + const nodes = pluginNodes(listWith("bad")); + const states = new Map([["bad", { kind: "incompatible", apiVersion: "^2" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out[0]?.frontendIssue).toBe("incompatible frontend plugin"); + }); + + it("renders the activation error for a failed FE half", () => { + const nodes = pluginNodes(listWith("broken")); + const states = new Map([["broken", { kind: "failed", error: "boom" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out[0]?.frontendIssue).toBe("frontend failed: boom"); + }); + + it("a loaded FE half adds no issue line; absent states add none", () => { + const nodes = pluginNodes(listWith("ok", "no-fe")); + const states = new Map([["ok", { kind: "loaded" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out.find((n) => n.id === "ok")?.frontendIssue).toBeUndefined(); + expect(out.find((n) => n.id === "no-fe")?.frontendIssue).toBeUndefined(); + }); + + it("never annotates non-loaded sections (discovered / missing rows)", () => { + const response: PluginListResponse = { + loaded: [], + disabledWithReason: [], + discovered: [ + { + id: "dis", + name: "dis", + version: "1.0.0", + tier: "standard", + trust: "local", + dir: "/plugins/dis", + }, + ], + missing: [], + }; + const states = new Map([["dis", { kind: "failed", error: "boom" } as const]]); + const out = withFrontendStates(pluginNodes(response), states); + expect(out[0]?.frontendIssue).toBeUndefined(); + }); + + it("an empty state map is the identity (no allocation on the hot path)", () => { + const nodes = pluginNodes(listWith("a")); + expect(withFrontendStates(nodes, new Map())).toBe(nodes); + }); +}); diff --git a/src/features/plugins/logic/view-model.ts b/src/features/plugins/logic/view-model.ts new file mode 100644 index 0000000..5a66ffa --- /dev/null +++ b/src/features/plugins/logic/view-model.ts @@ -0,0 +1,160 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import type { FrontendPluginState } from "../../../core/plugin-abi"; +import { normalizeTier, type PluginNode, type PluginSection, TIER_ORDER } from "./types"; + +/** + * Normalize a `GET /plugins` response into the flat `PluginNode` list the + * manager tables + graph logic consume. Each response row kind maps to a + * node: + * - `loaded` → section "loaded" (`loaded: true`, `locked` from the row — + * tier "core" is always locked); + * - `disabledWithReason` → section "discovered" with the refusal `reason` + * (present on disk, refused/failed — greyed rows with a visible why); + * - `discovered` → section "discovered" (`loaded: false`, unchecked); + * - `missing` → section "missing" (override record whose directory is gone). + * + * Pure input → output; zero DOM/fetch. + */ +export function pluginNodes(response: PluginListResponse): readonly PluginNode[] { + const nodes: PluginNode[] = []; + for (const row of response.loaded) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + // Normalize at the boundary: a missing/out-of-contract tier (backend + // bug, stale contract) folds into "standard" so every node's tier is + // canonical and the tier-keyed consumers below stay total. + tier: normalizeTier(row.tier), + trust: row.trust, + loaded: true, + locked: row.locked, + dependsOn: row.dependsOn, + reason: null, + section: "loaded", + }); + } + for (const row of response.disabledWithReason) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + tier: normalizeTier(row.tier), + trust: row.trust, + loaded: false, + locked: false, + dependsOn: [], + reason: row.reason, + section: "discovered", + }); + } + for (const row of response.discovered) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + tier: normalizeTier(row.tier), + trust: row.trust, + loaded: false, + locked: false, + dependsOn: [], + reason: null, + section: "discovered", + }); + } + for (const row of response.missing) { + nodes.push({ + id: row.id, + name: row.id, + version: "", + tier: "standard", + trust: null, + loaded: false, + locked: false, + dependsOn: [], + reason: row.reason ?? "directory is gone", + section: "missing", + }); + } + return nodes; +} + +/** + * Merge the frontend-plugin (plan 03) per-plugin FE states into the node + * list — the manager rows' `frontendIssue` line. Pure input → output: + * - `incompatible` → the plan-mandated "incompatible frontend plugin" + * grammar (the backend apiVersion-mismatch wording, FE half); + * - `failed` → the activation error (the FE half failed; the backend half + * stays loaded). + * Loaded rows only — the FE states never apply to discovered/missing rows. + */ +export function withFrontendStates( + nodes: readonly PluginNode[], + states: ReadonlyMap<string, FrontendPluginState>, +): readonly PluginNode[] { + if (states.size === 0) return nodes; + return nodes.map((node) => { + if (node.section !== "loaded") return node; + const state = states.get(node.id); + if (state === undefined) return node; + const issue = + state.kind === "incompatible" + ? "incompatible frontend plugin" + : state.kind === "failed" + ? `frontend failed: ${state.error}` + : null; + return issue === null ? node : { ...node, frontendIssue: issue }; + }); +} + +/** + * Cluster nodes for one manager table: tier groups in display order (Core → + * Providers → Standard), each tier's rows sorted by id. Tiers with no rows + * are omitted. Used by the loaded table's tier grouping (plan 07 §7.2). + * + * Duplicate-key-proof BY CONSTRUCTION: nodes are bucketed through a Map keyed + * by the NORMALIZED tier, so each of the three canonical tiers can yield at + * most one group — `PluginTable.svelte` renders a keyed each + * (`{#each groups as group (group.tier)}`), and a duplicate key throws + * `each_key_duplicate` in Svelte 5, aborting the whole manager render. The + * normalization also means an unknown/missing tier (contract violation — the + * backend rejects tierless externals at boot and directory scans fail + * validation) folds into the `standard` bucket instead of forming a separate + * trailing group; the old fallback (`rest[0]?.tier ?? "standard"`) produced a + * DUPLICATE `standard` key when canonical standard rows were present. + * groupByTier normalizes on the way in even though `pluginNodes` already + * does at the boundary — it is exported and must be safe for any caller. + */ +export function groupByTier( + nodes: readonly PluginNode[], +): ReadonlyArray<{ readonly tier: PluginNode["tier"]; readonly rows: readonly PluginNode[] }> { + const byTier = new Map<PluginNode["tier"], PluginNode[]>(); + for (const n of nodes) { + const tier = normalizeTier(n.tier); + const rows = byTier.get(tier); + if (rows) rows.push(n); + else byTier.set(tier, [n]); + } + const out: { tier: PluginNode["tier"]; rows: PluginNode[] }[] = []; + for (const tier of TIER_ORDER) { + const rows = byTier.get(tier); + if (!rows || rows.length === 0) continue; + out.push({ tier, rows: rows.sort((a, b) => a.id.localeCompare(b.id)) }); + } + return out; +} + +/** Split nodes into the manager's two tables + the missing strip. */ +export function splitSections(nodes: readonly PluginNode[]): { + readonly loaded: readonly PluginNode[]; + readonly discovered: readonly PluginNode[]; + readonly missing: readonly PluginNode[]; +} { + const bySection = (section: PluginSection): PluginNode[] => + nodes.filter((n) => n.section === section); + return { + loaded: bySection("loaded"), + discovered: bySection("discovered"), + missing: bySection("missing"), + }; +} diff --git a/src/features/plugins/logic/workspace-row.test.ts b/src/features/plugins/logic/workspace-row.test.ts new file mode 100644 index 0000000..4c7d29e --- /dev/null +++ b/src/features/plugins/logic/workspace-row.test.ts @@ -0,0 +1,67 @@ +import type { WorkspacePluginRow } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./workspace-row"; + +function row(overrides: Partial<WorkspacePluginRow> = {}): WorkspacePluginRow { + return { + pluginId: "mcp", + tier: "standard", + state: "undefined", + loaded: true, + locked: false, + ...overrides, + }; +} + +describe("rowChecked", () => { + it("is true only for an explicit enabled record", () => { + expect(rowChecked(row({ state: "enabled" }))).toBe(true); + expect(rowChecked(row({ state: "disabled" }))).toBe(false); + expect(rowChecked(row({ state: "undefined" }))).toBe(false); + }); +}); + +describe("rowSelectable", () => { + it("is true for a loaded, unlocked row (all three states)", () => { + for (const state of ["enabled", "disabled", "undefined"] as const) { + expect(rowSelectable(row({ state }))).toBe(true); + } + }); + + it("is false for an UNLOADED plugin (§7 strict layering — greyed out)", () => { + expect(rowSelectable(row({ loaded: false, state: "disabled" }))).toBe(false); + }); + + it("is false for a LOCKED (core) row — the server refuses the PUT (409)", () => { + expect(rowSelectable(row({ tier: "core", locked: true, state: "enabled" }))).toBe(false); + }); +}); + +describe("rowBlockReason", () => { + it("points at the global manager for unloaded plugins", () => { + const reason = rowBlockReason(row({ loaded: false })); + expect(reason).toContain("plugin manager"); + }); + + it("explains the core lock", () => { + const reason = rowBlockReason(row({ tier: "core", locked: true })); + expect(reason).toContain("Core plugins are enabled by default"); + }); + + it("prefers the unloaded reason when both apply", () => { + const reason = rowBlockReason(row({ loaded: false, tier: "core", locked: true })); + expect(reason).toContain("plugin manager"); + }); + + it("is null for an interactive row", () => { + expect(rowBlockReason(row())).toBeNull(); + }); +}); + +describe("stateLabel", () => { + it("renders the three wire states in a uniform grammar", () => { + expect(stateLabel("enabled")).toBe("enabled"); + expect(stateLabel("disabled")).toBe("disabled"); + expect(stateLabel("undefined")).toBe("not set"); + }); +}); diff --git a/src/features/plugins/logic/workspace-row.ts b/src/features/plugins/logic/workspace-row.ts new file mode 100644 index 0000000..c85b759 --- /dev/null +++ b/src/features/plugins/logic/workspace-row.ts @@ -0,0 +1,54 @@ +import type { WorkspacePluginRow, WorkspacePluginStateValue } from "@dispatch/transport-contract"; + +/** + * Pure helpers for the per-workspace plugin modal (plan 06 §6.6 wire rows → + * checkbox semantics). Zero DOM. + * + * State model (backend-canonical): `"enabled"` / `"disabled"` — an explicit + * record — or `"undefined"` for NO record (the blank slate; behaves disabled + * for Providers/Standard). Core rows arrive materialized `enabled` (Q-A). + */ + +/** Whether the modal checkbox is checked for a row (explicit `enabled` only). */ +export function rowChecked(row: WorkspacePluginRow): boolean { + return row.state === "enabled"; +} + +/** + * Whether the row's checkbox is selectable. Two unselectable cases (§7 + * strict layering + plan 06 §6.6): + * - the plugin is NOT loaded globally → greyed out and unselectable (only the + * global manager loads code); + * - the server reports the row locked (tier "core") → the server refuses the + * PUT (`core-locked` 409), so the control is disabled with a tooltip. + */ +export function rowSelectable(row: WorkspacePluginRow): boolean { + return row.loaded && !row.locked; +} + +/** + * Why the row is unselectable (tooltip text), or null when interactive. + * Unloaded beats locked in the copy (the user's actionable next step is the + * global manager). + */ +export function rowBlockReason(row: WorkspacePluginRow): string | null { + if (!row.loaded) { + return "Not loaded globally — load it in the plugin manager first (only the manager loads code)."; + } + if (row.locked) { + return "Core plugins are enabled by default in every workspace and managed globally."; + } + return null; +} + +/** The effective-state label shown next to a row (uniform three-state grammar). */ +export function stateLabel(state: WorkspacePluginStateValue): string { + switch (state) { + case "enabled": + return "enabled"; + case "disabled": + return "disabled"; + case "undefined": + return "not set"; + } +} diff --git a/src/features/plugins/store.svelte.ts b/src/features/plugins/store.svelte.ts new file mode 100644 index 0000000..fe48940 --- /dev/null +++ b/src/features/plugins/store.svelte.ts @@ -0,0 +1,218 @@ +import type { + PluginListResponse, + SetWorkspacePluginStateRequest, + WorkspacePluginRow, +} from "@dispatch/transport-contract"; +import type { FrontendPluginState } from "../../core/plugin-abi"; +import type { PluginOpResult, PluginScanResult, PluginsHttp } from "./adapter/http"; +import type { PluginNode } from "./logic/types"; +import { pluginNodes } from "./logic/view-model"; + +/** + * Plugins store — a thin reactive wrapper over the injected `PluginsHttp` + * edge (the workspaces-store pattern). Owns: + * - the manager's plugin list (`GET /plugins`) normalized to `PluginNode`s; + * - ONE open workspace modal's view (`GET /workspaces/:id/plugins` — read-through + * materialization makes this first read the workspace's "open"); + * - pending/error state for the mutations. + * + * Multi-client coherence (§11): the composition root calls + * `onExtensionsChanged()` when the server broadcasts `extensions-changed` — + * the store refetches the list (and the open workspace view), so every + * connected client converges. No ambient state: one instance per composition + * root, passed explicitly; no module-level mutables. + */ +export interface PluginsStore { + /** The last `GET /plugins` response (null until the first fetch lands). */ + readonly list: PluginListResponse | null; + /** The normalized nodes across ALL sections (loaded + discovered + missing). */ + readonly nodes: readonly PluginNode[]; + readonly loading: boolean; + /** The last mutation/scan/load error message, or null. */ + readonly error: string | null; + /** The manual scan's result (null until a scan ran), for the summary line. */ + readonly lastScan: PluginScanResult | null; + + /** Refetch `GET /plugins`. */ + refresh(): Promise<void>; + /** `POST /plugins/scan` (manual; the server never auto-loads new plugins). */ + scan(): Promise<PluginScanResult>; + /** `POST /plugins/:id/load`. */ + load(id: string): Promise<PluginOpResult>; + /** `POST /plugins/:id/unload` with `{ force }` (the §8 force semantics). */ + unload(id: string, force: boolean): Promise<PluginOpResult>; + /** `POST /plugins/:id/reload`. */ + reload(id: string): Promise<PluginOpResult>; + + /** + * The open workspace modal's rows (null when nothing loaded yet). Cleared + * IMMEDIATELY on open so a slow/failing fetch never shows the previous + * workspace's rows (the refreshCwd/refreshComputer pattern). + */ + readonly workspaceView: WorkspacePluginsView | null; + /** Load/reload the workspace modal's view (`GET /workspaces/:id/plugins`). */ + openWorkspace(workspaceId: string): Promise<void>; + /** + * Write one plugin's state in one workspace (`PUT .../plugins/:pluginId`). + * On success the open view's row is updated in place from the response; on + * failure the error is surfaced AND the view refetched (server-authoritative + * reconciliation — the checkbox never lies). + */ + setWorkspaceState( + workspaceId: string, + pluginId: string, + state: SetWorkspacePluginStateRequest["state"], + ): Promise<void>; + + /** + * Multi-client coherence hook (§11): the composition root calls + * `onExtensionsChanged()` when the server broadcasts `extensions-changed` — + * the store refetches the list (and the open workspace view), so every + * connected client converges. Fire-and-forget. + */ + onExtensionsChanged(): void; + + /** + * The frontend-plugin (plan 03) per-plugin FE states — reported by the + * plugin host after every driver sync. Rendered by the manager rows: + * `incompatible` → "incompatible frontend plugin" (the backend-mismatch + * grammar), `failed` → the activation error. Absent = no FE section. + */ + readonly frontendStates: ReadonlyMap<string, FrontendPluginState>; + /** The plugin host's post-sync report (the only writer). */ + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void; +} + +/** One open workspace modal's view (rows + materialized provider count). */ +export interface WorkspacePluginsView { + readonly workspaceId: string; + readonly rows: readonly WorkspacePluginRow[]; + /** Loaded provider-tier plugins effectively enabled here (§6.5). */ + readonly enabledProviderCount: number; +} + +export function createPluginsStore(http: PluginsHttp): PluginsStore { + let list = $state<PluginListResponse | null>(null); + let loading = $state(false); + let error = $state<string | null>(null); + let lastScan = $state<PluginScanResult | null>(null); + let workspaceView = $state<WorkspacePluginsView | null>(null); + let frontendStates = $state<ReadonlyMap<string, FrontendPluginState>>(new Map()); + + // Normalized nodes — derived so the graph logic re-runs only when the list + // changes, not on every read. + const nodes = $derived<readonly PluginNode[]>(list === null ? [] : pluginNodes(list)); + + return { + get list(): PluginListResponse | null { + return list; + }, + get nodes(): readonly PluginNode[] { + return nodes; + }, + get loading(): boolean { + return loading; + }, + get error(): string | null { + return error; + }, + get lastScan(): PluginScanResult | null { + return lastScan; + }, + get workspaceView(): WorkspacePluginsView | null { + return workspaceView; + }, + + async refresh(): Promise<void> { + loading = true; + const data = await http.list(); + if (data !== null) list = data; + loading = false; + }, + + async scan(): Promise<PluginScanResult> { + error = null; + const result = await http.scan(); + lastScan = result; + if (!result.ok) error = result.error; + // A scan broadcasts `extensions-changed` server-side; refresh anyway so + // the manager is coherent even without a WS connection. + await this.refresh(); + return result; + }, + + async load(id): Promise<PluginOpResult> { + error = null; + const result = await http.load(id); + if (!result.ok) error = result.error; + await this.refresh(); + return result; + }, + + async unload(id, force): Promise<PluginOpResult> { + error = null; + const result = await http.unload(id, force); + if (!result.ok) error = result.error; + await this.refresh(); + return result; + }, + + async reload(id): Promise<PluginOpResult> { + error = null; + const result = await http.reload(id); + if (!result.ok) error = result.error; + await this.refresh(); + return result; + }, + + async openWorkspace(workspaceId): Promise<void> { + // Clear immediately so a slow/failing fetch never shows the PREVIOUS + // workspace's rows in the new modal. + workspaceView = null; + const data = await http.workspacePlugins(workspaceId); + if (data === null) { + error = `Failed to load plugins for workspace "${workspaceId}"`; + return; + } + workspaceView = { + workspaceId: data.workspaceId, + rows: data.plugins, + enabledProviderCount: data.enabledProviderCount, + }; + }, + + async setWorkspaceState(workspaceId, pluginId, state): Promise<void> { + const result = await http.setWorkspaceState(workspaceId, pluginId, state); + if (result.ok) { + // Server-authoritative in-place row update (the response carries the + // resulting effective state — "undefined" after "unset"). + if (workspaceView?.workspaceId === workspaceId) { + const rows = workspaceView.rows.map( + (row): WorkspacePluginRow => + row.pluginId === pluginId ? { ...row, state: result.response.state } : row, + ); + workspaceView = { ...workspaceView, rows }; + } + return; + } + error = result.error; + // Refusal (409 core-locked / unloaded) or failure — reconcile from the + // server so the checkbox never lies. + await this.openWorkspace(workspaceId); + }, + + onExtensionsChanged(): void { + void this.refresh(); + const open = workspaceView?.workspaceId; + if (open !== undefined) void this.openWorkspace(open); + }, + + get frontendStates(): ReadonlyMap<string, FrontendPluginState> { + return frontendStates; + }, + + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void { + frontendStates = states; + }, + }; +} diff --git a/src/features/plugins/store.test.ts b/src/features/plugins/store.test.ts new file mode 100644 index 0000000..be301ee --- /dev/null +++ b/src/features/plugins/store.test.ts @@ -0,0 +1,183 @@ +import type { PluginListResponse, WorkspacePluginsResponse } from "@dispatch/transport-contract"; +import { describe, expect, it, vi } from "vitest"; +import type { + PluginOpResult, + PluginScanResult, + PluginsHttp, + WorkspaceStateResult, +} from "./adapter/http"; +import { createPluginsStore } from "./store.svelte"; + +const listResponse: PluginListResponse = { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + ], + disabledWithReason: [], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "1.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [], +}; + +const wsResponse: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + { pluginId: "kernel", tier: "core", state: "enabled", loaded: true, locked: true }, + { pluginId: "mcp", tier: "standard", state: "undefined", loaded: true, locked: false }, + ], + enabledProviderCount: 1, +}; + +/** A fake PluginsHttp with a controllable list + workspace response. */ +function fakeHttp(opts: { + list?: PluginListResponse | null; + workspacePlugins?: WorkspacePluginsResponse | null; +}) { + // `=== undefined` (not `??`) so an explicit `null` override defeats the default. + const list = () => (opts.list === undefined ? listResponse : opts.list); + const ws = () => (opts.workspacePlugins === undefined ? wsResponse : opts.workspacePlugins); + const opOk = (action: "load" | "unload" | "reload") => ({ ok: true, action, id: "x" }) as const; + return { + list: vi.fn(async (): Promise<PluginListResponse | null> => list()), + scan: vi.fn( + async (): Promise<PluginScanResult> => ({ + ok: true, + response: { root: "/plugins", newPlugins: [], invalid: [], incompatible: [] }, + }), + ), + load: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("load") })), + unload: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("unload") })), + reload: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("reload") })), + workspacePlugins: vi.fn(async (): Promise<WorkspacePluginsResponse | null> => ws()), + setWorkspaceState: vi.fn( + async (): Promise<WorkspaceStateResult> => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "mcp", state: "enabled" }, + }), + ), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + } satisfies PluginsHttp; +} + +describe("createPluginsStore — manager list", () => { + it("fetches + normalizes the list into nodes", async () => { + const store = createPluginsStore(fakeHttp({})); + expect(store.list).toBeNull(); + expect(store.nodes).toEqual([]); + await store.refresh(); + expect(store.list).toEqual(listResponse); + expect(store.nodes.map((n) => n.id)).toEqual(["kernel", "fresh"]); + }); + + it("mutation results are surfaced and the list is refetched", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + await store.refresh(); + const result = await store.unload("kernel", false); + expect(result.ok).toBe(true); + expect(http.unload).toHaveBeenCalledWith("kernel", false); + expect(http.list).toHaveBeenCalledTimes(2); + }); + + it("scan records the result + refreshes", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + await store.scan(); + expect(store.lastScan?.ok).toBe(true); + expect(http.scan).toHaveBeenCalledOnce(); + expect(http.list).toHaveBeenCalledTimes(1); // refresh after scan (initial list fetch absent) + }); + + it("a mutation error lands in store.error", async () => { + const http = fakeHttp({}); + http.unload = vi.fn(async (): Promise<PluginOpResult> => ({ ok: false, error: "blocked" })); + const store = createPluginsStore(http); + await store.unload("kernel", false); + expect(store.error).toBe("blocked"); + }); +}); + +describe("createPluginsStore — workspace view", () => { + it("openWorkspace loads the rows + enabled provider count", async () => { + const store = createPluginsStore(fakeHttp({})); + await store.openWorkspace("ws"); + expect(store.workspaceView).toEqual({ + workspaceId: "ws", + rows: wsResponse.plugins, + enabledProviderCount: 1, + }); + }); + + it("setWorkspaceState updates the row in place from the response", async () => { + const store = createPluginsStore(fakeHttp({})); + await store.openWorkspace("ws"); + await store.setWorkspaceState("ws", "mcp", "enabled"); + const row = store.workspaceView?.rows.find((r) => r.pluginId === "mcp"); + expect(row?.state).toBe("enabled"); + }); + + it("a refused setWorkspaceState refetches (server-authoritative reconcile)", async () => { + const http = fakeHttp({}); + http.setWorkspaceState = vi.fn( + async (): Promise<WorkspaceStateResult> => ({ + ok: false, + error: "core is locked", + reason: "core-locked", + }), + ); + const store = createPluginsStore(http); + await store.openWorkspace("ws"); + await store.setWorkspaceState("ws", "kernel", "disabled"); + expect(store.error).toBe("core is locked"); + expect(http.workspacePlugins).toHaveBeenCalledTimes(2); + }); + + it("a failed openWorkspace clears the view (no stale rows from another workspace)", async () => { + const http = fakeHttp({ workspacePlugins: null }); + const store = createPluginsStore(http); + await store.openWorkspace("other"); + expect(store.workspaceView).toBeNull(); + expect(store.error).toContain("other"); + }); +}); + +describe("createPluginsStore — multi-client coherence (§11)", () => { + it("onExtensionsChanged refetches the list + the open workspace view", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + await store.openWorkspace("ws"); + expect(http.list).toHaveBeenCalledTimes(0); + expect(http.workspacePlugins).toHaveBeenCalledTimes(1); + + store.onExtensionsChanged(); + await vi.waitFor(() => { + expect(http.list).toHaveBeenCalledTimes(1); + expect(http.workspacePlugins).toHaveBeenCalledTimes(2); + }); + }); + + it("onExtensionsChanged does not refetch a workspace view when none is open", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + store.onExtensionsChanged(); + await vi.waitFor(() => { + expect(http.list).toHaveBeenCalledTimes(1); + expect(http.workspacePlugins).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/src/features/plugins/ui/PluginManager.svelte b/src/features/plugins/ui/PluginManager.svelte new file mode 100644 index 0000000..fd2a8a7 --- /dev/null +++ b/src/features/plugins/ui/PluginManager.svelte @@ -0,0 +1,221 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import type { PluginsStore } from "../store.svelte"; + import type { PluginNode } from "../logic/types"; + import { computeCascade } from "../logic/cascade"; + import { autoCheckBackendPrereqs } from "../logic/prereqs"; + import { dependencyHighlightSets } from "../logic/highlight"; + import { unloadBlockReason } from "../logic/last-provider"; + import { splitSections, withFrontendStates } from "../logic/view-model"; + import PluginTable from "./PluginTable.svelte"; + import UnloadConfirmModal from "./UnloadConfirmModal.svelte"; + + let { plugins, onClose }: { plugins: PluginsStore; onClose: () => void } = $props(); + + onMount(() => { + void plugins.refresh(); + }); + + // ── Derived sections + hover highlighting (§8) ──────────────────────────── + // The FE-half states (plan 03) merge into the nodes here — one reactive + // derivation over both inputs (the normalized nodes + the plugin host's + // post-sync state map). + const nodes = $derived(withFrontendStates(plugins.nodes, plugins.frontendStates)); + const sections = $derived(splitSections(nodes)); + let hoveredId = $state<string | null>(null); + const highlight = $derived(dependencyHighlightSets(hoveredId, nodes)); + + function highlightFor(id: string): "dep" | "dependent" | null { + if (highlight.deps.has(id)) return "dep"; + if (highlight.dependents.has(id)) return "dependent"; + return null; + } + + // ── Mutations ───────────────────────────────────────────────────────────── + let busyIds = $state<ReadonlySet<string>>(new Set()); + let scanning = $state(false); + + async function withBusy(id: string, op: () => Promise<unknown>): Promise<void> { + busyIds = new Set(busyIds).add(id); + try { + await op(); + } finally { + const next = new Set(busyIds); + next.delete(id); + busyIds = next; + } + } + + // ── Unload confirm state (cascade warn §8 + in-use force §8) ───────────── + // The pending unload the confirm modal describes. `cascade` lists the loaded + // dependents that will also be unchecked; `notice` carries a refused-attempt + // error (in-use / engine refusal) that motivated the force offer. + let pendingUnload = $state<{ + node: PluginNode; + cascade: readonly string[]; + notice: string | null; + } | null>(null); + + async function toggle(node: PluginNode, checked: boolean): Promise<void> { + if (busyIds.has(node.id)) return; + if (checked) { + // §8: checking auto-checks the required backend plugins (transitive, + // one-directional). Load the missing prereqs first, then the target. + const prereqs = autoCheckBackendPrereqs(node.id, plugins.nodes); + for (const id of prereqs) { + await withBusy(id, () => plugins.load(id)); + } + await withBusy(node.id, () => plugins.load(node.id)); + return; + } + // Unchecking: warn when loaded dependents would cascade-uncheck (§8). + const cascade = computeCascade(node.id, plugins.nodes); + if (cascade.length > 0) { + pendingUnload = { node, cascade, notice: null }; + return; + } + // No local cascade — attempt the unload; a server refusal (in-use / + // engine) offers force with the global-stop warning (§8). + const result = await withBusyRes(node.id, () => plugins.unload(node.id, false)); + if (result !== null && !result.ok) { + pendingUnload = { node, cascade: result.dependents ?? [], notice: result.error }; + } + } + + /** Like `withBusy` but returns the op's result (for the unload flow). */ + async function withBusyRes<T>(id: string, op: () => Promise<T>): Promise<T | null> { + busyIds = new Set(busyIds).add(id); + try { + return await op(); + } finally { + const next = new Set(busyIds); + next.delete(id); + busyIds = next; + } + } + + async function confirmUnload(): Promise<void> { + const pending = pendingUnload; + pendingUnload = null; + if (pending === null) return; + // Force cascades the dependents server-side (plan 05: reverse-topological + // unload) and stops ALL agents globally first — the modal's copy says so. + await withBusy(pending.node.id, () => plugins.unload(pending.node.id, true)); + } + + function cancelUnload(): void { + pendingUnload = null; + } + + async function reload(node: PluginNode): Promise<void> { + await withBusy(node.id, () => plugins.reload(node.id)); + } + + async function scan(): Promise<void> { + scanning = true; + await plugins.scan(); + scanning = false; + } + + const scanSummary = $derived.by(() => { + const last = plugins.lastScan; + if (last === null || !last.ok) return null; + const r = last.response; + const parts: string[] = [`${r.newPlugins.length} new`]; + if (r.invalid.length > 0) parts.push(`${r.invalid.length} invalid`); + if (r.incompatible.length > 0) parts.push(`${r.incompatible.length} incompatible`); + return `Scan of ${r.root}: ${parts.join(", ")}`; + }); +</script> + +<div class="modal modal-open" role="dialog" aria-label="Plugin manager"> + <div class="modal-box max-w-3xl"> + <div class="flex items-center justify-between"> + <h3 class="text-lg font-bold">Plugin manager</h3> + <button + type="button" + class="btn btn-sm" + disabled={scanning} + onclick={scan} + title="Rescan the plugin directory (new plugins stay unloaded — the consent gate)" + > + {#if scanning} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Scan + {/if} + </button> + </div> + + {#if plugins.error} + <p class="mt-2 text-xs text-error" role="alert">{plugins.error}</p> + {/if} + {#if scanSummary} + <p class="mt-2 text-xs opacity-70" role="status">{scanSummary}</p> + {/if} + + <div class="mt-3 flex flex-col gap-4"> + <PluginTable + title="Loaded" + rows={sections.loaded} + blockReasonFor={(id) => unloadBlockReason(id, plugins.nodes)} + {highlightFor} + {busyIds} + onToggle={toggle} + onReload={reload} + onHover={(id) => (hoveredId = id)} + onUnhover={(id) => (hoveredId = hoveredId === id ? null : hoveredId)} + /> + + <PluginTable + title="Discovered (not loaded)" + rows={sections.discovered} + blockReasonFor={() => null} + {highlightFor} + {busyIds} + onToggle={toggle} + onHover={(id) => (hoveredId = id)} + onUnhover={(id) => (hoveredId = hoveredId === id ? null : hoveredId)} + /> + + {#if sections.missing.length > 0} + <section class="flex flex-col gap-1"> + <h4 class="text-xs font-semibold uppercase opacity-60">Missing (directory gone)</h4> + <ul class="flex flex-col gap-1"> + {#each sections.missing as node (node.id)} + <li class="rounded bg-base-200/40 px-2 py-1 text-xs opacity-60"> + <span class="font-mono">{node.id}</span> — {node.reason} + </li> + {/each} + </ul> + </section> + {/if} + </div> + + <div class="modal-action"> + <p class="mr-auto self-center text-xs opacity-50"> + Hover a row to highlight its dependencies and dependents. + </p> + <button type="button" class="btn btn-sm" onclick={onClose}>Close</button> + </div> + </div> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div class="modal-backdrop" onclick={onClose}></div> +</div> + +{#if pendingUnload !== null} + <UnloadConfirmModal + headline={pendingUnload.node.loaded ? `Unload ${pendingUnload.node.id}?` : `Force-unload ${pendingUnload.node.id}?`} + lines={[ + ...(pendingUnload.cascade.length > 0 + ? [ + `Unloading ${pendingUnload.node.id} also unchecks: ${pendingUnload.cascade.join(", ")}.`, + ] + : []), + ...(pendingUnload.notice !== null ? [pendingUnload.notice] : []), + ]} + confirmLabel={pendingUnload.cascade.length > 0 ? "Unload with force" : "Force unload"} + onCancel={cancelUnload} + onConfirm={confirmUnload} + /> +{/if} diff --git a/src/features/plugins/ui/PluginManager.test.ts b/src/features/plugins/ui/PluginManager.test.ts new file mode 100644 index 0000000..c81938c --- /dev/null +++ b/src/features/plugins/ui/PluginManager.test.ts @@ -0,0 +1,284 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginOpResult, PluginsHttp } from "../adapter/http"; +import { createPluginsStore } from "../store.svelte"; +import PluginManager from "./PluginManager.svelte"; + +const listResponse: PluginListResponse = { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: [], + }, + { + id: "transport-http", + name: "Transport HTTP", + version: "1.1.0", + tier: "standard", + trust: "bundled", + locked: false, + dependsOn: ["heartbeat"], + }, + { + id: "heartbeat", + name: "Heartbeat", + version: "1.1.0", + tier: "standard", + trust: "bundled", + locked: false, + dependsOn: [], + }, + ], + disabledWithReason: [ + { + id: "broken", + name: "Broken", + version: "0.1.0", + tier: "standard", + trust: "local", + reason: "activation threw", + }, + ], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [{ id: "gone", reason: "directory is gone" }], +}; + +/** A fake PluginsHttp (the OUTERMOST edge — the store itself is real). */ +function fakeHttp(overrides: Partial<PluginsHttp> = {}): PluginsHttp { + return { + list: vi.fn(async () => listResponse), + scan: vi.fn(async () => ({ + ok: true, + response: { root: "/plugins", newPlugins: [], invalid: [], incompatible: [] }, + })), + load: vi.fn(async () => ({ ok: true, response: { ok: true, action: "load", id: "x" } })), + unload: vi.fn(async () => ({ ok: true, response: { ok: true, action: "unload", id: "x" } })), + reload: vi.fn(async () => ({ ok: true, response: { ok: true, action: "reload", id: "x" } })), + workspacePlugins: vi.fn(async () => null), + setWorkspaceState: vi.fn(async () => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "x", state: "enabled" }, + })), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + ...overrides, + } as unknown as PluginsHttp; +} + +async function renderManager(http: PluginsHttp = fakeHttp()) { + const store = createPluginsStore(http); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + return { store, http }; +} + +describe("PluginManager — tables + locked core", () => { + it("renders the loaded table with tier groups and the discovered table", async () => { + await renderManager(); + expect(screen.getByText("Loaded")).toBeInTheDocument(); + expect(screen.getByText("Discovered (not loaded)")).toBeInTheDocument(); + // Tier subheadings (Core / Provider / Standard) — each also appears as a + // row badge, so assert at least one occurrence of each. + expect(screen.getAllByText("Core").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Provider").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Standard").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Missing (directory gone)")).toBeInTheDocument(); + expect(screen.getByText("gone")).toBeInTheDocument(); + }); + + it("core rows show a lock instead of a checkbox", async () => { + await renderManager(); + // The kernel row has no checkbox (the lock glyph instead). + expect(screen.queryByRole("checkbox", { name: /Unload kernel/i })).not.toBeInTheDocument(); + // Provider + standard rows have checkboxes. + expect(screen.getByRole("checkbox", { name: /Unload opencode/i })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: /Unload heartbeat/i })).toBeChecked(); + }); + + it("disabled-with-reason rows show their reason", async () => { + await renderManager(); + expect(screen.getByText("activation threw")).toBeInTheDocument(); + }); +}); + +describe("PluginManager — cascade-uncheck warning (§8)", () => { + it("unchecking a plugin with loaded dependents warns first (no request yet)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + // transport-http depends on heartbeat → unchecking heartbeat cascades. + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + expect(http.unload).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: /Unload heartbeat/i })).toBeInTheDocument(); + expect(screen.getByText(/also unchecks: transport-http/)).toBeInTheDocument(); + }); + + it("accepting the warning issues the forced unload (the cascade is server-side)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + await user.click(screen.getByRole("button", { name: "Unload with force" })); + expect(http.unload).toHaveBeenCalledWith("heartbeat", true); + expect(screen.queryByRole("dialog", { name: /Unload heartbeat/i })).not.toBeInTheDocument(); + }); + + it("cancelling the warning issues nothing", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(http.unload).not.toHaveBeenCalled(); + }); + + it("unchecking WITHOUT dependents unloads directly (no warning)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + // transport-http has no loaded dependents (it DEPENDS on heartbeat, not + // the reverse) — and it is not a provider, so the last-provider lock + // (which correctly disables `opencode`, the fixture's only provider) + // does not apply. + await user.click(screen.getByRole("checkbox", { name: /Unload transport-http/i })); + expect(http.unload).toHaveBeenCalledWith("transport-http", false); + expect(screen.queryByRole("dialog", { name: /Unload/i })).not.toBeInTheDocument(); + }); + + it("a refused unload offers force with the global-stop copy (§8 in-use force)", async () => { + const user = userEvent.setup(); + const http = fakeHttp({ + unload: vi.fn( + async (): Promise<PluginOpResult> => ({ + ok: false, + error: "the engine refused — the plugin is in use", + reason: "engine-refused", + }), + ), + }); + await renderManager(http); + await user.click(screen.getByRole("checkbox", { name: /Unload transport-http/i })); + // The refusal is surfaced both in the manager's error line and the + // confirm dialog's notice (the dialog appears after the async refusal). + expect( + await screen.findAllByText(/the engine refused/).then((els) => els.length), + ).toBeGreaterThanOrEqual(1); + expect(await screen.findByText(/stops ALL agents globally/i)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Force unload" })); + expect(http.unload).toHaveBeenLastCalledWith("transport-http", true); + }); +}); + +describe("PluginManager — last-provider lock (§8)", () => { + it("the last loaded provider's checkbox is disabled with a tooltip", async () => { + const single: PluginListResponse = { + loaded: [ + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: [], + }, + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + ], + disabledWithReason: [], + discovered: [], + missing: [], + }; + const store = createPluginsStore(fakeHttp({ list: async () => single })); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + const checkbox = screen.getByRole("checkbox", { name: /Unload opencode/i }); + expect(checkbox).toBeDisabled(); + // The explanatory copy is wired via the title attribute (the tooltip). + expect(checkbox.getAttribute("title")).toMatch(/last provider/i); + }); +}); + +describe("PluginManager — load with auto-checked prereqs (§8)", () => { + it("checking a discovered plugin loads it and reflects the stateful refetch", async () => { + const user = userEvent.setup(); + // Stateful fake: after `load("fresh")`, the list reports fresh as loaded + // (the store refetches from the server after the op — the checkbox must + // reflect the NEW list, not a local guess). + let freshLoaded = false; + const stateful: PluginsHttp = { + ...fakeHttp(), + list: vi.fn(async () => + freshLoaded + ? { + ...listResponse, + discovered: [], + loaded: [ + ...listResponse.loaded, + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + locked: false, + dependsOn: [], + }, + ], + } + : listResponse, + ), + load: vi.fn(async (id: string) => { + if (id === "fresh") freshLoaded = true; + return { ok: true, response: { ok: true, action: "load", id } }; + }), + } as unknown as PluginsHttp; + const store = createPluginsStore(stateful); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + await user.click(screen.getByRole("checkbox", { name: /Load fresh/i })); + expect(stateful.load).toHaveBeenCalledWith("fresh"); + expect(screen.getByRole("checkbox", { name: /Unload fresh/i })).toBeChecked(); + }); + + it("the Scan button triggers a manual scan", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("button", { name: /Scan/ })); + expect(http.scan).toHaveBeenCalledOnce(); + }); + + it("the Reload affordance calls reload for a loaded row", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("button", { name: /Reload transport-http/i })); + expect(http.reload).toHaveBeenCalledWith("transport-http"); + }); +}); diff --git a/src/features/plugins/ui/PluginRow.svelte b/src/features/plugins/ui/PluginRow.svelte new file mode 100644 index 0000000..73e0b18 --- /dev/null +++ b/src/features/plugins/ui/PluginRow.svelte @@ -0,0 +1,120 @@ +<script lang="ts"> + import type { PluginNode } from "../logic/types"; + import { tierLabel } from "../logic/types"; + + let { + node, + highlight, + blockReason = null, + busy = false, + onToggle, + onReload, + onHover, + onUnhover, + }: { + node: PluginNode; + /** Hover-highlight state for this row: a direct dep, a dependent, or none. */ + highlight: "dep" | "dependent" | null; + /** + * Why the unload control is disabled (core-locked / last-provider), or + * null when interactive. Missing rows are always disabled (no plugin). + */ + blockReason?: string | null; + /** Whether a mutation for this row is in flight (spinner in the controls). */ + busy?: boolean; + onToggle: (node: PluginNode, checked: boolean) => void; + onReload?: (node: PluginNode) => void; + onHover: (id: string) => void; + onUnhover: (id: string) => void; + } = $props(); + + // Missing rows have no checkbox at all — there is no plugin to load. + const hasCheckbox = $derived(node.section !== "missing"); + // Core rows are LOCKED (no load/unload control) per §3/§5. + const interactive = $derived(blockReason === null && !busy); + const rowClass = $derived( + [ + node.section === "missing" || node.locked || (!node.loaded && node.section === "discovered") + ? "opacity-50" + : "", + highlight === "dep" + ? "outline outline-1 outline-info bg-info/10" + : highlight === "dependent" + ? "outline outline-1 outline-warning bg-warning/10" + : "", + ] + .filter(Boolean) + .join(" "), + ); +</script> + +<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> +<tr + class={rowClass} + onmouseover={() => onHover(node.id)} + onmouseout={() => onUnhover(node.id)} + onfocus={() => onHover(node.id)} + onblur={() => onUnhover(node.id)} +> + <td class="w-10"> + {#if hasCheckbox} + {#if node.locked} + <span + class="inline-block w-4 text-center opacity-40" + title={blockReason ?? "Core plugins cannot be unloaded"} + aria-label="Locked (core)">🔒</span + > + {:else} + <input + type="checkbox" + class="checkbox checkbox-sm" + checked={node.loaded} + disabled={!interactive} + title={blockReason ?? undefined} + aria-label={`${node.loaded ? "Unload" : "Load"} ${node.id}`} + onchange={(e) => onToggle(node, e.currentTarget.checked)} + /> + {/if} + {/if} + </td> + <td> + <div class="flex flex-col"> + <span class="font-medium">{node.name}</span> + {#if node.reason !== null} + <span class="text-xs text-error">{node.reason}</span> + {/if} + {#if node.frontendIssue !== undefined} + <!-- plan 03: the FE half's state — "incompatible frontend plugin" or the activation error --> + <span class="text-xs text-error">{node.frontendIssue}</span> + {/if} + {#if node.dependsOn.length > 0} + <span class="text-xs opacity-60">depends on: {node.dependsOn.join(", ")}</span> + {/if} + </div> + </td> + <td class="text-xs"> + <span class="badge badge-ghost badge-sm">{tierLabel(node.tier)}</span> + {#if node.trust !== null} + <span class="badge badge-ghost badge-sm">{node.trust}</span> + {/if} + </td> + <td class="text-xs opacity-60">{node.version}</td> + <td class="text-right"> + {#if node.loaded && onReload !== undefined} + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={!interactive} + title="Reload this plugin's code (picks up updates)" + aria-label={`Reload ${node.id}`} + onclick={() => onReload?.(node)} + > + {#if busy} + <span class="loading loading-spinner loading-xs"></span> + {:else} + ↻ + {/if} + </button> + {/if} + </td> +</tr> diff --git a/src/features/plugins/ui/PluginTable.svelte b/src/features/plugins/ui/PluginTable.svelte new file mode 100644 index 0000000..717f839 --- /dev/null +++ b/src/features/plugins/ui/PluginTable.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> + import type { PluginNode } from "../logic/types"; + import { tierLabel } from "../logic/types"; + import { groupByTier } from "../logic/view-model"; + import PluginRow from "./PluginRow.svelte"; + + let { + title, + rows, + blockReasonFor, + highlightFor, + busyIds, + onToggle, + onReload, + onHover, + onUnhover, + }: { + title: string; + rows: readonly PluginNode[]; + /** + * Why a row's control is disabled (core-locked / last-provider), or null + * when interactive. Injected by the manager (it owns the graph context). + */ + blockReasonFor: (id: string) => string | null; + /** + * The §8 hover-highlight state for one row: a highlighted dependency, a + * highlighted dependent, or none. Injected by the manager (it owns the + * hovered id + the graph). + */ + highlightFor: (id: string) => "dep" | "dependent" | null; + /** Ids with an in-flight mutation (spinner in their controls). */ + busyIds: ReadonlySet<string>; + onToggle: (node: PluginNode, checked: boolean) => void; + onReload?: (node: PluginNode) => void; + onHover: (id: string) => void; + onUnhover: (id: string) => void; + } = $props(); + + // Tier-grouped rows (Core → Providers → Standard) per the plan-07 decision. + const groups = $derived(groupByTier(rows)); +</script> + +<section class="flex flex-col gap-1"> + <h4 class="text-xs font-semibold uppercase opacity-60">{title}</h4> + {#if rows.length === 0} + <p class="text-xs opacity-50">Nothing here.</p> + {:else} + <table class="table table-sm"> + <thead> + <tr> + <th class="w-10"> + <span class="sr-only">Load/unload</span> + </th> + <th>Plugin</th> + <th>Class</th> + <th>Version</th> + <th class="text-right"> + <span class="sr-only">Actions</span> + </th> + </tr> + </thead> + {#each groups as group (group.tier)} + <tbody> + <tr> + <td colspan="5" class="bg-base-200/40 text-xs font-semibold uppercase opacity-60"> + {tierLabel(group.tier)} + </td> + </tr> + {#each group.rows as node (node.id)} + <PluginRow + {node} + highlight={highlightFor(node.id)} + blockReason={blockReasonFor(node.id)} + busy={busyIds.has(node.id)} + {onToggle} + {...(onReload ? { onReload } : {})} + {onHover} + {onUnhover} + /> + {/each} + </tbody> + {/each} + </table> + {/if} +</section> diff --git a/src/features/plugins/ui/ResetLayoutConfirm.svelte b/src/features/plugins/ui/ResetLayoutConfirm.svelte new file mode 100644 index 0000000..1f26601 --- /dev/null +++ b/src/features/plugins/ui/ResetLayoutConfirm.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + /** + * The "Reset layout to default?" confirm (plan 04 §4.4) — the dialog copy + * names the consequence exactly: this workspace's layout returns to the + * default; plugin and dropdown customizations are lost (there is NO + * undo — the no-revert semantics make the reset the recovery path). + */ + let { + onConfirm, + onCancel, + }: { + onConfirm: () => void; + onCancel: () => void; + } = $props(); +</script> + +<div class="modal modal-open" role="dialog" aria-label="Reset layout to default?"> + <div class="modal-box max-w-md"> + <h3 class="text-lg font-bold text-warning">Reset layout to default?</h3> + <p class="py-3 text-sm"> + This workspace's layout returns to the default; plugin and dropdown customizations are lost. + </p> + <div class="modal-action"> + <button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>Cancel</button> + <button type="button" class="btn btn-warning btn-sm" onclick={onConfirm}>Reset</button> + </div> + </div> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div class="modal-backdrop" onclick={onCancel}></div> +</div> diff --git a/src/features/plugins/ui/UnloadConfirmModal.svelte b/src/features/plugins/ui/UnloadConfirmModal.svelte new file mode 100644 index 0000000..6ae95cc --- /dev/null +++ b/src/features/plugins/ui/UnloadConfirmModal.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + let { + headline, + lines, + confirmLabel, + onCancel, + onConfirm, + }: { + /** The warning headline (e.g. "Unload heartbeat?"). */ + headline: string; + /** The warning body lines: what else unchecks + the force blast radius. */ + lines: readonly string[]; + confirmLabel: string; + onCancel: () => void; + onConfirm: () => void; + } = $props(); +</script> + +<div class="modal modal-open" role="dialog" aria-label={headline}> + <div class="modal-box max-w-md"> + <h3 class="text-lg font-bold text-warning">{headline}</h3> + <div class="flex flex-col gap-1 py-3"> + {#each lines as line (line)} + <p class="text-sm">{line}</p> + {/each} + <p class="mt-1 text-xs text-error"> + Force stops ALL agents globally — every active turn in every workspace — not just agents + using this plugin. + </p> + </div> + <div class="modal-action"> + <button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>Cancel</button> + <button type="button" class="btn btn-warning btn-sm" onclick={onConfirm}> + {confirmLabel} + </button> + </div> + </div> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div class="modal-backdrop" onclick={onCancel}></div> +</div> diff --git a/src/features/plugins/ui/WorkspacePluginModal.svelte b/src/features/plugins/ui/WorkspacePluginModal.svelte new file mode 100644 index 0000000..0ba7cd6 --- /dev/null +++ b/src/features/plugins/ui/WorkspacePluginModal.svelte @@ -0,0 +1,171 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import type { PluginsStore } from "../store.svelte"; + import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "../logic/workspace-row"; + import type { WorkspaceLayoutPort } from "../logic/types"; + import ResetLayoutConfirm from "./ResetLayoutConfirm.svelte"; + + let { + workspaceId, + workspaceTitle, + plugins, + layout, + onClose, + }: { + workspaceId: string; + /** The workspace's display title (modal heading). */ + workspaceTitle: string; + plugins: PluginsStore; + /** + * The layout port (plan 04 §4.4) — gates + powers the "Reset layout to + * default" affordance. Absent → the section doesn't render (tests). + */ + layout?: WorkspaceLayoutPort; + onClose: () => void; + } = $props(); + + onMount(() => { + // Load only when the view isn't already showing THIS workspace (reopening + // the same modal never flashes; a different id reloads it). + if (plugins.workspaceView?.workspaceId !== workspaceId) { + void plugins.openWorkspace(workspaceId); + } + }); + + // The default workspace is the template (§7): its states are what other + // workspaces inherit when they first open. + const isTemplate = $derived(workspaceId === "default"); + + async function toggle(pluginId: string, checked: boolean): Promise<void> { + await plugins.setWorkspaceState(workspaceId, pluginId, checked ? "enabled" : "disabled"); + // The per-workspace activation rule (layout): toggling a plugin may have + // changed which layout plugin serves THIS workspace — re-probe so the + // newly-enabled provider's tree (or the no-provider degrade) renders + // without a reload. The store ignores non-active workspaces. + layout?.refresh(workspaceId); + } + + // ── The reset affordance (plan 04 §4.4) ───────────────────────────────── + let resetOpen = $state(false); + let resetting = $state(false); + let resetError = $state<string | null>(null); + + async function confirmReset(): Promise<void> { + if (layout === undefined || resetting) return; + resetting = true; + resetError = null; + const failure = await layout.reset(workspaceId); + resetting = false; + resetOpen = false; + if (failure !== null) resetError = failure; + } +</script> + +<div class="modal modal-open" role="dialog" aria-label={`Plugins for ${workspaceTitle}`}> + <div class="modal-box max-w-2xl"> + <h3 class="text-lg font-bold">Plugins — {workspaceTitle}</h3> + <p class="mt-1 text-xs opacity-60"> + What THIS workspace uses. Unloaded plugins are greyed out — only the global plugin manager + loads code. + </p> + {#if isTemplate} + <p class="mt-1 text-xs text-info"> + This is the default workspace — other workspaces inherit these states when they first open. + </p> + {/if} + + {#if plugins.error} + <p class="mt-2 text-xs text-error" role="alert">{plugins.error}</p> + {/if} + + {#if plugins.workspaceView === null} + <div class="flex h-24 items-center justify-center"> + <span class="loading loading-spinner loading-sm opacity-60"></span> + </div> + {:else} + <table class="table table-sm mt-2"> + <thead> + <tr> + <th class="w-10"> + <span class="sr-only">Enabled</span> + </th> + <th>Plugin</th> + <th>State</th> + </tr> + </thead> + <tbody> + {#each plugins.workspaceView.rows as row (row.pluginId)} + <tr class={row.loaded ? "" : "opacity-50"}> + <td> + {#if rowSelectable(row)} + <input + type="checkbox" + class="checkbox checkbox-sm" + checked={rowChecked(row)} + aria-label={`${rowChecked(row) ? "Disable" : "Enable"} ${row.pluginId} in this workspace`} + onchange={(e) => toggle(row.pluginId, e.currentTarget.checked)} + /> + {:else} + <input + type="checkbox" + class="checkbox checkbox-sm" + checked={rowChecked(row)} + disabled + aria-label={`${row.pluginId} (unavailable in this workspace)`} + title={rowBlockReason(row) ?? undefined} + /> + {/if} + </td> + <td> + <span class="font-medium">{row.pluginId}</span> + <span class="badge badge-ghost badge-sm ml-2 text-xs">{row.tier}</span> + </td> + <td class="text-xs opacity-70"> + {stateLabel(row.state)} + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + + {#if layout} + <div class="mt-3 border-t border-base-300 pt-3"> + <div class="flex items-center justify-between gap-3"> + <div> + <p class="text-sm font-medium">Layout</p> + {#if layout.providerActive} + <p class="text-xs opacity-60">Managed by the active layout plugin.</p> + {:else} + <p class="text-xs opacity-60">No layout plugin enabled — no layout to reset.</p> + {/if} + </div> + <button + type="button" + class="btn btn-warning btn-sm" + disabled={!layout.providerActive || resetting} + title={layout.providerActive + ? "Restore this workspace's default layout" + : "No layout plugin enabled"} + onclick={() => (resetOpen = true)} + > + Reset layout to default + </button> + </div> + {#if resetError} + <p class="mt-1 text-xs text-error" role="alert">Resetting the layout failed: {resetError}</p> + {/if} + </div> + {/if} + + <div class="modal-action"> + <button type="button" class="btn btn-sm" onclick={onClose}>Close</button> + </div> + </div> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div class="modal-backdrop" onclick={onClose}></div> +</div> + +{#if resetOpen && layout} + <ResetLayoutConfirm onConfirm={() => void confirmReset()} onCancel={() => (resetOpen = false)} /> +{/if} diff --git a/src/features/plugins/ui/WorkspacePluginModal.test.ts b/src/features/plugins/ui/WorkspacePluginModal.test.ts new file mode 100644 index 0000000..4c75ab3 --- /dev/null +++ b/src/features/plugins/ui/WorkspacePluginModal.test.ts @@ -0,0 +1,258 @@ +import type { WorkspacePluginsResponse } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginsHttp } from "../adapter/http"; +import type { WorkspaceLayoutPort } from "../logic/types"; +import { createPluginsStore } from "../store.svelte"; +import WorkspacePluginModal from "./WorkspacePluginModal.svelte"; + +const wsResponse: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + // Core: enabled + locked (the server materializes core to enabled and + // refuses its PUT with a 409 core-locked — plan 06 §6.6). + { pluginId: "kernel", tier: "core", state: "enabled", loaded: true, locked: true }, + // Loaded standard: interactive. + { pluginId: "mcp", tier: "standard", state: "undefined", loaded: true, locked: false }, + // UNLOADED: greyed out + unselectable (§7 strict layering). + { pluginId: "notloaded", tier: "standard", state: "disabled", loaded: false, locked: false }, + ], + enabledProviderCount: 1, +}; + +function fakeHttp(overrides: Partial<PluginsHttp> = {}): PluginsHttp { + return { + list: vi.fn(async () => null), + scan: vi.fn(), + load: vi.fn(), + unload: vi.fn(), + reload: vi.fn(), + workspacePlugins: vi.fn(async () => wsResponse), + setWorkspaceState: vi.fn(async () => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "mcp", state: "enabled" }, + })), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + ...overrides, + } as unknown as PluginsHttp; +} + +async function renderModal(http: PluginsHttp = fakeHttp()) { + const store = createPluginsStore(http); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { workspaceId: "ws", workspaceTitle: "My Workspace", plugins: store, onClose: vi.fn() }, + }); + return { store, http }; +} + +/** A layout port (plan 04 §4.4) scripted per test. */ +function fakeLayoutPort( + providerActive = true, + reset: WorkspaceLayoutPort["reset"] = vi.fn(async () => null), + refresh: WorkspaceLayoutPort["refresh"] = vi.fn(), +): WorkspaceLayoutPort { + return { providerActive, reset, refresh }; +} + +describe("WorkspacePluginModal", () => { + it("renders the workspace's plugin rows", async () => { + await renderModal(); + expect(screen.getByText("Plugins — My Workspace")).toBeInTheDocument(); + expect(screen.getByText("kernel")).toBeInTheDocument(); + expect(screen.getByText("mcp")).toBeInTheDocument(); + }); + + it("checkboxes reflect the workspace state (enabled=checked, undefined=unchecked)", async () => { + await renderModal(); + expect(screen.getByRole("checkbox", { name: /kernel/i })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: /Enable mcp/i })).not.toBeChecked(); + }); + + it("unloaded plugins are greyed out AND unselectable (§7)", async () => { + await renderModal(); + const checkbox = screen.getByRole("checkbox", { name: /notloaded/i }); + expect(checkbox).toBeDisabled(); + // The row is visually greyed (the opacity marker class). + expect(checkbox.closest("tr")?.className).toContain("opacity-50"); + // The tooltip points at the global manager. + expect(checkbox.getAttribute("title")).toMatch(/plugin manager/i); + }); + + it("core rows render locked (checked but unselectable — the server refuses the PUT)", async () => { + await renderModal(); + const checkbox = screen.getByRole("checkbox", { name: /kernel/i }); + expect(checkbox).toBeDisabled(); + expect(checkbox.getAttribute("title")).toMatch(/Core plugins are enabled by default/i); + }); + + it("checking a selectable row writes enabled via the store", async () => { + const user = userEvent.setup(); + const { http } = await renderModal(); + await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i })); + expect(http.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "enabled"); + }); + + it("unchecking a checked row writes disabled", async () => { + const user = userEvent.setup(); + const enabled: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + { pluginId: "mcp", tier: "standard", state: "enabled", loaded: true, locked: false }, + ], + enabledProviderCount: 1, + }; + const fake = fakeHttp({ workspacePlugins: async () => enabled }); + const store = createPluginsStore(fake); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + onClose: vi.fn(), + }, + }); + await user.click(screen.getByRole("checkbox", { name: /Disable mcp/i })); + expect(fake.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "disabled"); + }); + + it("a successful toggle calls the layout port's refresh (the per-workspace activation may change the served tree)", async () => { + const user = userEvent.setup(); + const refresh = vi.fn(); + const store = createPluginsStore(fakeHttp()); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + layout: fakeLayoutPort( + true, + vi.fn(async () => null), + refresh, + ), + onClose: vi.fn(), + }, + }); + await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i })); + expect(refresh).toHaveBeenCalledWith("ws"); + }); + + it("a REFUSED toggle (state write fails) still calls refresh — the probe is the idempotent catch-up", async () => { + const user = userEvent.setup(); + const refresh = vi.fn(); + const fake = fakeHttp({ + setWorkspaceState: async () => ({ + ok: false, + error: "Plugin is not loaded — load it globally first", + }), + }); + const store = createPluginsStore(fake); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + layout: fakeLayoutPort( + true, + vi.fn(async () => null), + refresh, + ), + onClose: vi.fn(), + }, + }); + await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i })); + // The attempted state change re-probes either way (a refused write changed + // nothing, so the probe re-syncs the same tree — harmless and consistent). + expect(refresh).toHaveBeenCalledWith("ws"); + }); + + it("shows the template hint for the default workspace", async () => { + const store = createPluginsStore(fakeHttp()); + await store.openWorkspace("default"); + render(WorkspacePluginModal, { + props: { + workspaceId: "default", + workspaceTitle: "Default", + plugins: store, + onClose: vi.fn(), + }, + }); + expect(screen.getByText(/other workspaces inherit these states/i)).toBeInTheDocument(); + }); +}); + +describe("WorkspacePluginModal — the reset-layout affordance (plan 04 §4.4)", () => { + async function renderWithLayout(layout?: ReturnType<typeof fakeLayoutPort>) { + const store = createPluginsStore(fakeHttp()); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + ...(layout ? { layout } : {}), + onClose: vi.fn(), + }, + }); + return layout; + } + + it("renders an ENABLED reset button when a layout provider is active", async () => { + await renderWithLayout(fakeLayoutPort(true)); + const button = screen.getByRole("button", { name: /reset layout to default/i }); + expect(button).toBeEnabled(); + }); + + it("the button is DISABLED with the 'no layout plugin enabled' reason when no provider is active", async () => { + await renderWithLayout(fakeLayoutPort(false)); + const button = screen.getByRole("button", { name: /reset layout to default/i }); + expect(button).toBeDisabled(); + expect(button.getAttribute("title")).toMatch(/no layout plugin enabled/i); + expect(screen.getByText(/no layout plugin enabled/i)).toBeInTheDocument(); + }); + + it("no layout port → the layout section doesn't render at all (tests / no wiring)", async () => { + await renderWithLayout(undefined); + expect(screen.queryByRole("button", { name: /reset layout to default/i })).toBeNull(); + }); + + it("confirming the dialog resets the layout (and the dialog copy names the consequence)", async () => { + const user = userEvent.setup(); + const reset = vi.fn(async () => null); + await renderWithLayout(fakeLayoutPort(true, reset)); + + await user.click(screen.getByRole("button", { name: /reset layout to default/i })); + // The confirm dialog names the consequence exactly (§4.4 copy). + expect( + screen.getByText( + /layout returns to the default; plugin and dropdown customizations are lost/i, + ), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /^reset$/i })); + expect(reset).toHaveBeenCalledWith("ws"); + }); + + it("cancelling the dialog never resets", async () => { + const user = userEvent.setup(); + const reset = vi.fn(async () => null); + await renderWithLayout(fakeLayoutPort(true, reset)); + + await user.click(screen.getByRole("button", { name: /reset layout to default/i })); + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(reset).not.toHaveBeenCalled(); + }); + + it("a failed reset surfaces the reason as an alert", async () => { + const user = userEvent.setup(); + const reset = vi.fn(async () => "no layout plugin enabled"); + await renderWithLayout(fakeLayoutPort(true, reset)); + + await user.click(screen.getByRole("button", { name: /reset layout to default/i })); + await user.click(screen.getByRole("button", { name: /^reset$/i })); + expect(await screen.findByRole("alert")).toHaveTextContent(/resetting the layout failed/i); + }); +}); diff --git a/src/features/search-settings/index.ts b/src/features/search-settings/index.ts new file mode 100644 index 0000000..a6ea160 --- /dev/null +++ b/src/features/search-settings/index.ts @@ -0,0 +1,22 @@ +export type { + LoadSearchBackendConfig, + SaveSearchBackend, + SearchBackendAvailability, + SearchBackendConfig, + SearchBackendConfigResult, +} from "./logic/view-model"; +export { + buildSearchBackendOptions, + NONE_BACKEND_ID, + NONE_BACKEND_LABEL, + normalizeSearchBackendConfig, + searchBackendChanged, + searchBackendLabel, +} from "./logic/view-model"; +export { default as SearchBackendField } from "./ui/SearchBackendField.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "Search Settings", + description: "Global search backend selection for the web_search tool (GET/PUT /search/config)", +} as const; diff --git a/src/features/search-settings/logic/view-model.test.ts b/src/features/search-settings/logic/view-model.test.ts new file mode 100644 index 0000000..7f3ceb5 --- /dev/null +++ b/src/features/search-settings/logic/view-model.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + buildSearchBackendOptions, + NONE_BACKEND_ID, + NONE_BACKEND_LABEL, + normalizeSearchBackendConfig, + type SearchBackendConfig, + searchBackendChanged, + searchBackendLabel, +} from "./view-model"; + +const CONFIG: SearchBackendConfig = { + backend: "firecrawl", + available: [ + { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }, + { id: "umans", label: "Umans (Exa)", configured: true }, + ], +}; + +describe("buildSearchBackendOptions", () => { + it("lists the available backends in wire order plus the None off state last", () => { + expect(buildSearchBackendOptions(CONFIG)).toEqual([ + { id: "firecrawl", label: "Firecrawl (self-hosted)", disabled: false }, + { id: "umans", label: "Umans (Exa)", disabled: false }, + { id: "none", label: NONE_BACKEND_LABEL, disabled: false }, + ]); + }); + + it("disables unconfigured backends with a suffix (listed, never an error)", () => { + const config: SearchBackendConfig = { + backend: "firecrawl", + available: [ + { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }, + { id: "umans", label: "Umans (Exa)", configured: false }, + ], + }; + const options = buildSearchBackendOptions(config); + expect(options[1]).toEqual({ + id: "umans", + label: "Umans (Exa) (not configured)", + disabled: true, + }); + expect(options[2]?.id).toBe(NONE_BACKEND_ID); + }); + + it("prepends an unknown persisted selection so the select can render it", () => { + const config: SearchBackendConfig = { backend: "gone", available: [] }; + const options = buildSearchBackendOptions(config); + expect(options[0]).toEqual({ id: "gone", label: "gone (unknown)", disabled: false }); + expect(options.at(-1)?.id).toBe(NONE_BACKEND_ID); + }); + + it("does not duplicate the none option when 'none' is selected", () => { + const config: SearchBackendConfig = { backend: NONE_BACKEND_ID, available: [] }; + const options = buildSearchBackendOptions(config); + expect(options.filter((o) => o.id === NONE_BACKEND_ID)).toHaveLength(1); + }); +}); + +describe("searchBackendLabel", () => { + it("maps a backend id to its advertised label", () => { + expect(searchBackendLabel(CONFIG, "umans")).toBe("Umans (Exa)"); + }); + + it("maps 'none' to the off-state label", () => { + expect(searchBackendLabel(CONFIG, NONE_BACKEND_ID)).toBe(NONE_BACKEND_LABEL); + }); + + it("falls back to the raw id for an unknown backend", () => { + expect(searchBackendLabel(CONFIG, "gone")).toBe("gone"); + }); +}); + +describe("searchBackendChanged", () => { + it("detects a real change and a no-op reselect", () => { + expect(searchBackendChanged("umans", "firecrawl")).toBe(true); + expect(searchBackendChanged("firecrawl", "firecrawl")).toBe(false); + expect(searchBackendChanged(NONE_BACKEND_ID, NONE_BACKEND_ID)).toBe(false); + }); +}); + +describe("normalizeSearchBackendConfig", () => { + it("normalizes a well-formed response", () => { + expect( + normalizeSearchBackendConfig({ + backend: "umans", + available: [{ id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }], + }), + ).toEqual({ + backend: "umans", + available: [{ id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }], + }); + }); + + it("degrades a malformed/partial response instead of crashing the renderer", () => { + expect(normalizeSearchBackendConfig(null)).toEqual({ backend: NONE_BACKEND_ID, available: [] }); + expect(normalizeSearchBackendConfig({})).toEqual({ backend: NONE_BACKEND_ID, available: [] }); + expect( + normalizeSearchBackendConfig({ + backend: "firecrawl", + available: [null, 42, { id: "" }, { label: "no id" }, { id: "umans" }], + }), + ).toEqual({ + backend: "firecrawl", + available: [{ id: "umans", label: "umans", configured: false }], + }); + }); +}); diff --git a/src/features/search-settings/logic/view-model.ts b/src/features/search-settings/logic/view-model.ts new file mode 100644 index 0000000..5d172c9 --- /dev/null +++ b/src/features/search-settings/logic/view-model.ts @@ -0,0 +1,124 @@ +/** + * Pure core for the search-settings feature — zero DOM, zero effects, zero + * Svelte. + * + * The "Search backend" dropdown (Settings pane) picks the engine backing the + * `web_search` tool's SEARCH mode. The selection is GLOBAL and persisted on + * the backend (`GET/PUT /search/config`); decided entries are exactly + * "Firecrawl (self-hosted)" + "Umans (Exa)" plus "None (search disabled)" as + * the off state. Scrape/crawl always route to the self-hosted Firecrawl + * backend regardless of the selection; the tool's `map` mode is removed + * entirely (backend plan). + */ + +/** + * One selectable backend as the backend's wire advertises it (mirrors + * `@dispatch/wire`'s `SearchBackendAvailability`). + */ +export interface SearchBackendAvailability { + readonly id: string; + readonly label: string; + /** The availability gate (umans: `UMANS_API_KEY` resolves; firecrawl: always). */ + readonly configured: boolean; +} + +/** The global search-backend config (`GET /search/config` response). */ +export interface SearchBackendConfig { + /** The selected backend id, or `"none"` (search off). */ + readonly backend: string; + readonly available: readonly SearchBackendAvailability[]; +} + +// ── Injected ports (consumer-defines-port; the composition root adapts the +// store's HTTP calls to these shapes). ────────────────────────────────────── + +/** Outcome of `GET/PUT /search/config`. `unavailable` marks the 503 degrade + * (the backend's search-config service not loaded) so the UI can disable the + * field with a "search not available" hint instead of a generic error. */ +export type SearchBackendConfigResult = + | { readonly ok: true; readonly config: SearchBackendConfig } + | { readonly ok: false; readonly error: string; readonly unavailable?: boolean }; + +export type LoadSearchBackendConfig = () => Promise<SearchBackendConfigResult>; + +export type SaveSearchBackend = (backend: string) => Promise<SearchBackendConfigResult>; + +/** The explicit off-state option label. */ +export const NONE_BACKEND_ID = "none"; +export const NONE_BACKEND_LABEL = "None (search disabled)"; + +/** One rendered `<option>` of the dropdown. */ +export interface SearchBackendOption { + readonly id: string; + readonly label: string; + /** Unconfigured backends stay LISTED but disabled — never an error. */ + readonly disabled: boolean; +} + +/** + * Build the dropdown options from the config: every advertised backend in + * wire order (unconfigured ones disabled with a "not configured" suffix), + * then the "None (search disabled)" off state appended last. If the persisted + * selection is not among them (an unknown id from a newer/older backend), it + * is prepended so the select can render it truthfully. + */ +export function buildSearchBackendOptions( + config: SearchBackendConfig, +): readonly SearchBackendOption[] { + const options: SearchBackendOption[] = config.available.map((b) => ({ + id: b.id, + label: b.configured ? b.label : `${b.label} (not configured)`, + disabled: !b.configured, + })); + if ( + config.backend !== NONE_BACKEND_ID && + !config.available.some((b) => b.id === config.backend) + ) { + options.unshift({ id: config.backend, label: `${config.backend} (unknown)`, disabled: false }); + } + options.push({ id: NONE_BACKEND_ID, label: NONE_BACKEND_LABEL, disabled: false }); + return options; +} + +/** The human-facing label for a backend id (the saved / error messages). */ +export function searchBackendLabel(config: SearchBackendConfig, id: string): string { + if (id === NONE_BACKEND_ID) { + return NONE_BACKEND_LABEL; + } + return config.available.find((b) => b.id === id)?.label ?? id; +} + +/** Whether selecting `selected` would change the persisted backend. */ +export function searchBackendChanged(selected: string, persisted: string): boolean { + return selected !== persisted; +} + +/** + * Normalize the untyped JSON at the network seam so a malformed/partial + * response can never crash the renderer (mirrors `normalizeHeartbeatConfig`). + * Unknown entries degrade to unconfigured; a missing backend falls back to + * `"none"` rendering (the safest default for a UI that must not lie). + */ +export function normalizeSearchBackendConfig(raw: unknown): SearchBackendConfig { + const obj = (raw ?? {}) as { + backend?: unknown; + available?: unknown; + }; + const available = Array.isArray(obj.available) ? obj.available : []; + return { + backend: + typeof obj.backend === "string" && obj.backend.length > 0 ? obj.backend : NONE_BACKEND_ID, + available: available.flatMap((entry) => { + if (entry === null || typeof entry !== "object") return []; + const e = entry as { id?: unknown; label?: unknown; configured?: unknown }; + if (typeof e.id !== "string" || e.id.length === 0) return []; + return [ + { + id: e.id, + label: typeof e.label === "string" ? e.label : e.id, + configured: e.configured === true, + }, + ]; + }), + }; +} diff --git a/src/features/search-settings/ui/SearchBackendField.svelte b/src/features/search-settings/ui/SearchBackendField.svelte new file mode 100644 index 0000000..c1ae450 --- /dev/null +++ b/src/features/search-settings/ui/SearchBackendField.svelte @@ -0,0 +1,142 @@ +<script lang="ts"> + /** + * The "Search backend" dropdown (Settings pane) — picks the engine backing + * the `web_search` tool's SEARCH mode. GLOBAL setting persisted on the + * backend (`GET/PUT /search/config`); SAVE ON CHANGE (no Set button). + * Unconfigured backends stay listed but disabled; "None (search disabled)" + * is the off state. The config affects search mode only — scrape/crawl + * always route to the self-hosted Firecrawl backend. + * + * Thin shell: seeds/reverts the select from the last PERSISTED value, shows + * saving/saved/error feedback, and degrades to a disabled field with a + * "search not available" hint when the backend's search-config service is + * not loaded (503) — matching heartbeat's degrade philosophy. + */ + import { untrack } from "svelte"; + import { + buildSearchBackendOptions, + type LoadSearchBackendConfig, + NONE_BACKEND_ID, + type SaveSearchBackend, + searchBackendChanged, + searchBackendLabel, + type SearchBackendConfig, + } from "../logic/view-model"; + + let { + load, + save, + }: { + /** Fetch the persisted global config (called once on mount). */ + load: LoadSearchBackendConfig; + /** Persist a new backend selection (save-on-change). */ + save: SaveSearchBackend; + } = $props(); + + let config = $state<SearchBackendConfig | null>(null); + let loading = $state(true); + let loadUnavailable = $state(false); + let loadError = $state<string | null>(null); + let selected = $state(""); + let lastSeed = $state(""); + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state<string | null>(null); + + $effect(() => { + // Load ONCE on first mount (the setting is GLOBAL — no workspace + // re-seeding); guard against strict-mode double effects via the flag. + untrack(async () => { + if (config !== null || loadUnavailable) return; + loading = true; + loadError = null; + const result = await load(); + loading = false; + if (result.ok) { + config = result.config; + selected = result.config.backend; + lastSeed = result.config.backend; + } else { + loadUnavailable = result.unavailable === true; + loadError = result.error; + } + }); + }); + + const options = $derived(config === null ? [] : buildSearchBackendOptions(config)); + const persisted = $derived(lastSeed); + + async function handleChange(): Promise<void> { + if (saving || config === null) return; + const next = selected; + if (!searchBackendChanged(next, persisted)) return; + saving = true; + error = null; + justSaved = null; + const result = await save(next); + saving = false; + if (result.ok) { + config = result.config; + // Reflect the persisted value back (the backend is authoritative). + selected = result.config.backend; + lastSeed = result.config.backend; + justSaved = searchBackendLabel(result.config, result.config.backend); + } else { + error = result.error; + // Revert the select to the last persisted value on failure. + selected = persisted; + } + } + + function onSelectChange(): void { + error = null; + justSaved = null; + void handleChange(); + } +</script> + +<div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Search backend</span> + {#if loading} + <div class="skeleton h-7 w-full" aria-label="Search backend loading"></div> + {:else if config === null} + <select + class="select select-bordered select-sm w-full text-xs" + disabled + aria-label="Search backend" + > + <option>{loadUnavailable ? "Search not available" : (loadError ?? "Unavailable")}</option> + </select> + {#if loadError && !loadUnavailable} + <p class="text-xs text-error">{loadError}</p> + {/if} + {:else} + <select + class="select select-bordered select-sm w-full text-xs" + bind:value={selected} + disabled={saving} + onchange={onSelectChange} + aria-label="Search backend" + > + {#each options as option (option.id)} + <option value={option.id} disabled={option.disabled}>{option.label}</option> + {/each} + </select> + {#if saving} + <p class="text-xs opacity-60 flex items-center gap-1"> + <span class="loading loading-spinner loading-xs"></span> + Saving… + </p> + {:else if error} + <p class="text-xs text-error">{error}</p> + {:else if justSaved !== null} + <p class="text-xs text-success">Saved: {justSaved}.</p> + {/if} + {/if} + {#if !loadUnavailable} + <p class="text-xs opacity-50"> + Affects the web_search tool's <em>search</em> mode only — scrape and crawl always use the + self-hosted Firecrawl backend, and the tool's map mode has been removed. + </p> + {/if} +</div> diff --git a/src/features/search-settings/ui/SearchBackendField.test.ts b/src/features/search-settings/ui/SearchBackendField.test.ts new file mode 100644 index 0000000..d8597ef --- /dev/null +++ b/src/features/search-settings/ui/SearchBackendField.test.ts @@ -0,0 +1,119 @@ +import { render, screen, waitFor } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { SearchBackendConfig, SearchBackendConfigResult } from "../logic/view-model"; +import SearchBackendField from "./SearchBackendField.svelte"; + +const CONFIG: SearchBackendConfig = { + backend: "firecrawl", + available: [ + { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }, + { id: "umans", label: "Umans (Exa)", configured: false }, + ], +}; + +function okLoad(backend = "firecrawl"): () => Promise<SearchBackendConfigResult> { + return async () => ({ ok: true, config: { ...CONFIG, backend } }); +} + +function okSave(backend: string): () => Promise<SearchBackendConfigResult> { + const saves: string[] = []; + const impl = async (value: string): Promise<SearchBackendConfigResult> => { + saves.push(value); + return { ok: true, config: { ...CONFIG, backend: value } }; + }; + // attach the recorder for assertions + (impl as unknown as { saves: string[] }).saves = saves; + void backend; + return impl as () => Promise<SearchBackendConfigResult>; +} + +describe("SearchBackendField", () => { + it("renders the options from the loaded config (unconfigured disabled, None last)", async () => { + render(SearchBackendField, { props: { load: okLoad(), save: okSave("none") } }); + const select = await screen.findByLabelText("Search backend"); + await waitFor(() => expect(select).toBeEnabled()); + const options = select.querySelectorAll("option"); + const labels = [...options].map((o) => o.textContent); + expect(labels).toEqual([ + "Firecrawl (self-hosted)", + "Umans (Exa) (not configured)", + "None (search disabled)", + ]); + expect(options[1]?.disabled).toBe(true); + expect((select as HTMLSelectElement).value).toBe("firecrawl"); + }); + + it("saves on change and shows the saved confirmation with the new label", async () => { + const user = userEvent.setup(); + const save = okSave("umans"); + render(SearchBackendField, { props: { load: okLoad(), save } }); + + const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement; + await waitFor(() => expect(select).toBeEnabled()); + + // Select the umans entry (disabled here would block it, so flip configured + // via a custom config below); instead select "none" which is always enabled. + await user.selectOptions(select, "none"); + await waitFor(() => expect(screen.getByText(/Saved/)).toBeInTheDocument()); + expect(screen.getByText(/Saved: None \(search disabled\)\./)).toBeInTheDocument(); + const saves = (save as unknown as { saves: string[] }).saves; + expect(saves).toEqual(["none"]); + expect(select.value).toBe("none"); + }); + + it("reverts the selection to the persisted value when the save fails", async () => { + const user = userEvent.setup(); + const save = async (): Promise<SearchBackendConfigResult> => ({ + ok: false, + error: "Set search backend failed (HTTP 500)", + }); + render(SearchBackendField, { props: { load: okLoad(), save } }); + + const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement; + await waitFor(() => expect(select).toBeEnabled()); + await user.selectOptions(select, "none"); + + expect(await screen.findByText(/HTTP 500/)).toBeInTheDocument(); + expect(select.value).toBe("firecrawl"); + }); + + it("renders disabled with a 'search not available' hint on the 503 degrade", async () => { + const load = async (): Promise<SearchBackendConfigResult> => ({ + ok: false, + error: "Search config failed (HTTP 503)", + unavailable: true, + }); + render(SearchBackendField, { props: { load, save: okSave("none") } }); + const select = await screen.findByLabelText("Search backend"); + await waitFor(() => expect(select).toBeDisabled()); + expect(screen.getByText(/Search not available/)).toBeInTheDocument(); + // No help text in the unavailable state (nothing to explain — search is off). + expect(screen.queryByText(/search.*mode only/i)).not.toBeInTheDocument(); + }); + + it("renders a loading skeleton while the initial GET is in flight", async () => { + let resolveLoad!: (r: SearchBackendConfigResult) => void; + const load = () => + new Promise<SearchBackendConfigResult>((resolve) => { + resolveLoad = resolve; + }); + render(SearchBackendField, { props: { load, save: okSave("none") } }); + expect(screen.getByLabelText("Search backend loading")).toBeInTheDocument(); + resolveLoad({ ok: true, config: CONFIG }); + await screen.findByLabelText("Search backend"); + }); + + it("prepends an unknown persisted selection as a selectable option", async () => { + const load = async (): Promise<SearchBackendConfigResult> => ({ + ok: true, + config: { backend: "gone", available: [] }, + }); + render(SearchBackendField, { props: { load, save: okSave("none") } }); + const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement; + await waitFor(() => expect(select).toBeEnabled()); + expect(select.value).toBe("gone"); + const labels = [...select.querySelectorAll("option")].map((o) => o.textContent); + expect(labels).toEqual(["gone (unknown)", "None (search disabled)"]); + }); +}); diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts index 1e21e3d..f6a3041 100644 --- a/src/features/surface-host/index.ts +++ b/src/features/surface-host/index.ts @@ -1,3 +1,4 @@ +export { MESSAGE_QUEUE_RENDERER_ID, messageQueueCount } from "./logic/message-queue"; export { buildInvoke, planSurface } from "./logic/plan"; export type { FieldView, SurfaceRenderPlan } from "./logic/types"; export { default as SurfaceView } from "./ui/SurfaceView.svelte"; diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts index 8d55eb7..93256b4 100644 --- a/src/features/surface-host/logic/message-queue.test.ts +++ b/src/features/surface-host/logic/message-queue.test.ts @@ -1,6 +1,12 @@ +import type { SurfaceSpec } from "@dispatch/ui-contract"; import type { QueuedMessage } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { parseMessageQueuePayload } from "./message-queue"; +import { + messageQueueCount, + parseMessageQueuePayload, + reconcileCancelledIds, + selectVisibleMessages, +} from "./message-queue"; const msg = (id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage => ({ id, @@ -46,3 +52,89 @@ describe("parseMessageQueuePayload", () => { 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"]); + }); +}); + +describe("messageQueueCount", () => { + const spec = (payload: unknown): SurfaceSpec => ({ + id: "message-queue", + region: "inline", + title: "Message queue", + fields: [{ kind: "custom", rendererId: "message-queue", payload }], + }); + + it("counts messages, including image-only input", () => { + expect(messageQueueCount(spec({ messages: [msg("m1", ""), msg("m2", "next")] }))).toBe(2); + }); + + it("distinguishes an empty queue from an unavailable snapshot", () => { + expect(messageQueueCount(spec({ messages: [] }))).toBe(0); + expect(messageQueueCount(null)).toBeUndefined(); + }); + + it("returns unknown for missing fields or a different renderer", () => { + expect(messageQueueCount({ ...spec(null), fields: [] })).toBeUndefined(); + expect( + messageQueueCount({ + ...spec(null), + fields: [{ kind: "custom", rendererId: "other", payload: { messages: [msg("m1", "x")] } }], + }), + ).toBeUndefined(); + }); + + it("returns unknown for malformed payloads", () => { + expect(messageQueueCount(spec({ messages: "nope" }))).toBeUndefined(); + expect(messageQueueCount(spec({ messages: [{ id: "m1" }] }))).toBeUndefined(); + }); +}); diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts index 79707a5..a49d4d0 100644 --- a/src/features/surface-host/logic/message-queue.ts +++ b/src/features/surface-host/logic/message-queue.ts @@ -1,3 +1,4 @@ +import type { SurfaceSpec } from "@dispatch/ui-contract"; import type { QueuedMessage } from "@dispatch/wire"; /** @@ -41,5 +42,75 @@ export function parseMessageQueuePayload(payload: unknown): MessageQueueData | n return { messages }; } +/** Queue size from a validated snapshot; absent/malformed data stays unknown. */ +export function messageQueueCount(spec: SurfaceSpec | null): number | undefined { + const field = spec?.fields.find( + (field) => field.kind === "custom" && field.rendererId === MESSAGE_QUEUE_RENDERER_ID, + ); + if (field?.kind !== "custom") return undefined; + return parseMessageQueuePayload(field.payload)?.messages.length; +} + /** 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/ui/MessageQueueList.svelte b/src/features/surface-host/ui/MessageQueueList.svelte index 554fa02..b260de2 100644 --- a/src/features/surface-host/ui/MessageQueueList.svelte +++ b/src/features/surface-host/ui/MessageQueueList.svelte @@ -1,21 +1,92 @@ <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)); + + // 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> + {#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> 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/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte index aed8d03..8ce8ade 100644 --- a/src/features/surface-host/ui/SurfaceView.svelte +++ b/src/features/surface-host/ui/SurfaceView.svelte @@ -11,7 +11,22 @@ 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 @@ -40,7 +55,7 @@ {#if group.field.rendererId === "table"} <SurfaceTable payload={group.field.payload} /> {:else if group.field.rendererId === "message-queue"} - <MessageQueueList payload={group.field.payload} /> + <MessageQueueList payload={group.field.payload} onCancel={onCancelQueuedMessage} /> {:else if group.field.rendererId === "todo"} <TodoList payload={group.field.payload} /> {/if} diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts index 7520215..145a3d9 100644 --- a/src/features/tabs/index.ts +++ b/src/features/tabs/index.ts @@ -1,3 +1,17 @@ +export type { GroupTone, TabGroupForest, TabGroupNode } from "./tab-groups"; +export { + buildTabGroups, + cascadeCloseIds, + directGroupChildren, + GROUP_TONES, + groupBleedPx, + groupDescendants, + groupGutterPx, + groupParentCandidates, + groupTone, + lastRowIsGroup, + visibleGroupParents, +} from "./tab-groups"; export type { Tab, TabsState } from "./tabs"; export { activeTab, @@ -9,6 +23,7 @@ export { newDraft, selectTab, setModel, + setTabGroupParent, setTitle, shortHandle, } from "./tabs"; diff --git a/src/features/tabs/tab-flip.test.ts b/src/features/tabs/tab-flip.test.ts new file mode 100644 index 0000000..43e966f --- /dev/null +++ b/src/features/tabs/tab-flip.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { cardFlipId, forestFlipIds, groupFlipId, isGroupFlipId, leavingFlipIds } from "./tab-flip"; +import { buildTabGroups } from "./tab-groups"; +import type { Tab } from "./tabs"; + +function tab(id: string, parent?: string): Tab { + return { + conversationId: id, + model: "m", + title: id, + workspaceId: "w", + ...(parent !== undefined ? { tabGroupParentId: parent } : {}), + }; +} + +describe("flip ids", () => { + it("distinguishes a card from the group box of the same conversation", () => { + expect(cardFlipId("p")).not.toBe(groupFlipId("p")); + expect(isGroupFlipId(groupFlipId("p"))).toBe(true); + expect(isGroupFlipId(cardFlipId("p"))).toBe(false); + }); +}); + +describe("forestFlipIds", () => { + it("lists every card plus a box for each tab that has visible subagents", () => { + const forest = buildTabGroups([tab("p"), tab("c", "p"), tab("g", "c"), tab("solo")]); + expect([...forestFlipIds(forest)].sort()).toEqual( + ["c", "g", "group:c", "group:p", "p", "solo"].sort(), + ); + }); + + it("has no boxes when nothing is grouped", () => { + expect([...forestFlipIds(buildTabGroups([tab("a"), tab("b")]))]).toEqual(["a", "b"]); + }); +}); + +describe("leavingFlipIds", () => { + it("returns the rendered ids the next render drops, in rendered order", () => { + const before = buildTabGroups([tab("p"), tab("c", "p"), tab("solo")]); + // Closing the only subagent drops its card AND the parent's group box. + const after = buildTabGroups([tab("p"), tab("solo")]); + expect(leavingFlipIds([...forestFlipIds(before)], forestFlipIds(after))).toEqual([ + "group:p", + "c", + ]); + }); + + it("is empty when a tab is only added", () => { + const next = forestFlipIds(buildTabGroups([tab("a"), tab("b", "a")])); + expect(leavingFlipIds(["a"], next)).toEqual([]); + }); +}); diff --git a/src/features/tabs/tab-flip.ts b/src/features/tabs/tab-flip.ts new file mode 100644 index 0000000..3bb4c03 --- /dev/null +++ b/src/features/tabs/tab-flip.ts @@ -0,0 +1,45 @@ +import type { TabGroupForest, TabGroupNode } from "./tab-groups"; + +/** + * Flip ids — the PURE identity scheme behind the tab grid's FLIP animation. + * + * Every animated element carries a `data-flip-id`: a tab card uses its + * conversation id, a group box uses `group:<parent conversation id>`. The ids + * are stable across re-renders even when the DOM node is replaced (a card + * becoming a double-width group parent is a NEW node), which is what lets + * GSAP Flip morph the old element into the new one. + */ + +const GROUP_PREFIX = "group:"; + +/** Flip id of a tab card. */ +export function cardFlipId(conversationId: string): string { + return conversationId; +} + +/** Flip id of the group box wrapping `parentConversationId` + its subagents. */ +export function groupFlipId(parentConversationId: string): string { + return `${GROUP_PREFIX}${parentConversationId}`; +} + +/** Whether a flip id names a group box (vs a tab card). */ +export function isGroupFlipId(flipId: string): boolean { + return flipId.startsWith(GROUP_PREFIX); +} + +/** Every flip id the grid renders for `forest` (cards + group boxes). */ +export function forestFlipIds(forest: TabGroupForest): Set<string> { + const out = new Set<string>(); + const walk = (node: TabGroupNode): void => { + out.add(cardFlipId(node.tab.conversationId)); + if (node.children.length > 0) out.add(groupFlipId(node.tab.conversationId)); + for (const child of node.children) walk(child); + }; + for (const root of forest.roots) walk(root); + return out; +} + +/** The currently-rendered flip ids that the NEXT render drops (order kept). */ +export function leavingFlipIds(current: readonly string[], next: ReadonlySet<string>): string[] { + return current.filter((id) => !next.has(id)); +} diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts new file mode 100644 index 0000000..46b04e7 --- /dev/null +++ b/src/features/tabs/tab-groups.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { + buildTabGroups, + cascadeCloseIds, + directGroupChildren, + groupBleedPx, + groupDescendants, + groupGutterPx, + groupParentCandidates, + groupTone, + lastRowIsGroup, + visibleGroupParents, +} from "./tab-groups"; +import type { Tab } from "./tabs"; + +function tab(id: string, parent?: string): Tab { + return { + conversationId: id, + model: "m", + title: id.toUpperCase(), + workspaceId: "w", + ...(parent !== undefined ? { tabGroupParentId: parent } : {}), + }; +} + +const ids = (tabs: readonly Tab[]): string[] => tabs.map((t) => t.conversationId); + +describe("visibleGroupParents", () => { + it("keeps a parent that is another open tab", () => { + const parents = visibleGroupParents([tab("a"), tab("b", "a")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBe("a"); + }); + + it("drops a parent that is not open (closed / other workspace) or is the tab itself", () => { + const parents = visibleGroupParents([tab("a", "gone"), tab("b", "b")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBeNull(); + }); + + it("treats every member of a cycle as a root, but keeps hangers-on below them", () => { + const parents = visibleGroupParents([tab("a", "b"), tab("b", "a"), tab("c", "a")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBeNull(); + expect(parents.get("c")).toBe("a"); + }); +}); + +describe("buildTabGroups", () => { + it("returns flat roots and depth 0 when nothing is grouped", () => { + const forest = buildTabGroups([tab("a"), tab("b"), tab("c")]); + expect(forest.maxDepth).toBe(0); + expect(forest.roots.map((n) => n.tab.conversationId)).toEqual(["a", "b", "c"]); + expect(forest.roots.every((n) => n.children.length === 0)).toBe(true); + }); + + it("nests children under their parent in tab order, preserving root order", () => { + // Child listed BEFORE its parent still nests under it. + const forest = buildTabGroups([tab("x", "p"), tab("a"), tab("p"), tab("y", "p")]); + expect(forest.maxDepth).toBe(1); + expect(forest.roots.map((n) => n.tab.conversationId)).toEqual(["a", "p"]); + const p = forest.roots[1]; + expect(p?.children.map((n) => n.tab.conversationId)).toEqual(["x", "y"]); + }); + + it("supports nested groups (subagents with subagents) and reports the depth", () => { + const forest = buildTabGroups([tab("p"), tab("c", "p"), tab("g", "c"), tab("gg", "g")]); + expect(forest.maxDepth).toBe(3); + const c = forest.roots[0]?.children[0]; + expect(c?.tab.conversationId).toBe("c"); + expect(c?.children[0]?.children[0]?.tab.conversationId).toBe("gg"); + }); + + it("renders every tab exactly once", () => { + const tabs = [tab("a", "b"), tab("b", "a"), tab("c", "a"), tab("d", "nope"), tab("e", "c")]; + const seen: string[] = []; + const walk = (nodes: ReturnType<typeof buildTabGroups>["roots"]): void => { + for (const n of nodes) { + seen.push(n.tab.conversationId); + walk(n.children); + } + }; + walk(buildTabGroups(tabs).roots); + expect(seen.sort()).toEqual(["a", "b", "c", "d", "e"]); + }); +}); + +describe("groupDescendants / directGroupChildren / groupParentCandidates", () => { + const tabs = [tab("p"), tab("c1", "p"), tab("c2", "p"), tab("g", "c1"), tab("solo")]; + + it("collects transitive descendants", () => { + expect([...groupDescendants(tabs, "p")].sort()).toEqual(["c1", "c2", "g"]); + expect([...groupDescendants(tabs, "c1")]).toEqual(["g"]); + expect(groupDescendants(tabs, "solo").size).toBe(0); + }); + + it("lists direct children only", () => { + expect(directGroupChildren(tabs, "p")).toEqual(["c1", "c2"]); + }); + + it("offers every tab except self, descendants (cycle) and the current parent", () => { + expect(ids(groupParentCandidates(tabs, "c1"))).toEqual(["c2", "solo"]); + expect(ids(groupParentCandidates(tabs, "p"))).toEqual(["solo"]); + expect(ids(groupParentCandidates(tabs, "solo"))).toEqual(["p", "c1", "c2", "g"]); + }); +}); + +describe("cascadeCloseIds (the ids a cascading tab close removes)", () => { + const tabs = [ + tab("p"), + tab("c1", "p"), + tab("c2", "p"), + tab("g", "c1"), // sub-sub-agent: grouped under c1, still cascades from p + tab("solo"), + ]; + + it("returns the tab first, then its transitive descendants in tab order", () => { + expect(cascadeCloseIds(tabs, "p")).toEqual(["p", "c1", "c2", "g"]); + }); + + it("starts the cascade at a mid-level tab, taking only its own subtree", () => { + expect(cascadeCloseIds(tabs, "c1")).toEqual(["c1", "g"]); + }); + + it("returns just the tab itself when it has no subagents (closes without a prompt)", () => { + expect(cascadeCloseIds(tabs, "solo")).toEqual(["solo"]); + expect(cascadeCloseIds(tabs, "c2")).toEqual(["c2"]); + }); + + it("matches the rendered forest for cycles and manual regrouping", () => { + const cycle = [tab("p", "c1"), tab("c1", "p"), tab("g", "c1")]; + expect(cascadeCloseIds(cycle, "p")).toEqual(["p"]); + expect(cascadeCloseIds(cycle, "c1")).toEqual(["c1", "g"]); + const regrouped = [tab("p"), tab("c1", "solo"), tab("g", "c1"), tab("solo")]; + expect(cascadeCloseIds(regrouped, "p")).toEqual(["p"]); + expect(cascadeCloseIds(regrouped, "solo")).toEqual(["solo", "c1", "g"]); + }); + + it("ignores tabs whose group parent is no longer open", () => { + const stale = [tab("p"), tab("orphan", "gone")]; + expect(cascadeCloseIds(stale, "p")).toEqual(["p"]); + }); +}); + +describe("groupTone", () => { + it("rotates three colours by level, starting at the outermost box", () => { + expect([1, 2, 3, 4, 5, 6, 7].map(groupTone)).toEqual([ + "secondary", + "success", + "accent", + "secondary", + "success", + "accent", + "secondary", + ]); + }); + + it("never gives a box the colour of its parent or grandparent", () => { + for (let level = 3; level <= 20; level++) { + expect(groupTone(level)).not.toBe(groupTone(level - 1)); + expect(groupTone(level)).not.toBe(groupTone(level - 2)); + } + }); + + it("never uses primary (reserved for the active tab)", () => { + for (let level = 1; level <= 6; level++) expect(groupTone(level)).not.toBe("primary"); + }); + + it("stays in range for out-of-range levels", () => { + expect(groupTone(0)).toBe("accent"); + expect(groupTone(-1)).toBe("success"); + }); +}); + +describe("groupBleedPx / groupGutterPx", () => { + it("is zero without groups", () => { + expect(groupGutterPx(0)).toBe(0); + expect(groupBleedPx(1, 0)).toBe(0); + }); + + it("bleeds outer boxes further than inner ones, the outermost filling the gutter", () => { + const max = 3; + const bleeds = [1, 2, 3].map((l) => groupBleedPx(l, max)); + expect(bleeds[0]).toBe(groupGutterPx(max)); + expect(bleeds[0]).toBeGreaterThan(bleeds[1] ?? 0); + expect(bleeds[1]).toBeGreaterThan(bleeds[2] ?? 0); + expect(bleeds[2]).toBeGreaterThan(0); + }); + + it("keeps the gutter bounded for deep nesting", () => { + expect(groupGutterPx(12)).toBeLessThanOrEqual(24); + expect(groupBleedPx(12, 12)).toBeGreaterThan(0); + }); +}); + +describe("lastRowIsGroup (mirrors grid row-dense placement)", () => { + const leaf = (id: string) => ({ tab: tab(id), children: [] }); + const group = (id: string) => ({ tab: tab(id), children: [leaf(`${id}-kid`)] }); + + it("is false with only cards", () => { + expect(lastRowIsGroup([])).toBe(false); + expect(lastRowIsGroup([leaf("a"), leaf("b"), leaf("c")])).toBe(false); + }); + + it("is true when a nested group comes last", () => { + expect(lastRowIsGroup([leaf("a"), group("g")])).toBe(true); + expect(lastRowIsGroup([group("g")])).toBe(true); + }); + + it("stays true when a later card back-fills an earlier half row", () => { + // [a, _] then [g, g]; b fills a's half row → g is still the last row. + expect(lastRowIsGroup([leaf("a"), group("g"), leaf("b")])).toBe(true); + }); + + it("is false when a later card has to open a new row below the group", () => { + expect(lastRowIsGroup([group("g"), leaf("a")])).toBe(false); + expect(lastRowIsGroup([leaf("a"), leaf("b"), group("g"), leaf("c")])).toBe(false); + }); +}); diff --git a/src/features/tabs/tab-groups.ts b/src/features/tabs/tab-groups.ts new file mode 100644 index 0000000..ff3ddd4 --- /dev/null +++ b/src/features/tabs/tab-groups.ts @@ -0,0 +1,211 @@ +import type { Tab } from "./tabs"; + +/** + * Subagent tab groups — the PURE model behind the tab grid. + * + * Each tab may name a tab group parent (`Tab.tabGroupParentId`, the effective + * value from the backend). The tab grid renders the tabs as a forest: a tab + * with ≥1 visible child is a GROUP (double-width card + a darker box wrapping + * it and its children); everything else is a single half-width card. Groups + * nest (subagents can summon subagents). Purely presentational. + */ + +export interface TabGroupNode { + readonly tab: Tab; + /** Visible children, in tab order. Non-empty ⇒ this node renders as a group. */ + readonly children: readonly TabGroupNode[]; +} + +export interface TabGroupForest { + /** Top-level nodes, in tab order. */ + readonly roots: readonly TabGroupNode[]; + /** Deepest group-box nesting level (0 = no groups, 1 = only top-level groups, …). */ + readonly maxDepth: number; +} + +/** + * The VISIBLE tab group parent of every tab in `tabs`: its `tabGroupParentId` + * when that names ANOTHER tab in the same set, else null. A tab whose + * ancestor chain loops back to itself (a cycle — never produced by the + * backend, but tolerated) is treated as a root, so rendering never loops. + */ +export function visibleGroupParents(tabs: readonly Tab[]): ReadonlyMap<string, string | null> { + const byId = new Map(tabs.map((t) => [t.conversationId, t])); + const direct = (id: string): string | null => { + const p = byId.get(id)?.tabGroupParentId; + return p !== undefined && p !== id && byId.has(p) ? p : null; + }; + const out = new Map<string, string | null>(); + for (const tab of tabs) { + const id = tab.conversationId; + const parent = direct(id); + let cyclic = false; + const seen = new Set<string>([id]); + let cursor = parent; + while (cursor !== null) { + if (cursor === id) { + cyclic = true; + break; + } + if (seen.has(cursor)) break; // a loop above us that doesn't include us + seen.add(cursor); + cursor = direct(cursor); + } + out.set(id, cyclic ? null : parent); + } + return out; +} + +/** Build the group forest for one workspace's tab set (input order preserved). */ +export function buildTabGroups(tabs: readonly Tab[]): TabGroupForest { + const parents = visibleGroupParents(tabs); + const childIds = new Map<string, string[]>(); + for (const tab of tabs) { + const p = parents.get(tab.conversationId) ?? null; + if (p === null) continue; + const list = childIds.get(p); + if (list === undefined) childIds.set(p, [tab.conversationId]); + else list.push(tab.conversationId); + } + const byId = new Map(tabs.map((t) => [t.conversationId, t])); + let maxDepth = 0; + const build = (tab: Tab, depth: number): TabGroupNode => { + const ids = childIds.get(tab.conversationId) ?? []; + if (ids.length > 0) maxDepth = Math.max(maxDepth, depth + 1); + const children: TabGroupNode[] = []; + for (const cid of ids) { + const child = byId.get(cid); + if (child !== undefined) children.push(build(child, depth + 1)); + } + return { tab, children }; + }; + const roots = tabs + .filter((t) => (parents.get(t.conversationId) ?? null) === null) + .map((t) => build(t, 0)); + return { roots, maxDepth }; +} + +/** Every (transitive) descendant id of `conversationId` in the VISIBLE grouping. */ +export function groupDescendants(tabs: readonly Tab[], conversationId: string): Set<string> { + const parents = visibleGroupParents(tabs); + const out = new Set<string>(); + let grew = true; + while (grew) { + grew = false; + for (const [id, p] of parents) { + if (out.has(id) || id === conversationId || p === null) continue; + if (p === conversationId || out.has(p)) { + out.add(id); + grew = true; + } + } + } + return out; +} + +/** + * The tabs removed by a CASCADING close of `conversationId` (the user chose + * "also close its subagents"): the tab itself first, then its transitive + * visible-group descendants in tab order. ONE decision covers the whole + * subtree — closing never re-prompts per level. A single-element result (the + * tab alone) means the tab has no subagents and closes without a prompt. + */ +export function cascadeCloseIds(tabs: readonly Tab[], conversationId: string): string[] { + const descendants = groupDescendants(tabs, conversationId); + const ids = [conversationId]; + for (const tab of tabs) { + if (descendants.has(tab.conversationId)) ids.push(tab.conversationId); + } + return ids; +} + +/** + * The tabs `conversationId` may be moved under (the "Add to group" menu): + * every other tab EXCEPT itself, its descendants (that would form a cycle), + * and its current visible group parent (already there). Tab order preserved. + */ +export function groupParentCandidates(tabs: readonly Tab[], conversationId: string): Tab[] { + const parents = visibleGroupParents(tabs); + const current = parents.get(conversationId) ?? null; + const descendants = groupDescendants(tabs, conversationId); + return tabs.filter( + (t) => + t.conversationId !== conversationId && + t.conversationId !== current && + !descendants.has(t.conversationId), + ); +} + +/** Ids of the tabs DIRECTLY grouped under `conversationId` (visible grouping). */ +export function directGroupChildren(tabs: readonly Tab[], conversationId: string): string[] { + const parents = visibleGroupParents(tabs); + return tabs + .filter((t) => parents.get(t.conversationId) === conversationId) + .map((t) => t.conversationId); +} + +/** + * Whether a group box's LAST grid row is a nested group box (rather than a + * card). Mirrors the grid's `row dense` auto-placement: the group parent's + * double-width card fills row 0; a card takes the first free half-row (or + * opens a new row); a nested group (spans both columns) always opens a new + * row. Used to trim the outer box's bottom padding so the gap below a + * nested box matches the gap at its sides (an even-looking box). + */ +export function lastRowIsGroup(children: readonly TabGroupNode[]): boolean { + let openHalfRows = 0; // rows with exactly one free cell + let lastIsGroup = false; // the row-0 parent card is not a group row + for (const child of children) { + if (child.children.length > 0) { + lastIsGroup = true; // opens a new (last) row + } else if (openHalfRows > 0) { + openHalfRows -= 1; // back-fills an earlier half row; last row unchanged + } else { + openHalfRows += 1; // opens a new (last) row, half-filled + lastIsGroup = false; + } + } + return lastIsGroup; +} + +/** + * The DaisyUI theme colour a group box at nesting `level` (1 = outermost) is + * tinted with. Three colours ROTATE by level, so a box always differs from + * the box around it AND from the one around that (with two colours a box + * would match its grandparent). `primary` is deliberately excluded — it marks + * the active tab, which must stay legible inside any box. + */ +export const GROUP_TONES = ["secondary", "success", "accent"] as const; +export type GroupTone = (typeof GROUP_TONES)[number]; + +export function groupTone(level: number): GroupTone { + const n = GROUP_TONES.length; + const i = (((Math.trunc(level) - 1) % n) + n) % n; // non-negative even for level < 1 + return GROUP_TONES[i] ?? GROUP_TONES[0]; +} + +/** + * Horizontal bleed (px) of a group box at nesting `level` (1 = outermost) + * when the deepest box is `maxDepth`. A box bleeds OUTWARD by this much (a + * negative margin + equal padding), so the cards inside every box sit on + * exactly the same two grid columns as top-level cards — all cards keep + * IDENTICAL dimensions at every depth. Outer boxes bleed more than inner + * ones so nested edges stay visible. The step shrinks for deep nesting so the + * total gutter (`groupGutterPx`) stays bounded. + */ +export function groupBleedPx(level: number, maxDepth: number): number { + if (maxDepth <= 0 || level < 1 || level > maxDepth) return 0; + return (maxDepth - level + 1) * groupStepPx(maxDepth); +} + +/** The horizontal gutter the grid must reserve so the outermost box fits. */ +export function groupGutterPx(maxDepth: number): number { + return maxDepth <= 0 ? 0 : maxDepth * groupStepPx(maxDepth); +} + +const MAX_STEP_PX = 6; +const MAX_GUTTER_PX = 18; + +function groupStepPx(maxDepth: number): number { + return Math.max(2, Math.min(MAX_STEP_PX, Math.floor(MAX_GUTTER_PX / maxDepth))); +} diff --git a/src/features/tabs/tabs-store.svelte.ts b/src/features/tabs/tabs-store.svelte.ts index d044b1f..8e63171 100644 --- a/src/features/tabs/tabs-store.svelte.ts +++ b/src/features/tabs/tabs-store.svelte.ts @@ -1,3 +1,4 @@ +import type { NamedAgentSelectionSnapshot } from "../named-agents"; import type { Tab, TabsState } from "./tabs"; import { initialState, @@ -7,7 +8,10 @@ import { openTab as reduceOpenTab, selectTab as reduceSelectTab, setModel as reduceSetModel, + setSnapshot as reduceSetSnapshot, + setTabGroupParent as reduceSetTabGroupParent, setTitle as reduceSetTitle, + setWorkspaceId as reduceSetWorkspaceId, activeTab as selectActiveTab, } from "./tabs"; @@ -27,7 +31,15 @@ export interface TabsStore { selectTab(conversationId: string): void; closeTab(conversationId: string): void; setModel(conversationId: string, model: string): void; + /** + * Set (or clear, with null) a tab's Named Agent snapshot (plan §7). The caller + * keeps the snapshot's captured `model` in sync with the tab's `model`. + */ + setSnapshot(conversationId: string, snapshot: NamedAgentSelectionSnapshot | null): void; setTitle(conversationId: string, title: string): void; + setWorkspaceId(conversationId: string, workspaceId: string): void; + /** Set (string) or clear (null) a tab's tab group parent (visual subagent group). */ + setTabGroupParent(conversationId: string, parentId: string | null): void; } export function createTabsStore(storage: TabsStorage): TabsStore { @@ -66,8 +78,17 @@ export function createTabsStore(storage: TabsStorage): TabsStore { setModel(conversationId: string, model: string): void { apply(reduceSetModel(state, conversationId, model)); }, + setSnapshot(conversationId: string, snapshot: NamedAgentSelectionSnapshot | null): void { + apply(reduceSetSnapshot(state, conversationId, snapshot)); + }, setTitle(conversationId: string, title: string): void { apply(reduceSetTitle(state, conversationId, title)); }, + setWorkspaceId(conversationId: string, workspaceId: string): void { + apply(reduceSetWorkspaceId(state, conversationId, workspaceId)); + }, + setTabGroupParent(conversationId: string, parentId: string | null): void { + apply(reduceSetTabGroupParent(state, conversationId, parentId)); + }, }; } diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts index ec93076..696e994 100644 --- a/src/features/tabs/tabs.test.ts +++ b/src/features/tabs/tabs.test.ts @@ -10,6 +10,7 @@ import { newDraft, selectTab, setModel, + setTabGroupParent, setTitle, shortHandle, } from "./tabs"; @@ -223,3 +224,31 @@ describe("shortHandle", () => { expect(shortHandle("abcd", ["abcd", "abcd1234"])).toBe("abcd"); }); }); + +describe("setTabGroupParent", () => { + const base: TabsState = { + tabs: [ + { conversationId: "a", model: "m", title: "A", workspaceId: "w" }, + { conversationId: "b", model: "m", title: "B", workspaceId: "w" }, + ], + activeConversationId: "a", + }; + + it("sets the tab group parent on the named tab only", () => { + const next = setTabGroupParent(base, "b", "a"); + expect(next.tabs[1]?.tabGroupParentId).toBe("a"); + expect(next.tabs[0]).toBe(base.tabs[0]); + expect(next.activeConversationId).toBe("a"); + }); + + it("clears the key entirely with null", () => { + const grouped = setTabGroupParent(base, "b", "a"); + const cleared = setTabGroupParent(grouped, "b", null); + expect(cleared.tabs[1]).toEqual(base.tabs[1]); + expect("tabGroupParentId" in (cleared.tabs[1] ?? {})).toBe(false); + }); + + it("is a no-op for an unknown tab", () => { + expect(setTabGroupParent(base, "zz", "a").tabs).toEqual(base.tabs); + }); +}); diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts index 63cda35..b2dad7d 100644 --- a/src/features/tabs/tabs.ts +++ b/src/features/tabs/tabs.ts @@ -1,9 +1,25 @@ +import type { NamedAgentSelectionSnapshot } from "../named-agents"; + export interface Tab { readonly conversationId: string; readonly model: string; readonly title: string; /** The workspace this tab belongs to (the workspace's URL slug). */ readonly workspaceId: string; + /** + * The Named Agent selection snapshot, when this conversation was selected via + * a Named Agent (plan §7). Retained so the conversation keeps showing the + * friendly label (and its captured raw model) even after the Named Agent is + * renamed/removed. Absent for a raw model selection. + */ + readonly snapshot?: NamedAgentSelectionSnapshot; + /** + * The EFFECTIVE tab group parent (backend `tabGroupParentId`): the + * conversation whose subagent group this tab is visually nested under in + * the tab grid. Purely presentational — distinct from the lineage + * `parentConversationId`. Absent = ungrouped (a top-level tab). + */ + readonly tabGroupParentId?: string; } export interface TabsState { @@ -73,7 +89,36 @@ export function closeTab(state: TabsState, conversationId: string): TabsState { } export function setModel(state: TabsState, conversationId: string, model: string): TabsState { - const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t)); + // A raw-model selection clears any Named Agent snapshot (a raw selection is + // not "via a Named Agent"). Omit the key entirely (`exactOptionalPropertyTypes`). + const tabs = state.tabs.map((t) => + t.conversationId !== conversationId ? t : omitSnapshot({ ...t, model }), + ); + return { tabs, activeConversationId: state.activeConversationId }; +} + +/** Drop the optional `snapshot` key (used to clear a Named Agent selection). */ +function omitSnapshot(tab: Tab): Tab { + if (!("snapshot" in tab)) return tab; + const { snapshot: _omit, ...rest } = tab; + void _omit; + return rest; +} + +/** + * Set (or clear) a tab's Named Agent selection snapshot (plan §7). The snapshot's + * captured `model` should equal the tab's `model` (the resolved raw model); this + * reducer does not rewrite `model` — the caller keeps them in sync. Pass `null` + * to clear (a raw model selection). + */ +export function setSnapshot( + state: TabsState, + conversationId: string, + snapshot: NamedAgentSelectionSnapshot | null, +): TabsState { + const tabs = state.tabs.map((t) => + t.conversationId === conversationId ? { ...t, ...(snapshot === null ? {} : { snapshot }) } : t, + ); return { tabs, activeConversationId: state.activeConversationId }; } @@ -82,6 +127,37 @@ export function setTitle(state: TabsState, conversationId: string, title: string return { tabs, activeConversationId: state.activeConversationId }; } +/** + * Set (string) or clear (null) a tab's tab group parent. Omits the key when + * cleared (`exactOptionalPropertyTypes`). No-op for an unknown tab. + */ +export function setTabGroupParent( + state: TabsState, + conversationId: string, + parentId: string | null, +): TabsState { + const tabs = state.tabs.map((t) => { + if (t.conversationId !== conversationId) return t; + if (parentId !== null) return { ...t, tabGroupParentId: parentId }; + if (!("tabGroupParentId" in t)) return t; + const { tabGroupParentId: _omit, ...rest } = t; + void _omit; + return rest; + }); + return { tabs, activeConversationId: state.activeConversationId }; +} + +export function setWorkspaceId( + state: TabsState, + conversationId: string, + workspaceId: string, +): TabsState { + const tabs = state.tabs.map((t) => + t.conversationId === conversationId ? { ...t, workspaceId } : 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; diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index ae2a021..8f775a1 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -1,6 +1,7 @@ -import { fireEvent, render, screen } from "@testing-library/svelte"; +import { fireEvent, render, screen, within } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { gsap } from "gsap"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Tab } from "./tabs"; import TabList from "./ui/TabList.svelte"; @@ -165,7 +166,7 @@ describe("TabList", () => { expect(screen.getByText("7c2d")).toBeInTheDocument(); }); - it("renders each tab as a single vertical row (flex-col list, not a horizontal strip)", () => { + it("renders the tabs as a 2-column grid of same-size cards", () => { render(TabList, { props: { tabs: sampleTabs, @@ -176,14 +177,17 @@ describe("TabList", () => { }, }); - // 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"); + const grid = tabs[0]?.parentElement; + expect(grid).toHaveClass("grid", "grid-cols-2"); + // Every card has the same fixed height; none is double-width without a group. + for (const t of tabs) { + expect(t).toHaveClass("h-[4.5rem]"); + expect(t).not.toHaveClass("col-span-2"); + } }); - it("fixes the tab list region at 40vh so a long set scrolls internally", () => { + it("fixes the tab grid region at 70vh so a long set scrolls internally", () => { render(TabList, { props: { tabs: sampleTabs, @@ -195,8 +199,8 @@ describe("TabList", () => { }); const tabs = screen.getAllByRole("tab"); - const region = tabs[0]?.parentElement; - expect(region).toHaveClass("h-[40vh]"); + const region = tabs[0]?.parentElement?.parentElement; + expect(region).toHaveClass("h-[70vh]"); expect(region).toHaveClass("overflow-y-auto"); }); @@ -295,4 +299,527 @@ describe("TabList", () => { // c3 has no status → no spinner at all. expect(idleTab.querySelector(".loading")).toBeNull(); }); + + describe("subagent groups", () => { + const grouped: readonly Tab[] = [ + { conversationId: "p", model: "m", title: "Parent", workspaceId: "w" }, + { + conversationId: "c1", + model: "m", + title: "Child 1", + workspaceId: "w", + tabGroupParentId: "p", + }, + { + conversationId: "c2", + model: "m", + title: "Child 2", + workspaceId: "w", + tabGroupParentId: "p", + }, + { + conversationId: "g", + model: "m", + title: "Grandchild", + workspaceId: "w", + tabGroupParentId: "c1", + }, + { conversationId: "solo", model: "m", title: "Solo", workspaceId: "w" }, + ]; + + function renderGrouped(onSetTabGroup = vi.fn()) { + render(TabList, { + props: { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup, + }, + }); + return onSetTabGroup; + } + + const card = (id: string): HTMLElement => { + const el = document.querySelector<HTMLElement>(`[role="tab"][data-conversation-id="${id}"]`); + if (el === null) throw new Error(`card ${id} not found`); + return el; + }; + + it("renders a group parent double-width inside a darker group box with its subagents", () => { + renderGrouped(); + const box = document.querySelector<HTMLElement>('[data-tab-group="p"]'); + expect(box).not.toBeNull(); + expect(box).toHaveClass("col-span-2"); + expect(card("p")).toHaveClass("col-span-2"); + expect(card("p")).toHaveTextContent("2 subagents"); + expect(box?.contains(card("c1"))).toBe(true); + expect(box?.contains(card("c2"))).toBe(true); + expect(box?.contains(card("solo"))).toBe(false); + // Subagents are regular half-width cards. + expect(card("c2")).not.toHaveClass("col-span-2"); + }); + + it("nests a subagent that has its own subagents in an inner box", () => { + renderGrouped(); + const outer = document.querySelector<HTMLElement>('[data-tab-group="p"]'); + const inner = document.querySelector<HTMLElement>('[data-tab-group="c1"]'); + expect(inner).not.toBeNull(); + expect(outer?.contains(inner)).toBe(true); + expect(inner?.dataset.level).toBe("2"); + expect(card("c1")).toHaveClass("col-span-2"); + expect(inner?.contains(card("g"))).toBe(true); + // Inner boxes bleed less than outer ones, so nested edges stay visible + // while the cards keep identical widths. + const bleed = (el: HTMLElement | null) => Number.parseFloat(el?.style.paddingLeft ?? ""); + expect(bleed(inner)).toBeGreaterThan(0); + expect(bleed(outer)).toBeGreaterThan(bleed(inner)); + // Equal internal padding on all four sides (an even-looking box). + for (const box of [outer, inner]) { + const s = box?.style; + expect([s?.paddingTop, s?.paddingRight, s?.paddingBottom]).toEqual([ + s?.paddingLeft, + s?.paddingLeft, + s?.paddingLeft, + ]); + } + // Each level is tinted with its own rotating theme colour. + expect(outer?.dataset.tone).toBe("secondary"); + expect(inner?.dataset.tone).toBe("success"); + // The tint lives on each box's own backdrop layer (FLIP fades it in). + const tint = (box: HTMLElement | null) => + box + ?.querySelector<HTMLElement>(":scope > [data-group-backdrop]") + ?.style.getPropertyValue("--group-tone"); + expect(tint(outer)).toBe("var(--color-secondary)"); + expect(tint(inner)).toBe("var(--color-success)"); + expect(outer?.style.getPropertyValue("margin-inline")).toBe(`-${outer?.style.paddingLeft}`); + }); + + it("returns the group parent to a half-width card once all its subagent tabs close", async () => { + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + expect(card("p")).toHaveClass("col-span-2"); + + // Close one subagent: still a group (1 left). + await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") }); + expect(card("p")).toHaveClass("col-span-2"); + expect(card("p")).toHaveTextContent("1 subagent"); + + // Close the rest (c1 + its own subagent g): back to a plain half-width card. + await rerender({ + ...props, + tabs: grouped.filter((t) => ["p", "solo"].includes(t.conversationId)), + }); + expect(card("p")).not.toHaveClass("col-span-2"); + expect(card("p")).not.toHaveTextContent("subagent"); + expect(document.querySelector("[data-tab-group]")).toBeNull(); + }); + + describe("FLIP animation (motion allowed)", () => { + /** Jump every running GSAP animation to `progress` (0–1), delays included. */ + function seekAnimations(progress: number): void { + for (const t of gsap.globalTimeline.getChildren(true, true, false)) t.progress(progress); + } + + // jsdom has no matchMedia, which the grid reads as "no motion" — stub + // the outermost edge so the GSAP Flip path actually runs. + beforeEach(() => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: false }) as unknown as MediaQueryList), + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const props = { + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup: vi.fn(), + }; + + it("leaves an inert ghost of a closed tab (and its dissolved box) that is never a tab", async () => { + const two = grouped.filter((t) => ["p", "c1", "solo"].includes(t.conversationId)); + const { rerender } = render(TabList, { props: { ...props, tabs: two } }); + // jsdom lays nothing out (every box is 0×0) — give elements a real size. + const rect = vi + .spyOn(Element.prototype, "getBoundingClientRect") + .mockReturnValue(new DOMRect(0, 0, 120, 72)); + // Close c1: its card AND p's group box leave. + await rerender({ ...props, tabs: two.filter((t) => t.conversationId !== "c1") }); + rect.mockRestore(); + + const ghosts = [...document.querySelectorAll<HTMLElement>("[data-tab-ghost]")]; + expect(ghosts).toHaveLength(2); + for (const g of ghosts) { + expect(g).toHaveAttribute("aria-hidden", "true"); + expect(g.querySelector("[data-conversation-id],[data-flip-id],[role]")).toBeNull(); + } + // Paint order: the dissolving box's tint goes FIRST in its enclosing + // grid (under every card); the closed card's ghost goes on top. + const grid = screen.getByRole("tablist"); + const boxGhost = grid.firstElementChild as HTMLElement; + expect(boxGhost).toHaveAttribute("data-tab-ghost"); + // A clipping frame around a copy of the tint only — no card copies. + expect(boxGhost.childElementCount).toBe(1); + expect(boxGhost.firstElementChild?.childElementCount).toBe(0); + // Ghosts SHRINK (a clipped height), they don't fade or scale: halfway + // through, each is shorter than it started; at the end they're gone. + const full = ghosts.map((g) => Number.parseFloat(g.style.height)); + seekAnimations(0.5); + ghosts.forEach((g, i) => { + expect(g.style.overflow).toBe("hidden"); + expect(g.style.opacity).toBe(""); + expect(Number.parseFloat(g.style.height)).toBeLessThan(full[i] ?? 0); + }); + const cardGhost = ghosts.find((g) => g !== boxGhost); + expect(cardGhost?.parentElement).toBe(grid.parentElement); + expect(cardGhost).toHaveTextContent("Child 1"); + // Ghosts are invisible to queries: only the real remaining tabs count. + expect(screen.getAllByRole("tab").map((t) => t.dataset.conversationId)).toEqual([ + "p", + "solo", + ]); + expect(screen.getAllByRole("button", { name: "Close tab" })).toHaveLength(2); + seekAnimations(1); + expect(document.querySelector("[data-tab-ghost]")).toBeNull(); + }); + + it("measures each element at most twice per change (no layout thrash)", async () => { + // Regression: GSAP's Flip plugin cost ~430ms of main-thread work per + // close with ~40 nested tabs. The hand-rolled FLIP reads every element's + // box once before the change and once after — never per tween. + const { rerender } = render(TabList, { props: { ...props, tabs: grouped } }); + const elements = document.querySelectorAll("[data-flip-id]").length; + const spy = vi.spyOn(Element.prototype, "getBoundingClientRect"); + try { + await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") }); + // 2 per element + 1 host read per ghost (one ghost here: c2's card). + expect(spy.mock.calls.length).toBeLessThanOrEqual(2 * elements + 1); + } finally { + spy.mockRestore(); + } + }); + + it("does not ghost anything when a subagent is summoned", async () => { + const base = grouped.filter((t) => ["p", "solo"].includes(t.conversationId)); + const { rerender } = render(TabList, { props: { ...props, tabs: base } }); + await rerender({ ...props, tabs: grouped }); + expect(document.querySelector("[data-tab-ghost]")).toBeNull(); + expect(screen.getAllByRole("tab")).toHaveLength(grouped.length); + // New cards start fully clipped (0 visible height) and grow in from + // the top edge; once done no motion style is left behind. + const ids = ["c1", "c2", "g"]; + for (const id of ids) expect(card(id).style.clipPath).toBe("inset(0% 0% 100% 0%)"); + seekAnimations(1); + for (const id of ids) { + expect(card(id).style.clipPath).toBe(""); + expect(card(id).style.transform).toBe(""); + } + }); + }); + + it("never animates when the user prefers reduced motion", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: true }) as unknown as MediaQueryList), + ); + try { + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") }); + expect(document.querySelector("[data-tab-ghost]")).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("keeps the parent wide while a nested descendant's own parent remains open", async () => { + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + // Close the grandchild only: c1 goes back to half width, p stays a group. + await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "g") }); + expect(card("c1")).not.toHaveClass("col-span-2"); + expect(document.querySelector('[data-tab-group="c1"]')).toBeNull(); + expect(card("p")).toHaveClass("col-span-2"); + }); + + it("trims the bottom padding under a nested box on the last row to match its side gaps", () => { + // p's children: c2 (card) then c1 (nested group) → c1's box is the last row. + const lastIsGroup: readonly Tab[] = [ + { conversationId: "p", model: "m", title: "Parent", workspaceId: "w" }, + { conversationId: "c2", model: "m", title: "C2", workspaceId: "w", tabGroupParentId: "p" }, + { conversationId: "c1", model: "m", title: "C1", workspaceId: "w", tabGroupParentId: "p" }, + { conversationId: "g", model: "m", title: "G", workspaceId: "w", tabGroupParentId: "c1" }, + ]; + render(TabList, { + props: { + tabs: lastIsGroup, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + const outer = document.querySelector<HTMLElement>('[data-tab-group="p"]'); + const inner = document.querySelector<HTMLElement>('[data-tab-group="c1"]'); + const px = (v: string | undefined) => Number.parseFloat(v ?? ""); + const outerSide = px(outer?.style.paddingLeft); + const innerBleed = px(inner?.style.paddingLeft); + // Side gap between the two box edges = outer bleed − inner bleed; the + // bottom gap must equal it. + expect(px(outer?.style.paddingBottom)).toBe(outerSide - innerBleed); + expect(px(outer?.style.paddingTop)).toBe(outerSide); + // The inner box ends in a card → untouched, even on all sides. + expect(px(inner?.style.paddingBottom)).toBe(innerBleed); + }); + + it("right-click opens the menu; 'Remove from group' ungroups the tab", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("c2"), { clientX: 10, clientY: 10 }); + const menu = screen.getByRole("menu"); + expect(menu).toBeInTheDocument(); + await fireEvent.click(screen.getByRole("menuitem", { name: /Remove from group/ })); + expect(onSetTabGroup).toHaveBeenCalledWith("c2", null); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("'Add to group' lists valid parents (no self/descendants/current) and moves the tab", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("c1")); + const candidates = [...document.querySelectorAll<HTMLElement>("[data-group-candidate]")].map( + (el) => el.dataset.groupCandidate, + ); + expect(candidates).toEqual(["c2", "solo"]); + const solo = document.querySelector<HTMLElement>('[data-group-candidate="solo"]'); + if (solo === null) throw new Error("solo candidate missing"); + await fireEvent.click(solo); + expect(onSetTabGroup).toHaveBeenCalledWith("c1", "solo"); + }); + + it("a group parent's menu can ungroup all of its direct subagents", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("p")); + expect(screen.queryByRole("menuitem", { name: /Remove from group/ })).toBeNull(); + await fireEvent.click(screen.getByRole("menuitem", { name: /Ungroup 2 subagents/ })); + expect(onSetTabGroup).toHaveBeenCalledTimes(2); + expect(onSetTabGroup).toHaveBeenCalledWith("c1", null); + expect(onSetTabGroup).toHaveBeenCalledWith("c2", null); + }); + + it("Escape closes the menu without changing anything", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("solo")); + expect(screen.getByRole("menu")).toBeInTheDocument(); + await fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("menu")).toBeNull(); + expect(onSetTabGroup).not.toHaveBeenCalled(); + }); + + it("touch-and-hold opens the menu and swallows the follow-up click", async () => { + vi.useFakeTimers(); + try { + const onSelect = vi.fn(); + render(TabList, { + props: { + tabs: grouped, + activeConversationId: "p", + onSelect, + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup: vi.fn(), + }, + }); + await fireEvent.pointerDown(card("solo"), { pointerType: "touch", clientX: 5, clientY: 5 }); + await vi.advanceTimersByTimeAsync(600); + expect(screen.getByRole("menu")).toBeInTheDocument(); + await fireEvent.click(card("solo")); + expect(onSelect).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("a touch that moves away cancels the long-press", async () => { + vi.useFakeTimers(); + try { + renderGrouped(); + await fireEvent.pointerDown(card("solo"), { pointerType: "touch", clientX: 5, clientY: 5 }); + await fireEvent.pointerMove(card("solo"), { + pointerType: "touch", + clientX: 5, + clientY: 60, + }); + await vi.advanceTimersByTimeAsync(600); + expect(screen.queryByRole("menu")).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + describe("closing a tab with subagents (confirm + cascade)", () => { + const dialog = (): HTMLElement => + screen.getByRole("dialog", { name: "Close subagents too?" }); + + async function clickCloseOn(id: string): Promise<void> { + await fireEvent.click(within(card(id)).getByRole("button", { name: "Close tab" })); + } + + function renderClosable(onClose = vi.fn()) { + render(TabList, { + props: { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }, + }); + return onClose; + } + + it("closes a tab WITHOUT subagents directly — no confirm dialog", async () => { + const onClose = renderClosable(); + await clickCloseOn("solo"); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith("solo"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("shows ONE confirm dialog for a group parent (never one per descendant)", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + // 3 subagent tabs: c1, c2 and the nested grandchild g. + expect(dialog()).toHaveTextContent("3 subagent tabs"); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("'No, keep them open' closes only the tab itself", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + await fireEvent.click(screen.getByRole("button", { name: /No, keep them open/ })); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith("p"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("'Yes, close all' cascades to every descendant — no further prompts", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ })); + // p first, then its transitive descendants in tab order (c1, c2, g). + expect(onClose.mock.calls.map((c) => c[0])).toEqual(["p", "c1", "c2", "g"]); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("cascades from a mid-level subagent, taking only its own subtree", async () => { + const onClose = renderClosable(); + await clickCloseOn("c1"); + expect(dialog()).toHaveTextContent("1 subagent tab"); + await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ })); + expect(onClose.mock.calls.map((c) => c[0])).toEqual(["c1", "g"]); + }); + + it("restores keyboard focus to the opener after cancellation", async () => { + renderClosable(); + const user = userEvent.setup(); + const opener = within(card("p")).getByRole("button", { name: "Close tab" }); + await user.click(opener); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(opener).toHaveFocus(); + }); + + it("Cancel and backdrop dismiss without closing any tab", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + await clickCloseOn("p"); + await fireEvent.submit(dialog().querySelector("form") as HTMLFormElement); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("keeps the confirmed subtree snapshot when new tabs arrive", async () => { + const onClose = vi.fn(); + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + await clickCloseOn("p"); + await rerender({ + ...props, + tabs: [ + ...grouped, + { + conversationId: "late", + model: "m", + title: "Late", + workspaceId: "w", + tabGroupParentId: "p", + }, + ], + }); + expect(dialog()).toHaveTextContent("3 subagent tabs"); + await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ })); + expect(onClose.mock.calls.map((call) => call[0])).toEqual(["p", "c1", "c2", "g"]); + }); + + it("dismisses when the target disappears (remote close or workspace switch)", async () => { + const onClose = vi.fn(); + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + await clickCloseOn("p"); + await rerender({ ...props, tabs: grouped.filter((tab) => tab.conversationId !== "p") }); + expect(screen.queryByRole("dialog")).toBeNull(); + await rerender(props); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("Escape dismisses the dialog without closing anything", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + await fireEvent(dialog(), new Event("cancel", { cancelable: true })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/features/tabs/ui/CloseSubagentsConfirm.svelte b/src/features/tabs/ui/CloseSubagentsConfirm.svelte new file mode 100644 index 0000000..7ffda6c --- /dev/null +++ b/src/features/tabs/ui/CloseSubagentsConfirm.svelte @@ -0,0 +1,83 @@ +<script lang="ts"> + import type { Tab } from "../tabs"; + + /** + * The close-subagents confirm dialog: shown when the user closes a tab that + * still has subagent tabs in its (visible) subagent group. ONE dialog for + * the whole subtree — "Yes" cascades the close to every transitive + * descendant (sub-sub-agents included, no further prompts); "No" closes + * only the tab itself. Escape / backdrop dismisses without closing anything. + * Native modal dialog with daisyUI chrome: top-layer rendering, focus + * containment, and inert background even inside the animated sidebar. + */ + let { + tab, + subagentCount, + onKeep, + onCloseAll, + onDismiss, + }: { + /** The tab being closed (drives the title shown in the dialog). */ + tab: Tab; + /** Number of tabs in the tab's subagent group (transitive). */ + subagentCount: number; + /** "No" — close only this tab, keep the subagents open. */ + onKeep: () => void; + /** "Yes" — close this tab AND cascade to all its subagents. */ + onCloseAll: () => void; + /** Escape / backdrop — close nothing. */ + onDismiss: () => void; + } = $props(); + function openModal(node: HTMLDialogElement) { + const opener = document.activeElement; + node.showModal(); + return { + destroy() { + node.close(); + if (opener instanceof HTMLElement && opener.isConnected) opener.focus(); + }, + }; + } +</script> + +<dialog + use:openModal + class="modal" + aria-label="Close subagents too?" + aria-describedby="close-subagents-description" + oncancel={(event) => { + event.preventDefault(); + onDismiss(); + }} +> + <div class="modal-box max-w-md"> + <h3 class="text-lg font-bold">Close subagents too?</h3> + <div id="close-subagents-description" class="flex flex-col gap-1 py-3"> + <p class="text-sm"> + "{tab.title}" has {subagentCount} + {subagentCount === 1 ? "subagent tab" : "subagent tabs"} (including nested ones). + </p> + <p class="text-sm"> + Close them as well? Their own subagents are closed too, and any in-flight turns are + stopped. + </p> + </div> + <div class="modal-action flex-wrap"> + <button type="button" class="btn btn-ghost btn-sm" onclick={onDismiss}>Cancel</button> + <button type="button" class="btn btn-sm" onclick={onKeep}>No, keep them open</button> + <button type="button" class="btn btn-warning btn-sm" onclick={onCloseAll}> + Yes, close all {subagentCount + 1} + </button> + </div> + </div> + <form + method="dialog" + class="modal-backdrop" + onsubmit={(event) => { + event.preventDefault(); + onDismiss(); + }} + > + <button type="submit" aria-label="Dismiss close confirmation">close</button> + </form> +</dialog> diff --git a/src/features/tabs/ui/TabCard.svelte b/src/features/tabs/ui/TabCard.svelte new file mode 100644 index 0000000..ae891c0 --- /dev/null +++ b/src/features/tabs/ui/TabCard.svelte @@ -0,0 +1,234 @@ +<script lang="ts"> + import { cardFlipId } from "../tab-flip"; + import type { Tab } from "../tabs"; + + /** + * One tab in the tab grid — a fixed-height card. Every card has IDENTICAL + * dimensions; a group parent (`wide`) simply spans both grid columns. + * Right-click (or touch-and-hold) asks the owner to open the tab menu. + */ + let { + tab, + handle, + active, + status, + wide = false, + subagentCount = 0, + onSelect, + onClose, + onRename, + onMenu, + }: { + tab: Tab; + /** Short-handle (git-style unique prefix) shown in the id badge. */ + handle: string; + active: boolean; + /** Lifecycle status (drives the spinner), when known. */ + status: string | undefined; + /** Group parent: spans both grid columns. */ + wide?: boolean; + /** Number of direct subagents (shown on a group parent). */ + subagentCount?: number; + onSelect: (conversationId: string) => void; + onClose: (conversationId: string) => void; + onRename?: ((conversationId: string, title: string) => void) | undefined; + /** Open the tab menu at viewport coordinates. */ + onMenu: (conversationId: string, x: number, y: number) => void; + } = $props(); + + // Inline rename: double-click the title to edit, Enter/blur to save. + let editing = $state(false); + let editValue = $state(""); + let editEl = $state<HTMLInputElement>(); + + function startRename(): void { + if (onRename === undefined) return; + editing = true; + editValue = tab.title; + queueMicrotask(() => editEl?.focus()); + } + + function commitRename(): void { + if (!editing) return; + const trimmed = editValue.trim(); + if (trimmed.length > 0 && onRename !== undefined) onRename(tab.conversationId, trimmed); + editing = false; + } + + // Click-to-copy the FULL conversation id; the badge text is selected as the + // feedback (and as a manual-copy fallback when the clipboard is unavailable). + async function copyId(el: HTMLElement): Promise<void> { + const range = document.createRange(); + range.selectNodeContents(el); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + try { + await clipboard.writeText(tab.conversationId); + } catch { + // Selection already lets the user copy manually. + } + } + + // Touch-and-hold → menu (iOS Safari never fires `contextmenu`; Android does, + // and opening twice at the same spot is idempotent). + const LONG_PRESS_MS = 500; + const MOVE_TOLERANCE_PX = 10; + let pressTimer: ReturnType<typeof setTimeout> | null = null; + let pressX = 0; + let pressY = 0; + let suppressClick = false; + + function cancelPress(): void { + if (pressTimer !== null) clearTimeout(pressTimer); + pressTimer = null; + } + + function onPointerDown(e: PointerEvent): void { + if (e.pointerType !== "touch") return; + cancelPress(); + pressX = e.clientX; + pressY = e.clientY; + pressTimer = setTimeout(() => { + pressTimer = null; + suppressClick = true; + onMenu(tab.conversationId, pressX, pressY); + }, LONG_PRESS_MS); + } + + function onPointerMove(e: PointerEvent): void { + if (pressTimer === null) return; + if (Math.hypot(e.clientX - pressX, e.clientY - pressY) > MOVE_TOLERANCE_PX) cancelPress(); + } + + $effect(() => cancelPress); +</script> + +<div + class="tab-card group/card relative flex h-[4.5rem] min-w-0 cursor-pointer flex-col justify-between rounded-md border px-2 py-1.5 text-sm transition-colors {active + ? 'border-primary bg-primary/15' + : 'border-base-content/10 bg-base-300 hover:border-base-content/25'}" + class:col-span-2={wide} + role="tab" + tabindex="0" + aria-selected={active} + title={tab.title} + data-conversation-id={tab.conversationId} + data-flip-id={cardFlipId(tab.conversationId)} + data-wide={wide ? "true" : undefined} + onclick={() => { + if (suppressClick) { + suppressClick = false; + return; + } + onSelect(tab.conversationId); + }} + onkeydown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter") onSelect(tab.conversationId); + else if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) { + e.preventDefault(); + const r = e.currentTarget.getBoundingClientRect(); + onMenu(tab.conversationId, r.left + 8, r.bottom); + } + }} + oncontextmenu={(e) => { + e.preventDefault(); + cancelPress(); + onMenu(tab.conversationId, e.clientX, e.clientY); + }} + onpointerdown={onPointerDown} + onpointermove={onPointerMove} + onpointerup={cancelPress} + onpointercancel={cancelPress} + onpointerleave={cancelPress} +> + <div class="flex min-w-0 items-center gap-1"> + <button + type="button" + class="shrink-0 select-text rounded bg-base-100 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(e.currentTarget); + }} + > + {handle} + </button> + {#if wide && subagentCount > 0} + <span + class="shrink-0 truncate rounded bg-base-100 px-1 py-0.5 text-[10px] leading-none text-base-content/60" + data-subagent-count={subagentCount} + > + {subagentCount} + {subagentCount === 1 ? "subagent" : "subagents"} + </span> + {/if} + <span class="flex-1"></span> + {#if status === "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 status === "active"} + <span class="loading loading-dots loading-xs shrink-0 text-primary"></span> + {/if} + <button + type="button" + class="btn btn-ghost btn-xs -mr-1 h-5 min-h-5 w-5 shrink-0 p-0 text-base-content/60" + aria-label="Close tab" + onclick={(e) => { + e.stopPropagation(); + onClose(tab.conversationId); + }} + > + × + </button> + </div> + {#if editing} + <input + bind:this={editEl} + bind:value={editValue} + class="w-full min-w-0 rounded bg-base-100 px-1 py-0.5 text-left text-xs outline outline-1 outline-primary" + onclick={(e) => e.stopPropagation()} + onkeydown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(); + } else if (e.key === "Escape") { + e.preventDefault(); + editing = false; + } + }} + onblur={commitRename} + /> + {:else} + <span + class="line-clamp-2 min-w-0 break-words text-left text-xs leading-tight" + role="button" + tabindex="-1" + title={tab.title} + ondblclick={(e) => { + e.stopPropagation(); + startRename(); + }} + > + {tab.title} + </span> + {/if} +</div> + +<style> + /* Touch-and-hold opens the tab menu — suppress the native callout/selection. */ + .tab-card { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + } +</style> diff --git a/src/features/tabs/ui/TabGroupBox.svelte b/src/features/tabs/ui/TabGroupBox.svelte new file mode 100644 index 0000000..5b644e0 --- /dev/null +++ b/src/features/tabs/ui/TabGroupBox.svelte @@ -0,0 +1,90 @@ +<script lang="ts"> + import type { Snippet } from "svelte"; + import type { TabGroupNode } from "../tab-groups"; + import { groupFlipId } from "../tab-flip"; + import { groupBleedPx, groupTone, lastRowIsGroup } from "../tab-groups"; + import type { Tab } from "../tabs"; + import TabGroupBox from "./TabGroupBox.svelte"; + + /** + * A subagent group: a tinted box spanning both grid columns that wraps the + * group parent's (double-width) card + its subagents' cards. Recursive — + * a subagent with its own subagents renders a nested box. The box BLEEDS + * outward (negative margin + equal padding, `groupBleedPx`) so the cards + * inside stay on the same two columns as top-level cards: every card keeps + * identical dimensions at every depth. The padding is EQUAL on all four + * sides (= the bleed) so the box looks even — except below a nested box on + * the last row, where it is trimmed to match that box's side gaps. Each + * box is tinted with one of three theme colours ROTATING by level + * (`groupTone`), painted OPAQUE (mixed into the sidebar well colour) so a + * nested box never blends with the box around it, plus a border in the same + * colour so every box edge reads at a glance. The tint lives on its own + * BACKDROP layer so the FLIP animation (`tab-flip.ts`) can fade a new + * group's tint in while its cards fly into place. + */ + let { + node, + level, + maxDepth, + card, + }: { + node: TabGroupNode; + /** Nesting level of THIS box (1 = outermost). */ + level: number; + maxDepth: number; + /** Renders one card: (tab, wide, subagentCount). */ + card: Snippet<[Tab, boolean, number]>; + } = $props(); + + const bleed = $derived(groupBleedPx(level, maxDepth)); + const tone = $derived(groupTone(level)); + // Even spacing: when the last row is a nested box, the gap below it would + // be this box's full padding while its side gaps are only the DIFFERENCE + // in bleed (the nested box bleeds outward too) — trim the bottom to match. + const padBottom = $derived( + lastRowIsGroup(node.children) ? bleed - groupBleedPx(level + 1, maxDepth) : bleed, + ); +</script> + +<div + class="relative col-span-2" + style:margin-inline="-{bleed}px" + style:padding="{bleed}px {bleed}px {padBottom}px" + role="group" + aria-label={`Subagents of ${node.tab.title}`} + data-tab-group={node.tab.conversationId} + data-level={level} + data-tone={tone} + data-flip-id={groupFlipId(node.tab.conversationId)} +> + <div + class="tab-group-backdrop pointer-events-none absolute inset-0 rounded-lg" + style:--group-tone="var(--color-{tone})" + data-group-backdrop + aria-hidden="true" + ></div> + <div class="relative grid grid-flow-row-dense grid-cols-2 gap-1.5"> + {@render card(node.tab, true, node.children.length)} + {#each node.children as child (child.tab.conversationId)} + {#if child.children.length > 0} + <TabGroupBox node={child} level={level + 1} {maxDepth} {card} /> + {:else} + {@render card(child.tab, false, 0)} + {/if} + {/each} + </div> +</div> + +<style> + /* Opaque tint: the tone mixed into the sidebar's `.inset-well` colour (the + surface the tab grid sits on), so nested boxes replace — never compound + with — the colour around them. */ + .tab-group-backdrop { + background-color: color-mix( + in oklab, + var(--group-tone) 16%, + var(--inset-well-bg, var(--color-base-200)) + ); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--group-tone) 55%, transparent); + } +</style> diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte index 6d85065..8b388f0 100644 --- a/src/features/tabs/ui/TabList.svelte +++ b/src/features/tabs/ui/TabList.svelte @@ -1,183 +1,220 @@ <script lang="ts"> - import type { Tab } from "../tabs"; - import { shortHandle } from "../tabs"; + import { untrack } from "svelte"; + import { forestFlipIds } from "../tab-flip"; + import { + buildTabGroups, + cascadeCloseIds, + directGroupChildren, + groupGutterPx, + groupParentCandidates, + visibleGroupParents, + } from "../tab-groups"; + import type { Tab } from "../tabs"; + import { shortHandle } from "../tabs"; + import CloseSubagentsConfirm from "./CloseSubagentsConfirm.svelte"; + import TabCard from "./TabCard.svelte"; + import TabGroupBox from "./TabGroupBox.svelte"; + import TabMenu from "./TabMenu.svelte"; + import { createTabFlip } from "./tab-flip"; - let { - tabs, - activeConversationId, - statusFor, - onSelect, - onClose, - onNewDraft, - onRename, - }: { - tabs: readonly Tab[]; - activeConversationId: string | null; - /** Returns the conversation's lifecycle status, or undefined when unknown. */ - statusFor?: (conversationId: string) => string | undefined; - onSelect: (conversationId: string) => void; - onClose: (conversationId: string) => void; - onNewDraft: () => void; - onRename?: (conversationId: string, title: string) => void; - } = $props(); + let { + tabs, + activeConversationId, + statusFor, + onSelect, + onClose, + onNewDraft, + onRename, + onSetTabGroup, + }: { + tabs: readonly Tab[]; + activeConversationId: string | null; + /** Returns the conversation's lifecycle status, or undefined when unknown. */ + statusFor?: (conversationId: string) => string | undefined; + onSelect: (conversationId: string) => void; + onClose: (conversationId: string) => void; + onNewDraft: () => void; + onRename?: (conversationId: string, title: string) => void; + /** + * Move a tab into a subagent group (parent id) or out of it (null). Purely + * presentational grouping. When absent, the tab menu is disabled. + */ + onSetTabGroup?: (conversationId: string, parentId: string | null) => void; + } = $props(); - // Git-style short handle (shortest unique prefix) per open tab — the visible - // "tab ID". Derived from the set of open conversation ids; pure helper. - const handles = $derived.by(() => { - const ids = tabs.map((t) => t.conversationId); - const map = new Map<string, string>(); - for (const id of ids) map.set(id, shortHandle(id, ids)); - return map; - }); + // Git-style short handle (shortest unique prefix) per open tab — the visible + // "tab ID". Derived from the set of open conversation ids; pure helper. + const handles = $derived.by(() => { + const ids = tabs.map((t) => t.conversationId); + const map = new Map<string, string>(); + for (const id of ids) map.set(id, shortHandle(id, ids)); + return map; + }); + const handleFor = (id: string): string => handles.get(id) ?? id; - // Inline rename: double-click a tab's title to edit, Enter/blur to save. - let editingId = $state<string | null>(null); - let editValue = $state(""); - let editEl = $state<HTMLInputElement>(); + // The subagent-group forest (pure) + the gutter the outermost box bleeds into. + const forest = $derived(buildTabGroups(tabs)); + const gutter = $derived(groupGutterPx(forest.maxDepth)); - function startRename(tab: Tab): void { - if (onRename === undefined) return; - editingId = tab.conversationId; - editValue = tab.title; - // Focus the input after it renders. - queueMicrotask(() => editEl?.focus()); - } + // FLIP: a summoned subagent / closed tab / regroup animates (GSAP Flip). + // Capture positions (+ ghost leaving elements) BEFORE the DOM updates, play + // AFTER. Keyed on `forest` only; the DOM work itself is untracked. + let scrollerEl = $state<HTMLDivElement | undefined>(); + const flip = createTabFlip(() => scrollerEl); + $effect.pre(() => { + const next = forestFlipIds(forest); + untrack(() => flip.capture(next)); + }); + $effect(() => { + void forest; + untrack(() => flip.play()); + }); - function commitRename(): void { - const id = editingId; - if (id !== null && onRename !== undefined) { - const trimmed = editValue.trim(); - if (trimmed.length > 0) onRename(id, trimmed); - } - editingId = null; - } + // Tab menu (right-click / touch-and-hold). Local UI state owned here. + let menu = $state<{ conversationId: string; x: number; y: number } | null>(null); + const menuTab = $derived( + menu === null ? null : (tabs.find((t) => t.conversationId === menu?.conversationId) ?? null), + ); + const menuGroupParent = $derived.by(() => { + if (menuTab === null) return null; + const pid = visibleGroupParents(tabs).get(menuTab.conversationId) ?? null; + return pid === null ? null : (tabs.find((t) => t.conversationId === pid) ?? null); + }); + const menuChildren = $derived( + menuTab === null ? [] : directGroupChildren(tabs, menuTab.conversationId), + ); + const menuCandidates = $derived( + menuTab === null ? [] : groupParentCandidates(tabs, menuTab.conversationId), + ); - function cancelRename(): void { - editingId = null; - } + function openMenu(conversationId: string, x: number, y: number): void { + if (onSetTabGroup === undefined) return; + menu = { conversationId, x, y }; + } - // 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. - } - } + // Close-with-confirm: a tab whose (visible) subagent group is non-empty + // gets ONE confirm dialog covering its whole subtree (never one per level). + // The dialog's ids are snapshotted at open time; a tab the backend closed + // in the meantime is a no-op for `onClose`. A plain tab closes directly. + let closeConfirm = $state<{ tab: Tab; cascadeIds: readonly string[] } | null>(null); - function selectText(el: HTMLElement): void { - const range = document.createRange(); - range.selectNodeContents(el); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - } + // A remote close or workspace switch must not leave an actionable dialog + // for a tab that is no longer in this list. + $effect(() => { + if ( + closeConfirm !== null && + !tabs.some((tab) => tab.conversationId === closeConfirm?.tab.conversationId) + ) { + closeConfirm = null; + } + }); + + function requestClose(conversationId: string): void { + menu = null; + const cascadeIds = cascadeCloseIds(tabs, conversationId); + if (cascadeIds.length <= 1) { + onClose(conversationId); + return; + } + const tab = tabs.find((t) => t.conversationId === conversationId); + if (tab === undefined) { + onClose(conversationId); + return; + } + closeConfirm = { tab, cascadeIds }; + } </script> +{#snippet card(tab: Tab, wide: boolean, subagentCount: number)} + <TabCard + {tab} + handle={handleFor(tab.conversationId)} + active={tab.conversationId === activeConversationId} + status={statusFor?.(tab.conversationId)} + {wide} + {subagentCount} + {onSelect} + onClose={requestClose} + {onRename} + onMenu={openMenu} + /> +{/snippet} + <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="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); - }} - > - <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} - </button> - {#if editingId === tab.conversationId} - <input - bind:this={editEl} - bind:value={editValue} - class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary" - onclick={(e) => e.stopPropagation()} - onkeydown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - commitRename(); - } else if (e.key === "Escape") { - e.preventDefault(); - cancelRename(); - } - }} - onblur={commitRename} - /> - {:else} - <span - class="min-w-0 flex-1 cursor-pointer truncate text-left" - role="button" - tabindex="-1" - title={tab.title} - ondblclick={(e) => { - e.stopPropagation(); - startRename(tab); - }} - > - {tab.title} - </span> - {/if} - {#if statusFor?.(tab.conversationId) === "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" - aria-label="Close tab" - onclick={(e) => { - e.stopPropagation(); - onClose(tab.conversationId); - }} - > - × - </button> - </div> - {/each} - </div> + <!-- 2-column grid of same-size tab cards, fixed at 70% of the viewport height + so a long tab set scrolls inside this region instead of growing the + sidebar. Subagent groups span both columns; `dense` lets a later card + fill the half-row a group would otherwise leave empty. --> + <div + bind:this={scrollerEl} + class="relative h-[70vh] overflow-y-auto py-0.5" + style:padding-inline="{gutter}px" + data-tab-grid + onscroll={() => (menu = null)} + > + <div class="relative grid grid-flow-row-dense grid-cols-2 gap-1.5" role="tablist"> + {#each forest.roots as node (node.tab.conversationId)} + {#if node.children.length > 0} + <TabGroupBox {node} level={1} maxDepth={forest.maxDepth} {card} /> + {:else} + {@render card(node.tab, false, 0)} + {/if} + {/each} + </div> + </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> + <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> + +{#if menu !== null && menuTab !== null && onSetTabGroup !== undefined} + {@const setGroup = onSetTabGroup} + {@const target = menuTab.conversationId} + {@const children = [...menuChildren]} + <TabMenu + tab={menuTab} + handle={handleFor(target)} + x={menu.x} + y={menu.y} + groupParent={menuGroupParent} + childCount={children.length} + candidates={menuCandidates} + {handleFor} + onAddToGroup={(parentId) => setGroup(target, parentId)} + onRemoveFromGroup={() => setGroup(target, null)} + onUngroupChildren={() => { + for (const id of children) setGroup(id, null); + }} + onDismiss={() => (menu = null)} + /> +{/if} + +{#if closeConfirm !== null} + {@const target = closeConfirm.tab} + {@const cascadeIds = [...closeConfirm.cascadeIds]} + {@const closeOwner = onClose} + <CloseSubagentsConfirm + tab={target} + subagentCount={cascadeIds.length - 1} + onKeep={() => { + closeOwner(target.conversationId); + closeConfirm = null; + }} + onCloseAll={() => { + for (const id of cascadeIds) closeOwner(id); + closeConfirm = null; + }} + onDismiss={() => (closeConfirm = null)} + /> +{/if} diff --git a/src/features/tabs/ui/TabMenu.svelte b/src/features/tabs/ui/TabMenu.svelte new file mode 100644 index 0000000..513463f --- /dev/null +++ b/src/features/tabs/ui/TabMenu.svelte @@ -0,0 +1,145 @@ +<script lang="ts"> + import type { Tab } from "../tabs"; + + /** + * The tab context menu (right-click / touch-and-hold on a tab card): add the + * tab to a subagent group, remove it from its group, or release its own + * subagents. Purely presentational grouping. Fixed-positioned at the + * pointer, clamped into the viewport; closes on Escape / outside press / + * scroll / resize. + */ + let { + tab, + handle, + x, + y, + groupParent, + childCount, + candidates, + handleFor, + onAddToGroup, + onRemoveFromGroup, + onUngroupChildren, + onDismiss, + }: { + tab: Tab; + handle: string; + /** Requested viewport position (pointer). */ + x: number; + y: number; + /** The tab's current visible group parent, or null when top-level. */ + groupParent: Tab | null; + /** Number of tabs directly grouped under this tab. */ + childCount: number; + /** Tabs this tab may be moved under. */ + candidates: readonly Tab[]; + handleFor: (conversationId: string) => string; + onAddToGroup: (parentId: string) => void; + onRemoveFromGroup: () => void; + onUngroupChildren: () => void; + onDismiss: () => void; + } = $props(); + + let menuEl = $state<HTMLDivElement>(); + let width = $state(0); + let height = $state(0); + const MARGIN = 8; + + const left = $derived( + Math.max(MARGIN, Math.min(x, (globalThis.innerWidth ?? 0) - width - MARGIN)), + ); + const top = $derived( + Math.max(MARGIN, Math.min(y, (globalThis.innerHeight ?? 0) - height - MARGIN)), + ); + + $effect(() => { + menuEl?.focus(); + }); + + function act(fn: () => void): void { + fn(); + onDismiss(); + } +</script> + +<svelte:window + onkeydown={(e) => { + if (e.key === "Escape") onDismiss(); + }} + onpointerdown={(e) => { + if (menuEl !== undefined && !menuEl.contains(e.target as Node)) onDismiss(); + }} + onresize={onDismiss} +/> + +<div + bind:this={menuEl} + bind:clientWidth={width} + bind:clientHeight={height} + class="fixed z-50 flex w-64 max-w-[calc(100vw-1rem)] flex-col rounded-lg border border-base-content/15 bg-base-100 p-1 text-sm shadow-xl outline-none" + style:left="{left}px" + style:top="{top}px" + role="menu" + tabindex="-1" + aria-label={`Tab menu for ${tab.title}`} + oncontextmenu={(e) => e.preventDefault()} +> + <div class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-xs text-base-content/60"> + <span class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none"> + {handle} + </span> + <span class="truncate">{tab.title}</span> + </div> + + {#if groupParent !== null} + <button + type="button" + role="menuitem" + class="rounded px-2 py-1.5 text-left hover:bg-base-300" + onclick={() => act(onRemoveFromGroup)} + > + Remove from group + <span class="block truncate text-xs text-base-content/50"> + Currently under {handleFor(groupParent.conversationId)} · {groupParent.title} + </span> + </button> + {/if} + + {#if childCount > 0} + <button + type="button" + role="menuitem" + class="rounded px-2 py-1.5 text-left hover:bg-base-300" + onclick={() => act(onUngroupChildren)} + > + Ungroup {childCount} + {childCount === 1 ? "subagent" : "subagents"} + </button> + {/if} + + <div class="mt-1 border-t border-base-content/10 px-2 pt-1.5 pb-1 text-xs text-base-content/60"> + Add to group under… + </div> + {#if candidates.length === 0} + <div class="px-2 py-1.5 text-xs text-base-content/40">No other tabs</div> + {:else} + <div class="flex max-h-56 flex-col overflow-y-auto"> + {#each candidates as candidate (candidate.conversationId)} + <button + type="button" + role="menuitem" + class="flex min-w-0 items-center gap-1.5 rounded px-2 py-1.5 text-left hover:bg-base-300" + data-group-candidate={candidate.conversationId} + onclick={() => act(() => onAddToGroup(candidate.conversationId))} + > + <span + class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60" + > + {handleFor(candidate.conversationId)} + </span> + <span class="truncate">{candidate.title}</span> + </button> + {/each} + </div> + {/if} +</div> diff --git a/src/features/tabs/ui/tab-flip.ts b/src/features/tabs/ui/tab-flip.ts new file mode 100644 index 0000000..ac9daaf --- /dev/null +++ b/src/features/tabs/ui/tab-flip.ts @@ -0,0 +1,374 @@ +import { gsap } from "gsap"; +import { isGroupFlipId, leavingFlipIds } from "../tab-flip"; + +/** + * FLIP animation for the tab grid — the DOM EFFECT half (the pure id scheme + * lives in `../tab-flip.ts`). + * + * Driven by the owning component in two calls: + * - `capture(nextIds)` — BEFORE the DOM updates: measure every + * `[data-flip-id]` element (First), and build an inert GHOST for each + * element the next render drops (a closed tab / dissolved group box). + * - `play()` — AFTER the DOM updates: measure again (Last), then run three + * STAGGERED phases, each only when it has something to do — + * 1. SHRINK — ghosts collapse to 0 height (everything else holds still); + * 2. MOVE — cards slide from their old box to their new one, and each + * group box's tint morphs from its old rect to its new one; + * 3. GROW — new cards (and new group boxes' tints) grow from 0 height. + * + * PERFORMANCE: this is a hand-rolled FLIP, not GSAP's Flip plugin. Flip + * recorded computed styles per element and re-measured while building its + * timeline — ~430ms of main-thread work per close with ~40 nested tabs, which + * both delayed the click and swallowed the shrink phase. Here each change + * costs exactly two batched layout reads (one in `capture`, one in `play`); + * every write happens after its batch of reads. Each phase is ONE GSAP tween + * on a plain progress value whose `onUpdate` writes inline styles directly — + * GSAP never reads an element's computed style (per-element CSS tweens did, + * forcing a style recalc per moved card). Cards only TRANSLATE (plus a + * width tween when a card turns into / out of a double-width group parent); + * a group box never moves itself — only its absolutely-positioned, empty tint + * layer does (inset tween), so no card is ever double-translated. + * + * Like the sidebar panels (`layout/ui/panel-motion.ts`), shrink/grow change + * only a HEIGHT, clipped — contents keep their natural size. Grow is a + * `clip-path` reveal so the settled grid never shifts under it. + * + * Data-driven, not click-driven: it animates ANY change to the tab set (a + * summoned subagent, a close from another device, a regroup). Re-entrant — + * `capture` measures the in-flight VISUAL boxes, `play` clears in-flight + * motion before measuring the new layout. Disabled when the user prefers + * reduced motion (or the environment can't tell, e.g. jsdom). + */ +export interface TabFlip { + capture(nextIds: ReadonlySet<string>): void; + play(): void; +} + +const SHRINK = 0.1; +const MOVE = 0.1; +const GROW = 0.1; +/** Close: starts slow, finishes fast (accelerates away). */ +const SHRINK_EASE = "power2.in"; +/** Move: slow → fast → slow. */ +const MOVE_EASE = "power2.inOut"; +/** Open: starts fast, settles slowly (the mirror of close). */ +const GROW_EASE = "power2.out"; +const GHOST_ATTR = "data-tab-ghost"; +/** Fully clipped from the bottom edge up (0 visible height). */ +const CLIP_HIDDEN = "inset(0% 0% 100% 0%)"; +/** Every inline style this module may leave on a live element mid-flight. */ +const MOTION_PROPS = ["transform", "width", "clip-path", "top", "right", "bottom", "left"]; + +interface Captured { + /** Visual box (incl. any in-flight motion) per flip id. Cards: the card; boxes: the tint. */ + readonly rects: ReadonlyMap<string, DOMRect>; + /** Built but not yet started — their shrink starts in `play`. */ + readonly ghosts: readonly HTMLElement[]; +} + +export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFlip { + let captured: Captured | null = null; + /** Live elements carrying motion styles, so `play` can reset exactly those. */ + const animating = new Set<HTMLElement>(); + /** The running MOVE + GROW phase tweens (ghost shrinks run independently). */ + let phases: gsap.core.Tween[] = []; + + return { + capture(nextIds) { + captured = null; + const scroller = getScroller(); + if (scroller === undefined || !motionAllowed()) return; + const els = flipElements(scroller); + if (els.length === 0) return; + + // READS (one layout). + const rects = new Map<string, DOMRect>(); + for (const el of els) rects.set(idOf(el), measureTarget(el).getBoundingClientRect()); + const leaving = leavingFlipIds([...rects.keys()], nextIds); + const byId = new Map(els.map((el) => [idOf(el), el])); + const plans = leaving + .map((id) => byId.get(id)) + .filter((el): el is HTMLElement => el !== undefined) + .map((el) => planGhost(scroller, el, rects.get(idOf(el)))) + .filter((p): p is GhostPlan => p !== null); + + // WRITES. + captured = { rects, ghosts: plans.map(insertGhost) }; + }, + + play() { + const prev = captured; + captured = null; + const scroller = getScroller(); + if (prev === null || scroller === undefined) return; + + // Reset in-flight motion on live elements so the read below is the + // settled layout (their previous VISUAL boxes are already in `prev`). + for (const tween of phases) tween.kill(); + phases = []; + for (const el of animating) clearMotion(el); + animating.clear(); + + // READS (one layout). + const next = flipElements(scroller).map((el) => { + const target = measureTarget(el); + return { target, id: idOf(el), rect: target.getBoundingClientRect() }; + }); + + // Plan (pure arithmetic — no DOM access). + const entering: HTMLElement[] = []; + const movers: Mover[] = []; + for (const { target, id, rect } of next) { + const before = prev.rects.get(id); + if (before === undefined) entering.push(target); + else if (!sameBox(before, rect)) + movers.push(planMove(target, isGroupFlipId(id), before, rect)); + } + const moveAt = prev.ghosts.length > 0 ? SHRINK : 0; + const growAt = moveAt + (movers.length > 0 ? MOVE : 0); + + // WRITES — first frame of every phase NOW (movers hold at their old + // boxes, new elements are fully clipped) so nothing flashes in between. + for (const m of movers) m.render(0); + for (const el of entering) el.style.clipPath = CLIP_HIDDEN; + + // 1. SHRINK — one tween drives every ghost. + if (prev.ghosts.length > 0) { + const ghosts = prev.ghosts.map((g) => ({ g, h: Number.parseFloat(g.style.height) })); + gsap.to( + { p: 0 }, + { + p: 1, + duration: SHRINK, + ease: SHRINK_EASE, + onUpdate(this: gsap.core.Tween) { + const p = this.targets<{ p: number }>()[0]?.p ?? 1; + for (const { g, h } of ghosts) g.style.height = `${h * (1 - p)}px`; + }, + onComplete: () => { + for (const { g } of ghosts) g.remove(); + }, + }, + ); + } + + // 2. MOVE — one tween drives every card slide + tint morph. + if (movers.length > 0) { + for (const m of movers) animating.add(m.el); + phases.push( + gsap.to( + { p: 0 }, + { + p: 1, + duration: MOVE, + delay: moveAt, + ease: MOVE_EASE, + onUpdate(this: gsap.core.Tween) { + const p = this.targets<{ p: number }>()[0]?.p ?? 1; + for (const m of movers) m.render(p); + }, + onComplete: () => { + for (const m of movers) { + clearMotion(m.el); + animating.delete(m.el); + } + }, + }, + ), + ); + } + + // 3. GROW — one tween reveals every new card / tint from its top edge. + if (entering.length > 0) { + for (const el of entering) animating.add(el); + phases.push( + gsap.to( + { p: 0 }, + { + p: 1, + duration: GROW, + delay: growAt, + ease: GROW_EASE, + onUpdate(this: gsap.core.Tween) { + const p = this.targets<{ p: number }>()[0]?.p ?? 1; + const clip = `inset(0% 0% ${(1 - p) * 100}% 0%)`; + for (const el of entering) el.style.clipPath = clip; + }, + onComplete: () => { + for (const el of entering) { + clearMotion(el); + animating.delete(el); + } + }, + }, + ), + ); + } + }, + }; +} + +interface Mover { + readonly el: HTMLElement; + /** Write the element's styles at move progress `p` (0 = old box, 1 = home). */ + render(p: number): void; +} + +/** + * A card slides (and, when it turned into / out of a double-width group + * parent, resizes) from its old box; a group tint morphs its insets from its + * old rect. Both end at their natural layout (`p = 1` ⇒ zero offsets). + */ +function planMove(el: HTMLElement, isTint: boolean, from: DOMRect, to: DOMRect): Mover { + if (isTint) { + const top = from.top - to.top; + const left = from.left - to.left; + const right = to.right - from.right; + const bottom = to.bottom - from.bottom; + return { + el, + render(p) { + const k = 1 - p; + el.style.top = `${top * k}px`; + el.style.left = `${left * k}px`; + el.style.right = `${right * k}px`; + el.style.bottom = `${bottom * k}px`; + }, + }; + } + const dx = from.left - to.left; + const dy = from.top - to.top; + const resized = Math.abs(from.width - to.width) >= 0.5; + return { + el, + render(p) { + const k = 1 - p; + el.style.transform = `translate(${dx * k}px, ${dy * k}px)`; + if (resized) el.style.width = `${to.width + (from.width - to.width) * k}px`; + }, + }; +} + +function clearMotion(el: HTMLElement): void { + for (const prop of MOTION_PROPS) el.style.removeProperty(prop); +} + +function motionAllowed(): boolean { + return ( + typeof window.matchMedia === "function" && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +function flipElements(scroller: HTMLElement): HTMLElement[] { + return [...scroller.querySelectorAll<HTMLElement>("[data-flip-id]")]; +} + +function idOf(el: HTMLElement): string { + return el.dataset.flipId ?? ""; +} + +/** What VISUALLY represents a flip element: a card itself, a group box's tint. */ +function measureTarget(el: HTMLElement): HTMLElement { + if (!isGroupFlipId(idOf(el))) return el; + return el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") ?? el; +} + +/** Whether two rects are the same box (sub-pixel tolerance). */ +function sameBox(a: DOMRect, b: DOMRect): boolean { + const close = (x: number, y: number) => Math.abs(x - y) < 0.5; + return ( + close(a.left, b.left) && + close(a.top, b.top) && + close(a.width, b.width) && + close(a.height, b.height) + ); +} + +interface GhostPlan { + readonly source: HTMLElement; + readonly host: HTMLElement; + readonly isGroup: boolean; + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + +/** + * READ half of a ghost: where an inert copy of a leaving element must sit. + * + * Paint order matters (cards are `relative`, so everything here stacks in DOM + * order): a leaving CARD's ghost goes on the scroller, ON TOP of the grid. A + * leaving GROUP BOX copies only its tint and goes FIRST in the box's + * enclosing grid: above the enclosing box's own tint, but BELOW every card. + */ +function planGhost( + scroller: HTMLElement, + el: HTMLElement, + rect: DOMRect | undefined, +): GhostPlan | null { + const isGroup = isGroupFlipId(idOf(el)); + const source = measureTarget(el); + const host = isGroup ? el.parentElement : scroller; + if (host === null || rect === undefined || (isGroup && source === el)) return null; + const h = host.getBoundingClientRect(); + return { + source, + host, + isGroup, + left: rect.left - h.left - host.clientLeft + host.scrollLeft, + top: rect.top - h.top - host.clientTop + host.scrollTop, + width: rect.width, + height: rect.height, + }; +} + +/** + * WRITE half of a ghost: a clipping frame (whose height later shrinks to 0) + * around a full-size copy — only the frame animates, the copy never resizes. + */ +function insertGhost(plan: GhostPlan): HTMLElement { + const copy = plan.source.cloneNode(!plan.isGroup) as HTMLElement; + // A ghost is never a tab/group to queries, tests or assistive tech. + for (const node of [copy, ...copy.querySelectorAll<HTMLElement>("*")]) { + for (const attr of [ + "data-flip-id", + "data-conversation-id", + "data-group-backdrop", + "role", + "id", + ]) { + node.removeAttribute(attr); + } + } + Object.assign(copy.style, { + position: "absolute", + inset: "auto", + left: "0", + top: "0", + width: `${plan.width}px`, + height: `${plan.height}px`, + margin: "0", + transform: "none", + clipPath: "none", + }); + const ghost = document.createElement("div"); + ghost.setAttribute(GHOST_ATTR, ""); + ghost.setAttribute("aria-hidden", "true"); + ghost.inert = true; + Object.assign(ghost.style, { + position: "absolute", + left: `${plan.left}px`, + top: `${plan.top}px`, + width: `${plan.width}px`, + height: `${plan.height}px`, + overflow: "hidden", + pointerEvents: "none", + zIndex: plan.isGroup ? "auto" : "2", + }); + ghost.appendChild(copy); + if (plan.isGroup) plan.host.prepend(ghost); + else plan.host.appendChild(ghost); + return ghost; +} diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts index 18d8939..d6409ca 100644 --- a/src/features/workspaces/adapter/http.test.ts +++ b/src/features/workspaces/adapter/http.test.ts @@ -79,15 +79,37 @@ describe("createWorkspaceHttp", () => { expect(result).toEqual({ ok: false, error: "invalid slug" }); }); - it("get returns null on 404", async () => { + it("get reports a missing lookup on 404", async () => { const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }])); - expect(await http.get("nope")).toBeNull(); + expect(await http.get("nope")).toEqual({ kind: "missing" }); }); it("get returns the workspace on 200", async () => { - const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 }; + const ws = { + id: "x", + title: "X", + defaultCwd: "/home", + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + }; const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }])); - expect(await http.get("x")).toEqual(ws); + expect(await http.get("x")).toEqual({ kind: "found", workspace: ws }); + }); + + it("get distinguishes a failed response from 404 (not 'missing')", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 500, body: { error: "db down" } }]), + ); + expect(await http.get("x")).toEqual({ kind: "failed", error: "db down" }); + }); + + it("get reports a network error as failed (not 'missing')", async () => { + const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")])); + expect(await http.get("x")).toEqual({ kind: "failed", error: "network" }); }); it("setTitle PUTs the title", async () => { @@ -186,4 +208,67 @@ describe("createWorkspaceHttp", () => { const result = await http.unstar("a"); expect(result).toEqual({ ok: false, error: "Failed to unstar workspace" }); }); + + it("list passes the archived filter as a query param", async () => { + const fetchImpl = fakeFetch([{ body: { workspaces: [] } }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + await http.list("true"); + expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces?archived=true`); + }); + + it("list omits the query param when no filter is given", async () => { + const fetchImpl = fakeFetch([{ body: { workspaces: [] } }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + await http.list(); + expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`); + }); + + it("archive PUTs /archive with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: true, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.archive("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/archive`); + expect(call?.[1]).toEqual({ method: "PUT" }); + }); + + it("unarchive DELETEs /archive with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.unarchive("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/archive`); + expect(call?.[1]).toEqual({ method: "DELETE" }); + }); + + it("archive surfaces the backend error on a 409 (default workspace)", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 409, body: { error: 'The "default" workspace cannot be archived' } }]), + ); + const result = await http.archive("default"); + expect(result).toEqual({ ok: false, error: 'The "default" workspace cannot be archived' }); + }); }); diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts index 5673881..3f7f058 100644 --- a/src/features/workspaces/adapter/http.ts +++ b/src/features/workspaces/adapter/http.ts @@ -9,6 +9,7 @@ import type { WorkspaceListResponse, WorkspaceResponse, } from "@dispatch/transport-contract"; +import type { WorkspaceLookup } from "../logic/create"; /** * Workspace HTTP effects — the injected edge that talks to the backend's @@ -19,24 +20,34 @@ import type { * safe empty/null on failure (non-fatal — the UI falls back gracefully). * * Endpoints ([email protected]): - * - `GET /workspaces` → list + * - `GET /workspaces` (optional `?archived=true|false|all`, default `false` = active only) → list * - `PUT /workspaces/:id` (create-on-miss, idempotent) → ensure - * - `GET /workspaces/:id` (404 → null) → get + * - `GET /workspaces/:id` (found | 404-missing | failed) → 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 + * - `PUT /workspaces/:id/archive` (create-on-miss; 409 for "default") → archive (hide from home grid) + * - `DELETE /workspaces/:id/archive` (create-on-miss; 409 for "default") → unarchive * - `DELETE /workspaces/:id` (409 for "default") → delete */ export type WorkspaceResult<T> = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: string }; +/** Which workspaces `GET /workspaces` returns: active-only (default), archived-only, or all. */ +export type WorkspaceArchivedFilter = "true" | "false" | "all"; + export interface WorkspaceHttp { - list(): Promise<readonly WorkspaceEntry[]>; + list(archived?: WorkspaceArchivedFilter): Promise<readonly WorkspaceEntry[]>; ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; - get(id: string): Promise<Workspace | null>; + /** + * Authoritative existence probe. 404 → `missing`, a network/server failure → + * `failed` (distinct on purpose: the create gate must not treat a broken + * check as "doesn't exist"), 200 → the workspace (`found`). + */ + get(id: string): Promise<WorkspaceLookup>; 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>>; @@ -44,6 +55,10 @@ export interface WorkspaceHttp { star(id: string): Promise<WorkspaceResult<Workspace>>; /** Unstar a workspace. Create-on-miss; no body. */ unstar(id: string): Promise<WorkspaceResult<Workspace>>; + /** Archive a workspace (hide from the home grid; data untouched). Create-on-miss; no body. */ + archive(id: string): Promise<WorkspaceResult<Workspace>>; + /** Unarchive a workspace (restore to the home grid). Create-on-miss; no body. */ + unarchive(id: string): Promise<WorkspaceResult<Workspace>>; delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } @@ -58,9 +73,10 @@ async function errText(res: Response): Promise<string> { export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): WorkspaceHttp { return { - async list(): Promise<readonly WorkspaceEntry[]> { + async list(archived?: WorkspaceArchivedFilter): Promise<readonly WorkspaceEntry[]> { try { - const res = await fetchImpl(`${httpBase}/workspaces`); + const qs = archived !== undefined ? `?archived=${archived}` : ""; + const res = await fetchImpl(`${httpBase}/workspaces${qs}`); if (!res.ok) return []; const data = (await res.json()) as WorkspaceListResponse; return data.workspaces; @@ -86,13 +102,17 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): } }, - async get(id): Promise<Workspace | null> { + async get(id): Promise<WorkspaceLookup> { 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; + if (res.status === 404) return { kind: "missing" }; + if (!res.ok) return { kind: "failed", error: await errText(res) }; + return { kind: "found", workspace: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { + kind: "failed", + error: err instanceof Error ? err.message : "Workspace lookup failed", + }; } }, @@ -171,6 +191,30 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): } }, + async archive(id): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/archive`, { + 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 : "Archive failed" }; + } + }, + + async unarchive(id): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/archive`, { + 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 : "Unarchive failed" }; + } + }, + async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> { try { const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { diff --git a/src/features/workspaces/adapter/tile-layout-http.ts b/src/features/workspaces/adapter/tile-layout-http.ts new file mode 100644 index 0000000..58af9d8 --- /dev/null +++ b/src/features/workspaces/adapter/tile-layout-http.ts @@ -0,0 +1,117 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; + +/** + * Home tile-layout HTTP effects — the injected edge that talks to the + * `home-tile-layout` plugin's well-known routes: + * - `GET /home/tile-layout/:mode` → `{ layout }` (a fresh orientation reads + * as the seeded empty document) + * - `PUT /home/tile-layout/:mode` body `{ layout }` → 200 `{ layout }` / + * 400 `{ error }` + * - `DELETE /home/tile-layout/:mode` → the fresh empty document (reset) + * + * Mirrors the layout adapter's pattern: `httpBase` + an injected `fetchImpl` + * (testable without the network). A 404 is NOT a "no document" signal (the + * backend never 404s a fresh orientation — it seeds): any non-2xx / network + * failure means "the routes aren't served" (an old backend) or "backend + * unreachable" — both degrade the store to its local state with ephemeral + * edits, never an error UI. + */ + +/** Outcome of a tile-layout read/write. */ +export type TileLayoutHttpResult = + | { readonly ok: true; readonly layout: TileLayoutData } + | { readonly ok: false; readonly notFound: boolean; readonly error: string }; + +export interface TileLayoutHttp { + getLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>; + putLayout(mode: TileGeometryMode, layout: TileLayoutData): Promise<TileLayoutHttpResult>; + deleteLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>; +} + +/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */ +async function errorOf(res: Response): Promise<string> { + try { + const body = (await res.json()) as { error?: unknown }; + if (typeof body.error === "string" && body.error.length > 0) return body.error; + } catch { + // fall through + } + return `HTTP ${res.status}`; +} + +/** A malformed 200 (e.g. an SPA-fallback page) degrades like a failure — never poisons the store. */ +function malformed(): TileLayoutHttpResult { + return { ok: false, notFound: false, error: "Malformed tile-layout response" }; +} + +function route(httpBase: string, mode: TileGeometryMode): string { + return `${httpBase}/home/tile-layout/${encodeURIComponent(mode)}`; +} + +export function createTileLayoutHttp(httpBase: string, fetchImpl: typeof fetch): TileLayoutHttp { + return { + async getLayout(mode): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode)); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + + async putLayout(mode, layout): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ layout }), + }); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + + async deleteLayout(mode): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode), { method: "DELETE" }); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + }; +} diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts index dab1dec..c7f57d5 100644 --- a/src/features/workspaces/index.ts +++ b/src/features/workspaces/index.ts @@ -1,5 +1,18 @@ export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; export { createWorkspaceHttp } from "./adapter/http"; +export { createTileLayoutHttp, type TileLayoutHttp } from "./adapter/tile-layout-http"; +export type { CreateGate, WorkspaceLookup } from "./logic/create"; +export { + archivedCreateMessage, + checkFailedCreateMessage, + gateCreate, +} from "./logic/create"; +export type { + DragEmit, + DragEvent as DragControllerEvent, + DragPhase, +} from "./logic/drag-controller"; +export { nextDragPhase } from "./logic/drag-controller"; export type { Route } from "./logic/route"; export { DEFAULT_WORKSPACE_ID, @@ -8,10 +21,36 @@ export { WORKSPACE_SLUG_RE, workspacePath, } from "./logic/route"; +export type { + GridGeometry, + Placement, + TileGeometryMode, + TileLayoutData, + TileLayoutEntry, +} from "./logic/tile-layout"; +export { + clampSpan, + displacePlace, + dragAppendLimit, + fillOrderKey, + firstEmptySlot, + geometryFor, + mergeLayout, + pack, + pinAtFirstEmpty, + placeAtCell, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + sortTiles, + toRenderCell, + toUniversalCell, +} from "./logic/tile-layout"; 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 type { TileLayoutProviderState, TileLayoutStore } from "./tile-layout-store.svelte"; +export { createTileLayoutStore } from "./tile-layout-store.svelte"; export { default as WorkspacesHome } from "./ui/WorkspacesHome.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ diff --git a/src/features/workspaces/logic/create.test.ts b/src/features/workspaces/logic/create.test.ts new file mode 100644 index 0000000..278bcd6 --- /dev/null +++ b/src/features/workspaces/logic/create.test.ts @@ -0,0 +1,50 @@ +import type { Workspace } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { + archivedCreateMessage, + checkFailedCreateMessage, + gateCreate, + type WorkspaceLookup, +} from "./create"; + +function workspace(overrides: Partial<Workspace> = {}): Workspace { + return { + id: "old", + title: "Old", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + ...overrides, + }; +} + +describe("gateCreate", () => { + it("blocks a slug whose workspace is archived, with the can't-create message", () => { + const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: true }) }; + const gate = gateCreate("old", lookup); + expect(gate).toEqual({ ok: false, error: archivedCreateMessage("old") }); + expect(gate.ok === false && gate.error).toContain( + 'A workspace named "old" already exists and is archived — you can\'t create it.', + ); + }); + + it("proceeds for an existing ACTIVE workspace (ensure is idempotent)", () => { + const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: false }) }; + expect(gateCreate("old", lookup)).toEqual({ ok: true }); + }); + + it("proceeds for a missing slug", () => { + expect(gateCreate("fresh", { kind: "missing" })).toEqual({ ok: true }); + }); + + it("blocks when the existence probe failed (never guesses)", () => { + const gate = gateCreate("maybe", { kind: "failed", error: "db down" }); + expect(gate).toEqual({ ok: false, error: checkFailedCreateMessage("maybe", "db down") }); + expect(gate.ok === false && gate.error).toContain( + 'Couldn\'t check whether a workspace named "maybe"', + ); + }); +}); diff --git a/src/features/workspaces/logic/create.ts b/src/features/workspaces/logic/create.ts new file mode 100644 index 0000000..c9385e1 --- /dev/null +++ b/src/features/workspaces/logic/create.ts @@ -0,0 +1,64 @@ +/** + * Pure create-gate logic for the New-workspace form — zero DOM, zero effects. + * + * `PUT /workspaces/:id` (ensure) is idempotent, so creating a slug that belongs + * to an ARCHIVED workspace would silently "succeed": the backend returns the + * archived workspace, the active list (which excludes archived) never shows it, + * and the home grid would still place a tile for something invisible. The + * frontend therefore probes `GET /workspaces/:id` first and refuses the create + * when the slug exists and is archived. + */ + +import type { Workspace } from "@dispatch/wire"; + +/** + * The authoritative existence probe's outcome (`GET /workspaces/:id`). The + * adapter maps HTTP status to these kinds; `failed` is deliberately distinct + * from `missing` (404) so a broken check is never mistaken for "no such + * workspace". + */ +export type WorkspaceLookup = + | { readonly kind: "found"; readonly workspace: Workspace } + | { readonly kind: "missing" } + | { readonly kind: "failed"; readonly error: string }; + +/** Whether a create may proceed: `ok` or a user-facing error message. */ +export type CreateGate = { readonly ok: true } | { readonly ok: false; readonly error: string }; + +/** + * The user-facing message when a slug belongs to an archived workspace. It + * states plainly that the create can't happen and points at the existing + * recovery affordance (the Archived workspaces list) without adding UI. + */ +export function archivedCreateMessage(id: string): string { + return `A workspace named "${id}" already exists and is archived — you can't create it. Unarchive it from Archived workspaces instead.`; +} + +/** + * The user-facing message when the existence probe itself failed. Proceeding + * blindly could recreate the silent archived-tile bug, so the create is + * refused with a retryable message instead. + */ +export function checkFailedCreateMessage(id: string, error: string): string { + return `Couldn't check whether a workspace named "${id}" already exists (${error}). Try again.`; +} + +/** + * Decide whether a create may proceed, given the probe outcome. Pure: + * `(id, lookup) → gate`. + * + * - archived slug → blocked with the "can't create" message; + * - missing slug or an ACTIVE existing slug → proceed (`ensure` is idempotent, + * so an active slug keeps today's behavior); + * - a failed probe → blocked too (plan §3.4): never guess, because proceeding + * could ensure an archived workspace's tile. + */ +export function gateCreate(id: string, lookup: WorkspaceLookup): CreateGate { + if (lookup.kind === "failed") { + return { ok: false, error: checkFailedCreateMessage(id, lookup.error) }; + } + if (lookup.kind === "found" && lookup.workspace.archived) { + return { ok: false, error: archivedCreateMessage(id) }; + } + return { ok: true }; +} diff --git a/src/features/workspaces/logic/drag-controller.test.ts b/src/features/workspaces/logic/drag-controller.test.ts new file mode 100644 index 0000000..459063a --- /dev/null +++ b/src/features/workspaces/logic/drag-controller.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import { type DragPhase, type DragTransition, nextDragPhase } from "./drag-controller"; + +const OPTS = { dragThresholdPx: 6 }; + +function down(over: Partial<Parameters<typeof nextDragPhase>[1] & { type: "pointer-down" }> = {}) { + return { + type: "pointer-down" as const, + id: "t1", + x: 10, + y: 10, + touch: false, + ...over, + }; +} + +function press(touch = false): DragPhase { + return nextDragPhase({ kind: "idle" }, down({ touch }), OPTS).phase; +} + +describe("nextDragPhase — mouse", () => { + it("a pointer-down enters the undecided pressing state", () => { + expect(press()).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false }); + }); + + it("movement past the threshold starts a drag", () => { + const transition = nextDragPhase( + press(), + { type: "pointer-move", x: 20, y: 12, targetCell: { col: 4, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 20, + y: 12, + targetCell: { col: 4, row: 1 }, + touch: false, + }); + expect(transition.emit).toBeUndefined(); + }); + + it("movement within the threshold stays pressing", () => { + const transition = nextDragPhase( + press(), + { type: "pointer-move", x: 12, y: 14, targetCell: { col: 1, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false }); + }); + + it("release without moving emits a tap (open the workspace)", () => { + const transition = nextDragPhase(press(), { type: "pointer-up" }, OPTS); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toEqual({ kind: "tap", id: "t1" }); + }); + + it("release while dragging emits a drop with the last tracked index", () => { + let transition: DragTransition = nextDragPhase( + press(), + { type: "pointer-move", x: 30, y: 30, targetCell: { col: 3, row: 1 } }, + OPTS, + ); + transition = nextDragPhase( + transition.phase, + { type: "pointer-move", x: 31, y: 31, targetCell: { col: 6, row: 1 } }, + OPTS, + ); + transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: { col: 6, row: 1 } }); + expect(transition.phase).toEqual({ kind: "idle" }); + }); +}); + +describe("nextDragPhase — touch", () => { + it("a long-press lifts the tile and emits lift (arm the drag + select)", () => { + const transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + expect(transition.emit).toEqual({ kind: "lift", id: "t1" }); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 10, + y: 10, + targetCell: null, + touch: true, + }); + }); + + it("movement past the threshold BEFORE the lift cancels (vertical scrolling wins)", () => { + const transition = nextDragPhase( + press(true), + { type: "pointer-move", x: 10, y: 80, targetCell: { col: 1, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toBeUndefined(); + }); + + it("movement within the threshold before the lift keeps pressing", () => { + const transition = nextDragPhase( + press(true), + { type: "pointer-move", x: 13, y: 12, targetCell: { col: 1, row: 1 } }, + OPTS, + ); + expect(transition.phase?.kind).toBe("pressing"); + }); + + it("after the lift, movement drags (never cancels)", () => { + let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + transition = nextDragPhase( + transition.phase, + { type: "pointer-move", x: 10, y: 90, targetCell: { col: 5, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 10, + y: 90, + targetCell: { col: 5, row: 1 }, + touch: true, + }); + }); + + it("a long-press never lifts a mouse press (desktop selects via right-click)", () => { + const transition = nextDragPhase(press(false), { type: "long-press" }, OPTS); + expect(transition.emit).toBeUndefined(); + expect(transition.phase?.kind).toBe("pressing"); + }); + + it("release while lifted (no movement) drops at the lifted tile's own slot", () => { + let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: null }); + }); +}); + +describe("nextDragPhase — cancellations", () => { + it("cancel returns to idle from any phase without emitting", () => { + for (const phase of [ + { kind: "idle" as const }, + press(), + nextDragPhase( + press(), + { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } }, + OPTS, + ).phase, + ]) { + const transition = nextDragPhase(phase, { type: "cancel" }, OPTS); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toBeUndefined(); + } + }); + + it("events in idle are inert", () => { + expect(nextDragPhase({ kind: "idle" }, { type: "pointer-up" }, OPTS).phase).toEqual({ + kind: "idle", + }); + expect( + nextDragPhase( + { kind: "idle" }, + { type: "pointer-move", x: 1, y: 1, targetCell: { col: 1, row: 1 } }, + OPTS, + ).phase, + ).toEqual({ kind: "idle" }); + expect(nextDragPhase({ kind: "idle" }, { type: "long-press" }, OPTS).phase).toEqual({ + kind: "idle", + }); + }); + + it("a second pointer-down restarts the gesture with the new tile", () => { + const dragging = nextDragPhase( + press(), + { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } }, + OPTS, + ).phase; + const transition = nextDragPhase(dragging, down({ id: "t2" }), OPTS); + expect(transition.phase).toEqual({ kind: "pressing", id: "t2", x: 10, y: 10, touch: false }); + }); +}); diff --git a/src/features/workspaces/logic/drag-controller.ts b/src/features/workspaces/logic/drag-controller.ts new file mode 100644 index 0000000..a1877aa --- /dev/null +++ b/src/features/workspaces/logic/drag-controller.ts @@ -0,0 +1,162 @@ +/** + * Pure drag/press state machine for the home tile grid — zero DOM, zero + * timers (the component schedules the long-press timer and dispatches its + * firing as an event), zero Svelte. + * + * Pointer semantics: + * - **Mouse**: press → move past the threshold = DRAG; release without + * moving = a TAP (open the workspace). Selection is right-click. + * - **Touch**: press → (no movement past the threshold) long-press = LIFT + * (arm the drag + select the tile) → further moves drag. Moving past the + * threshold BEFORE the lift cancels the press — vertical scrolling wins, + * so the two gestures never fight. Release while lifted = DROP. + * + * Pure: `(phase, event, options) → { phase, emit }`. The component owns the + * reactive `$state` and performs the emits (navigate / select / place). + */ + +import type { Cell } from "./tile-layout"; + +/** Where the interaction currently stands. */ +export type DragPhase = + | { readonly kind: "idle" } + /** Pointer is down; not yet decided between tap / drag / long-press. */ + | { + readonly kind: "pressing"; + readonly id: string; + readonly x: number; + readonly y: number; + readonly touch: boolean; + } + /** The tile is lifted and following the pointer (touch: after the long-press). */ + | { + readonly kind: "dragging"; + readonly id: string; + readonly x: number; + readonly y: number; + /** The grid CELL under the pointer (null = not over the canvas yet). */ + readonly targetCell: Cell | null; + readonly touch: boolean; + }; + +/** An action the component should perform as a result of a transition. */ +export type DragEmit = + | { readonly kind: "tap"; readonly id: string } + | { readonly kind: "lift"; readonly id: string } + | { readonly kind: "drop"; readonly id: string; readonly targetCell: Cell | null }; + +/** Events the component dispatches (DOM events + the long-press timer firing). */ +export type DragEvent = + | { + readonly type: "pointer-down"; + readonly id: string; + readonly x: number; + readonly y: number; + readonly touch: boolean; + } + | { + readonly type: "pointer-move"; + readonly x: number; + readonly y: number; + readonly targetCell: Cell | null; + } + | { readonly type: "pointer-up" } + | { readonly type: "long-press" } + /** pointercancel / blur / the tile unmounting / Escape. */ + | { readonly type: "cancel" }; + +export interface DragOptions { + /** Pointer travel (px) that turns a press into a drag (or cancels a touch press). */ + readonly dragThresholdPx: number; +} + +export interface DragTransition { + readonly phase: DragPhase; + readonly emit?: DragEmit; +} + +const IDLE: DragPhase = { kind: "idle" }; + +/** Advance the state machine. Pure: phase + event in, NEXT phase + emit out. */ +export function nextDragPhase( + phase: DragPhase, + event: DragEvent, + options: DragOptions, +): DragTransition { + switch (event.type) { + case "pointer-down": + // A press always restarts (a pointerdown while dragging is a new gesture). + return { + phase: { + kind: "pressing", + id: event.id, + x: event.x, + y: event.y, + touch: event.touch, + }, + }; + case "pointer-move": { + if (phase.kind === "pressing") { + const travelled = Math.hypot(event.x - phase.x, event.y - phase.y); + if (travelled < options.dragThresholdPx) return { phase }; + // Touch: a big movement before the long-press lift is a SCROLL, not a + // drag — abandon the press so the canvas scrolls natively. + if (phase.touch) return { phase: IDLE }; + return { + phase: { + kind: "dragging", + id: phase.id, + x: event.x, + y: event.y, + targetCell: event.targetCell, + touch: phase.touch, + }, + }; + } + if (phase.kind === "dragging") { + return { + phase: { + ...phase, + x: event.x, + y: event.y, + targetCell: event.targetCell, + }, + }; + } + return { phase }; + } + case "pointer-up": { + if (phase.kind === "pressing") { + // No drag, no lift → a tap. + return { phase: IDLE, emit: { kind: "tap", id: phase.id } }; + } + if (phase.kind === "dragging") { + return { + phase: IDLE, + emit: { kind: "drop", id: phase.id, targetCell: phase.targetCell }, + }; + } + return { phase }; + } + case "long-press": { + if (phase.kind === "pressing" && phase.touch) { + // Touch lift: arm the drag at the press point (the pointer will move + // from here) + tell the component to select the tile. + return { + phase: { + kind: "dragging", + id: phase.id, + x: phase.x, + y: phase.y, + targetCell: null, + touch: true, + }, + emit: { kind: "lift", id: phase.id }, + }; + } + return { phase }; + } + case "cancel": + return { phase: IDLE }; + } +} diff --git a/src/features/workspaces/logic/tile-layout.test.ts b/src/features/workspaces/logic/tile-layout.test.ts new file mode 100644 index 0000000..b538373 --- /dev/null +++ b/src/features/workspaces/logic/tile-layout.test.ts @@ -0,0 +1,857 @@ +import { describe, expect, it } from "vitest"; +import { + clampSpan, + displacePlace, + dragAppendLimit, + fillOrderKey, + firstEmptySlot, + type GridGeometry, + geometryFor, + mergeLayout, + type Placement, + pack, + pinAtFirstEmpty, + placeAtCell, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + type TileLayoutEntry, + toRenderCell, + toUniversalCell, + UNIVERSAL_ROWS, +} from "./tile-layout"; + +const WIDE = geometryFor(1920, 1080); +const TALL = geometryFor(390, 844); + +function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { + return { id, order, colSpan, rowSpan }; +} + +describe("geometryFor", () => { + it("resolves landscape/desktop viewports to the WIDE geometry", () => { + const geometry = geometryFor(1920, 1080); + expect(geometry.mode).toBe("wide"); + expect(geometry.flowRows).toBe(4); + expect(geometry.flowCols).toBe(0); + expect(geometry.maxColSpan).toBe(4); + expect(geometry.maxRowSpan).toBe(2); + expect(geometry.unit.height).toBeGreaterThan(0); + expect(geometry.unit.width).toBeGreaterThan(0); + }); + + it("resolves portrait/mobile viewports to the TALL geometry", () => { + const geometry = geometryFor(390, 844); + expect(geometry.mode).toBe("tall"); + expect(geometry.flowCols).toBe(2); + expect(geometry.flowRows).toBe(0); + expect(geometry.maxColSpan).toBe(2); + expect(geometry.maxRowSpan).toBe(2); + }); + + it("picks the picker dimensions from the span caps (4×2 wide, 2×2 tall)", () => { + // The resize picker IS the span cap: cols × rows of small squares. + expect([WIDE.maxColSpan, WIDE.maxRowSpan]).toEqual([4, 2]); + expect([TALL.maxColSpan, TALL.maxRowSpan]).toEqual([2, 2]); + }); + + it("resolves degenerate (unmeasured) sizes to wide", () => { + expect(geometryFor(0, 0).mode).toBe("wide"); + }); + + it("derives the wide unit from the canvas height so the 4 rows fill it", () => { + // 4·unit + 3·gap + 2·margin ≈ height: an 800px canvas gets ~160px tiles, + // NOT the old fixed 120px (the "squares are so small" complaint). + const geometry = geometryFor(1440, 800); + const filled = 4 * geometry.unit.height + 3 * geometry.unit.gap + 2 * geometry.margin; + expect(geometry.unit.height).toBeGreaterThanOrEqual(140); + expect(Math.abs(filled - 800)).toBeLessThanOrEqual(8); + expect(geometry.unit.width).toBe(geometry.unit.height); + }); + + it("clamps the wide unit to its band on very short and very tall canvases", () => { + const short = geometryFor(1200, 300); + const huge = geometryFor(5000, 4000); // wide (width ≥ height) with a huge height + expect(short.unit.height).toBe(112); + expect(huge.unit.height).toBe(184); + // The margin never collapses (the "no margins" complaint) nor dominates. + expect(short.margin).toBeGreaterThanOrEqual(24); + expect(huge.margin).toBeLessThanOrEqual(64); + }); + + it("always carries a positive margin (the tile field never hugs the edge)", () => { + const sizes: [number, number][] = [ + [1920, 1080], + [390, 844], + [0, 0], + [1200, 300], + ]; + for (const [w, h] of sizes) { + expect(geometryFor(w, h).margin).toBeGreaterThan(0); + } + }); + + it("keeps the tall unit fixed with a gap (its columns are fluid)", () => { + expect(TALL.unit.width).toBe(0); + expect(TALL.unit.height).toBeGreaterThanOrEqual(120); + expect(TALL.unit.gap).toBeGreaterThan(8); + }); +}); + +describe("clampSpan", () => { + it("clamps spans to the geometry caps", () => { + expect(clampSpan({ colSpan: 4, rowSpan: 2 }, WIDE)).toEqual({ colSpan: 4, rowSpan: 2 }); + expect(clampSpan({ colSpan: 9, rowSpan: 9 }, WIDE)).toEqual({ colSpan: 4, rowSpan: 2 }); + expect(clampSpan({ colSpan: 3, rowSpan: 1 }, TALL)).toEqual({ colSpan: 2, rowSpan: 1 }); + }); + + it("coerces garbage spans to 1×1", () => { + expect(clampSpan({ colSpan: 0, rowSpan: -2 }, WIDE)).toEqual({ colSpan: 1, rowSpan: 1 }); + expect(clampSpan({ colSpan: Number.NaN, rowSpan: 1.9 }, WIDE)).toEqual({ + colSpan: 1, + rowSpan: 1, + }); + }); +}); + +describe("pack", () => { + it("places tiles in order at the first fitting slot (wide, column-wise flow)", () => { + const placements = pack([tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)], WIDE); + expect(placements).toEqual([ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "d", col: 1, row: 4, colSpan: 1, rowSpan: 1 }, + ]); + }); + + it("flows to the next column when the current one is full (wide)", () => { + const placements = pack( + [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3), tile("e", 4)], + WIDE, + ); + expect(placements.map((p) => [p.col, p.row])).toEqual([ + [1, 1], + [1, 2], + [1, 3], + [1, 4], + [2, 1], + ]); + }); + + it("respects the persisted order over ids", () => { + const placements = pack([tile("z", 0), tile("a", 1)], WIDE); + expect(placements.map((p) => p.id)).toEqual(["z", "a"]); + }); + + it("places a double-height tile at the first slot with room (wide)", () => { + const placements = pack([tile("a", 0, 1, 2), tile("b", 1), tile("c", 2)], WIDE); + expect(placements.map((p) => [p.id, p.col, p.row, p.rowSpan])).toEqual([ + ["a", 1, 1, 2], + ["b", 1, 3, 1], + ["c", 1, 4, 1], + ]); + }); + + it("a tile too tall for the remaining rows moves to the next column (wide)", () => { + // a,b,c fill rows 1-3; a 2-row tile does not fit the single remaining row → col 2. + const placements = pack( + [tile("a", 0), tile("b", 1), tile("c", 2), tile("tall", 3, 1, 2)], + WIDE, + ); + expect(placements[3]).toEqual({ id: "tall", col: 2, row: 1, colSpan: 1, rowSpan: 2 }); + }); + + it("flows row-wise across two fixed columns in tall mode", () => { + const placements = pack([tile("a", 0), tile("b", 1), tile("c", 2)], TALL); + expect(placements.map((p) => [p.col, p.row])).toEqual([ + [1, 1], + [2, 1], + [1, 2], + ]); + }); + + it("clamps spans per geometry when packing (persisted spans stay raw)", () => { + const tiles = [tile("wide", 0, 3, 1), tile("b", 1, 4, 2)]; + const placements = pack(tiles, TALL); + expect(placements).toEqual([ + { id: "wide", col: 1, row: 1, colSpan: 2, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 2, rowSpan: 2 }, + ]); + // The input is untouched. + expect(tiles[0]).toEqual({ id: "wide", order: 0, colSpan: 3, rowSpan: 1 }); + }); + + it("never overlaps tiles", () => { + const tiles = [ + tile("a", 0, 2, 2), + tile("b", 1, 2, 2), + tile("c", 2, 4, 1), + tile("d", 3), + tile("e", 4, 1, 2), + tile("f", 5), + ]; + const placements = pack(tiles, WIDE); + const seen = new Set<string>(); + for (const p of placements) { + for (let r = p.row; r < p.row + p.rowSpan; r++) { + for (let c = p.col; c < p.col + p.colSpan; c++) { + const key = `${r}:${c}`; + expect(seen.has(key)).toBe(false); + seen.add(key); + } + } + } + }); +}); + +describe("fillOrderKey", () => { + it("ranks wide slots column-wise (top-to-bottom, then next column)", () => { + expect(fillOrderKey(1, 1, WIDE)).toBeLessThan(fillOrderKey(1, 2, WIDE)); + expect(fillOrderKey(1, 4, WIDE)).toBeLessThan(fillOrderKey(2, 1, WIDE)); + }); + + it("ranks tall slots row-wise (left-to-right, then next row)", () => { + expect(fillOrderKey(1, 1, TALL)).toBeLessThan(fillOrderKey(2, 1, TALL)); + expect(fillOrderKey(2, 1, TALL)).toBeLessThan(fillOrderKey(1, 2, TALL)); + }); +}); + +describe("firstEmptySlot", () => { + it("finds the first fitting empty slot in fill order", () => { + // Wide, rows 1-2 of col 1 occupied by a double-height tile → first empty is (col1,row3). + const tiles = [tile("a", 0, 1, 2)]; + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 3 }); + }); + + it("skips slots too small for the requested footprint", () => { + // (col1,row4) is the first empty 1×1 slot after three singles, but a + // 2-row tile needs rows 4-5 of a 4-row grid — impossible → next column. + const tiles = [tile("a", 0), tile("b", 1), tile("c", 2)]; + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 4 }); + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 2 }, WIDE)).toEqual({ col: 2, row: 1 }); + }); + + it("returns the origin for an empty layout", () => { + expect(firstEmptySlot([], { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 1 }); + }); + + it("lands in a brand-new column/row beyond the occupied extent", () => { + // Two 4×2 tiles fill columns 1-4 across all 4 rows → the next slot is col 5. + const tiles = [tile("a", 0, 4, 2), tile("b", 1, 4, 2)]; + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 5, row: 1 }); + }); +}); + +describe("pinAtFirstEmpty", () => { + it("pins a new tile at the first empty slot (holes count)", () => { + // A double-height tile fills rows 1-2, a single fills row 3 → the first + // empty slot is (col1,row4); the new tile PINS exactly there. + const tiles = [tile("big", 0, 1, 2), tile("c", 1)]; + const next = pinAtFirstEmpty(tiles, "new", WIDE); + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); + const placements = pack(next, WIDE); + expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([ + ["big", 1, 1], + ["c", 1, 3], + ["new", 1, 4], + ]); + }); + + it("pins into a GAP between pinned tiles", () => { + // big is pinned out of the way; the flow left row 3 free for a 1×1. + const tiles = [ + { ...tile("big", 0, 1, 2), col: 1, row: 1 }, + { ...tile("z", 1), col: 1, row: 4 }, + ]; + const next = pinAtFirstEmpty(tiles, "new", WIDE); + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 3 }); + }); + + it("re-pins an already-present tile instead of duplicating (refresh-race idempotency)", () => { + const tiles = [tile("a", 0), tile("x", 1), tile("b", 2, 1, 2)]; + const first = pinAtFirstEmpty(tiles, "x", WIDE); + const second = pinAtFirstEmpty(first, "x", WIDE); + expect(second).toEqual(first); + expect(second.filter((t) => t.id === "x")).toHaveLength(1); + }); + + it("appends (pins beyond the current extent) when the grid is full", () => { + const tiles = [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)]; + const next = pinAtFirstEmpty(tiles, "e", WIDE); + expect(next.find((t) => t.id === "e")).toMatchObject({ col: 2, row: 1 }); + }); + + it("preserves the spans of a re-pinned existing tile", () => { + const tiles = [tile("x", 0, 2, 2), tile("a", 1), tile("b", 2), tile("c", 3)]; + const next = pinAtFirstEmpty(tiles, "x", WIDE); + expect(next.find((t) => t.id === "x")).toMatchObject({ colSpan: 2, rowSpan: 2 }); + }); + + it("saves the pin in the universal frame (tall)", () => { + const tiles = [{ ...tile("a", 0), col: 1, row: 1 }]; + const next = pinAtFirstEmpty(tiles, "new", TALL); + // a renders at tall (1,1); the first empty slot is tall (2,1) → universal + // (col 1, row 2). + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 2 }); + }); +}); + +describe("resizeTile", () => { + it("sets the persisted spans of one tile", () => { + const next = resizeTile([tile("a", 0), tile("b", 1)], "b", 4, 2); + expect(next.find((t) => t.id === "b")).toMatchObject({ colSpan: 4, rowSpan: 2 }); + expect(next.find((t) => t.id === "a")).toMatchObject({ colSpan: 1, rowSpan: 1 }); + }); + + it("sanitizes garbage spans to 1 and preserves order", () => { + const next = resizeTile([tile("a", 5, 2, 2)], "a", Number.NaN, -3); + expect(next).toEqual([{ id: "a", order: 5, colSpan: 1, rowSpan: 1 }]); + }); + + it("ignores unknown ids", () => { + const tiles = [tile("a", 0)]; + expect(resizeTile(tiles, "nope", 2, 2)).toEqual(tiles); + }); + + it("does NOT clamp to the current geometry (render-time concern)", () => { + // A 4-wide desktop tile resized while wide stays 4-wide in the document; + // tall rendering clamps its VIEW. + const next = resizeTile([tile("a", 0)], "a", 4, 2); + expect(next[0]).toMatchObject({ colSpan: 4, rowSpan: 2 }); + }); +}); + +describe("displacePlace", () => { + /** Render placements as the column-1 stream (cells 1..n, `_` = empty, + * one character per tile — the id's first char). */ + const stream = (placements: Placement[], geometry: GridGeometry): string => { + const cells = new Map<number, string>(); + for (const p of placements) { + for (let r = p.row; r < p.row + p.rowSpan; r++) { + for (let c = p.col; c < p.col + p.colSpan; c++) { + cells.set(fillOrderKey(c, r, geometry), p.id.charAt(0)); + } + } + } + const maxKey = Math.max(0, ...cells.keys()); + const out: string[] = []; + for (let key = 0; key <= maxKey; key++) { + out.push(cells.get(key) ?? "_"); + } + return out.join(""); + }; + + it("USER EXAMPLE 1: X_X + Y placed in slot 1 → YXX (the gap absorbs the shift)", () => { + // X at cells 1,3 (wide: (1,1) and (1,3)); Y sits at cell 5 (dragged in + // from outside the shown range — its origin is beyond the gap). + const placements: Placement[] = [ + { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE); + // Y@1; X (was@1) pushed into the gap at 2; X2 (was@3) stays. The array + // keeps the INPUT render order (only coordinates change). + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["X", 1, 2], + ["X2", 1, 3], + ["Y", 1, 1], + ]); + }); + + it("USER EXAMPLE 2: _XZ_ + Y placed in slot 2 → _YXZ (leading gap preserved)", () => { + // X@(1,2), Z@(1,3); Y dragged in from cell 5. + const placements: Placement[] = [ + { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 2 }, WIDE); + // Same coordinates, input render order: X, Z, then the dragged Y. + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["X", 1, 3], + ["Z", 1, 4], + ["Y", 1, 2], + ]); + }); + + it("dragging a tile OUT opens a gap at its old anchor (nothing slides back)", () => { + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + ]; + // Drag a (cell 1) to cell 3 — b stays at 2; a lands at 3. + const next = displacePlace(placements, "a", { col: 1, row: 3 }, WIDE); + // The array keeps the input render order (a first) — only a's coords move. + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 3], + ["b", 1, 2], + ]); + }); + + it("internal move rightward over a full run pushes the tail forward", () => { + // a,b,c in cells 1-3; drag a to cell 2: b→3 pushes c→4. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 1, row: 2 }, WIDE); + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 2], + ["b", 1, 3], + ["c", 1, 4], + ]); + }); + + it("an unknown dragged id changes nothing (a copy of the input)", () => { + const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }]; + const next = displacePlace(placements, "nope", { col: 1, row: 2 }, WIDE); + expect(next).toEqual(placements); + }); + + it("a spanned displaced tile keeps its spans and re-anchors forward as a rect", () => { + // big (2-tall) at rows 1-2; tail (2-tall) at rows 3-4. Drag big to (1,2): + // its own old body is free-ish but (1,2)+(1,3) collides with tail's + // (1,3)+(1,4) → tail re-anchors FORWARD: no 2-tall slot fits in column 1 + // anymore → column 2. + const placements: Placement[] = [ + { id: "big", col: 1, row: 1, colSpan: 1, rowSpan: 2 }, + { id: "tail", col: 1, row: 3, colSpan: 1, rowSpan: 2 }, + ]; + const next = displacePlace(placements, "big", { col: 1, row: 2 }, WIDE); + expect(next.map((p) => [p.id, p.col, p.row, p.rowSpan])).toEqual([ + ["big", 1, 2, 2], + ["tail", 2, 1, 2], + ]); + }); + + it("displacing into the NEXT COLUMN when the column is full", () => { + // 4 tiles fill column 1; drag the first to the LAST cell of column 1. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "d", col: 1, row: 4, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 1, row: 4 }, WIDE); + // d is pushed past the column's end → column 2 row 1. The array keeps the + // INPUT render order (a,b,c,d) — only coordinates change. + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 4], + ["b", 1, 2], + ["c", 1, 3], + ["d", 2, 1], + ]); + }); + + it("works in TALL mode along the row-major stream", () => { + // Tall: 2 columns, row-major. a@(1,1), b@(2,1); drag a to cell (2,1). + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 2, row: 1 }, TALL); + // b pushed forward past the row's end → next row. Stream order: a(1) b(2). + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 2, 1], + ["b", 1, 2], + ]); + }); + + it("the stream helper renders the documented examples (sanity)", () => { + const placements: Placement[] = [ + { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE); + expect(stream(next, WIDE)).toBe("YXX"); + const placements2: Placement[] = [ + { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next2 = displacePlace(placements2, "Y", { col: 1, row: 2 }, WIDE); + expect(stream(next2, WIDE)).toBe("_YXZ"); + }); +}); + +describe("placeAtCell", () => { + it("pins every tile at its displaced anchor in the universal frame", () => { + // Wide: a@(1,1), b@(1,2); drag a to (2,1) — a pins there, b KEEPS its + // cell (the gap at (1,1) opens and stays). + const tiles = [tile("a", 0), tile("b", 1)]; + const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, WIDE); + expect(next.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 }); + expect(next.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 }); + // And the pinned layout packs exactly as placed (b first — stream order). + expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["b", 1, 2], + ["a", 2, 1], + ]); + }); + + it("keeps persisted spans raw (never clamped) through the commit", () => { + // A 4-wide desktop tile dropped in TALL commits the tall cell but keeps + // its raw 4-wide span. + const tiles = [{ ...tile("big", 0, 4, 1) }, tile("b", 1)]; + const next = placeAtCell(tiles, "big", { col: 1, row: 3 }, TALL); + expect(next.find((t) => t.id === "big")).toMatchObject({ + colSpan: 4, + rowSpan: 1, + col: 1, + row: 5, + }); + }); + + it("renumbers orders densely in stream order", () => { + const tiles = [tile("a", 0), tile("b", 5), tile("c", 9)]; + const next = placeAtCell(tiles, "c", { col: 1, row: 1 }, WIDE); + expect(next.every((t, i) => t.order === i)).toBe(true); + }); + + it("maps tall commits to the universal frame", () => { + // Tall (col 2, row 1) → universal (col 1, row 2). + const tiles = [tile("a", 0)]; + const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, TALL); + expect(next.find((t) => t.id === "a")).toMatchObject({ col: 1, row: 2 }); + }); + + it("an unknown id changes nothing (no-op)", () => { + const tiles = [tile("a", 0)]; + expect(placeAtCell(tiles, "nope", { col: 1, row: 1 }, WIDE)).toEqual(tiles); + }); +}); + +describe("dragAppendLimit", () => { + it("caps a wide drag at 3 columns past the right-most content edge (span-aware)", () => { + // b spans cols 3-4 → the right-most occupied column is 4; +3 = 7. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 3, row: 1, colSpan: 2, rowSpan: 1 }, + ]; + expect(dragAppendLimit(placements, WIDE)).toBe(7); + }); + + it("caps a tall drag at 3 rows past the bottom-most content edge (span-aware)", () => { + // b spans rows 3-4 → the bottom-most occupied row is 4; +3 = 7. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 2 }, + { id: "b", col: 2, row: 3, colSpan: 1, rowSpan: 2 }, + ]; + expect(dragAppendLimit(placements, TALL)).toBe(7); + }); + + it("an empty grid still allows the first tiles within the first 3 columns", () => { + expect(dragAppendLimit([], WIDE)).toBe(3); + expect(dragAppendLimit([], TALL)).toBe(3); + }); + + it("honors a custom extra", () => { + const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }]; + expect(dragAppendLimit(placements, WIDE, 5)).toBe(6); + }); +}); + +describe("sortByRecentActivity", () => { + it("re-applies the recent-activity order (descending)", () => { + const tiles = [tile("a", 0), tile("b", 1), tile("c", 2)]; + const next = sortByRecentActivity( + tiles, + new Map([ + ["a", 100], + ["b", 300], + ["c", 200], + ]), + ); + expect(next.map((t) => t.id)).toEqual(["b", "c", "a"]); + }); + + it("treats unknown ids as least-recent and renumbers", () => { + const tiles = [tile("a", 0), tile("b", 1)]; + // `a` has recent activity (50); `b` is unknown → least-recent → last. + const next = sortByRecentActivity(tiles, new Map([["a", 50]])); + expect(next.map((t) => [t.id, t.order])).toEqual([ + ["a", 0], + ["b", 1], + ]); + }); + + it("CLEARs every pin (an explicit arrangement reset — gaps gone)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("b", 1), col: 2, row: 3 }, + ]; + const next = sortByRecentActivity( + tiles, + new Map([ + ["a", 100], + ["b", 300], + ]), + ); + expect(next.every((t) => t.col === undefined && t.row === undefined)).toBe(true); + // And the now-unpinned layout packs compactly. + expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["b", 1, 1], + ["a", 1, 2], + ]); + }); +}); + +describe("mergeLayout", () => { + it("starts workspaces unknown to the layout (appends after the max order)", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("a", 0)] }, ["a", "b", "c"]); + expect(merged.map((t) => [t.id, t.order])).toEqual([ + ["a", 0], + ["b", 1], + ["c", 2], + ]); + }); + + it("appends unknowns in the live list's order", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("z", 0)] }, ["z", "y", "x"]); + expect(merged.map((t) => t.id)).toEqual(["z", "y", "x"]); + }); + + it("drops entries for deleted workspaces", () => { + const merged = mergeLayout( + { version: 1, tiles: [tile("a", 0), tile("gone", 1), tile("b", 2)] }, + ["a", "b"], + ); + expect(merged.map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("keeps manual order (not the list order) for known workspaces", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("c", 0), tile("a", 1)] }, ["a", "c"]); + expect(merged.map((t) => t.id)).toEqual(["c", "a"]); + }); + + it("clamps spans in the view when a geometry is given (persisted data unchanged)", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("a", 0, 3, 1)] }, ["a"], TALL); + expect(merged[0]).toMatchObject({ colSpan: 2, rowSpan: 1 }); + expect(mergeLayout({ version: 1, tiles: [tile("a", 0, 3, 1)] }, ["a"])).toEqual([ + { id: "a", order: 0, colSpan: 3, rowSpan: 1 }, + ]); + }); + + it("deduplicates corrupted duplicate ids (first by order wins)", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("a", 1, 2, 2), tile("a", 0)] }, [ + "a", + "b", + ]); + expect(merged).toEqual([ + { id: "a", order: 0, colSpan: 1, rowSpan: 1 }, + { id: "b", order: 1, colSpan: 1, rowSpan: 1 }, + ]); + }); + + it("handles a null/undefined persisted document", () => { + expect(mergeLayout(null, ["a"])).toEqual([{ id: "a", colSpan: 1, rowSpan: 1, order: 1 }]); + expect(mergeLayout(undefined, [])).toEqual([]); + }); +}); + +describe("sanitizeLayout", () => { + it("accepts a valid v2 document (anchors kept)", () => { + expect( + sanitizeLayout({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }], + }), + ).toEqual({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }], + }); + }); + + it("MIGRATES a v1 document to v2 in memory (all tiles unpinned)", () => { + expect( + sanitizeLayout({ version: 1, tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }] }), + ).toEqual({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }], + }); + }); + + it("rejects foreign versions and malformed documents", () => { + expect(sanitizeLayout(null)).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout("nope")).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout({ version: 3, tiles: [] })).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout({ version: 1, tiles: "x" })).toEqual({ version: 2, tiles: [] }); + }); + + it("coerces malformed entries and drops anonymous/duplicate ones", () => { + const data = sanitizeLayout({ + version: 1, + tiles: [ + { id: "a", colSpan: "x", rowSpan: 0, order: "y" }, + null, + { colSpan: 2 }, + { id: "a", order: 9 }, + { id: "b", colSpan: 3.9, order: 2.5 }, + ], + }); + expect(data.tiles).toEqual([ + { id: "a", colSpan: 1, rowSpan: 1, order: 0 }, + { id: "b", colSpan: 3, rowSpan: 1, order: 2 }, + ]); + }); + + it("drops garbage anchors (non-positive / fractional / half a pair)", () => { + const data = sanitizeLayout({ + version: 2, + tiles: [ + { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 }, + { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2.5, row: 3 }, + { id: "zero", colSpan: 1, rowSpan: 1, order: 2, col: 0, row: 1 }, + { id: "lone", colSpan: 1, rowSpan: 1, order: 3, col: 2 }, + { id: "neg", colSpan: 1, rowSpan: 1, order: 4, col: -1, row: -1 }, + ], + }); + expect(data.tiles).toEqual([ + { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 }, + { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2, row: 3 }, + { id: "zero", colSpan: 1, rowSpan: 1, order: 2 }, + { id: "lone", colSpan: 1, rowSpan: 1, order: 3 }, + { id: "neg", colSpan: 1, rowSpan: 1, order: 4 }, + ]); + }); +}); + +describe("geometry-agnostic persistence", () => { + it("the SAME persisted data renders in either geometry (unpinned flow re-derives)", () => { + const tiles: TileLayoutEntry[] = [ + tile("a", 0, 4, 2), + tile("b", 1), + tile("c", 2), + tile("d", 3, 2, 2), + tile("e", 4), + ]; + const widePlacements = pack(tiles, WIDE); + const tallPlacements = pack(tiles, TALL); + // Wide flows column-wise across 4 rows; tall flows row-wise across 2 columns. + expect(widePlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 4, rowSpan: 2 }); + expect(widePlacements.at(-1)).toMatchObject({ id: "e" }); + expect(tallPlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 2, rowSpan: 2 }); + // An unpinned entry carries NO anchor — only id/spans/order. + expect(Object.keys(tiles[0] ?? {}).sort()).toEqual(["colSpan", "id", "order", "rowSpan"]); + }); +}); + +describe("pinned pack (v2)", () => { + it("pinned tiles land at their anchors; unpinned flow into the remaining free cells", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 3 }, + { ...tile("b", 1), col: 1, row: 1 }, + tile("c", 2), // unpinned → first free = (1,2) + tile("d", 3), // unpinned → next free = (1,4) + ]; + // pack returns ENTRY order (a, b, c, d); positions are what matters. + const placements = pack(tiles, WIDE); + expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 3], + ["b", 1, 1], + ["c", 1, 2], + ["d", 1, 4], + ]); + }); + + it("keeps the GAP between two pins (flow does not compact it away)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("z", 1), col: 1, row: 4 }, + ]; + expect(pack(tiles, WIDE).map((p) => [p.id, p.row])).toEqual([ + ["a", 1], + ["z", 4], + ]); + }); + + it("a pin whose span no longer fits its anchor reads as UNPINNED (flows)", () => { + // A 2-tall tile pinned at row 4 cannot fit the 4-row cross-axis at that + // anchor → it flows to the first fitting slot instead: (1,2). + const tiles: TileLayoutEntry[] = [ + { ...tile("big", 0, 1, 2), col: 1, row: 4 }, + { ...tile("a", 1), col: 1, row: 1 }, + ]; + const placements = pack(tiles, WIDE); + expect(placements.find((p) => p.id === "big")).toMatchObject({ col: 1, row: 2 }); + }); + + it("overlapping pins resolve first-wins by entry order (the loser flows)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 2 }, + { ...tile("b", 1), col: 1, row: 2 }, + ]; + const placements = pack(tiles, WIDE); + expect(placements.find((p) => p.id === "a")).toMatchObject({ col: 1, row: 2 }); + expect(placements.find((p) => p.id === "b")).toMatchObject({ col: 1, row: 1 }); + }); + + it("a wide pin at col > 1 reads as UNPINNED in tall (documented lossy case)", () => { + const tiles: TileLayoutEntry[] = [{ ...tile("a", 0), col: 3, row: 1 }]; + expect(pack(tiles, TALL).find((p) => p.id === "a")).toMatchObject({ col: 1, row: 1 }); + }); + + it("tall pins map through the universal frame in both directions", () => { + // Universal column 1 folds onto tall's 2 columns in reading order: + // u1→(1,1) u2→(2,1) u3→(1,2) u4→(2,2). + expect(toRenderCell({ col: 1, row: 1 }, TALL)).toEqual({ col: 1, row: 1 }); + expect(toRenderCell({ col: 1, row: 2 }, TALL)).toEqual({ col: 2, row: 1 }); + expect(toRenderCell({ col: 1, row: 3 }, TALL)).toEqual({ col: 1, row: 2 }); + expect(toRenderCell({ col: 1, row: 4 }, TALL)).toEqual({ col: 2, row: 2 }); + expect(toUniversalCell({ col: 2, row: 2 }, TALL)).toEqual({ col: 1, row: 4 }); + // Wide is the identity. + expect(toRenderCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 }); + expect(toUniversalCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 }); + expect(UNIVERSAL_ROWS).toBe(4); + }); + + it("a layout dragged in TALL renders its pins when back in WIDE", () => { + // Committed in tall: a at tall (1,1) → universal (1,1); b at tall (2,1) → + // universal (1,2). Back in wide they render exactly there (col 1). + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("b", 1), col: 1, row: 2 }, + ]; + expect(pack(tiles, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 1], + ["b", 1, 2], + ]); + }); + + it("deleting a workspace leaves its cells EMPTY (mergeLayout keeps other pins)", () => { + const persisted = { + version: 2 as const, + tiles: [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("gone", 1), col: 1, row: 2 }, + { ...tile("b", 2), col: 1, row: 3 }, + ] as TileLayoutEntry[], + }; + const merged = mergeLayout(persisted, ["a", "b"]); + expect(merged.map((t) => [t.id, t.col, t.row])).toEqual([ + ["a", 1, 1], + ["b", 1, 3], + ]); + // The gap at (1,2) stays: a new workspace flows/pins INTO it. + expect(firstEmptySlot(merged, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 2 }); + }); +}); + +describe("type surface", () => { + it("exposes the geometry fields the picker and grid need", () => { + // A 500px-tall canvas: the raw unit (100) clamps UP to the 112px minimum, + // so the gap is 12px and the margin 37px (never the old fixed 8px gap). + const geometry: GridGeometry = geometryFor(1000, 500); + expect([ + geometry.mode, + geometry.flowRows, + geometry.flowCols, + geometry.unit.gap, + geometry.margin, + ]).toEqual(["wide", 4, 0, 12, 37]); + }); +}); diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts new file mode 100644 index 0000000..6f7c281 --- /dev/null +++ b/src/features/workspaces/logic/tile-layout.ts @@ -0,0 +1,709 @@ +/** + * Pure tile-layout logic for the Win8-Start-style workspace home grid — zero + * DOM, zero Svelte, zero effects. + * + * The PERSISTED data (v2): `{ version, tiles: [{ id, colSpan, rowSpan, order, + * col?, row? }] }` — each tile's spans, arrival order, and OPTIONAL pinned + * anchor. Anchors live in a UNIVERSAL 4-row frame (wide's frame; tall folds + * its 2 columns onto frame column 1), so the same document renders in either + * geometry with no orientation conversion step: an anchor that doesn't map + * into the current geometry (or whose clamped span can't fit there) reads as + * UNPINNED, and unpinned tiles flow into the first free cells — holes between + * pins included. Empty cells are real: nothing ever slides backward, so gaps + * only close when a drop's displacement pushes a run of filled cells through + * them (see {@link displacePlace}). + * + * Terminology (distinct from the layout feature's nested split-tree "tile"): + * a HOME TILE is one workspace's cell in the flat start-screen grid. + */ + +/** The grid geometry mode: landscape/desktop ("wide") or portrait/mobile ("tall"). */ +export type TileGeometryMode = "wide" | "tall"; + +/** The px size of one tile unit (one grid cell) + the grid gap. */ +export interface TileUnitSize { + /** + * The unit's px width. `0` means "fluid" (the tall grid sizes its fixed + * columns in `1fr` so two tiles fill a phone's width). + */ + readonly width: number; + readonly height: number; + readonly gap: number; +} + +/** + * The full grid geometry a viewport resolves to. `flowRows`/`flowCols` name + * the FIXED track counts across the fill direction's cross axis: + * wide mode fixes ROWS (tiles flow column-wise, columns grow unbounded); + * tall mode fixes COLS (tiles flow row-wise, rows grow unbounded). `0` = + * unbounded. The picker (resize) dimensions equal the span caps. + */ +export interface GridGeometry { + readonly mode: TileGeometryMode; + /** Fixed row tracks (wide: 4; tall: 0 = rows grow unbounded). */ + readonly flowRows: number; + /** Fixed column tracks (tall: 2; wide: 0 = columns grow unbounded). */ + readonly flowCols: number; + /** Maximum horizontal span a tile may occupy in this geometry. */ + readonly maxColSpan: number; + /** Maximum vertical span a tile may occupy in this geometry. */ + readonly maxRowSpan: number; + readonly unit: TileUnitSize; + /** + * The breathing room around the whole tile field — the grid never renders + * flush against the canvas edge. In wide mode the scroller pads all four + * sides by this amount (the pan arrow/scroll indicator offset inward by it + * too); in tall mode it insets the two fluid columns horizontally. + */ + readonly margin: number; +} + +/** One persisted tile: which workspace it pins, how big it wants to be, its manual order, and its anchor. */ +export interface TileLayoutEntry { + /** The workspace id (the slug). */ + readonly id: string; + readonly colSpan: number; + readonly rowSpan: number; + /** Lower sorts earlier. Arrival order for unpinned flow + first-wins tie-breaks. */ + readonly order: number; + /** + * The pinned anchor cell (1-based) in the UNIVERSAL 4-row frame, or absent = + * unpinned (the tile flows into the first free cells at render time). Tall + * mode normalizes its 2-column grid into this frame: render { c, r } ↔ + * universal { col: 1, row: 2(r−1)+c }. A pin outside the current geometry's + * fixed cross-axis reads as UNPINNED (render-time clamp — like spans). + */ + readonly col?: number; + readonly row?: number; +} + +/** The persisted document (localStorage via the injected `LocalStore` adapter). */ +export interface TileLayoutData { + readonly version: 1 | 2; + readonly tiles: readonly TileLayoutEntry[]; +} + +/** A tile's resolved footprint on the grid (1-based grid coordinates). */ +export interface Placement { + readonly id: string; + readonly col: number; + readonly row: number; + readonly colSpan: number; + readonly rowSpan: number; +} + +/** A requested span (pre-clamping). */ +export interface Span { + readonly colSpan: number; + readonly rowSpan: number; +} + +/** The viewport assumed before the canvas has been measured (a landscape desktop). */ +export const DEFAULT_VIEWPORT = { width: 1280, height: 800 } as const; + +/** + * The wide unit's clamp band (px). Tiles FILL the canvas' height across the 4 + * fixed rows (the Start-screen feel), but never shrink below a legible small + * tile nor grow past a comfortable large one. + */ +const WIDE_UNIT_MIN = 112; +const WIDE_UNIT_MAX = 184; + +/** The wide canvas' outer margin band (px) — proportional to the unit size. */ +const WIDE_MARGIN_MIN = 24; +const WIDE_MARGIN_MAX = 64; + +/** The tall unit: the two fluid columns size in `1fr` (width `0` = fluid). */ +const TALL_UNIT: TileUnitSize = { width: 0, height: 148, gap: 12 }; +const TALL_MARGIN = 16; + +/** Round to whole pixels (fractional track sizes blur tile edges). */ +function px(value: number): number { + return Math.round(value); +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** + * Resolve the grid geometry from the tile canvas' size. Landscape/desktop → + * WIDE (4 fixed rows, column-wise flow, 4×2 span cap); portrait/mobile → TALL + * (2 fixed columns, row-wise flow, 2×2 span cap). Degenerate sizes (not yet + * measured) resolve to wide. + * + * The wide unit is DERIVED from the canvas height so the 4 rows exactly fill + * it (unit + margin + gap all proportional, clamped to their bands); the tall + * unit is fixed (its columns are fluid instead). Pure: numbers in, geometry + * out. + */ +export function geometryFor(width: number, height: number): GridGeometry { + const tall = width > 0 && height > 0 && height > width; + if (tall) { + return { + mode: "tall", + flowRows: 0, + flowCols: 2, + maxColSpan: 2, + maxRowSpan: 2, + unit: TALL_UNIT, + margin: TALL_MARGIN, + }; + } + // Solve 4·unit + 3·gap + 2·margin = height with gap = unit/9 and margin = + // unit/3: 4u + u/3 + 2u/3 = 5u → u = height/5. Unmeasured (height ≤ 0) → + // the band's middle, not a tiny tile. + const raw = height > 0 ? height / 5 : (WIDE_UNIT_MIN + WIDE_UNIT_MAX) / 2; + const unit = px(clampNumber(raw, WIDE_UNIT_MIN, WIDE_UNIT_MAX)); + const gap = Math.max(10, px(unit / 9)); + const margin = px(clampNumber(unit / 3, WIDE_MARGIN_MIN, WIDE_MARGIN_MAX)); + return { + mode: "wide", + flowRows: 4, + flowCols: 0, + maxColSpan: 4, + maxRowSpan: 2, + unit: { width: unit, height: unit, gap }, + margin, + }; +} + +/** Sort entries by their persisted order (stable — ties keep their relative order). */ +export function sortTiles<T extends TileLayoutEntry>(tiles: readonly T[]): T[] { + return [...tiles].sort((a, b) => a.order - b.order); +} + +/** Clamp a requested span to a geometry's caps, coercing garbage (non-positive/NaN) to 1. */ +export function clampSpan<T extends Span>(span: T, geometry: GridGeometry): Span { + return { + colSpan: clampUnit(span.colSpan, geometry.maxColSpan), + rowSpan: clampUnit(span.rowSpan, geometry.maxRowSpan), + }; +} + +function clampUnit(value: number, max: number): number { + if (!Number.isFinite(value)) return 1; + const int = Math.floor(value); + if (int < 1) return 1; + return Math.min(int, max); +} + +/** + * A position's rank in the geometry's FILL ORDER — the order slots are visited + * when packing: wide fills column-wise (top-to-bottom, then the next column); + * tall fills row-wise (left-to-right, then the next row). Pure; only used for + * comparisons within one geometry. + */ +export function fillOrderKey(col: number, row: number, geometry: GridGeometry): number { + if (geometry.mode === "wide") return (col - 1) * geometry.flowRows + (row - 1); + return (row - 1) * geometry.flowCols + (col - 1); +} + +/** + * The universal frame's fixed row count (the wide grid's). Tall render cells + * map onto column 1 of this frame; wide is the identity. + */ +export const UNIVERSAL_ROWS = 4; + +/** A tile's anchor cell (1-based). */ +export interface Cell { + readonly col: number; + readonly row: number; +} + +/** + * Universal-frame anchor → render-geometry cell. Wide: identity. Tall: column + * 1 of the universal frame folds onto the 2 render columns (universal row u → + * render { col: (u−1)%2 + 1, row: ⌈u/2⌉ }); any universal col > 1 cannot be + * represented in tall → `null` (the tile reads as UNPINNED in tall). + * Pure. + */ +export function toRenderCell(anchor: Cell, geometry: GridGeometry): Cell | null { + if (geometry.mode === "wide") return anchor; + if (anchor.col !== 1) return null; + return { col: ((anchor.row - 1) % 2) + 1, row: Math.floor((anchor.row - 1) / 2) + 1 }; +} + +/** Render-geometry cell → universal-frame anchor (the commit direction). Pure. */ +export function toUniversalCell(cell: Cell, geometry: GridGeometry): Cell { + if (geometry.mode === "wide") return cell; + return { col: 1, row: 2 * (cell.row - 1) + cell.col }; +} + +function cellsFree( + occupied: ReadonlySet<string>, + col: number, + row: number, + colSpan: number, + rowSpan: number, +): boolean { + for (let r = row; r < row + rowSpan; r++) { + for (let c = col; c < col + colSpan; c++) { + if (occupied.has(`${r}:${c}`)) return false; + } + } + return true; +} + +function occupy( + occupied: Set<string>, + col: number, + row: number, + colSpan: number, + rowSpan: number, +): void { + for (let r = row; r < row + rowSpan; r++) { + for (let c = col; c < col + colSpan; c++) { + occupied.add(`${r}:${c}`); + } + } +} + +function maxOccupied(occupied: ReadonlySet<string>, axis: "col" | "row"): number { + let max = 0; + for (const cell of occupied) { + const value = Number.parseInt( + axis === "row" ? (cell.split(":")[0] ?? "") : (cell.split(":")[1] ?? ""), + 10, + ); + if (Number.isFinite(value) && value > max) max = value; + } + return max; +} + +/** + * The first slot (in fill order) where a span-sized footprint fits without + * overlapping or exceeding the geometry's fixed cross-axis. Beyond the + * occupied region every cell is free, so the scan is bounded by the occupied + * extent — the result always exists (the flow is unbounded along its main + * axis). Pure: occupancy + span + geometry in, slot out. + */ +function firstFit( + occupied: ReadonlySet<string>, + span: Span, + geometry: GridGeometry, +): { col: number; row: number } { + const { colSpan, rowSpan } = clampSpan(span, geometry); + if (geometry.mode === "wide") { + const maxCol = maxOccupied(occupied, "col"); + for (let col = 1; col <= maxCol; col++) { + for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) { + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: maxCol + 1, row: 1 }; + } + const maxRow = maxOccupied(occupied, "row"); + for (let row = 1; row <= maxRow; row++) { + for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) { + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: 1, row: maxRow + 1 }; +} + +/** + * THE entries → coordinates transform, v2. Two passes, one scan each: + * + * 1. **Pinned pass** (in entry order — first-pinned-wins on overlap, strictly + * one-ply: a displaced pin never bumps another pin): a pinned tile lands at + * its anchor (universal frame → render cell) when the anchor maps into this + * geometry, its clamped footprint fits the fixed cross-axis there, and no + * already-placed tile overlaps. Otherwise the tile reads as UNPINNED. + * 2. **Flow pass**: unpinned tiles (never pinned, out-of-range, clashing) take + * the first fitting FREE slot in fill order — holes between pins count. + * + * Requested spans clamp to the geometry (render-time only; the persisted spans + * stay raw). Pure: entries + geometry in, a NEW placements array out (entry + * order — the render order). + */ +export function pack(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Placement[] { + const occupied = new Set<string>(); + const placements: (Placement | undefined)[] = []; + const flowing: { index: number; tile: TileLayoutEntry }[] = []; + const ordered = sortTiles(tiles); + for (let index = 0; index < ordered.length; index++) { + const tile = ordered[index]; + if (!tile) continue; + const { colSpan, rowSpan } = clampSpan(tile, geometry); + const anchor = pinnedAnchor(tile, geometry); + if ( + anchor !== null && + fitsCrossAxis(anchor, colSpan, rowSpan, geometry) && + cellsFree(occupied, anchor.col, anchor.row, colSpan, rowSpan) + ) { + occupy(occupied, anchor.col, anchor.row, colSpan, rowSpan); + placements[index] = { id: tile.id, col: anchor.col, row: anchor.row, colSpan, rowSpan }; + } else { + flowing.push({ index, tile }); + } + } + for (const { index, tile } of flowing) { + const { colSpan, rowSpan } = clampSpan(tile, geometry); + const slot = firstFit(occupied, { colSpan, rowSpan }, geometry); + occupy(occupied, slot.col, slot.row, colSpan, rowSpan); + placements[index] = { id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan }; + } + return placements.filter((placement) => placement !== undefined); +} + +/** + * A pinned tile's render-geometry anchor, or `null` when the tile is unpinned + * (no anchor) or its anchor cannot be represented in this geometry (a wide + * column > 1 read in tall). Pure. + */ +function pinnedAnchor(tile: TileLayoutEntry, geometry: GridGeometry): Cell | null { + if (tile.col === undefined || tile.row === undefined) return null; + return toRenderCell({ col: tile.col, row: tile.row }, geometry); +} + +/** The footprint fits the geometry's FIXED cross-axis at this anchor. */ +function fitsCrossAxis( + anchor: Cell, + colSpan: number, + rowSpan: number, + geometry: GridGeometry, +): boolean { + if (geometry.mode === "wide") return anchor.row + rowSpan - 1 <= geometry.flowRows; + return anchor.col + colSpan - 1 <= geometry.flowCols; +} + +/** + * The first fitting slot at or after a minimum stream key (the displacement + * re-anchor scan — pushed tiles only move FORWARD). Pure. + */ +function firstFitFrom( + occupied: ReadonlySet<string>, + span: Span, + geometry: GridGeometry, + minKey: number, +): Cell { + const { colSpan, rowSpan } = clampSpan(span, geometry); + if (geometry.mode === "wide") { + const maxCol = maxOccupied(occupied, "col") + 1; + for (let col = 1; col <= maxCol + 1; col++) { + for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) { + if (fillOrderKey(col, row, geometry) < minKey) continue; + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: maxCol + 2, row: 1 }; + } + const maxRow = maxOccupied(occupied, "row") + 1; + for (let row = 1; row <= maxRow + 1; row++) { + for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) { + if (fillOrderKey(col, row, geometry) < minKey) continue; + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: 1, row: maxRow + 2 }; +} + +/** + * THE displacement op — drop `draggedId` at `cell` over the CURRENT RENDER + * placements (the squares exactly as the user sees them). Semantics (user- + * specified): the dragged tile leaves its slot (the gap OPENS — nothing slides + * backward, ever), anchors exactly at `cell`, and the contiguous run of filled + * cells pushes FORWARD in fill order until the first empty cell absorbs it: + * + * `X _ X` + Y at slot 1 → `Y X X` + * `_ X Z _` + Y at slot 2 → `_ Y X Z` + * + * Tiles keep their positions unless their footprint overlaps an already-placed + * one; the displaced re-anchor at the next fitting slot forward (spans ride + * along; a span that can't fit the fixed cross-axis anywhere forward wraps to + * the first fitting slot — it must land somewhere). Pure: placements + drop + * in, a NEW placements array out — in the INPUT array order (only coordinates + * change). A keyed `{#each}` over the result therefore never moves DOM nodes; + * moving a captured element drops its pointer capture — the "stuck tile" bug. + */ +export function displacePlace( + placements: readonly Placement[], + draggedId: string, + cell: Cell, + geometry: GridGeometry, +): Placement[] { + const dragged = placements.find((placement) => placement.id === draggedId); + if (!dragged) return [...placements]; + const occupied = new Set<string>(); + occupy(occupied, cell.col, cell.row, dragged.colSpan, dragged.rowSpan); + const placed: Placement[] = [{ ...dragged, col: cell.col, row: cell.row }]; + const others = placements + .filter((placement) => placement.id !== draggedId) + .map((placement) => ({ + placement, + key: fillOrderKey(placement.col, placement.row, geometry), + })) + .sort((a, b) => a.key - b.key); + for (const { placement, key } of others) { + const { colSpan, rowSpan } = placement; + if (cellsFree(occupied, placement.col, placement.row, colSpan, rowSpan)) { + occupy(occupied, placement.col, placement.row, colSpan, rowSpan); + placed.push(placement); + continue; + } + let slot = firstFitFrom(occupied, placement, geometry, key); + if (!fitsCrossAxis(slot, colSpan, rowSpan, geometry)) { + slot = firstFit(occupied, placement, geometry); + } + occupy(occupied, slot.col, slot.row, colSpan, rowSpan); + placed.push({ ...placement, col: slot.col, row: slot.row }); + } + // Return in the INPUT render order (coordinates change only): the live drag + // preview feeds a keyed `{#each}`, and a reordered array moves the DOM nodes + // — a moved captured element silently loses pointer capture (the stuck-tile + // bug). The commit path re-sorts to stream order itself (`placeAtCell`). + const byId = new Map(placed.map((placement) => [placement.id, placement])); + return placements.map((placement) => byId.get(placement.id) ?? placement); +} + +/** + * The commit path: pack the persisted entries, displace at `cell`, and pin + * EVERY tile back to the entries at its resulting anchor (universal frame — + * the persisted spans stay raw, only anchors + order change; order renumbers + * in stream order). This is the drag-drop write. Pure: entries in, NEW + * entries out. + */ +export function placeAtCell( + tiles: readonly TileLayoutEntry[], + id: string, + cell: Cell, + geometry: GridGeometry, +): TileLayoutEntry[] { + // An unknown dragged id changes nothing (order-sorted copy — the op's + // canonical no-op shape, like `moveTile` before it). + if (!tiles.some((tile) => tile.id === id)) return sortTiles(tiles); + // displacePlace preserves the input RENDER order for the live preview; the + // commit pins + renumbers in STREAM order, so re-sort before pinning. + const displaced = displacePlace(pack(tiles, geometry), id, cell, geometry) + .slice() + .sort((a, b) => fillOrderKey(a.col, a.row, geometry) - fillOrderKey(b.col, b.row, geometry)); + const result: TileLayoutEntry[] = []; + for (const placement of displaced) { + const entry = tiles.find((tile) => tile.id === placement.id); + if (!entry) continue; + const anchor = toUniversalCell(placement, geometry); + result.push({ ...entry, col: anchor.col, row: anchor.row }); + } + return result.map((entry, index) => (entry.order === index ? entry : { ...entry, order: index })); +} + +/** + * The drag target's clamp on the geometry's UNBOUNDED axis — a drop may aim + * at most `extra` columns (wide) / rows (tall) past the current content edge, + * so a drag can never create an endlessly growing canvas ("hard to place if + * it just grows"): wide bound = the right-most occupied column (span-aware) + + * `extra`; tall = the bottom-most occupied row + `extra`. Empty content → the + * first tiles may land within the first `extra` columns. The bound is measured + * on the COMMITTED placements — a drag always compares against the layout it + * started from, never the live preview, so the limit cannot ratchet mid-drag. + * Pure: placements + geometry in, the max allowed column (wide) / row (tall) + * out. + */ +export function dragAppendLimit( + placements: readonly Placement[], + geometry: GridGeometry, + extra = 3, +): number { + let extent = 0; + for (const placement of placements) { + extent = Math.max( + extent, + geometry.mode === "wide" + ? placement.col + placement.colSpan - 1 + : placement.row + placement.rowSpan - 1, + ); + } + return extent + extra; +} + +/** + * The first footprint-fitting EMPTY slot in fill order — where a NEW tile of + * the given span would go (the header's create flow). Pure: entries + span + + * geometry in, a slot placement out (always exists; may be a new column/row + * beyond the current extent). + */ +export function firstEmptySlot( + tiles: readonly TileLayoutEntry[], + span: Span, + geometry: GridGeometry, +): { col: number; row: number } { + return firstFit(packOccupancy(tiles, geometry), span, geometry); +} + +/** Build the occupancy set of the tiles' packed positions. */ +function packOccupancy(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Set<string> { + const occupied = new Set<string>(); + for (const placement of pack(tiles, geometry)) { + occupy(occupied, placement.col, placement.row, placement.colSpan, placement.rowSpan); + } + return occupied; +} + +/** Re-assign orders 0..n-1 by array position (the canonical order encoding after structural edits). */ +function renumber(tiles: readonly TileLayoutEntry[]): TileLayoutEntry[] { + return tiles.map((tile, index) => (tile.order === index ? tile : { ...tile, order: index })); +} + +/** + * Pin a tile at the first EMPTY slot in fill order — the header-create + * placement (holes between pins count). Idempotent: an id already in the + * layout is RE-PINNED there (spans preserved), not duplicated — this closes + * the race where the list-refresh sync appended the id first. Unknown ids + * append as a new 1×1 pinned entry. Pure: entries in, a NEW entries array out + * (order preserved — anchors, not order, place tiles now). + */ +export function pinAtFirstEmpty( + tiles: readonly TileLayoutEntry[], + id: string, + geometry: GridGeometry, +): TileLayoutEntry[] { + const without = sortTiles(tiles).filter((tile) => tile.id !== id); + const slot = firstEmptySlot(without, { colSpan: 1, rowSpan: 1 }, geometry); + const anchor = toUniversalCell(slot, geometry); + const existing = tiles.find((tile) => tile.id === id); + const entry: TileLayoutEntry = existing + ? { ...existing, col: anchor.col, row: anchor.row } + : { + id, + colSpan: 1, + rowSpan: 1, + order: without.reduce((max, tile) => Math.max(max, tile.order + 1), 0), + col: anchor.col, + row: anchor.row, + }; + return [...without, entry]; +} + +/** + * Resize one tile: set its persisted spans (sanitized to positive integers — + * NOT clamped to the current geometry; clamping is a render-time concern so a + * wide-sized tile survives an orientation switch). Unknown ids are ignored. + * Pure: entries in, a NEW entries array out. + */ +export function resizeTile( + tiles: readonly TileLayoutEntry[], + id: string, + colSpan: number, + rowSpan: number, +): TileLayoutEntry[] { + const sanitized: Span = { + colSpan: clampUnit(colSpan, Number.MAX_SAFE_INTEGER), + rowSpan: clampUnit(rowSpan, Number.MAX_SAFE_INTEGER), + }; + return sortTiles(tiles).map((tile) => + tile.id === id ? { ...tile, colSpan: sanitized.colSpan, rowSpan: sanitized.rowSpan } : tile, + ); +} + +/** + * Re-apply the recent-activity order (the header overflow "Sort" action) AND + * clear every pin — an explicit ARRANGEMENT RESET: tiles sort by their + * workspace's `lastActivityAt` descending (unknown ids last, ties stable), + * orders renumber, and the now-unpinned tiles first-fit with no gaps. Pure. + */ +export function sortByRecentActivity( + tiles: readonly TileLayoutEntry[], + lastActivityById: ReadonlyMap<string, number>, +): TileLayoutEntry[] { + const sorted = sortTiles(tiles).sort( + (a, b) => (lastActivityById.get(b.id) ?? 0) - (lastActivityById.get(a.id) ?? 0), + ); + return renumber( + sorted.map((tile) => { + const { col: _col, row: _row, ...unpinned } = tile; + return unpinned; + }), + ); +} + +/** + * Merge the persisted layout with the live workspace list: workspaces unknown + * to the layout (e.g. auto-created via deep-link) APPEND at the end (default + * 1×1, order after the current max); entries for deleted workspaces DROP; + * duplicate ids dedup (first wins); the result sorts by order. When a + * geometry is given, the returned VIEW clamps spans to it (the persisted + * document keeps the raw spans — clamping is transient per geometry). Pure: + * both inputs in, a NEW entries array out. + */ +export function mergeLayout( + persisted: TileLayoutData | null | undefined, + workspaceIds: readonly string[], + geometry?: GridGeometry, +): TileLayoutEntry[] { + const known = new Set(workspaceIds); + const kept: TileLayoutEntry[] = []; + const seen = new Set<string>(); + let maxOrder = 0; + for (const tile of sortTiles(persisted?.tiles ?? [])) { + if (!known.has(tile.id) || seen.has(tile.id)) continue; + seen.add(tile.id); + kept.push(tile); + if (tile.order > maxOrder) maxOrder = tile.order; + } + const appended: TileLayoutEntry[] = []; + for (const id of workspaceIds) { + if (seen.has(id)) continue; + seen.add(id); + maxOrder += 1; + appended.push({ id, colSpan: 1, rowSpan: 1, order: maxOrder }); + } + const merged = [...kept, ...appended]; + if (!geometry) return merged; + return merged.map((tile) => ({ ...tile, ...clampSpan(tile, geometry) })); +} + +/** + * Coerce an unknown parsed-JSON value into layout data (the `LocalStore` load + * is untyped at the boundary). v1 documents (order + spans only) MIGRATE to + * v2 in memory: their tiles become unpinned, which packs identically to v1's + * first-fit — the layout only starts carrying anchors after the user's first + * drop. v2 entries keep their anchors; malformed entries coerce (missing + * spans → 1, garbage anchors → unpinned, garbage entries → dropped). Anything + * else yields the empty layout. Pure: unknown in, v2 data out. + */ +export function sanitizeLayout(raw: unknown): TileLayoutData { + if (typeof raw !== "object" || raw === null) return EMPTY_LAYOUT; + const version = (raw as { version?: unknown }).version; + if (version !== 1 && version !== 2) return EMPTY_LAYOUT; + const rawTiles = (raw as { tiles?: unknown }).tiles; + if (!Array.isArray(rawTiles)) return EMPTY_LAYOUT; + const tiles: TileLayoutEntry[] = []; + const seen = new Set<string>(); + for (let index = 0; index < rawTiles.length; index++) { + const rawTile = rawTiles[index]; + if (typeof rawTile !== "object" || rawTile === null) continue; + const id = (rawTile as { id?: unknown }).id; + if (typeof id !== "string" || id === "" || seen.has(id)) continue; + seen.add(id); + const col = coerceAnchor((rawTile as { col?: unknown }).col); + const row = coerceAnchor((rawTile as { row?: unknown }).row); + tiles.push({ + id, + colSpan: coerceSpan((rawTile as { colSpan?: unknown }).colSpan), + rowSpan: coerceSpan((rawTile as { rowSpan?: unknown }).rowSpan), + order: coerceOrder((rawTile as { order?: unknown }).order, index), + // An anchor is only meaningful as a PAIR — a lone coordinate drops both. + ...(version === 2 && col !== undefined && row !== undefined ? { col, row } : {}), + }); + } + return { version: 2, tiles }; +} + +const EMPTY_LAYOUT: TileLayoutData = { version: 2, tiles: [] }; + +function coerceSpan(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1; + return Math.floor(value); +} + +function coerceOrder(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.floor(value); +} + +/** A valid anchor coordinate (1-based positive integer) or undefined. */ +function coerceAnchor(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return undefined; + return Math.floor(value); +} diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts index 44d2f31..31366ba 100644 --- a/src/features/workspaces/logic/view-model.test.ts +++ b/src/features/workspaces/logic/view-model.test.ts @@ -1,6 +1,39 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./view-model"; +import { + applyArchived, + applyStarred, + pageTitle, + relativeTime, + sortWorkspaces, + tileTone, +} from "./view-model"; + +describe("tileTone", () => { + const TONES = ["primary", "secondary", "accent", "info", "success", "warning"]; + + it("is deterministic — the same id always gets the same tone", () => { + expect(tileTone("my-workspace")).toBe(tileTone("my-workspace")); + }); + + it("only returns known tones", () => { + for (const id of ["default", "a", "my-workspace", "x-1", "project-phoenix", "zzz"]) { + expect(TONES).toContain(tileTone(id)); + } + }); + + it("spreads distinct ids across more than one tone", () => { + const tones = new Set( + ["default", "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"].map(tileTone), + ); + expect(tones.size).toBeGreaterThan(1); + }); + + it("is case-sensitive (ids are slugs, but the hash makes no normalization promise)", () => { + // Not a correctness requirement — just documents the hash reads raw chars. + expect(typeof tileTone("ABC")).toBe("string"); + }); +}); describe("relativeTime", () => { const now = 1_000_000_000_000; // 2001-09-09 @@ -23,10 +56,15 @@ describe("relativeTime", () => { expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d"); }); - it("is a short date beyond a week", () => { - // 7+ days ago: just check it is a MM/DD string. - const s = relativeTime(now - 10 * 24 * 60 * 60_000, now); - expect(s).toMatch(/^\d{2}\/\d{2}$/); + it("keeps days past a week (no short date) up to a month", () => { + expect(relativeTime(now - 10 * 24 * 60 * 60_000, now)).toBe("10d"); + expect(relativeTime(now - 29 * 24 * 60 * 60_000, now)).toBe("29d"); + }); + + it("is months past a month", () => { + expect(relativeTime(now - 30 * 24 * 60 * 60_000, now)).toBe("1mo"); + expect(relativeTime(now - 75 * 24 * 60 * 60_000, now)).toBe("2mo"); + expect(relativeTime(now - 365 * 24 * 60 * 60_000, now)).toBe("12mo"); }); }); @@ -38,6 +76,7 @@ describe("pageTitle", () => { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -75,6 +114,7 @@ describe("sortWorkspaces", () => { defaultCwd: null, defaultComputerId: null, starred, + archived: false, createdAt: 0, lastActivityAt, conversationCount: 0, @@ -133,6 +173,7 @@ describe("applyStarred", () => { defaultCwd: null, defaultComputerId: null, starred, + archived: false, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -177,3 +218,50 @@ describe("applyStarred", () => { expect(reverted[0]?.starred).toBe(false); }); }); + +describe("applyArchived", () => { + const entry = (id: string, archived: boolean): WorkspaceEntry => ({ + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); + + it("sets the named workspace's archived flag", () => { + const list = [entry("a", false), entry("b", false)]; + const next = applyArchived(list, "b", true); + expect(next.map((w) => [w.id, w.archived])).toEqual([ + ["a", false], + ["b", true], + ]); + }); + + it("returns a new array (does not mutate the input)", () => { + const list = [entry("a", false)]; + const next = applyArchived(list, "a", true); + expect(next).not.toBe(list); + expect(list[0]?.archived).toBe(false); + expect(next[0]?.archived).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 = applyArchived([a, b], "b", true); + expect(next[0]).toBe(a); + expect(next[1]).not.toBe(b); + }); + + it("can revert by re-applying the previous value (unarchive)", () => { + const list = [entry("a", false)]; + const optimistic = applyArchived(list, "a", true); + expect(optimistic[0]?.archived).toBe(true); + const reverted = applyArchived(optimistic, "a", false); + expect(reverted[0]?.archived).toBe(false); + }); +}); diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts index b994b7c..cdf99c8 100644 --- a/src/features/workspaces/logic/view-model.ts +++ b/src/features/workspaces/logic/view-model.ts @@ -6,6 +6,40 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import type { Route } from "./route"; /** + * A home tile's color tone. Windows 8's Start screen gave every tile its own + * saturated color; here the tile's workspace id DETERMINISTICALLY picks one of + * the theme's six semantic tones, so the same workspace keeps its color across + * sessions and themes (the tone name maps to a DaisyUI color, which adapts to + * the active theme). Starred stays a separate visual concern (a badge), NOT a + * tone — any tile may be starred regardless of its color. + */ +export type TileTone = "primary" | "secondary" | "accent" | "info" | "success" | "warning"; + +/** The tones a tile may take, in hash order. */ +const TILE_TONES: readonly TileTone[] = [ + "primary", + "secondary", + "accent", + "info", + "success", + "warning", +]; + +/** + * Map a workspace id to its tile tone. Deterministic (FNV-1a hash of the id) + * and uniformly distributed across the six tones — pure: id in, tone out. + */ +export function tileTone(id: string): TileTone { + let hash = 0x811c9dc5; + for (let index = 0; index < id.length; index++) { + hash ^= id.charCodeAt(index); + // FNV prime (16777619), kept in 32-bit unsigned range. + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return TILE_TONES[hash % TILE_TONES.length] ?? "primary"; +} + +/** * 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`) @@ -50,6 +84,20 @@ export function applyStarred<T extends WorkspaceEntry>( } /** + * Return a NEW list with the one workspace's `archived` flag set (immutably — + * the entry is replaced, the rest keep their identity). Pure: the optimistic + * archive/unarchive transformation shared by the apply + the error revert. A + * missing `id` leaves the list unchanged; a subsequent refresh reconciles. + */ +export function applyArchived<T extends WorkspaceEntry>( + workspaces: readonly T[], + id: string, + archived: boolean, +): T[] { + return workspaces.map((w) => (w.id === id ? { ...w, archived } : 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". @@ -62,10 +110,8 @@ export function relativeTime(then: number, now: number): string { 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}`; + if (days < 30) return `${days}d`; + // Past a month: months (a month ≈ 30 days). No short date — the label + // stays relative forever. + return `${Math.floor(days / 30)}mo`; } diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts index 22d73b5..b9b646b 100644 --- a/src/features/workspaces/store.svelte.ts +++ b/src/features/workspaces/store.svelte.ts @@ -1,7 +1,8 @@ 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"; +import { gateCreate } from "./logic/create"; +import { applyArchived, applyStarred, sortWorkspaces } from "./logic/view-model"; /** * Workspace store — a thin reactive wrapper over the pure HTTP edge. Owns the @@ -16,12 +17,25 @@ export interface WorkspaceStore { * backend's `lastActivityAt`-desc order; this getter re-sorts reactively. */ readonly list: readonly WorkspaceEntry[]; + /** Archived workspaces, for the archived view. Loaded via `refreshArchived()`. */ + readonly archivedList: readonly WorkspaceEntry[]; readonly loading: boolean; + readonly archivedLoading: boolean; readonly error: string | null; /** Refresh the list from the backend. */ refresh(): Promise<void>; + /** Refresh the archived list (`GET /workspaces?archived=true`). */ + refreshArchived(): Promise<void>; /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */ ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; + /** + * Create a workspace via the New-workspace flow. Probes `GET /workspaces/:id` + * first and refuses a slug that belongs to an ARCHIVED workspace (the + * idempotent `ensure` would otherwise silently resurrect an invisible tile). + * A failed probe is refused too (see `gateCreate`). On success it falls + * through to `ensure`, unchanged. + */ + create(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. */ @@ -34,25 +48,41 @@ export interface WorkspaceStore { * reverts to the prior value. No full refresh on success (avoids flicker). */ setStarred(id: string, starred: boolean): Promise<WorkspaceResult<Workspace>>; + /** + * Archive or unarchive a workspace. Optimistic: the local `archived` flag + * flips immediately (archiving drops the tile from the home grid); on error + * it reverts to the prior value. A successful archive/unarchive is followed + * by a `refresh()` so the active list reflects the backend (the archived + * workspace leaves/enters the default-filtered list). + */ + setArchived(id: string, archived: 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[]>([]); + let archivedList = $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 archivedLoading = $state(false); let error = $state<string | null>(null); return { get list(): readonly WorkspaceEntry[] { return sorted; }, + get archivedList(): readonly WorkspaceEntry[] { + return archivedList; + }, get loading(): boolean { return loading; }, + get archivedLoading(): boolean { + return archivedLoading; + }, get error(): string | null { return error; }, @@ -69,6 +99,24 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { } }, + async refreshArchived(): Promise<void> { + archivedLoading = true; + error = null; + try { + archivedList = await http.list("true"); + } catch (err) { + error = err instanceof Error ? err.message : "Failed to load archived workspaces"; + } finally { + archivedLoading = false; + } + }, + + async create(id, body): Promise<WorkspaceResult<Workspace>> { + const gate = gateCreate(id, await http.get(id)); + if (!gate.ok) return { ok: false, error: gate.error }; + return this.ensure(id, body); + }, + async ensure(id, body): Promise<WorkspaceResult<Workspace>> { const result = await http.ensure(id, body); if (result.ok) void this.refresh(); @@ -105,6 +153,22 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { return result; }, + async setArchived(id, archived): Promise<WorkspaceResult<Workspace>> { + const prev = list.find((w) => w.id === id)?.archived ?? false; + // Optimistic: flip immediately so the tile leaves/enters the home grid. + list = applyArchived(list, id, archived); + const result = archived ? await http.archive(id) : await http.unarchive(id); + if (!result.ok) { + // Revert the optimistic flip on failure. + list = applyArchived(list, id, prev); + return result; + } + // Reconcile with the backend's default (active-only) filter so the + // archived tile is dropped / the unarchived tile is restored. + void this.refresh(); + return result; + }, + async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> { const result = await http.delete(id); if (result.ok) void this.refresh(); diff --git a/src/features/workspaces/store.test.ts b/src/features/workspaces/store.test.ts index 4caac9f..faec5bf 100644 --- a/src/features/workspaces/store.test.ts +++ b/src/features/workspaces/store.test.ts @@ -1,6 +1,7 @@ import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import { describe, expect, it, vi } from "vitest"; import type { WorkspaceResult } from "./adapter/http"; +import { archivedCreateMessage, type WorkspaceLookup } from "./logic/create"; import { createWorkspaceStore } from "./store.svelte"; function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { @@ -10,6 +11,7 @@ function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 1, lastActivityAt: 2, conversationCount: 0, @@ -22,11 +24,25 @@ function fakeHttp(opts: { list?: readonly WorkspaceEntry[]; star?: (id: string) => Promise<WorkspaceResult<Workspace>>; unstar?: (id: string) => Promise<WorkspaceResult<Workspace>>; + archive?: (id: string) => Promise<WorkspaceResult<Workspace>>; + unarchive?: (id: string) => Promise<WorkspaceResult<Workspace>>; + get?: (id: string) => Promise<WorkspaceLookup>; + ensure?: (id: string) => Promise<WorkspaceResult<Workspace>>; }) { return { - list: vi.fn(async (): Promise<readonly WorkspaceEntry[]> => opts.list ?? []), - ensure: vi.fn(), - get: vi.fn(), + list: vi.fn( + async (_archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> => + opts.list ?? [], + ), + ensure: + opts.ensure ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id }), + }), + ), + get: vi.fn(opts.get ?? (async (): Promise<WorkspaceLookup> => ({ kind: "missing" }))), setTitle: vi.fn(), setDefaultCwd: vi.fn(), setDefaultComputer: vi.fn(), @@ -46,6 +62,22 @@ function fakeHttp(opts: { value: entry({ id, starred: false }), }), ), + archive: + opts.archive ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id, archived: true }), + }), + ), + unarchive: + opts.unarchive ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id, archived: false }), + }), + ), delete: vi.fn(), }; } @@ -143,3 +175,131 @@ describe("createWorkspaceStore — setStarred", () => { expect(store.list).toHaveLength(0); }); }); + +describe("createWorkspaceStore — setArchived", () => { + it("optimistically flips archived to true before the request resolves", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + let observedDuringCall = false; + http.archive = 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]?.archived === true; + return { ok: true, value: entry({ id: "a", archived: true }) }; + }); + + await store.setArchived("a", true); + + expect(observedDuringCall).toBe(true); + expect(http.archive).toHaveBeenCalledWith("a"); + }); + + it("calls unarchive (DELETE) when archiving false", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: true })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + await store.setArchived("a", false); + + expect(http.unarchive).toHaveBeenCalledWith("a"); + expect(http.archive).not.toHaveBeenCalled(); + }); + + it("reverts the optimistic flip on error", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + http.archive = vi.fn( + async (): Promise<WorkspaceResult<Workspace>> => ({ ok: false, error: "boom" }), + ); + + const result = await store.setArchived("a", true); + + expect(result).toEqual({ ok: false, error: "boom" }); + expect(store.list[0]?.archived).toBe(false); + }); + + it("refreshes the active list after a successful archive", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + const listCallsBefore = http.list.mock.calls.length; + + await store.setArchived("a", true); + + // setArchived triggers a refresh() to reconcile the active-only list. + expect(http.list.mock.calls.length).toBeGreaterThan(listCallsBefore); + }); +}); + +describe("createWorkspaceStore — create (archived-slug gate)", () => { + it("refuses an archived slug with the can't-create message and never calls ensure", async () => { + const http = fakeHttp({ + get: async () => ({ kind: "found", workspace: entry({ id: "old", archived: true }) }), + }); + const store = createWorkspaceStore(http); + + const result = await store.create("old"); + + expect(result).toEqual({ ok: false, error: archivedCreateMessage("old") }); + expect(http.ensure).not.toHaveBeenCalled(); + }); + + it("proceeds for an existing non-archived slug (ensure is called as before)", async () => { + const http = fakeHttp({ + get: async () => ({ kind: "found", workspace: entry({ id: "taken", archived: false }) }), + }); + const store = createWorkspaceStore(http); + + const result = await store.create("taken"); + + expect(result.ok).toBe(true); + expect(http.ensure).toHaveBeenCalledTimes(1); + }); + + it("proceeds for a brand-new slug (404 → missing)", async () => { + const http = fakeHttp({ get: async () => ({ kind: "missing" }) }); + const store = createWorkspaceStore(http); + + const result = await store.create("fresh"); + + expect(result.ok).toBe(true); + expect(http.ensure).toHaveBeenCalledTimes(1); + }); + + it("refuses when the existence check itself failed, without calling ensure (§3.4)", async () => { + const http = fakeHttp({ get: async () => ({ kind: "failed", error: "db down" }) }); + const store = createWorkspaceStore(http); + + const result = await store.create("maybe"); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("Couldn't check whether"); + expect(http.ensure).not.toHaveBeenCalled(); + }); +}); + +describe("createWorkspaceStore — refreshArchived", () => { + it("loads the archived list via list('true') without touching the main list", async () => { + const http = fakeHttp({ list: [entry({ id: "active", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + // Point the archived call at a different payload. + http.list = vi.fn( + async (archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> => + archived === "true" + ? [entry({ id: "done", archived: true })] + : [entry({ id: "active", archived: false })], + ); + + await store.refreshArchived(); + + expect(http.list).toHaveBeenCalledWith("true"); + expect(store.archivedList.map((w) => w.id)).toEqual(["done"]); + // The main (active) list is untouched. + expect(store.list.map((w) => w.id)).toEqual(["active"]); + }); +}); diff --git a/src/features/workspaces/tile-layout-store.svelte.ts b/src/features/workspaces/tile-layout-store.svelte.ts new file mode 100644 index 0000000..98a5c33 --- /dev/null +++ b/src/features/workspaces/tile-layout-store.svelte.ts @@ -0,0 +1,308 @@ +import type { + TileLayoutData as ContractTileLayoutData, + TileGeometryMode, +} from "@dispatch/tile-layout-contract"; +import { createLocalStore } from "../../adapters/local-storage"; +import type { TileLayoutHttp } from "./adapter/tile-layout-http"; +import { + type Cell, + type GridGeometry, + mergeLayout, + pinAtFirstEmpty, + placeAtCell, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + type TileLayoutData, + type TileLayoutEntry, +} from "./logic/tile-layout"; + +/** + * Tile-layout store — the synced arrangement of the home grid, ONE shared + * document PER device orientation (`wide` = landscape/desktop, `tall` = + * portrait/mobile), persisted through the INJECTED `TileLayoutHttp` edge + * (the `home-tile-layout` plugin's well-known routes). Mirrors the layout + * feature's store semantics: + * + * - **Orientation selection** — `setMode(mode)` (the home page's effect on + * the resolved grid geometry) is the boot + orientation-change trigger: + * an orientation CHANGE seeds from its cached server layout and probes that + * orientation's document fresh. Same-mode calls are a no-op. The store + * probes its INITIAL mode on creation (the boot probe). + * - **Server-backed** — a successful probe applies the server document; + * edits apply locally IMMEDIATELY, then PUT the whole document (decision 7 + * — no patch protocol) lightly debounced; a failed PUT on a present + * backend rolls back to the last server document with a quiet console + * warning (never an error UI). + * - **WS push** — `onServerTileLayout(mode, data)` applies a `tile-layout` + * broadcast, ignoring the OTHER orientation (the server fans out to all + * clients; the client filters by mode). + * - **Quiet degrade** — any probe/write failure (backend unreachable, routes + * not served) keeps the local state with EPHEMERAL edits (in-memory only — + * only server-confirmed layouts are cached); logged once, no error UI. + * - **Sync gating** — `sync(ids)` no-ops while the first probe for an + * orientation is in flight (`providerState === "unknown"`): merging the + * live workspace list into an unprobed empty document would PUT a wiped + * arrangement. The caller's effect re-runs when the state settles. + * + * No ambient state: one instance per home page, created by the page entry + * (or injected in tests). + */ + +/** Whether the backend is serving the active orientation's document. */ +export type TileLayoutProviderState = "unknown" | "present" | "absent"; + +export interface TileLayoutStore { + /** The persisted entries of the ACTIVE orientation, order-sorted. */ + readonly entries: readonly TileLayoutEntry[]; + /** The active orientation (the home page's resolved grid geometry mode). */ + readonly mode: TileGeometryMode; + /** The backend's serving state for the ACTIVE orientation. */ + readonly providerState: TileLayoutProviderState; + /** + * Select the active orientation and probe its document. Same-mode calls + * are a no-op (the boot probe fires on creation for the initial mode); + * an orientation CHANGE seeds from its cache + probes fresh. + */ + setMode(mode: TileGeometryMode): void; + /** Re-probe the ACTIVE orientation's document (reconnect catch-up). */ + probe(): Promise<void>; + /** Server `tile-layout` broadcast. Other orientations are ignored. */ + onServerTileLayout(mode: TileGeometryMode, data: ContractTileLayoutData): void; + /** + * Merge the live workspace ids into the layout: workspaces unknown to the + * layout append (unpinned — they flow to the first free cells), entries for + * deleted workspaces drop (their cells become gaps). A no-op (no write) + * when the merge changes nothing — so an effect that tracks both the + * workspace list and this store settles instead of looping. + */ + sync(workspaceIds: readonly string[]): void; + /** Resize a tile (persisted spans; render-time clamping is separate). */ + resize(id: string, colSpan: number, rowSpan: number): void; + /** + * Drop a tile at a specific CELL (the drag commit): the contiguous filled + * run pushes forward from that cell (gaps absorb it), every resulting + * position pins. See `placeAtCell`. + */ + placeAtCell(id: string, cell: Cell, geometry: GridGeometry): void; + /** + * Pin (or re-pin) the tile at the FIRST EMPTY slot — the header-create + * flow. Idempotent (see `pinAtFirstEmpty`). + */ + placeFirstEmpty(id: string, geometry: GridGeometry): void; + /** Re-apply the recent-activity order AND clear all pins (the reset). */ + sortByRecentActivity(lastActivityById: ReadonlyMap<string, number>): void; +} + +export interface TileLayoutStoreOptions { + /** The injected HTTP edge (the `home-tile-layout` plugin's routes). */ + readonly http: TileLayoutHttp; + /** Initial viewport orientation (default wide). */ + readonly initialMode?: TileGeometryMode; + /** Injected localStorage for server-layout snapshots; absent → no cache. */ + readonly storage?: Storage | undefined; + /** Edit-PUT debounce window in ms (default 300; tests shrink it). */ + readonly debounceMs?: number; + /** The quiet-failure hook (default `console.warn` — never an error UI). */ + readonly warn?: (message: string) => void; +} + +const DEFAULT_DEBOUNCE_MS = 300; + +export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutStore { + const http = opts.http; + const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const warn = opts.warn ?? ((message: string) => console.warn(message)); + + const cache = { + wide: createLocalStore<TileLayoutData>("dispatch.tile-layout.wide", { storage: opts.storage }), + tall: createLocalStore<TileLayoutData>("dispatch.tile-layout.tall", { storage: opts.storage }), + }; + const initialMode = opts.initialMode ?? "wide"; + let mode = $state<TileGeometryMode>(initialMode); + // The ACTIVE orientation's document — the server's copy once a probe has + // landed (sanitized defensively; the backend validates strictly), the + // cached server snapshot (or empty default) until then. Never PUT the seed. + let data = $state<TileLayoutData>(sanitizeLayout(cache[initialMode].load())); + let providerState = $state<TileLayoutProviderState>("unknown"); + + /** The last document known to come from the server (GET / WS push / PUT echo) — the rollback target. */ + let lastServerLayout: TileLayoutData | null = null; + /** The pending edit-PUT timer (the "lightly debounced" write). */ + let putTimer: ReturnType<typeof setTimeout> | null = null; + /** Logged-once guard for the degrade log (reset when the backend returns). */ + let loggedAbsent = false; + + function noteAbsent(reason: string): void { + providerState = "absent"; + if (!loggedAbsent) { + loggedAbsent = true; + warn( + `tile-layout: backend not serving the layout (${reason}) — rendering the local default; edits are ephemeral`, + ); + } + } + + function notePresent(): void { + providerState = "present"; + loggedAbsent = false; + } + + function applyServerLayout(next: TileLayoutData): void { + lastServerLayout = next; + data = next; + cache[mode].save(next); + } + + function clearPutTimer(): void { + if (putTimer !== null) { + clearTimeout(putTimer); + putTimer = null; + } + } + + async function flushPut(): Promise<void> { + putTimer = null; + // Read the CURRENT document at fire time: a newer edit supersedes the + // debounced snapshot (one in-flight write per window, last wins). + const writtenMode = mode; + const result = await http.putLayout(writtenMode, { version: 2, tiles: data.tiles }); + if (result.ok) cache[writtenMode].save(sanitizeLayout(result.layout)); + if (writtenMode !== mode) return; + if (result.ok) { + notePresent(); + // The server's validated echo is the new rollback target. + lastServerLayout = sanitizeLayout(result.layout); + return; + } + if (providerState !== "present") { + // The backend is gone (or never was): keep the local edit (ephemeral), + // no rollback — there is no server document to roll back TO. + noteAbsent(result.error); + return; + } + // A failed write on a PRESENT backend: roll back to the last server + // document, quiet console warning (never an error UI). + warn( + `tile-layout: persisting the layout failed (${result.error}) — rolled back to the last server document`, + ); + if (lastServerLayout !== null) data = lastServerLayout; + } + + async function runProbe(): Promise<void> { + // Capture the mode AT START: a setMode may happen while the fetch is in + // flight — only the CURRENT mode's result may land (the reset in + // setMode already staged the fresh default; a stale response must never + // overwrite it). + const probed = mode; + const result = await http.getLayout(mode); + if (probed !== mode) return; + if (result.ok) { + notePresent(); + applyServerLayout(sanitizeLayout(result.layout)); + } else { + // A real failure (5xx, 404 = routes not served, malformed, network) + // degrades quietly — the local default keeps rendering. + noteAbsent(result.error); + } + } + + function commit(tiles: readonly TileLayoutEntry[]): void { + // A pure op that changed nothing (e.g. a no-op place) doesn't write. + if (sameEntries(tiles, data.tiles)) return; + data = { version: 2, tiles }; + if (providerState === "unknown") return; // first probe still in flight + if (providerState === "absent") return; // ephemeral — no write, no warning + clearPutTimer(); + putTimer = setTimeout(() => { + void flushPut().catch(() => { + // Network-grade failure: roll back to the last server document, quietly. + warn("tile-layout: persisting the layout failed — rolled back to the last server document"); + if (lastServerLayout !== null) data = lastServerLayout; + }); + }, debounceMs); + } + + // Boot probe for the initial mode — the home page's setMode effect either + // confirms it (no-op) or switches it (fresh probe). + void runProbe(); + + return { + get entries(): readonly TileLayoutEntry[] { + return data.tiles; + }, + get mode(): TileGeometryMode { + return mode; + }, + get providerState(): TileLayoutProviderState { + return providerState; + }, + + setMode(next) { + if (next === mode) return; + mode = next; + // The document belongs to the OLD orientation now: seed from this + // mode's cache while the probe resolves (never flash the other + // orientation's arrangement), and drop any queued edit. + clearPutTimer(); + lastServerLayout = null; + data = sanitizeLayout(cache[mode].load()); + providerState = "unknown"; + void runProbe(); + }, + + async probe() { + await runProbe(); + }, + + onServerTileLayout(pushMode, next) { + if (pushMode !== mode) return; // clients filter by orientation + notePresent(); + clearPutTimer(); // the server's copy supersedes any queued edit + applyServerLayout(sanitizeLayout(next)); + }, + + sync(workspaceIds): void { + // Never merge into an UNPROBED empty document: before the first probe + // resolves, "empty" is our placeholder, not a real arrangement — and + // merging live ids into it could PUT a wiped layout. The caller's + // effect re-runs when the state settles (it tracks providerState). + if (providerState === "unknown") return; + const merged = mergeLayout(data, workspaceIds); + if (!sameEntries(merged, data.tiles)) commit(merged); + }, + + resize(id, colSpan, rowSpan): void { + commit(resizeTile(data.tiles, id, colSpan, rowSpan)); + }, + + placeAtCell(id, cell, geometry): void { + commit(placeAtCell(data.tiles, id, cell, geometry)); + }, + + placeFirstEmpty(id, geometry): void { + commit(pinAtFirstEmpty(data.tiles, id, geometry)); + }, + + sortByRecentActivity(lastActivityById): void { + commit(sortByRecentActivity(data.tiles, lastActivityById)); + }, + }; +} + +/** Structural equality over entries (ids, spans, orders, anchors). */ +function sameEntries(a: readonly TileLayoutEntry[], b: readonly TileLayoutEntry[]): boolean { + if (a.length !== b.length) return false; + return a.every((entry, index) => { + const other = b[index]; + return ( + other !== undefined && + entry.id === other.id && + entry.colSpan === other.colSpan && + entry.rowSpan === other.rowSpan && + entry.order === other.order && + entry.col === other.col && + entry.row === other.row + ); + }); +} diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts new file mode 100644 index 0000000..b5af8b6 --- /dev/null +++ b/src/features/workspaces/tile-layout-store.test.ts @@ -0,0 +1,449 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TileLayoutHttp, TileLayoutHttpResult } from "./adapter/tile-layout-http"; +import { geometryFor, type TileLayoutEntry } from "./logic/tile-layout"; +import { createTileLayoutStore } from "./tile-layout-store.svelte"; + +const WIDE = geometryFor(1920, 1080); + +function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { + return { id, order, colSpan, rowSpan }; +} + +function doc(...tiles: TileLayoutEntry[]): TileLayoutData { + return { version: 2, tiles }; +} + +/** A controllable fake HTTP edge: per-mode documents + a call log (no mocks of our modules). */ +function fakeHttp( + initial: Partial<Record<TileGeometryMode, TileLayoutData>> = {}, +): TileLayoutHttp & { + docs: Map<TileGeometryMode, TileLayoutData>; + puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }>; + failNext: { get: string | null; put: string | null }; +} { + const docs = new Map<TileGeometryMode, TileLayoutData>( + Object.entries(initial) as Array<[TileGeometryMode, TileLayoutData]>, + ); + const puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }> = []; + const failNext = { get: null as string | null, put: null as string | null }; + const ok = (layout: TileLayoutData): TileLayoutHttpResult => ({ ok: true, layout }); + return { + docs, + puts, + failNext, + async getLayout(mode) { + if (failNext.get !== null) { + const error = failNext.get; + failNext.get = null; + return { ok: false, notFound: false, error }; + } + return ok(docs.get(mode) ?? doc()); + }, + async putLayout(mode, layout) { + if (failNext.put !== null) { + const error = failNext.put; + failNext.put = null; + return { ok: false, notFound: false, error }; + } + puts.push({ mode, layout }); + docs.set(mode, layout); + return ok(layout); + }, + async deleteLayout(mode) { + docs.set(mode, doc()); + return ok(docs.get(mode) as TileLayoutData); + }, + }; +} + +/** Flush the boot probe + debounced timers (microtasks first, then the clock). */ +async function settle(): Promise<void> { + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(300); + await Promise.resolve(); +} + +async function flushOnly(): Promise<void> { + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); +} + +describe("createTileLayoutStore", () => { + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + }); + afterEach(() => { + vi.useRealTimers(); + localStorage.clear(); + }); + + it("the boot probe applies the server document for the initial mode", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0, 2, 2)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries).toEqual([tile("a", 0, 2, 2)]); + expect(store.providerState).toBe("present"); + expect(store.mode).toBe("wide"); + }); + + it("honors the initial tall mode with exactly one probe and no same-mode reset", async () => { + const http = fakeHttp({ tall: doc(tile("mobile", 0)) }); + const get = vi.spyOn(http, "getLayout"); + const store = createTileLayoutStore({ http, initialMode: "tall" }); + store.setMode("tall"); + await flushOnly(); + expect(get.mock.calls).toEqual([["tall"]]); + expect(store.mode).toBe("tall"); + expect(store.entries).toEqual([tile("mobile", 0)]); + }); + + it("seeds from cache immediately without PUT or caching edits before revalidation", async () => { + const cached = doc(tile("cached", 0, 2, 2)); + localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached)); + const http = fakeHttp(); + const gate: { release?: (result: TileLayoutHttpResult) => void } = {}; + http.getLayout = () => + new Promise((resolve) => { + gate.release = resolve; + }); + const store = createTileLayoutStore({ http, initialMode: "tall", storage: localStorage }); + expect(store.entries).toEqual(cached.tiles); + expect(store.providerState).toBe("unknown"); + + store.sync(["cached", "new"]); + store.resize("cached", 1, 1); + await settle(); + expect(http.puts).toEqual([]); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(cached); + + const fresh = doc(tile("server", 0)); + gate.release?.({ ok: true, layout: fresh }); + await settle(); + expect(store.entries).toEqual(fresh.tiles); + expect(store.providerState).toBe("present"); + expect(http.puts).toEqual([]); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(fresh); + }); + + it("resets to the new mode's cache and re-probes on orientation change", async () => { + const cached = doc(tile("cached-mobile", 0)); + localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached)); + const http = fakeHttp({ wide: doc(tile("desktop", 0)) }); + const get = vi.spyOn(http, "getLayout"); + const store = createTileLayoutStore({ http, storage: localStorage }); + await flushOnly(); + + store.setMode("tall"); + expect(store.providerState).toBe("unknown"); + expect(store.entries).toEqual(cached.tiles); + await flushOnly(); + expect(store.entries).toEqual([]); + expect(get.mock.calls).toEqual([["wide"], ["tall"]]); + }); + + it("caches server GET, WS push, and successful PUT echoes, never unconfirmed edits", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, storage: localStorage }); + const cached = () => JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null"); + await flushOnly(); + expect(cached()).toEqual(doc(tile("a", 0))); + + store.onServerTileLayout("tall", doc(tile("ignored", 0))); + expect(localStorage.getItem("dispatch.tile-layout.tall")).toBeNull(); + store.onServerTileLayout("wide", doc(tile("push", 0))); + expect(cached()).toEqual(doc(tile("push", 0))); + + store.resize("push", 2, 2); + expect(cached()).toEqual(doc(tile("push", 0))); + await settle(); + expect(cached()).toEqual(doc(tile("push", 0, 2, 2))); + + http.failNext.put = "network down"; + store.resize("push", 1, 1); + await settle(); + expect(cached()).toEqual(doc(tile("push", 0, 2, 2))); + }); + + it("caches an in-flight PUT echo under its written mode after an orientation change", async () => { + const http = fakeHttp({ wide: doc(tile("desktop", 0)), tall: doc(tile("mobile", 0)) }); + const gate: { release?: (result: TileLayoutHttpResult) => void } = {}; + http.putLayout = () => + new Promise((resolve) => { + gate.release = resolve; + }); + const store = createTileLayoutStore({ http, storage: localStorage }); + await flushOnly(); + store.resize("desktop", 2, 2); + await settle(); + store.setMode("tall"); + await flushOnly(); + gate.release?.({ ok: true, layout: doc(tile("desktop", 0, 2, 2)) }); + await flushOnly(); + expect(store.entries).toEqual([tile("mobile", 0)]); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual( + doc(tile("mobile", 0)), + ); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null")).toEqual( + doc(tile("desktop", 0, 2, 2)), + ); + }); + + it.each([ + "invalid JSON", + '{"version":2,"tiles":"bad"}', + ])("ignores malformed cache (%s) and supports storageless contexts", (raw) => { + localStorage.setItem("dispatch.tile-layout.wide", raw); + const store = createTileLayoutStore({ http: fakeHttp(), storage: localStorage }); + expect(store.entries).toEqual([]); + expect(createTileLayoutStore({ http: fakeHttp() }).entries).toEqual([]); + }); + + it("sanitizes the server document defensively (a junk shape degrades to empty)", async () => { + const http = fakeHttp(); + http.docs.set("wide", { junk: true } as unknown as TileLayoutData); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries).toEqual([]); + }); + + it("a probe failure degrades to absent (ephemeral edits, no PUT)", async () => { + const warn = vi.fn(); + const http = fakeHttp(); + http.failNext.get = "network down"; + const store = createTileLayoutStore({ http, warn }); + await flushOnly(); + expect(store.providerState).toBe("absent"); + + store.resize("a", 3, 3); // applied locally… + store.sync(["a"]); + await settle(); + expect(store.entries.map((t) => t.id)).toEqual(["a"]); + expect(http.puts).toHaveLength(0); // …but never written + expect(warn).toHaveBeenCalledTimes(1); // logged ONCE, no error UI + }); + + it("setMode switches orientation: reset + fresh probe (documents are independent)", async () => { + const http = fakeHttp({ + wide: doc(tile("desktop", 0)), + tall: doc(tile("mobile", 0)), + }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + + store.setMode("tall"); + expect(store.entries).toEqual([]); + expect(store.providerState).toBe("unknown"); + await flushOnly(); + expect(store.mode).toBe("tall"); + expect(store.entries.map((t) => t.id)).toEqual(["mobile"]); + + store.setMode("tall"); // same-mode call: a no-op + store.setMode("wide"); + await flushOnly(); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + }); + + it("an in-flight probe for a stale mode never lands", async () => { + const http = fakeHttp({ wide: doc(tile("desktop", 0)) }); + // A holder object defeats TS's closure narrowing (a bare `let` read after + // the callback-assigned write narrows to `null` and the call sites to + // `never`). + const gate: { release: ((value: TileLayoutHttpResult) => void) | null } = { release: null }; + const slow = http.getLayout.bind(http); + http.getLayout = (mode) => + mode === "tall" + ? new Promise<TileLayoutHttpResult>((resolve) => { + gate.release = resolve; + }) + : slow(mode); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.setMode("tall"); // probe in flight… + store.setMode("wide"); // …superseded before it resolves + gate.release?.({ ok: true, layout: doc(tile("mobile", 0)) }); + await flushOnly(); + + // The stale tall response must NOT overwrite the active wide document. + expect(store.mode).toBe("wide"); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + }); + + it("a WS push applies for the ACTIVE mode and is IGNORED for the other", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.onServerTileLayout("tall", doc(tile("mobile-push", 0))); + expect(store.entries.map((t) => t.id)).toEqual(["a"]); // filtered + + store.onServerTileLayout("wide", doc(tile("push", 0))); + expect(store.entries.map((t) => t.id)).toEqual(["push"]); // applied + }); + + it("edits apply locally IMMEDIATELY, then PUT the whole document (debounced)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, debounceMs: 300 }); + await flushOnly(); + + store.resize("a", 4, 2); + expect(store.entries).toEqual([tile("a", 0, 4, 2)]); // local, no wait + expect(http.puts).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(300); + expect(http.puts).toEqual([{ mode: "wide", layout: doc(tile("a", 0, 4, 2)) }]); + }); + + it("edits ride the ACTIVE mode's document (the tall PUT names tall)", async () => { + const http = fakeHttp({ tall: doc(tile("m", 0)) }); + const store = createTileLayoutStore({ http }); + store.setMode("tall"); + await flushOnly(); + + store.resize("m", 2, 2); + await settle(); + expect(http.puts).toEqual([{ mode: "tall", layout: doc(tile("m", 0, 2, 2)) }]); + }); + + it("a failed PUT on a present backend rolls back to the last server document", async () => { + const warn = vi.fn(); + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, warn, debounceMs: 300 }); + await flushOnly(); + + http.failNext.put = "500 gone"; + store.resize("a", 4, 2); + await settle(); + + expect(store.entries).toEqual([tile("a", 0)]); // rolled back + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("sync merges the live workspace list (gated while the first probe is in flight)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + // NO flush — the boot probe is still in flight (providerState unknown). + + store.sync(["a", "b"]); // would merge into the empty placeholder… + expect(store.providerState).toBe("unknown"); + expect(store.entries).toEqual([]); // …so it is a no-op until the probe settles + + await flushOnly(); + store.sync(["a", "b"]); + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("sync appends unknown ids, drops deleted ones, and persists once", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0), tile("gone", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sync(["a", "b"]); + await settle(); + + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + // The max order counts KEPT entries (the dropped one doesn't reserve a slot). + expect(http.puts.at(-1)?.layout).toEqual( + doc(tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }), + ); + }); + + it("sync is a no-op (no write) when the merge changes nothing", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sync(["a"]); + store.sync(["a"]); + await settle(); + + expect(http.puts).toHaveLength(0); + }); + + it("placeAtCell commits the displacement and pins EVERY tile (persisted to the server)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0), tile("b", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + // a@(1,1), b@(1,2) → drag a to (2,1): a pins there, b keeps its cell. + store.placeAtCell("a", { col: 2, row: 1 }, WIDE); + await settle(); + + const entries = store.entries; + expect(entries.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 }); + expect(entries.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 }); + expect(http.puts.at(-1)?.layout.version).toBe(2); + }); + + it("placeAtCell with an unknown id is a no-op (no write)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.placeAtCell("nope", { col: 1, row: 1 }, WIDE); + await settle(); + + expect(http.puts).toHaveLength(0); + expect(store.entries).toEqual([tile("a", 0)]); + }); + + it("placeFirstEmpty PINS the new id at the first empty slot (idempotent on re-call)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0, 1, 2), tile("b", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.placeFirstEmpty("new", WIDE); + await settle(); + + // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → pinned. + expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); + expect(http.puts.at(-1)?.layout.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]); + + store.placeFirstEmpty("new", WIDE); // the create flow's re-call must be stable + expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); + }); + + it("sortByRecentActivity re-applies recent-activity order and CLEARs pins", async () => { + const http = fakeHttp({ + wide: doc({ ...tile("a", 0), col: 1, row: 1 }, { ...tile("b", 1), col: 2, row: 3 }), + }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sortByRecentActivity( + new Map([ + ["a", 10], + ["b", 99], + ]), + ); + + expect(store.entries.map((t) => t.id)).toEqual(["b", "a"]); + expect(store.entries.every((t) => t.col === undefined && t.row === undefined)).toBe(true); + }); + + it("a WS push supersedes a queued edit (the server's copy wins)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, debounceMs: 300 }); + await flushOnly(); + + store.resize("a", 4, 2); // edit queued… + store.onServerTileLayout("wide", doc(tile("server-wins", 0))); // …server lands first + await settle(); + + expect(store.entries.map((t) => t.id)).toEqual(["server-wins"]); + expect(http.puts).toHaveLength(0); // the queued edit was dropped, not PUT + }); + + it("probe() re-reads the active orientation (the reconnect catch-up)", async () => { + const http = fakeHttp({ wide: doc() }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + http.docs.set("wide", doc(tile("fresh", 0))); + await store.probe(); + expect(store.entries.map((t) => t.id)).toEqual(["fresh"]); + }); +}); diff --git a/src/features/workspaces/ui/ArchivedWorkspaces.svelte b/src/features/workspaces/ui/ArchivedWorkspaces.svelte new file mode 100644 index 0000000..584fd68 --- /dev/null +++ b/src/features/workspaces/ui/ArchivedWorkspaces.svelte @@ -0,0 +1,119 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import { relativeTime } from "../logic/view-model"; + + let { + store, + onOpen, + onClose, + }: { + store: WorkspaceStore; + /** Open (view) a workspace — navigates to `/<id>`. */ + onOpen: (id: string) => void; + onClose: () => void; + } = $props(); + + // Load the archived list when the modal mounts. + $effect(() => { + void store.refreshArchived(); + }); + + let busyId = $state<string | null>(null); + let actionError = $state<string | null>(null); + + async function unarchive(ws: WorkspaceEntry): Promise<void> { + if (busyId !== null) return; + busyId = ws.id; + actionError = null; + const result = await store.setArchived(ws.id, false); + busyId = null; + if (!result.ok) { + actionError = result.error; + return; + } + // Refresh the archived list so the unarchived workspace leaves it. + void store.refreshArchived(); + } + + function open(ws: WorkspaceEntry): void { + onOpen(ws.id); + onClose(); + } +</script> + +<div class="modal modal-open" role="dialog" aria-label="Archived workspaces"> + <div class="modal-box max-w-lg"> + <div class="flex items-start justify-between gap-2"> + <div> + <h2 class="text-lg font-bold">Archived workspaces</h2> + <p class="text-xs opacity-50">Hidden from the home grid. Unarchive to restore.</p> + </div> + <button + type="button" + class="btn btn-circle btn-ghost btn-sm" + aria-label="Close archived workspaces" + onclick={onClose}>✕</button + > + </div> + + {#if actionError} + <p class="mt-3 text-xs text-error">{actionError}</p> + {/if} + + <div class="mt-4"> + {#if store.archivedLoading && store.archivedList.length === 0} + <div class="flex justify-center py-8"> + <span class="loading loading-spinner loading-md opacity-60"></span> + </div> + {:else if store.archivedList.length === 0} + <p class="py-8 text-center text-sm opacity-50">No archived workspaces.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each store.archivedList as ws (ws.id)} + <li + class="flex items-center justify-between gap-3 rounded-box border border-base-content/10 bg-base-200/40 px-3 py-2" + > + <div class="min-w-0 flex-1"> + <p class="truncate font-semibold">{ws.title}</p> + <p class="font-mono text-xs opacity-50"> + /{ws.id} · {ws.conversationCount} conv · {relativeTime( + ws.lastActivityAt, + Date.now(), + )} ago + </p> + </div> + <div class="flex shrink-0 items-center gap-1"> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => open(ws)} + aria-label={`Open ${ws.title}`}>View</button + > + <button + type="button" + class="btn btn-outline btn-xs" + disabled={busyId === ws.id} + onclick={() => void unarchive(ws)} + aria-label={`Unarchive ${ws.title}`} + > + {#if busyId === ws.id} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Unarchive + {/if} + </button> + </div> + </li> + {/each} + </ul> + {/if} + </div> + </div> + <!-- Click outside closes. --> + <button + type="button" + class="modal-backdrop" + aria-label="Close archived workspaces backdrop" + onclick={onClose}></button> +</div> diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte new file mode 100644 index 0000000..5329b2d --- /dev/null +++ b/src/features/workspaces/ui/HomeHeader.svelte @@ -0,0 +1,198 @@ +<script lang="ts"> + import ArchiveIcon from "phosphor-svelte/lib/ArchiveIcon"; + import DotsThreeIcon from "phosphor-svelte/lib/DotsThreeIcon"; + import PlusIcon from "phosphor-svelte/lib/PlusIcon"; + import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon"; + import { isValidSlug } from "../logic/route"; + + /** The create outcome the page reports back to the popover. */ + export type CreateResult = { readonly ok: true } | { readonly ok: false; readonly error: string }; + + let { + compact = false, + withPlugins = false, + withSort = false, + onCreate, + onOpenPlugins, + onSort, + onOpenArchived, + }: { + /** Tall geometry: the controls collapse to compact icon buttons and the + * new-workspace popover becomes a bottom sheet. */ + compact?: boolean; + /** The plugins port is present → the Plugins button renders. */ + withPlugins?: boolean; + /** The Sort overflow action renders (recent-activity re-apply). */ + withSort?: boolean; + /** Create the workspace on the backend (does NOT navigate); the page + * places the new tile + enters rename mode. */ + onCreate: (slug: string) => Promise<CreateResult>; + onOpenPlugins: () => void; + onSort: () => void; + /** Open the archived-workspaces view. */ + onOpenArchived: () => void; + } = $props(); + + // ── New workspace popover (today's inline form logic, preserved) ────────── + let newSlug = $state(""); + let slugError = $state<string | null>(null); + let popoverOpen = $state(false); + let submitting = $state(false); + let slugInput = $state<HTMLInputElement | undefined>(); + + const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug)); + + function openPopover(): void { + popoverOpen = true; + slugError = null; + queueMicrotask(() => slugInput?.focus()); + } + + function closePopover(): void { + popoverOpen = false; + newSlug = ""; + slugError = null; + } + + async function submit(): Promise<void> { + const slug = newSlug.trim(); + if (!isValidSlug(slug)) { + slugError = "Lowercase letters, digits, and hyphens (1–40 chars)."; + return; + } + slugError = null; + submitting = true; + const result = await onCreate(slug); + submitting = false; + if (!result.ok) { + slugError = result.error; + return; + } + closePopover(); + } +</script> + +<!-- `.app-navbar` (app.css): the same Artix-wiki navbar look as the workspace + top bar — gradient, drop shadow, engraved primary-blue title. --> +<header class="app-navbar flex shrink-0 items-end justify-between gap-2 px-8 pt-6 pb-4 md:px-12"> + <div class="flex items-baseline gap-3"> + <h1 class="app-navbar-engraved text-4xl font-black tracking-tight text-primary">Dispatch</h1> + <span class="hidden text-sm font-medium tracking-widest uppercase text-primary/70 sm:inline"> + Workspaces + </span> + </div> + + <!-- Navbar actions (app.css): "New workspace" is the one raised button + (`.navbar-btn`, primary-tinted); the secondary actions are plain text + links (`.navbar-link`) — the light-blue colour + spacing say clickable + without button chrome. Compact: labels collapse to Phosphor icons. --> + <div class="flex items-center gap-1" class:gap-3={!compact}> + <!-- New workspace --> + <button + type="button" + class="navbar-btn navbar-btn-primary" + class:navbar-btn-icon={compact} + aria-label="New workspace" + title="New workspace" + onclick={() => (popoverOpen ? closePopover() : openPopover())} + > + <PlusIcon size={16} weight="bold" aria-hidden="true" /> + {#if !compact}<span>New workspace</span>{/if} + </button> + + {#if withPlugins} + <button + type="button" + class="navbar-link" + aria-label="Plugins" + title="Open the plugin manager (load/unload plugins globally)" + onclick={onOpenPlugins} + > + {#if compact}<PuzzlePieceIcon size={18} aria-hidden="true" />{:else}<span>Plugins</span>{/if} + </button> + {/if} + + <!-- Archived workspaces --> + <button + type="button" + class="navbar-link" + aria-label="Archived workspaces" + title="View archived workspaces" + onclick={onOpenArchived} + > + {#if compact}<ArchiveIcon size={18} aria-hidden="true" />{:else}<span>Archived</span>{/if} + </button> + + {#if withSort} + <!-- Overflow last, like a navbar's trailing menu. --> + <div class="dropdown dropdown-end"> + <button + type="button" + class="navbar-link" + aria-label="More actions" + title="More" + > + <DotsThreeIcon size={20} weight="bold" aria-hidden="true" /> + </button> + <ul class="dropdown-content menu z-50 mt-1 w-52 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-xl"> + <li> + <button type="button" onclick={onSort}>Reset arrangement by recent activity</button> + </li> + </ul> + </div> + {/if} + </div> +</header> + +{#if popoverOpen} + <!-- Backdrop closes the popover. --> + <button + type="button" + class="fixed inset-0 z-40 cursor-default" + aria-label="Close new workspace" + onclick={closePopover}></button> + + <!-- One popover body; the geometry only changes its positioning (bottom + sheet on tall screens, a corner card on wide ones). --> + <div + class="fixed z-50 border border-base-content/15 bg-base-100 p-4 shadow-2xl" + class:inset-x-0={compact} + class:bottom-0={compact} + class:rounded-t-box={compact} + class:top-14={!compact} + class:right-6={!compact} + class:w-80={!compact} + class:rounded-box={!compact} + > + <form + class="flex flex-col gap-2" + onsubmit={(e) => { + e.preventDefault(); + void submit(); + }} + > + <label for="new-ws" class="text-xs font-semibold uppercase opacity-60">New workspace</label> + <input + id="new-ws" + bind:this={slugInput} + class="input input-bordered w-full" + placeholder="my-workspace" + bind:value={newSlug} + autocomplete="off" + spellcheck="false" + /> + {#if slugError}<p class="text-xs text-error">{slugError}</p>{/if} + <div class="flex justify-end gap-2"> + {#if compact} + <button type="button" class="btn btn-ghost btn-sm" onclick={closePopover}>Cancel</button> + {/if} + <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid || submitting}> + {#if submitting} + <span class="loading loading-spinner loading-xs"></span> + {/if} + Create + </button> + </div> + </form> + </div> +{/if} diff --git a/src/features/workspaces/ui/HomeHeader.test.ts b/src/features/workspaces/ui/HomeHeader.test.ts new file mode 100644 index 0000000..4db8b04 --- /dev/null +++ b/src/features/workspaces/ui/HomeHeader.test.ts @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import HomeHeader from "./HomeHeader.svelte"; + +function props() { + return { + onCreate: vi.fn(async (_slug: string): Promise<{ ok: true }> => ({ ok: true })), + onOpenPlugins: vi.fn(), + onSort: vi.fn(), + onOpenArchived: vi.fn(), + }; +} + +describe("HomeHeader", () => { + it("renders the display title and the primary New workspace button", () => { + const p = props(); + render(HomeHeader, { props: p }); + expect(screen.getByRole("heading", { name: "Dispatch" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "New workspace" })).toBeInTheDocument(); + }); + + it("the Plugins button renders only with the plugins port", () => { + const p = props(); + const without = render(HomeHeader, { props: p }); + expect(without.queryByRole("button", { name: "Plugins" })).toBeNull(); + without.unmount(); + + render(HomeHeader, { props: { ...p, withPlugins: true } }); + screen.getByRole("button", { name: "Plugins" }).click(); + expect(p.onOpenPlugins).toHaveBeenCalledTimes(1); + }); + + it("the Sort overflow renders only when asked, and dispatches", async () => { + const user = userEvent.setup(); + const p = props(); + render(HomeHeader, { props: { ...p, withSort: true } }); + + await user.click(screen.getByRole("button", { name: "More actions" })); + await user.click(screen.getByRole("button", { name: "Reset arrangement by recent activity" })); + expect(p.onSort).toHaveBeenCalledTimes(1); + }); + + it("the Create button disables for an invalid slug (validation before submit)", async () => { + const user = userEvent.setup(); + const p = props(); + render(HomeHeader, { props: p }); + + await user.click(screen.getByRole("button", { name: "New workspace" })); + const input = screen.getByRole("textbox", { name: "New workspace" }); + await user.type(input, "Invalid Slug!"); + expect(screen.getByRole("button", { name: "Create" })).toBeDisabled(); + expect(p.onCreate).not.toHaveBeenCalled(); + + // A valid slug enables it. + await user.clear(input); + await user.type(input, "my-workspace"); + expect(screen.getByRole("button", { name: "Create" })).toBeEnabled(); + }); + + it("submitting the form with Enter creates via the callback and closes the popover", async () => { + const user = userEvent.setup(); + const p = props(); + render(HomeHeader, { props: p }); + + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace{Enter}"); + + expect(p.onCreate).toHaveBeenCalledWith("my-workspace"); + }); + + it("a backend error keeps the form open with the message", async () => { + const user = userEvent.setup(); + const onCreate = vi.fn( + async (): Promise<{ ok: false; error: string }> => ({ ok: false, error: "taken" }), + ); + render(HomeHeader, { props: { ...props(), onCreate } }); + + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace"); + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(await screen.findByText("taken")).toBeInTheDocument(); + }); + + it("compact mode collapses the primary button to an icon", () => { + const p = props(); + render(HomeHeader, { props: { ...p, compact: true } }); + const button = screen.getByRole("button", { name: "New workspace" }); + expect(button).toHaveClass("navbar-btn-icon"); + expect(button).toHaveTextContent(""); // icon only — the label collapses + expect(button.querySelector("svg")).not.toBeNull(); + }); +}); diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte new file mode 100644 index 0000000..40a46c5 --- /dev/null +++ b/src/features/workspaces/ui/HomeTileGrid.svelte @@ -0,0 +1,568 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import { gsap } from "gsap"; + import { Flip } from "gsap/Flip"; + import { tick } from "svelte"; + import { nextDragPhase, type DragEvent, type DragPhase } from "../logic/drag-controller"; + import { + displacePlace, + dragAppendLimit, + type Cell, + type GridGeometry, + type Placement, + type Span, + } from "../logic/tile-layout"; + import { tileTone, type TileTone } from "../logic/view-model"; + import type { WorkspaceStore } from "../store.svelte"; + import WorkspaceTile from "./WorkspaceTile.svelte"; + + gsap.registerPlugin(Flip); + + let { + geometry, + placements, + workspacesById, + store, + selectedId = null, + renamingId = null, + preview = null, + hasActive, + onOpen, + onSelect, + onPlace, + onRenameEnd, + }: { + geometry: GridGeometry; + /** Packed placements of the COMMITTED layout (the drag preview derives). */ + placements: readonly Placement[]; + workspacesById: ReadonlyMap<string, WorkspaceEntry>; + /** Forwarded to tiles for the inline rename (semantics preserved). */ + store: WorkspaceStore; + selectedId?: string | null; + renamingId?: string | null; + /** The resize picker's transient ghost footprint for this tile. */ + preview?: { readonly id: string; readonly colSpan: number; readonly rowSpan: number } | null; + /** Optional port: does the workspace have an active (generating) conversation. */ + hasActive?: (workspaceId: string) => boolean; + onOpen: (id: string) => void; + onSelect: (id: string) => void; + /** The drag dropped `id` at `cell` (null = the pointer never resolved to a cell). */ + onPlace: (id: string, cell: Cell | null) => void; + onRenameEnd: () => void; + } = $props(); + + // ── Drag/press state machine (pure logic, wired to pointer events) ──────── + const DRAG_THRESHOLD_PX = 6; + const LONG_PRESS_MS = 500; + let phase = $state<DragPhase>({ kind: "idle" }); + let longPressTimer: ReturnType<typeof setTimeout> | undefined; + /** Pointer position where the current press began (the follow transform base). */ + let pressOrigin = $state<{ x: number; y: number } | null>(null); + /** Where within the pressed tile the pointer grabbed (its follow-transform base). */ + let grabOffset = $state<{ x: number; y: number } | null>(null); + /** The tile mid-settle after a drop (excluded from FLIP until it lands). */ + let settling = $state<string | null>(null); + + function armLongPress(): void { + disarmLongPress(); + longPressTimer = setTimeout(() => { + longPressTimer = undefined; + dispatch({ type: "long-press" }); + }, LONG_PRESS_MS); + } + + function disarmLongPress(): void { + if (longPressTimer !== undefined) { + clearTimeout(longPressTimer); + longPressTimer = undefined; + } + } + + function dispatch(event: DragEvent): void { + const wasDragging = phase.kind === "dragging" ? phase.id : null; + const transition = nextDragPhase(phase, event, { dragThresholdPx: DRAG_THRESHOLD_PX }); + phase = transition.phase; + const emit = transition.emit; + if (emit) { + if (emit.kind === "tap") onOpen(emit.id); + else if (emit.kind === "lift") onSelect(emit.id); + else if (emit.kind === "drop") drop(emit.id, emit.targetCell); + } + if (phase.kind === "idle") { + // The gesture ended (tap/drop/cancel). A DROP settles via `drop` above; + // a CANCEL mid-drag flies the tile back to its committed slot. + if (wasDragging && emit?.kind !== "drop") flyBack(wasDragging); + pressOrigin = null; + grabOffset = null; + } + // Keep the long-press armed exactly while a TOUCH press is undecided. + if (phase.kind === "pressing" && phase.touch) armLongPress(); + else disarmLongPress(); + } + + /** The tile being dragged (or settling after a drop) follows no FLIP. */ + const excludedFromFlip = $derived( + new Set( + [phase.kind === "dragging" ? phase.id : null, settling].filter( + (id): id is string => id !== null, + ), + ), + ); + + /** Commit the drop, then settle the tile from the pointer onto its new cell. */ + function drop(id: string, cell: Cell | null): void { + if (cell !== null) onPlace(id, cell); + flyBack(id); + } + + /** + * Animate the tile's follow transform to identity at its (possibly new) + * committed slot — the drop settle AND the cancel/Escape fly-back. The tile + * is excluded from Flip until the animation completes so the two never + * fight over the same transform. + */ + function flyBack(id: string): void { + const el = tileEls.get(id); + if (!el) return; + settling = id; + void tick().then(() => { + gsap.to(el, { + x: 0, + y: 0, + scale: 1, + duration: 0.2, + ease: "power2.out", + onComplete: () => { + gsap.set(el, { clearProps: "transform" }); + settling = null; + }, + }); + }); + } + + /** Interactive targets inside a tile (the rename input) own their gestures. */ + function insideInteractive(target: EventTarget | null): boolean { + const el = target instanceof HTMLElement ? target : null; + return el?.closest("input, select, textarea, button, a") !== null; + } + + /** + * Drag gestures live on the GRID CONTAINER, not the tiles: the container is + * never re-parented, never toggles `pointer-events`, and never unmounts, so + * pointer capture set here survives the whole gesture. (A captured element + * whose DOM node MOVES loses its capture — the "stuck tile" bug — and an + * uncaptured pointer can't stream events over empty cells — the empty-cell + * bug.) Capture also keeps move/up flowing when the pointer leaves the grid. + */ + function gridPointerDown(e: PointerEvent): void { + if (e.button !== 0) return; + const tileEl = (e.target as Element | null)?.closest("[data-tile-id]"); + if (!tileEl || insideInteractive(e.target)) return; + const id = tileEl.getAttribute("data-tile-id"); + if (id === null) return; + // Capture mouse/pen so fast drags keep streaming to this container. TOUCH + // is purposely NOT captured here: an early touch capture can suppress + // native scrolling (WebKit), and touch already gets the browser's implicit + // capture — after the long-press lift the moved events bubble up to this + // container anyway. + if (e.pointerType !== "touch") gridEl?.setPointerCapture?.(e.pointerId); + // Where within the tile the pointer grabbed — the follow transform keeps + // THIS point under the pointer (not the tile's top-left origin). + const rect = tileEl.getBoundingClientRect(); + grabOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + pressOrigin = { x: e.clientX, y: e.clientY }; + dispatch({ + type: "pointer-down", + id, + x: e.clientX, + y: e.clientY, + touch: e.pointerType === "touch", + }); + } + + function gridPointerMove(e: PointerEvent): void { + if (phase.kind === "idle") return; + dispatch({ + type: "pointer-move", + x: e.clientX, + y: e.clientY, + targetCell: cellAt(e.clientX, e.clientY), + }); + } + + function gridPointerUp(): void { + if (phase.kind === "idle") return; + dispatch({ type: "pointer-up" }); + } + + // Escape mid-drag: cancel BEFORE the page-level Escape unwinds the + // selection — the committed layout is untouched and the tile flies home. + function windowKeydown(e: KeyboardEvent): void { + if (e.key !== "Escape" || phase.kind === "idle") return; + e.stopPropagation(); + dispatch({ type: "cancel" }); + } + + // ── Cell hit math (pointer → grid cell; empty cells are drop targets) ───── + let gridEl = $state<HTMLDivElement | undefined>(); + + const pitchX = $derived(geometry.unit.width + geometry.unit.gap); + const pitchY = $derived(geometry.unit.height + geometry.unit.gap); + + /** + * The drag target's clamp on the growth axis — at most 3 columns (wide) / + * rows (tall) past the current content edge. Measured on the COMMITTED + * placements (the drag preview never ratchets the limit mid-drag), so the + * canvas can't endlessly grow when dragging far right / down. + */ + const appendLimit = $derived(dragAppendLimit(placements, geometry)); + + function cellAt(clientX: number, clientY: number): Cell | null { + const el = gridEl; + if (!el) return null; + const rect = el.getBoundingClientRect(); + const wide = geometry.mode === "wide"; + // Wide: fixed-width columns at the pitch. Tall: fluid 1fr columns. + const cellW = wide ? pitchX : rect.width / geometry.flowCols; + const col = Math.max(1, Math.floor((clientX - rect.left) / cellW) + 1); + const row = Math.max(1, Math.floor((clientY - rect.top) / pitchY) + 1); + return { + col: wide ? Math.min(col, appendLimit) : Math.min(col, geometry.flowCols), + row: wide ? Math.min(row, geometry.flowRows) : Math.min(row, appendLimit), + }; + } + + // ── The drag preview: COMMITTED placements + displacement, fresh per cell ─ + /** The dragged tile's id while a drag is in flight (else null). */ + const draggedId = $derived<string | null>( + phase.kind === "dragging" ? phase.id : null, + ); + const draggedPlacement = $derived( + draggedId !== null + ? (placements.find((placement) => placement.id === draggedId) ?? null) + : null, + ); + + /** The pointer's hovered cell while dragging (else null). */ + const hoveredCell = $derived<Cell | null>( + phase.kind === "dragging" ? phase.targetCell : null, + ); + + /** The landing cell: where the pointer is, else the tile's committed slot. */ + const targetCell = $derived<Cell | null>( + hoveredCell ?? + (draggedPlacement ? { col: draggedPlacement.col, row: draggedPlacement.row } : null), + ); + + /** + * What to render: while dragging, the displacement of the COMMITTED layout + * at the target cell — recomputed from committed on EVERY hover change (as + * if the user moved straight from the original to here); the FLIP below + * animates from wherever the tiles currently are. Otherwise the committed + * placements. + */ + const rendered = $derived( + draggedPlacement && targetCell && draggedId !== null + ? displacePlace(placements, draggedId, targetCell, geometry) + : placements, + ); + + // ── FLIP reflow (GSAP Flip — re-entrant: captures mid-flight positions) ─── + let tileEls = new Map<string, HTMLElement>(); + + function trackTile(el: HTMLElement, id: string): { destroy: () => void } { + tileEls.set(id, el); + return { + destroy() { + tileEls.delete(id); + }, + }; + } + + let flipState: ReturnType<typeof Flip.getState> | null = null; + + $effect.pre(() => { + // Track the layout inputs; the DOM read below is intentionally untracked. + void rendered + .map((p) => `${p.id}:${p.col},${p.row},${p.colSpan}x${p.rowSpan}`) + .join("|"); + void geometry.mode; + flipState = Flip.getState( + [...tileEls].filter(([id]) => !excludedFromFlip.has(id)).map(([, el]) => el), + ); + }); + + $effect(() => { + void rendered; + void geometry.mode; + const state = flipState; + flipState = null; + if (!state) return; + Flip.from(state, { + duration: 0.25, + // Fast start, decelerating arrival — the reflow sweeps out and settles + // into the destination (an inOut curve would ease INTO the motion too). + ease: "power3.out", + nested: true, + onEnter: (elements) => gsap.fromTo(elements, { opacity: 0 }, { opacity: 1, duration: 0.2 }), + }); + }); + + // ── Wide-mode horizontal panning: wheel + pan arrow + scroll indicator ──── + let scrollerEl = $state<HTMLDivElement | undefined>(); + let scrollProgress = $state(0); + let canPan = $state(false); + + function updateScroll(): void { + const el = scrollerEl; + if (!el) return; + const max = el.scrollWidth - el.clientWidth; + canPan = geometry.mode === "wide" && max > 1; + scrollProgress = max > 0 ? el.scrollLeft / max : 0; + } + + $effect(() => { + // Re-measure when the geometry flips, the content changes, or a drag + // starts/ends (the canvas grows with the preview and shrinks back after). + void geometry.mode; + void placements.length; + void dragging; + updateScroll(); + }); + + function handleWheel(e: WheelEvent): void { + // Vertical wheel pans the wide canvas horizontally. + if (geometry.mode !== "wide" || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + e.preventDefault(); + scrollerEl?.scrollBy({ left: e.deltaY }); + } + + function pan(): void { + const el = scrollerEl; + if (!el) return; + const max = el.scrollWidth - el.clientWidth; + const atEnd = el.scrollLeft >= max - 1; + // One viewport sideways; past the end, pan back to the start. + el.scrollBy({ left: atEnd ? -max - el.clientWidth : el.clientWidth, behavior: "smooth" }); + } + + // ── Grid + tile styles (the geometry decides; no breakpoint classes) ────── + const dragging = $derived(phase.kind === "dragging"); + + const gridStyle = $derived.by(() => { + const wide = geometry.mode === "wide"; + // Content-driven canvas: the grid hugs its content (max-content / auto), + // so the scrollbar GROWS as the preview reaches right/down and SHRINKS when + // the gesture ends — the drag sees the scrollbar move as it should. The + // drop-target clamp (`cellAt` → dragAppendLimit) bounds how far the preview + // can extend, so the canvas never grows past the committed edge + 3. + const base = + (wide + ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` + + ` grid-auto-flow: column; grid-auto-columns: ${geometry.unit.width}px;` + + ` gap: ${geometry.unit.gap}px; width: max-content;` + : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` + + ` grid-auto-flow: row; grid-auto-rows: ${geometry.unit.height}px;` + + ` gap: ${geometry.unit.gap}px;`); + if (!dragging) return base; + // The drop-target dots: one faint dot at each unit cell's CENTER, painted + // as a background whose tile is the cell pitch. One extra pitch of padding + // past the content end gives the append-slot a dot too. + const dotSize = wide ? `${pitchX}px` : `calc(100% / ${geometry.flowCols})`; + const dotX = wide ? geometry.unit.width / 2 : 0; + const dot = + ` background-image: radial-gradient(circle 2.5px,` + + ` color-mix(in oklab, var(--color-base-content) 22%, transparent) 98%, transparent 100%);` + + ` background-size: ${dotSize} ${pitchY}px;` + + ` background-position: ${dotX}px ${geometry.unit.height / 2}px;` + + (wide ? ` padding-right: ${pitchX}px;` : ` padding-bottom: ${pitchY}px;`); + return base + dot; + }); + + function spanFor(placement: Placement): Span { + if (preview && preview.id === placement.id) { + return { colSpan: preview.colSpan, rowSpan: preview.rowSpan }; + } + return { colSpan: placement.colSpan, rowSpan: placement.rowSpan }; + } + + function wrapperStyle(placement: Placement): string { + const span = spanFor(placement); + return `grid-column: ${placement.col} / span ${span.colSpan}; grid-row: ${placement.row} / span ${span.rowSpan};`; + } + + /** + * The landing cell's top-left in CLIENT coordinates — where the CSS grid + * actually places a tile anchored at `targetCell` (the true column pitch: + * fixed in wide; the fluid column width PLUS the gap in tall). The follow + * transform cancels exactly this, so the grab point tracks the pointer. + */ + const targetCellOrigin = $derived.by<{ x: number; y: number } | null>(() => { + const el = gridEl; + if (!el || !targetCell) return null; + const rect = el.getBoundingClientRect(); + const wide = geometry.mode === "wide"; + const colPitch = wide ? pitchX : (rect.width + geometry.unit.gap) / geometry.flowCols; + return { + x: rect.left + (targetCell.col - 1) * colPitch, + y: rect.top + (targetCell.row - 1) * pitchY, + }; + }); + + /** The dragged tile's follow transform (it keeps its committed slot). */ + function followStyle(placement: Placement): string { + if (draggedId !== placement.id || !pressOrigin || !grabOffset || phase.kind !== "dragging") { + return ""; + } + const origin = targetCellOrigin; + if (!origin) return ""; + // Pointer minus the tile's GRID anchor minus the grab offset — the grab + // point the user pressed tracks the pointer. A raw pointer-displacement + // translate would ride the tile's origin instead and jump by the grab + // offset (a center grab snaps the tile's top-left under the pointer). + const dx = phase.x - origin.x - grabOffset.x; + const dy = phase.y - origin.y - grabOffset.y; + return `transform: translate(${dx}px, ${dy}px) scale(1.05);`; + } + + // ── The drop ghost: a dashed footprint at the target anchor ─────────────── + const ghostStyle = $derived.by(() => { + if (!dragging || !targetCell || !draggedPlacement) return null; + const span = spanFor(draggedPlacement); + const wide = geometry.mode === "wide"; + const h = span.rowSpan * pitchY - geometry.unit.gap; + const x = wide + ? `${(targetCell.col - 1) * pitchX}px` + : `calc(${((targetCell.col - 1) / geometry.flowCols) * 100}%)`; + const width = wide + ? `${span.colSpan * pitchX - geometry.unit.gap}px` + : `calc(${(span.colSpan / geometry.flowCols) * 100}% - ${(geometry.unit.gap * (geometry.flowCols - span.colSpan)) / geometry.flowCols}px)`; + return `left: ${x}; top: ${(targetCell.row - 1) * pitchY}px; width: ${width}; height: ${h}px;`; + }); + + /** + * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see + * the literal `bg-*`/`text-*-content` names to emit them). A tile is its + * tone's SATURATED color with the tone's `-content` text, like Win8's + * colored tiles on the dark Start canvas. + */ + const TONE_CLASSES: Record<TileTone, string> = { + primary: "bg-primary text-primary-content border-primary", + secondary: "bg-secondary text-secondary-content border-secondary", + accent: "bg-accent text-accent-content border-accent", + info: "bg-info text-info-content border-info", + success: "bg-success text-success-content border-success", + warning: "bg-warning text-warning-content border-warning", + }; +</script> + +<svelte:window onkeydowncapture={windowKeydown} /> + +<div class="relative h-full"> + <div + bind:this={scrollerEl} + class="h-full overscroll-contain" + class:overflow-x-auto={geometry.mode === "wide"} + class:overflow-y-auto={geometry.mode === "tall"} + style={`padding: ${geometry.margin}px; scroll-snap-type: ${geometry.mode === "wide" ? "x" : "y"} proximity;`} + onscroll={updateScroll} + onwheel={handleWheel} + > + <!-- svelte-ignore a11y_no_static_element_interactions -- the grid owns the drag gestures (capture must live on a never-reparented element) --> + <div + bind:this={gridEl} + class="relative grid" + style={gridStyle} + onpointerdown={gridPointerDown} + onpointermove={gridPointerMove} + onpointerup={gridPointerUp} + onpointercancel={() => dispatch({ type: "cancel" })} + > + {#if ghostStyle && draggedPlacement} + <!-- The landing footprint (the dragged tile's tone, dashed). --> + <div + aria-hidden="true" + class="pointer-events-none absolute z-0 rounded-box border-2 border-dashed opacity-70" + class:border-primary={tileTone(draggedPlacement.id) === "primary"} + class:border-secondary={tileTone(draggedPlacement.id) === "secondary"} + class:border-accent={tileTone(draggedPlacement.id) === "accent"} + class:border-info={tileTone(draggedPlacement.id) === "info"} + class:border-success={tileTone(draggedPlacement.id) === "success"} + class:border-warning={tileTone(draggedPlacement.id) === "warning"} + style={ghostStyle} + ></div> + {/if} + {#each rendered as placement (placement.id)} + {@const ws = workspacesById.get(placement.id)} + {#if ws} + {@const isDragged = draggedId === placement.id} + <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> + <div + data-tile-id={placement.id} + class={`raised flex select-none flex-col overflow-hidden rounded-box border transition-[box-shadow] duration-150 ${ + TONE_CLASSES[tileTone(placement.id)] + } ${ + selectedId === placement.id + ? "ring-4 ring-base-content/80 ring-offset-2 ring-offset-base-100" + : "border-transparent" + } ${ + isDragged + ? "pointer-events-none relative z-40 shadow-2xl" + : "hover:-translate-y-0.5 hover:shadow-xl" + }`} + style={`${wrapperStyle(placement)} ${isDragged ? followStyle(placement) : ""} scroll-snap-align: start;`} + role="button" + tabindex="0" + aria-label={`Open workspace ${ws.title}`} + use:trackTile={placement.id} + oncontextmenu={(e) => { + e.preventDefault(); + onSelect(placement.id); + }} + onkeydown={(e) => { + if (insideInteractive(e.target)) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(placement.id); + } + }} + > + <WorkspaceTile + {ws} + {store} + {placement} + selected={selectedId === placement.id} + renaming={renamingId === placement.id} + active={hasActive?.(ws.id) ?? false} + {onRenameEnd} + /> + </div> + {/if} + {/each} + </div> + </div> + + {#if canPan} + <!-- Pan arrow + thin scroll-position indicator (wide geometry only). + Both sit INSIDE the canvas' outer margin. --> + <button + type="button" + class="btn btn-circle absolute z-10 border-none bg-base-100 text-base-content shadow-xl" + style={`right: ${Math.max(12, geometry.margin / 2)}px; bottom: ${Math.max(12, geometry.margin / 2)}px;`} + aria-label="Pan right" + title="Pan one screen sideways" + onclick={pan} + > + → + </button> + <div + class="absolute right-0 bottom-0 left-0 z-10 h-0.5 bg-base-content/10" + style={`margin: 0 ${geometry.margin}px;`} + > + <div + class="h-full bg-primary transition-[width] duration-100" + style={`width: ${Math.round(scrollProgress * 100)}%;`} + ></div> + </div> + {/if} +</div> diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts new file mode 100644 index 0000000..a8365d5 --- /dev/null +++ b/src/features/workspaces/ui/HomeTileGrid.test.ts @@ -0,0 +1,200 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { geometryFor, type Placement } from "../logic/tile-layout"; +import type { WorkspaceStore } from "../store.svelte"; +import HomeTileGrid from "./HomeTileGrid.svelte"; + +const WIDE = geometryFor(1920, 1080); + +function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "a", + title: "Workspace A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 1, + ...overrides, + }; +} + +function placement(id: string, col: number, row: number): Placement { + return { id, col, row, colSpan: 1, rowSpan: 1 }; +} + +function fakeStore(): WorkspaceStore { + return { + rename: vi.fn(), + } as unknown as WorkspaceStore; +} + +function baseProps() { + return { + geometry: WIDE, + workspacesById: new Map([ + ["a", ws()], + ["b", ws({ id: "b", title: "Workspace B" })], + ]), + store: fakeStore(), + onOpen: vi.fn(), + onSelect: vi.fn(), + onPlace: vi.fn(), + onRenameEnd: vi.fn(), + }; +} + +describe("HomeTileGrid", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders one tile per placement, positioned by the packed spans", () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { + ...props, + placements: [placement("a", 1, 1), placement("b", 2, 1)], + }, + }); + + expect(screen.getByRole("button", { name: "Open workspace Workspace A" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open workspace Workspace B" })).toBeInTheDocument(); + const wrappers = container.querySelectorAll("[data-tile-id]"); + expect(wrappers).toHaveLength(2); + expect(wrappers[0]?.getAttribute("style")).toContain("grid-column: 1 / span 1"); + expect(wrappers[1]?.getAttribute("style")).toContain("grid-column: 2 / span 1"); + }); + + it("a tap (press + release without moving) opens the workspace", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerUp(wrapper); + + expect(props.onOpen).toHaveBeenCalledWith("a"); + expect(props.onSelect).not.toHaveBeenCalled(); + }); + + it("right-click selects the tile", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.contextMenu(wrapper); + + expect(props.onSelect).toHaveBeenCalledWith("a"); + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("dragging shows the ghost + follow transform, and dropping PLACES at the hovered cell", async () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1), placement("b", 2, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + // The cell math resolves against the grid element's rect (jsdom: 0s). + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); + + // The dashed landing ghost appears… + const ghost = container.querySelector("[aria-hidden='true'].border-dashed"); + expect(ghost).not.toBeNull(); + // …and the dragged tile itself follows the pointer (transform on the tile). + const tile = container.querySelector("[data-tile-id='a']"); + expect(tile?.getAttribute("style")).toContain("translate("); + + fireEvent.pointerUp(wrapper); + // The ghost + follow disappear on drop… + expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull(); + // …and the drop committed a PLACE at the hovered cell (a lands where it + // already was — the pointer hovered its own slot; a no-move drop). + expect(props.onPlace).toHaveBeenCalledWith("a", { col: 1, row: 1 }); + // And no navigation either (a drag is not a tap). + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("Escape mid-drag CANCELS: no place commits and the tile un-lifts", async () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(props.onPlace).not.toHaveBeenCalled(); + // The drag visuals are gone (the tile is no longer lifted). + expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull(); + }); + + it("a touch long-press lifts the tile and selects it (never opens)", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { + button: 0, + clientX: 10, + clientY: 10, + pointerType: "touch", + }); + await vi.advanceTimersByTimeAsync(500); + + expect(props.onSelect).toHaveBeenCalledWith("a"); + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("a touch drag that moves before the long-press cancels (scroll wins)", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { + button: 0, + clientX: 10, + clientY: 10, + pointerType: "touch", + }); + fireEvent.pointerMove(wrapper, { clientX: 10, clientY: 120 }); + await vi.advanceTimersByTimeAsync(500); + + expect(props.onSelect).not.toHaveBeenCalled(); + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("previews a transient span for the resizing tile", () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { + ...props, + placements: [placement("a", 1, 1)], + preview: { id: "a", colSpan: 3, rowSpan: 2 }, + }, + }); + + const wrapper = container.querySelector("[data-tile-id='a']"); + expect(wrapper?.getAttribute("style")).toContain("span 3"); + expect(wrapper?.getAttribute("style")).toContain("span 2"); + }); +}); diff --git a/src/features/workspaces/ui/ResizePicker.svelte b/src/features/workspaces/ui/ResizePicker.svelte new file mode 100644 index 0000000..e066519 --- /dev/null +++ b/src/features/workspaces/ui/ResizePicker.svelte @@ -0,0 +1,107 @@ +<script lang="ts"> + import { onMount, untrack } from "svelte"; + import type { GridGeometry, Span } from "../logic/tile-layout"; + + let { + geometry, + initial, + onPreview, + onCommit, + onCancel, + }: { + geometry: GridGeometry; + /** The tile's current (clamped) spans — the picker's starting cell. */ + initial: Span; + /** Transiently ghost-preview the hovered footprint on the real grid. */ + onPreview: (span: Span | null) => void; + onCommit: (colSpan: number, rowSpan: number) => void; + onCancel: () => void; + } = $props(); + + // The picker IS the span cap: wide = 4×2 small squares, tall = 2×2. + const cols = $derived(geometry.maxColSpan); + const rows = $derived(geometry.maxRowSpan); + + // The currently hovered cell (c, r) — the highlighted rectangle spans + // 1..c × 1..r from the picker's top-left (insert-table style). The picker + // mounts fresh per resize session, so seeding from the prop is intended. + let hovered = $state({ + colSpan: untrack(() => Math.min(initial.colSpan, cols)), + rowSpan: untrack(() => Math.min(initial.rowSpan, rows)), + }); + + function hover(colSpan: number, rowSpan: number): void { + hovered = { colSpan, rowSpan }; + onPreview({ colSpan, rowSpan }); + } + + function commit(): void { + onCommit(hovered.colSpan, hovered.rowSpan); + } + + function keydown(e: KeyboardEvent): void { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + commit(); + return; + } + let dc = 0; + let dr = 0; + if (e.key === "ArrowRight") dc = 1; + else if (e.key === "ArrowLeft") dc = -1; + else if (e.key === "ArrowDown") dr = 1; + else if (e.key === "ArrowUp") dr = -1; + else return; + e.preventDefault(); + hover( + Math.max(1, Math.min(cols, hovered.colSpan + dc)), + Math.max(1, Math.min(rows, hovered.rowSpan + dr)), + ); + } + + let root = $state<HTMLDivElement | undefined>(); + onMount(() => root?.focus()); +</script> + +<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_no_noninteractive_tabindex --> +<div + bind:this={root} + class="fixed inset-x-0 bottom-20 z-50 mx-auto flex w-max flex-col gap-2 rounded-box border border-base-content/20 bg-base-100 p-3 shadow-2xl outline-none" + role="group" + aria-label="Resize tile" + tabindex="0" + onkeydown={keydown} + onmouseleave={() => onPreview(null)} +> + <p class="text-xs font-semibold uppercase opacity-60">Resize — click a size</p> + <div class="grid gap-1" style={`grid-template-columns: repeat(${cols}, 1.5rem);`}> + {#each Array.from({ length: rows }) as _, row (row)} + {#each Array.from({ length: cols }) as __, col (col)} + <button + type="button" + class={`size-6 border border-base-content/20 ${ + col < hovered.colSpan && row < hovered.rowSpan + ? "bg-primary/60" + : col === hovered.colSpan - 1 && row === hovered.rowSpan - 1 + ? "bg-primary/20" + : "" + }`} + aria-label={`Resize to ${col + 1} by ${row + 1}`} + onmouseenter={() => hover(col + 1, row + 1)} + onfocus={() => hover(col + 1, row + 1)} + onclick={() => { + hover(col + 1, row + 1); + commit(); + }} + ></button> + {/each} + {/each} + </div> + <p class="text-center font-mono text-xs opacity-70">{hovered.colSpan} × {hovered.rowSpan}</p> + <p class="text-center text-[10px] opacity-50">Enter commits · Escape cancels</p> +</div> diff --git a/src/features/workspaces/ui/ResizePicker.test.ts b/src/features/workspaces/ui/ResizePicker.test.ts new file mode 100644 index 0000000..796a556 --- /dev/null +++ b/src/features/workspaces/ui/ResizePicker.test.ts @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { geometryFor } from "../logic/tile-layout"; +import ResizePicker from "./ResizePicker.svelte"; + +const WIDE = geometryFor(1920, 1080); +const TALL = geometryFor(390, 844); + +function props() { + return { + onPreview: vi.fn(), + onCommit: vi.fn(), + onCancel: vi.fn(), + }; +} + +describe("ResizePicker", () => { + it("renders one small square per cap (4×2 wide, 2×2 tall)", () => { + const p = props(); + const wide = render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + expect(screen.getAllByRole("button")).toHaveLength(8); + wide.unmount(); + + render(ResizePicker, { + props: { geometry: TALL, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + expect(screen.getAllByRole("button")).toHaveLength(4); + }); + + it("hovering square (r, c) previews that rectangle", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + + await user.hover(screen.getByRole("button", { name: "Resize to 3 by 2" })); + expect(p.onPreview).toHaveBeenCalledWith({ colSpan: 3, rowSpan: 2 }); + }); + + it("clicking a square commits that size", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + + await user.click(screen.getByRole("button", { name: "Resize to 2 by 1" })); + expect(p.onCommit).toHaveBeenCalledWith(2, 1); + }); + + it("arrow keys move within the picker, Enter commits, Escape cancels", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 2, rowSpan: 1 }, ...p }, + }); + + await user.keyboard("{ArrowRight}"); + expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 3, rowSpan: 1 }); + await user.keyboard("{ArrowDown}"); + expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 3, rowSpan: 2 }); + await user.keyboard("{ArrowLeft}"); + expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 2, rowSpan: 2 }); + + await user.keyboard("{Enter}"); + expect(p.onCommit).toHaveBeenCalledWith(2, 2); + expect(p.onCancel).not.toHaveBeenCalled(); + + await user.keyboard("{Escape}"); + expect(p.onCancel).toHaveBeenCalledTimes(1); + }); + + it("leaving the picker clears the preview", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + + await user.hover(screen.getByRole("button", { name: "Resize to 4 by 2" })); + await user.unhover(screen.getByRole("button", { name: "Resize to 4 by 2" })); + expect(p.onPreview).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/src/features/workspaces/ui/TileActionBar.svelte b/src/features/workspaces/ui/TileActionBar.svelte new file mode 100644 index 0000000..ffb8f1d --- /dev/null +++ b/src/features/workspaces/ui/TileActionBar.svelte @@ -0,0 +1,73 @@ +<script lang="ts"> + import { fly } from "svelte/transition"; + import type { WorkspaceEntry } from "@dispatch/wire"; + + let { + ws, + withPlugins = false, + onOpen, + onRename, + onResize, + onToggleStar, + onDetails, + onPlugins, + onArchive, + onDelete, + onClose, + }: { + ws: WorkspaceEntry; + /** The plugins port is present → the Plugins action renders. */ + withPlugins?: boolean; + onOpen: () => void; + onRename: () => void; + onResize: () => void; + onToggleStar: () => void; + /** The details flyout (default cwd + default computer). */ + onDetails: () => void; + onPlugins: () => void; + /** Archive the workspace (hide from the home grid; reversible). */ + onArchive: () => void; + onDelete: () => void; + onClose: () => void; + } = $props(); +</script> + +<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> +<div + class="fixed inset-x-0 bottom-0 z-40 flex justify-center p-4" + transition:fly={{ y: 80, duration: 150 }} + onclick={(e) => { + // The bar owns its clicks — don't let them deselect via the backdrop. + e.stopPropagation(); + }} +> + <div + class="flex flex-wrap items-center justify-center gap-1 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-2xl" + role="toolbar" + aria-label={`Actions for ${ws.title}`} + > + <button type="button" class="btn btn-primary btn-sm" onclick={onOpen}>Open</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onRename}>Rename</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onResize}>Resize</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onDetails}>Details</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onToggleStar}> + {ws.starred ? "Unstar" : "Star"} + </button> + {#if withPlugins} + <button type="button" class="btn btn-ghost btn-sm" onclick={onPlugins}>Plugins</button> + {/if} + <button type="button" class="btn btn-ghost btn-sm" onclick={onArchive}>Archive</button> + <button + type="button" + class="btn btn-ghost btn-sm text-error" + onclick={onDelete}>Delete</button + > + <button + type="button" + class="btn btn-circle btn-ghost btn-sm" + aria-label="Close actions" + title="Close" + onclick={onClose}>✕</button + > + </div> +</div> diff --git a/src/features/workspaces/ui/TileActionBar.test.ts b/src/features/workspaces/ui/TileActionBar.test.ts new file mode 100644 index 0000000..c00de19 --- /dev/null +++ b/src/features/workspaces/ui/TileActionBar.test.ts @@ -0,0 +1,93 @@ +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 TileActionBar from "./TileActionBar.svelte"; + +function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 0, + ...overrides, + }; +} + +function handlers() { + return { + onOpen: vi.fn(), + onRename: vi.fn(), + onResize: vi.fn(), + onToggleStar: vi.fn(), + onDetails: vi.fn(), + onPlugins: vi.fn(), + onArchive: vi.fn(), + onDelete: vi.fn(), + onClose: vi.fn(), + }; +} + +describe("TileActionBar", () => { + it("renders the Open · Rename · Resize · Star · Details · Delete actions", () => { + const h = handlers(); + render(TileActionBar, { props: { ws: ws(), ...h } }); + expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Rename" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Resize" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Star" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Details" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Archive" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Close actions" })).toBeInTheDocument(); + }); + + it("omits Plugins when no plugins port is present, renders it when one is", () => { + const h = handlers(); + const without = render(TileActionBar, { props: { ws: ws(), ...h } }); + expect(without.queryByRole("button", { name: "Plugins" })).toBeNull(); + without.unmount(); + + render(TileActionBar, { props: { ws: ws(), withPlugins: true, ...h } }); + expect(screen.getByRole("button", { name: "Plugins" })).toBeInTheDocument(); + }); + + it("labels the star action by the current state (Star ↔ Unstar)", () => { + const h = handlers(); + const unstarred = render(TileActionBar, { props: { ws: ws({ starred: false }), ...h } }); + expect(screen.getByRole("button", { name: "Star" })).toBeInTheDocument(); + unstarred.unmount(); + + render(TileActionBar, { props: { ws: ws({ starred: true }), ...h } }); + expect(screen.getByRole("button", { name: "Unstar" })).toBeInTheDocument(); + }); + + it("dispatches each action", async () => { + const user = userEvent.setup(); + const h = handlers(); + render(TileActionBar, { props: { ws: ws(), ...h } }); + + await user.click(screen.getByRole("button", { name: "Open" })); + await user.click(screen.getByRole("button", { name: "Rename" })); + await user.click(screen.getByRole("button", { name: "Resize" })); + await user.click(screen.getByRole("button", { name: "Star" })); + await user.click(screen.getByRole("button", { name: "Details" })); + await user.click(screen.getByRole("button", { name: "Archive" })); + await user.click(screen.getByRole("button", { name: "Delete" })); + await user.click(screen.getByRole("button", { name: "Close actions" })); + + expect(h.onOpen).toHaveBeenCalledTimes(1); + expect(h.onRename).toHaveBeenCalledTimes(1); + expect(h.onResize).toHaveBeenCalledTimes(1); + expect(h.onToggleStar).toHaveBeenCalledTimes(1); + expect(h.onDetails).toHaveBeenCalledTimes(1); + expect(h.onArchive).toHaveBeenCalledTimes(1); + expect(h.onDelete).toHaveBeenCalledTimes(1); + expect(h.onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte deleted file mode 100644 index 6de4109..0000000 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ /dev/null @@ -1,279 +0,0 @@ -<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 deleted file mode 100644 index 72f4e60..0000000 --- a/src/features/workspaces/ui/WorkspaceCard.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -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/WorkspaceDetails.svelte b/src/features/workspaces/ui/WorkspaceDetails.svelte new file mode 100644 index 0000000..6595725 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceDetails.svelte @@ -0,0 +1,132 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import ComputerSelect from "../../computer/ui/ComputerSelect.svelte"; + + let { + ws, + store, + computers, + onClose, + }: { + ws: WorkspaceEntry; + store: WorkspaceStore; + /** Discovered computers (`GET /computers`), for the default-computer dropdown. */ + computers: readonly ComputerEntry[]; + onClose: () => void; + } = $props(); + + // ── Default cwd: input + Set (semantics preserved from the WorkspaceCard) ── + 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; + } +</script> + +<div class="modal modal-open" role="dialog" aria-label={`Workspace details: ${ws.title}`}> + <div class="modal-box"> + <div class="flex items-start justify-between gap-2"> + <div> + <h2 class="text-lg font-bold">{ws.title}</h2> + <p class="font-mono text-xs opacity-50">/{ws.id}</p> + </div> + <button + type="button" + class="btn btn-circle btn-ghost btn-sm" + aria-label="Close details" + onclick={onClose}>✕</button + > + </div> + + <div class="mt-4 flex flex-col gap-3"> + <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") void saveCwd(); + }} + /> + <button + type="button" + class="btn btn-primary btn-xs" + disabled={!cwdDirty || savingCwd} + onclick={() => void saveCwd()} + > + {#if savingCwd} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + </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} + + <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={(id) => void saveComputer(id)} + /> + {#if savingComputer} + <span class="loading loading-spinner loading-xs shrink-0"></span> + {/if} + </div> + + {#if computerError} + <p class="text-xs text-error">{computerError}</p> + {:else if !ws.defaultComputerId} + <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p> + {/if} + </div> + </div> + <!-- Click outside closes. --> + <button + type="button" + class="modal-backdrop" + aria-label="Close details backdrop" + onclick={onClose}></button> +</div> diff --git a/src/features/workspaces/ui/WorkspaceDetails.test.ts b/src/features/workspaces/ui/WorkspaceDetails.test.ts new file mode 100644 index 0000000..9138742 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceDetails.test.ts @@ -0,0 +1,115 @@ +import type { ComputerEntry, 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 WorkspaceDetails from "./WorkspaceDetails.svelte"; + +function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 0, + ...overrides, + }; +} + +function fakeStore(): WorkspaceStore { + return { + setDefaultCwd: vi.fn( + async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultCwd }), + }), + ), + setDefaultComputer: vi.fn( + async ( + id: string, + defaultComputerId: string | null, + ): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultComputerId }), + }), + ), + } as unknown as WorkspaceStore; +} + +describe("WorkspaceDetails", () => { + it("renders the title, slug, cwd input, and computer select", () => { + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose: () => {} }, + }); + expect(screen.getByText("My Workspace")).toBeInTheDocument(); + expect(screen.getByText("/my-ws")).toBeInTheDocument(); + expect(screen.getByLabelText("Default working directory")).toBeInTheDocument(); + expect(screen.getByLabelText("Computer")).toBeInTheDocument(); + }); + + it("saves the cwd via the store; an empty value clears to null", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const onClose = vi.fn(); + render(WorkspaceDetails, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose }, + }); + + 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("a failed cwd save surfaces the error", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + store.setDefaultCwd = vi.fn( + async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "nope" }), + ); + render(WorkspaceDetails, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose: () => {} }, + }); + + await user.type(screen.getByLabelText("Default working directory"), "!{Enter}"); + expect(await screen.findByText("nope")).toBeInTheDocument(); + }); + + it("picking a computer saves it via the store", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const computers: ComputerEntry[] = [ + { + alias: "lab", + hostName: "lab.example", + port: 22, + user: "me", + identityFile: null, + knownHost: true, + usageCount: 0, + }, + ]; + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store, computers, onClose: () => {} }, + }); + + await user.selectOptions(screen.getByLabelText("Computer"), "lab"); + expect(store.setDefaultComputer).toHaveBeenCalledWith("my-ws", "lab"); + }); + + it("the close button dismisses the dialog", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose }, + }); + + await user.click(screen.getByRole("button", { name: "Close details" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/workspaces/ui/WorkspaceTile.svelte b/src/features/workspaces/ui/WorkspaceTile.svelte new file mode 100644 index 0000000..b101192 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceTile.svelte @@ -0,0 +1,146 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import { relativeTime } from "../logic/view-model"; + import type { Placement } from "../logic/tile-layout"; + + let { + ws, + store, + placement, + selected = false, + renaming = false, + active = false, + onRenameEnd, + }: { + ws: WorkspaceEntry; + store: WorkspaceStore; + /** The tile's resolved footprint (already preview-adjusted by the grid). */ + placement: Placement; + selected?: boolean; + /** The parent-driven rename mode (the action bar's Rename action). */ + renaming?: boolean; + /** At least one conversation in this workspace is generating/queued. */ + active?: boolean; + /** The tile finished its rename session (saved or cancelled). */ + onRenameEnd: () => void; + } = $props(); + + // ── Title: double-click to rename inline (semantics preserved from the + // WorkspaceCard), or enter via the action bar's Rename action (`renaming`). + let editing = $state(false); + let titleDraft = $state(""); + let titleInput = $state<HTMLInputElement | undefined>(); + let titleError = $state<string | null>(null); + + function startEdit(): void { + titleDraft = ws.title; + titleError = null; + editing = true; + queueMicrotask(() => titleInput?.focus()); + } + + // Seed (only) when the parent-driven rename mode turns on. + $effect(() => { + if (renaming) startEdit(); + }); + + async function saveTitle(): Promise<void> { + if (!editing) return; + const title = titleDraft.trim(); + editing = false; + onRenameEnd(); + if (title === "" || title === ws.title) return; + const result = await store.rename(ws.id, title); + if (!result.ok) titleError = result.error; + } + + function cancelTitle(): void { + editing = false; + titleError = null; + onRenameEnd(); + } + + // The face's semantic key (tests read it). + const faceKey = $derived(`tile-${placement.id}`); + + // Span-aware face: bigger footprints get more air + bigger type (all styles + // static so Tailwind sees them). + const wide = $derived(placement.colSpan > 1); + const tall = $derived(placement.rowSpan > 1); + const facePadding = $derived(wide || tall ? "p-5" : "p-4"); + const titleClass = $derived( + wide && tall + ? "text-3xl leading-tight line-clamp-3" + : wide + ? "text-2xl leading-snug line-clamp-2" + : tall + ? "text-xl leading-snug line-clamp-4" + : "text-lg leading-snug line-clamp-3", + ); + /** 1×1 tiles are tight: "20 conv." — wider tiles spell "conversations" out. */ + const countLabel = $derived( + ws.conversationCount === 1 ? (wide ? "conversation" : "conv.") : wide ? "conversations" : "conv.", + ); +</script> + +<div data-tile-face={faceKey} class={`relative flex h-full w-full flex-col ${facePadding}`}> + <!-- The slug label at the very top — the Win8 tile's small "app" caption. --> + <div class="flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase"> + <span class="truncate opacity-70">/{ws.id}</span> + {#if active} + <span + class="loading loading-dots loading-xs shrink-0" + aria-label="Workspace has active conversations" + title="A conversation in this workspace is generating"></span> + {/if} + </div> + + {#if ws.starred} + <!-- Starred is a badge, not a tint — the tone stays the tile's identity. --> + <span + class="absolute top-2.5 right-3 text-lg leading-none opacity-90" + aria-label="Starred workspace" + title="Starred — priority in the agent queue">★</span + > + {/if} + + {#if editing} + <input + bind:this={titleInput} + bind:value={titleDraft} + class="input input-sm mt-2 w-full border-current/40 bg-black/25 text-inherit placeholder:text-current/50" + aria-label="Workspace title" + onkeydown={(e) => { + if (e.key === "Enter") void saveTitle(); + else if (e.key === "Escape") cancelTitle(); + }} + onblur={() => void saveTitle()} + /> + {:else} + <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> + <span + class={`mt-1 cursor-default font-bold ${titleClass}`} + title="Double-click to rename" + ondblclick={startEdit}>{ws.title}</span + > + {/if} + + {#if titleError} + <p class="mt-1 text-xs font-semibold">{titleError}</p> + {/if} + + <!-- The stat line pinned to the tile's bottom edge, like a live tile's + status row. Single-line, truncation-safe (narrow 1×1 tiles stay clean). --> + <div + class="mt-auto flex items-center gap-2 overflow-hidden border-t border-current/20 pt-2 text-xs font-medium whitespace-nowrap" + > + <span class="min-w-0 truncate"> + {ws.conversationCount} + {countLabel} + </span> + <span class="ml-auto shrink-0 font-mono opacity-70"> + {relativeTime(ws.lastActivityAt, Date.now())} + </span> + </div> +</div> diff --git a/src/features/workspaces/ui/WorkspaceTile.test.ts b/src/features/workspaces/ui/WorkspaceTile.test.ts new file mode 100644 index 0000000..420791a --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceTile.test.ts @@ -0,0 +1,171 @@ +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 { Placement } from "../logic/tile-layout"; +import type { WorkspaceStore } from "../store.svelte"; +import WorkspaceTile from "./WorkspaceTile.svelte"; + +function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: Date.now() - 90_000, // "1m" relative time + conversationCount: 3, + ...overrides, + }; +} + +function fakeStore(): WorkspaceStore { + return { + rename: vi.fn( + async (id: string, title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, title }), + }), + ), + } as unknown as WorkspaceStore; +} + +function fakePlacement(overrides: Partial<Placement> = {}): Placement { + return { id: "my-ws", col: 1, row: 1, colSpan: 1, rowSpan: 1, ...overrides }; +} + +describe("WorkspaceTile", () => { + it("renders the title, slug, conversation count, and relative last-activity", () => { + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + onRenameEnd: () => {}, + }, + }); + expect(screen.getByText("My Workspace")).toBeInTheDocument(); + expect(screen.getByText("/my-ws")).toBeInTheDocument(); + // A 1×1 tile is tight: the count compacts to "conv." (wide tiles spell it out). + expect(screen.getByText(/3 conv\./)).toBeInTheDocument(); + expect(screen.getByText("1m")).toBeInTheDocument(); + }); + + it("spells out 'conversations' on a wide tile", () => { + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement({ colSpan: 2, rowSpan: 1 }), + onRenameEnd: () => {}, + }, + }); + expect(screen.getByText(/3 conversations/)).toBeInTheDocument(); + }); + + it("shows the active indicator only when the active port says so", async () => { + const { container, rerender } = render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + active: true, + onRenameEnd: () => {}, + }, + }); + expect( + container.querySelector('[aria-label="Workspace has active conversations"]'), + ).not.toBeNull(); + + await rerender({ + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + active: false, + onRenameEnd: () => {}, + }); + expect(container.querySelector('[aria-label="Workspace has active conversations"]')).toBeNull(); + }); + + it("double-clicking the title reveals the rename input (semantics preserved)", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + onRenameEnd: () => {}, + }, + }); + + await user.dblClick(screen.getByText("My Workspace")); + expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace"); + }); + + it("the parent-driven rename mode (action bar) opens the same inline editor", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + renaming: true, + onRenameEnd: () => {}, + }, + }); + const input = screen.getByLabelText("Workspace title"); + expect(input).toHaveValue("My Workspace"); + + await user.clear(input); + await user.type(input, "Renamed{Enter}"); + expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed"); + }); + + it("a failed rename surfaces the error on the face", async () => { + const user = userEvent.setup(); + const store = { + rename: vi.fn( + async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: false, + error: "boom", + }), + ), + } as unknown as WorkspaceStore; + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + renaming: true, + onRenameEnd: () => {}, + }, + }); + + await user.type(screen.getByLabelText("Workspace title"), "x{Enter}"); + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + + it("an unchanged title does not hit the store and still ends the session", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const onRenameEnd = vi.fn(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + renaming: true, + onRenameEnd, + }, + }); + + await user.type(screen.getByLabelText("Workspace title"), "{Enter}"); + expect(store.rename).not.toHaveBeenCalled(); + expect(onRenameEnd).toHaveBeenCalled(); + }); +}); diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index d97eab7..196e831 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -1,110 +1,363 @@ <script lang="ts"> - import { onMount } from "svelte"; + import { onMount, untrack } from "svelte"; import type { ComputerEntry } from "@dispatch/wire"; + import { + PluginManager, + WorkspacePluginModal, + type PluginsStore, + type WorkspaceLayoutPort, + } from "../../plugins"; import type { WorkspaceStore } from "../store.svelte"; - import { isValidSlug, workspacePath } from "../logic/route"; - import WorkspaceCard from "./WorkspaceCard.svelte"; + import { workspacePath } from "../logic/route"; + import { + DEFAULT_VIEWPORT, + geometryFor, + mergeLayout, + pack, + type GridGeometry, + type Span, + } from "../logic/tile-layout"; + import { type TileLayoutStore } from "../tile-layout-store.svelte"; + import HomeHeader from "./HomeHeader.svelte"; + import HomeTileGrid from "./HomeTileGrid.svelte"; + import TileActionBar from "./TileActionBar.svelte"; + import ResizePicker from "./ResizePicker.svelte"; + import WorkspaceDetails from "./WorkspaceDetails.svelte"; + import ArchivedWorkspaces from "./ArchivedWorkspaces.svelte"; let { store, onNavigate, computers, hasActive, + plugins, + layout, + tiles: tilesOverride, }: { 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. + * Optional port: whether the workspace has at least one active + * (generating / queued) conversation — the tile's live indicator. * Wired by the composition root to the app store. Absent → no indicator. */ hasActive?: (workspaceId: string) => boolean; + /** + * The plugin manager store (plan 07): drives the header's global + * "Plugins" button + the action bar's per-workspace Plugins action. + * Optional (absent in tests that don't exercise plugins). + */ + plugins?: PluginsStore; + /** + * The layout port (plan 04 §4.4): forwarded to the per-workspace plugins + * modal's "Reset layout to default" affordance. Absent → the modal's + * layout section doesn't render. + */ + layout?: WorkspaceLayoutPort; + /** + * The synced tile layout store (ONE shared document per device + * orientation, persisted by the backend's home-tile-layout plugin). + * Created by the page entry (App.svelte — the composition root that owns + * the httpBase); tests inject one. + */ + tiles: TileLayoutStore; } = $props(); onMount(() => { void store.refresh(); }); - let newSlug = $state(""); - let slugError = $state<string | null>(null); + // The store arrives fully wired (the page entry created it over the real + // HTTP edge). The `untrack` capture (the repo's initial-value pattern, cf. + // App.svelte) dealiases the prop once — the component instance is the + // store's single owner from here on. + const tiles: TileLayoutStore = untrack(() => tilesOverride); + + // Keep the layout merged with the live workspace list: unknown workspaces + // (deep-link auto-creates) append at the end; deleted ones drop. The + // no-op guards inside sync keep this effect from looping. Skipped until + // the first non-empty list arrives — `sync([])` before the initial + // `refresh()` resolves would WIPE the persisted order (the backend always + // serves "default", so an empty list only ever means "not loaded yet"). + $effect(() => { + const ids = store.list.map((ws) => ws.id); + if (ids.length === 0) return; + tiles.sync(ids); + }); + + // ── The tile canvas' size decides the geometry (ONE pure function — no + // breakpoint classes). Start from the viewport, matching the boot probe; + // the canvas measurement then refines it (no transient wide reset on phones). + const initialViewport = typeof window === "undefined" + ? DEFAULT_VIEWPORT + : { width: window.innerWidth, height: window.innerHeight }; + let regionEl = $state<HTMLDivElement | undefined>(); + let size = $state<{ width: number; height: number } | undefined>(); + const geometry: GridGeometry = $derived( + geometryFor(size?.width ?? initialViewport.width, size?.height ?? initialViewport.height), + ); + + $effect(() => { + const el = regionEl; + if (!el || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + const rect = entries[0]?.contentRect; + if (rect) size = { width: rect.width, height: rect.height }; + }); + observer.observe(el); + return () => observer.disconnect(); + }); + + // Lock the store's active orientation to the resolved grid geometry: the + // landscape/portrait split IS the device-type split — every landscape + // device shares the `wide` document, every portrait device the `tall` one. + // Same-mode calls are a no-op inside the store; an orientation change + // resets + re-probes (never flashing the other orientation's arrangement). + $effect(() => { + tiles.setMode(geometry.mode); + }); + + // The grid view: the SAME persisted data packs into either geometry — + // positions re-derive here, spans clamp for the RENDER only. + // Render-only fallback: workspace discovery must not wait for layout GET. + // Never sync/commit this provisional arrangement while the probe is unknown. + const entries = $derived(tiles.entries.length > 0 + ? tiles.entries + : mergeLayout(null, store.list.map((ws) => ws.id))); + const placements = $derived(pack(entries, geometry)); + const workspacesById = $derived(new Map(store.list.map((ws) => [ws.id, ws]))); + + // ── Selection + action bar ───────────────────────────────────────────────── + let selectedId = $state<string | null>(null); + const selected = $derived(selectedId !== null ? (workspacesById.get(selectedId) ?? null) : null); + + function select(id: string): void { + selectedId = id; + } + + function deselect(): void { + selectedId = null; + } + + // ── Rename mode (the create flow + the action bar's Rename action) ──────── + let renamingId = $state<string | null>(null); + + // ── Resize picker (transient ghost preview; commit persists) ────────────── + let resizing = $state<{ id: string; span: Span | null } | null>(null); + const resizingEntry = $derived( + resizing ? (tiles.entries.find((tile) => tile.id === resizing?.id) ?? null) : null, + ); + + function startResize(): void { + if (!selected) return; + const entry = tiles.entries.find((tile) => tile.id === selected.id); + resizing = { + id: selected.id, + span: entry + ? { colSpan: entry.colSpan, rowSpan: entry.rowSpan } + : { colSpan: 1, rowSpan: 1 }, + }; + } + + function commitResize(colSpan: number, rowSpan: number): void { + if (!resizing) return; + tiles.resize(resizing.id, colSpan, rowSpan); + resizing = null; + } + + function cancelResize(): void { + resizing = null; + } + + // ── Details flyout (the dense per-card settings). Id-based + gated on the + // selection, so deselecting (Escape) also dismisses it. ───────────────────── + let detailsForId = $state<string | null>(null); + const detailsFor = $derived( + selectedId !== null && detailsForId === selectedId + ? (workspacesById.get(selectedId) ?? null) + : null, + ); - const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug)); + // ── Per-workspace plugin modal ───────────────────────────────────────────── + let pluginsModalId = $state<string | null>(null); + const pluginsModalFor = $derived( + pluginsModalId !== null ? (workspacesById.get(pluginsModalId) ?? null) : null, + ); - function createWorkspace(): void { - const slug = newSlug.trim(); - if (!isValidSlug(slug)) { - slugError = "Lowercase letters, digits, and hyphens (1–40 chars)."; + // Global plugin manager modal (plan 07 §7.2): the ONLY surface that + // loads/unloads code (strict layering). + let managerOpen = $state(false); + + // ── Archived workspaces modal ────────────────────────────────────────────── + let archivedOpen = $state(false); + + // ── Actions (the action bar) ─────────────────────────────────────────────── + function openWorkspace(id: string): void { + deselect(); + onNavigate(workspacePath(id)); + } + + async function deleteSelected(): Promise<void> { + if (!selected) return; + const ws = selected; + if ( + !window.confirm( + `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`, + ) + ) { return; } - slugError = null; - newSlug = ""; - onNavigate(workspacePath(slug)); + const result = await store.remove(ws.id); + if (result.ok) deselect(); + } + + async function toggleStar(): Promise<void> { + if (!selected) return; + await store.setStarred(selected.id, !selected.starred); + } + + async function archiveSelected(): Promise<void> { + if (!selected) return; + const ws = selected; + // Reversible without a confirm dialog — the workspace is hidden, not deleted. + const result = await store.setArchived(ws.id, true); + if (result.ok) deselect(); + } + + // ── Create (header popover): check the slug resolves to no archived + // workspace, create on the backend — do NOT navigate — place the new tile in + // the first empty slot, and go straight into rename. + async function createWorkspace( + slug: string, + ): Promise<{ ok: true } | { ok: false; error: string }> { + const result = await store.create(slug); + if (!result.ok) return { ok: false, error: result.error }; + await store.refresh(); + // Idempotent: if the list refresh's sync already appended the id, this + // MOVES it into the first empty slot instead of duplicating. + tiles.placeFirstEmpty(slug, geometry); + renamingId = slug; + return { ok: true }; + } + + // ── Sort overflow: re-apply the recent-activity order ────────────────────── + function sortByRecent(): void { + const activity = new Map(store.list.map((ws) => [ws.id, ws.lastActivityAt])); + tiles.sortByRecentActivity(activity); + } + + // Escape unwinds the overlays: resize → selection. + function handleKeydown(e: KeyboardEvent): void { + if (e.key !== "Escape") return; + if (resizing) { + cancelResize(); + } else if (selectedId !== null) { + deselect(); + } } </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"> +<svelte:window onkeydown={handleKeydown} /> + +<div class="h-app flex w-full flex-col"> + <HomeHeader + compact={geometry.mode === "tall"} + withPlugins={plugins !== undefined} + withSort={store.list.length > 1} + onCreate={createWorkspace} + onOpenPlugins={() => (managerOpen = true)} + onSort={() => void sortByRecent()} + onOpenArchived={() => (archivedOpen = true)} + /> + + <div bind:this={regionEl} class="min-h-0 w-full flex-1"> {#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 class="flex h-full items-center justify-center"> + <span class="loading loading-spinner loading-lg 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> + <div class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center"> + <p class="text-2xl font-black tracking-tight opacity-80">No workspaces yet</p> + <p class="max-w-md text-sm opacity-60"> + Create one with <strong>New workspace</strong> above, or visit + <code class="rounded bg-base-200 px-1.5 py-0.5">/your-name</code> in the URL — the first + tile drops onto the canvas ready to rename. + </p> + </div> {:else} - <ul class="flex flex-col gap-2"> - {#each store.list as ws (ws.id)} - <WorkspaceCard - {ws} - {store} - {onNavigate} - {computers} - {...(hasActive ? { hasActive } : {})} - /> - {/each} - </ul> + <HomeTileGrid + {geometry} + {placements} + {workspacesById} + {store} + {selectedId} + {renamingId} + preview={ + resizing?.id !== undefined && resizing.span !== null + ? { id: resizing.id, ...resizing.span } + : null + } + {...(hasActive ? { hasActive } : {})} + onOpen={openWorkspace} + onSelect={select} + onPlace={(id, cell) => cell !== null && tiles.placeAtCell(id, cell, geometry)} + onRenameEnd={() => (renamingId = null)} + /> {/if} </div> </div> + +{#if selected && !renamingId} + <TileActionBar + ws={selected} + withPlugins={plugins !== undefined} + onOpen={() => openWorkspace(selected.id)} + onRename={() => (renamingId = selected.id)} + onResize={startResize} + onToggleStar={() => void toggleStar()} + onDetails={() => (detailsForId = selected.id)} + onPlugins={() => (pluginsModalId = selected.id)} + onArchive={() => void archiveSelected()} + onDelete={() => void deleteSelected()} + onClose={deselect} + /> +{/if} + +{#if resizing && resizingEntry} + <ResizePicker + {geometry} + initial={{ colSpan: resizingEntry.colSpan, rowSpan: resizingEntry.rowSpan }} + onPreview={(span) => { + if (resizing) resizing = { id: resizing.id, span }; + }} + onCommit={commitResize} + onCancel={cancelResize} + /> +{/if} + +{#if detailsFor} + <WorkspaceDetails ws={detailsFor} {store} {computers} onClose={() => (detailsForId = null)} /> +{/if} + +{#if pluginsModalFor && plugins} + <WorkspacePluginModal + workspaceId={pluginsModalFor.id} + workspaceTitle={pluginsModalFor.title} + {plugins} + {...(layout ? { layout } : {})} + onClose={() => (pluginsModalId = null)} + /> +{/if} + +{#if managerOpen && plugins} + <PluginManager {plugins} onClose={() => (managerOpen = false)} /> +{/if} + +{#if archivedOpen} + <ArchivedWorkspaces + {store} + onOpen={(id) => openWorkspace(id)} + onClose={() => (archivedOpen = false)} + /> +{/if} diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts new file mode 100644 index 0000000..5a66a42 --- /dev/null +++ b/src/features/workspaces/ui/WorkspacesHome.test.ts @@ -0,0 +1,333 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; +import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; +import { render, screen, waitFor } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceHttp, WorkspaceResult } from "../adapter/http"; +import type { TileLayoutHttp, TileLayoutHttpResult } from "../adapter/tile-layout-http"; +import type { WorkspaceLookup } from "../logic/create"; +import { createTileLayoutStore } from "../tile-layout-store.svelte"; +import WorkspacesHome from "./WorkspacesHome.svelte"; + +function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 1, + ...overrides, + }; +} + +/** A fake WorkspaceHttp edge with a controllable list (no mocks of our modules). */ +function fakeHttp( + list: WorkspaceEntry[] = [ws()], + lookup: (id: string) => Promise<WorkspaceLookup> = async () => ({ kind: "missing" }), +): WorkspaceHttp { + return { + list: vi.fn(async (): Promise<WorkspaceEntry[]> => list), + // Create-on-miss: the backend persists the new workspace, so the next + // `list` serves it too. + ensure: vi.fn(async (id: string): Promise<WorkspaceResult<Workspace>> => { + if (!list.some((w) => w.id === id)) list.push(ws({ id, title: id })); + return { ok: true, value: ws({ id, title: id }) }; + }), + get: vi.fn(lookup), + setTitle: vi.fn(), + setDefaultCwd: vi.fn(), + setDefaultComputer: vi.fn(), + star: vi.fn(), + unstar: vi.fn(), + delete: vi.fn(async (): Promise<WorkspaceResult<{ closedCount: number }>> => { + // Mirrors the backend: the workspace leaves the list. + list.splice(0); + return { ok: true, value: { closedCount: 0 } }; + }), + } as unknown as WorkspaceHttp; +} + +/** + * A fake tile-layout HTTP edge serving ONE controllable document per mode + * (no mocks of our modules). `docs` is the live store — assertions read it + * after `waitFor` (the boot probe + debounced sync writes land there). + */ +function memoryLayout( + initial: TileLayoutData | null = null, +): TileLayoutHttp & { docs: Map<TileGeometryMode, TileLayoutData> } { + const docs = new Map<TileGeometryMode, TileLayoutData>(); + if (initial !== null) docs.set("wide", initial); + return { + docs, + async getLayout(mode) { + return { ok: true, layout: docs.get(mode) ?? { version: 2, tiles: [] } }; + }, + async putLayout(mode, layout) { + docs.set(mode, layout); + return { ok: true, layout }; + }, + async deleteLayout(mode) { + docs.set(mode, { version: 2, tiles: [] }); + return { ok: true, layout: docs.get(mode) as TileLayoutData }; + }, + }; +} + +describe("WorkspacesHome (tile canvas)", () => { + it("renders one tile per workspace, in the persisted tile order (not the list order)", async () => { + const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })]; + const layout = memoryLayout({ + version: 2, + tiles: [ + { id: "b", order: 0, colSpan: 1, rowSpan: 1 }, + { id: "a", order: 1, colSpan: 1, rowSpan: 1 }, + ], + }); + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)), + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: layout }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace B" }); + const wrappers = document.querySelectorAll("[data-tile-id]"); + expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]); + }); + + it.each([ + false, + true, + ])("renders before the layout probe resolves (cached: %s)", async (cached) => { + const layout = memoryLayout(); + const gate: { release?: (result: TileLayoutHttpResult) => void } = {}; + layout.getLayout = vi.fn( + () => + new Promise<TileLayoutHttpResult>((resolve) => { + gate.release = resolve; + }), + ); + const put = vi.spyOn(layout, "putLayout"); + const stored = { + version: 2, + tiles: [ + { id: "b", order: 0, colSpan: 2, rowSpan: 2 }, + { id: "a", order: 1, colSpan: 1, rowSpan: 1 }, + ], + }; + const storage = cached ? localStorage : undefined; + storage?.setItem("dispatch.tile-layout.tall", JSON.stringify(stored)); + const width = vi.spyOn(window, "innerWidth", "get").mockReturnValue(390); + const height = vi.spyOn(window, "innerHeight", "get").mockReturnValue(844); + const tiles = createTileLayoutStore({ http: layout, initialMode: "tall", storage }); + try { + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore( + fakeHttp([ws(), ws({ id: "b", title: "B" })]), + ), + onNavigate: vi.fn(), + computers: [], + tiles, + }, + }); + await screen.findByRole("button", { name: "Open workspace A" }); + await screen.findByRole("button", { name: "Open workspace B" }); + expect( + [...document.querySelectorAll("[data-tile-id]")].map((el) => + el.getAttribute("data-tile-id"), + ), + ).toEqual(cached ? ["b", "a"] : ["a", "b"]); + expect(tiles.providerState).toBe("unknown"); + expect(tiles.entries).toEqual(cached ? stored.tiles : []); + expect(layout.getLayout).toHaveBeenCalledExactlyOnceWith("tall"); + expect(put).not.toHaveBeenCalled(); + gate.release?.({ + ok: true, + layout: { + version: 2, + tiles: stored.tiles + .slice() + .reverse() + .map((tile, order) => ({ ...tile, order })), + }, + }); + await waitFor(() => { + expect(tiles.providerState).toBe("present"); + expect(tiles.entries.map((tile) => tile.id)).toEqual(["a", "b"]); + // CSS-grid coordinates determine visual order; keyed DOM nodes may stay put. + expect(document.querySelector('[data-tile-id="a"]')).toHaveStyle("grid-row: 1 / span 1"); + expect(document.querySelector('[data-tile-id="b"]')).toHaveStyle("grid-row: 2 / span 2"); + }); + } finally { + width.mockRestore(); + height.mockRestore(); + storage?.removeItem("dispatch.tile-layout.tall"); + } + }); + + it("re-probes when the canvas changes orientation", async () => { + let measure: ResizeObserverCallback = () => {}; + const observer = vi.spyOn(globalThis, "ResizeObserver").mockImplementation((callback) => { + measure = callback; + return { observe() {}, unobserve() {}, disconnect() {} }; + }); + const layout = memoryLayout(); + const get = vi.spyOn(layout, "getLayout"); + const tiles = createTileLayoutStore({ http: layout }); + try { + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()), + onNavigate: vi.fn(), + computers: [], + tiles, + }, + }); + await screen.findByRole("button", { name: "Open workspace A" }); + measure( + [{ contentRect: { width: 390, height: 700 } }] as ResizeObserverEntry[], + {} as ResizeObserver, + ); + await waitFor(() => { + expect(tiles.mode).toBe("tall"); + expect(get.mock.calls).toEqual([["wide"], ["tall"]]); + }); + await screen.findByRole("button", { name: "Open workspace A" }); + } finally { + observer.mockRestore(); + } + }); + + it("clicking a tile navigates to the workspace path", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()), + onNavigate, + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + const tile = await screen.findByRole("button", { name: "Open workspace A" }); + await user.click(tile); + expect(onNavigate).toHaveBeenCalledWith("/a"); + }); + + it("creating a workspace from the header does NOT navigate; the new tile enters rename mode", async () => { + const user = userEvent.setup(); + const list = [ws({ id: "a", title: "A" })]; + const onNavigate = vi.fn(); + const store = (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)); + render(WorkspacesHome, { + props: { + store, + onNavigate, + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace A" }); + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "fresh-ws{Enter}"); + + await waitFor(() => { + expect(screen.getByLabelText("Workspace title", { selector: "input" })).toBeInTheDocument(); + }); + expect(onNavigate).not.toHaveBeenCalled(); + // The backend was asked to create it (create-on-miss). + expect(store.list.map((w) => w.id)).toContain("fresh-ws"); + }); + + it("refuses to create an archived slug: message in the popover, form stays open, no tile", async () => { + const user = userEvent.setup(); + const list = [ws({ id: "a", title: "A" })]; + const http = fakeHttp(list, async (id) => ({ + kind: "found", + workspace: ws({ id, title: id, archived: true }), + })); + const store = (await import("../store.svelte")).createWorkspaceStore(http); + render(WorkspacesHome, { + props: { + store, + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace A" }); + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "old-ws{Enter}"); + + expect(await screen.findByText(/already exists and is archived/)).toBeInTheDocument(); + // The popover stays open (the form/input is still mounted). + expect(screen.getByRole("textbox", { name: "New workspace" })).toBeInTheDocument(); + // Nothing was ensured, no tile appeared, and no rename mode was entered. + expect(http.ensure).not.toHaveBeenCalled(); + expect(store.list.map((w) => w.id)).toEqual(["a"]); + expect(screen.queryByLabelText("Workspace title")).toBeNull(); + }); + + it("right-clicking a tile opens the action bar; Delete keeps the confirm + store call", async () => { + const user = userEvent.setup(); + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + const list = [ws({ id: "a", title: "A" })]; + const store = (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)); + render(WorkspacesHome, { + props: { + store, + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + const tile = await screen.findByRole("button", { name: "Open workspace A" }); + await user.pointer({ keys: "[MouseRight]", target: tile }); + + const deleteButton = await screen.findByRole("button", { name: "Delete" }); + await user.click(deleteButton); + + expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('Delete workspace "A"?')); + await waitFor(() => { + expect(store.list.length).toBe(0); + }); + confirmSpy.mockRestore(); + }); + + it("workspaces unknown to the layout append at the end (deep-link auto-create)", async () => { + const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })]; + const layout = memoryLayout({ + version: 2, + tiles: [{ id: "b", order: 0, colSpan: 1, rowSpan: 1 }], + }); + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)), + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: layout }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace B" }); + await screen.findByRole("button", { name: "Open workspace A" }); + const wrappers = document.querySelectorAll("[data-tile-id]"); + expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]); + // And the sync persisted the appended entry. + await waitFor(() => { + const tiles = layout.docs.get("wide")?.tiles ?? []; + expect(tiles.map((tile) => tile.id)).toEqual(["b", "a"]); + }); + }); +}); diff --git a/src/plugin-runtime/svelte-api.ts b/src/plugin-runtime/svelte-api.ts new file mode 100644 index 0000000..cfc1de5 --- /dev/null +++ b/src/plugin-runtime/svelte-api.ts @@ -0,0 +1,6 @@ +// The Svelte API surface exposed to dynamically-imported frontend plugins +// (plan 03): the import map maps the bare `svelte` specifier to this module's +// emitted chunk, whose star re-exports are the SAME module instances the app +// bundles (one rollup graph → one runtime). Entry-module exports are +// preserved in full, so plugins can import bindings the app never uses. +export * from "svelte"; diff --git a/src/plugin-runtime/svelte-internal-client.ts b/src/plugin-runtime/svelte-internal-client.ts new file mode 100644 index 0000000..cc6b4d6 --- /dev/null +++ b/src/plugin-runtime/svelte-internal-client.ts @@ -0,0 +1,12 @@ +// The compiled-runes runtime exposed to dynamically-imported frontend +// plugins (plan 03): the import map maps the bare `svelte/internal/client` +// specifier to this module's emitted chunk — the same runtime instance the +// app's own compiled components use. +// +// `svelte/internal/client` is intentionally untyped (the compiled-runes +// runtime — its API is compiler-private), so the re-export needs the +// suppression below. Nothing in the app imports THROUGH this module — the +// import map hands it to plugins; its entry exports are preserved in full. +// +// @ts-expect-error — svelte/internal/client ships no declaration file +export * from "svelte/internal/client"; diff --git a/vite.config.ts b/vite.config.ts index 3229445..4a45c51 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,21 +1,239 @@ -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; +import { lookup } from "node:dns/promises"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +console.log("[spike] config module evaluated"); + import { svelte } from "@sveltejs/vite-plugin-svelte"; import tailwindcss from "@tailwindcss/vite"; import { svelteTesting } from "@testing-library/svelte/vite"; -import { defineConfig } from "vitest/config"; +import { defineConfig, type Plugin, type UserConfig } from "vitest/config"; +import { + buildAllowedHosts, + type DevNetworkPolicy, + normalizeHost, + tailscaleSelf, +} from "./scripts/network-policy"; +import { applyViteNetworkPolicy } from "./scripts/vite-network-guard"; + +async function secureDevConfig(config: UserConfig, serving: boolean): Promise<UserConfig> { + // Build/test have no listener and need no subprocess or DNS effects. + if (!serving) return config; + const network = await detectDevNetworkPolicy(); + return applyViteNetworkPolicy(config, network); +} + +async function detectDevNetworkPolicy(): Promise<DevNetworkPolicy> { + let tailscaleStatusJson: string | undefined; + try { + tailscaleStatusJson = execFileSync("tailscale", ["status", "--self", "--json"], { + encoding: "utf8", + timeout: 2000, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + console.warn( + "[network] Tailscale detection unavailable; using loopback and explicit env hosts", + ); + } + const allowedHosts = new Set( + buildAllowedHosts({ + ...(tailscaleStatusJson !== undefined ? { tailscaleStatusJson } : {}), + env: { + ...(process.env.DISPATCH_ALLOWED_HOSTS !== undefined + ? { DISPATCH_ALLOWED_HOSTS: process.env.DISPATCH_ALLOWED_HOSTS } + : {}), + ...(process.env.DISPATCH_TAILSCALE_NAME !== undefined + ? { DISPATCH_TAILSCALE_NAME: process.env.DISPATCH_TAILSCALE_NAME } + : {}), + }, + }), + ); + const self = tailscaleSelf(tailscaleStatusJson); + if (self.hostName) { + try { + const answers = await lookup(self.hostName, { all: true }); + if (answers.some(({ address }) => self.ips.includes(normalizeHost(address)))) { + const name = normalizeHost(self.hostName); + if (name) allowedHosts.add(name); + } + } catch { + // The OS hostname is not necessarily the MagicDNS name. Fail closed. + } + } + return { allowedHosts, allowedAddresses: new Set(["127.0.0.1", "::1", ...self.ips]) }; +} + +/** + * SPIKE (plan 03) — the Svelte-runtime import map. + * + * Dynamically-imported frontend plugin modules are built STANDALONE with the + * whole `svelte` package externalized — their bare `svelte` / + * `svelte/internal/client` imports are resolved in the BROWSER by this import + * map, which points them at the SAME module instances the host app uses (one + * Svelte 5 runtime — two runtimes = broken reactivity). + * + * - Dev: the aliases below make the app resolve the bare specifiers to + * svelte's SOURCE files (stable URLs, no prebundle); the map maps the same + * keys to those URLs. + * - Prod: the two `src/plugin-runtime/*` entry modules are built as their own + * chunks whose exports ARE the app's module instances (one rollup graph); + * the map maps the keys to the emitted chunk files. + */ +const svelteApiEntry = fileURLToPath( + new URL("./src/plugin-runtime/svelte-api.ts", import.meta.url), +); +const svelteInternalEntry = fileURLToPath( + new URL("./src/plugin-runtime/svelte-internal-client.ts", import.meta.url), +); + +function svelteRuntimeImportMap(): Plugin { + /** Build-time map: the emitted runtime-entry chunk file names. */ + const prodMap: Record<string, string> = {}; + return { + name: "dispatch-svelte-runtime-import-map", + transformIndexHtml: { + // Post + head-prepend: runs AFTER generateBundle (so the build-time + // chunk capture is available) and still lands the import map as the + // FIRST head child — before any module script (the import-map spec + // requirement). + order: "post", + handler(html) { + let imports: Record<string, string> = {}; + if (Object.keys(prodMap).length > 0) { + imports = prodMap; + } else { + // Serve (or a build where the runtime entries were dropped): map + // to the alias targets — svelte's source files, with the dep + // optimizer's browserHash so the URLs are identical to the app's + // own imports. + const hash = readBrowserHash(); + const targets: Record<string, string> = { + svelte: "/node_modules/svelte/src/index-client.js", + "svelte/internal/client": "/node_modules/svelte/src/internal/client/index.js", + "svelte/internal/disclose-version": + "/node_modules/svelte/src/internal/disclose-version.js", + "svelte/internal/flags/legacy": "/node_modules/svelte/src/internal/flags/legacy.js", + "svelte/store": "/node_modules/svelte/src/store/index-client.js", + }; + for (const [key, path] of Object.entries(targets)) { + imports[key] = `${path}${hash === null ? "" : `?v=${hash}`}`; + } + } + return { + html, + tags: [ + { + tag: "script", + attrs: { type: "importmap" }, + children: JSON.stringify({ imports }), + injectTo: "head-prepend", + }, + ], + }; + }, + }, + generateBundle(_options, bundle) { + for (const [fileName, chunk] of Object.entries(bundle)) { + if (chunk.type !== "chunk") continue; + const facade = (chunk.facadeModuleId ?? "").split("?")[0]; + if (facade === svelteApiEntry) prodMap.svelte = `/${fileName}`; + if (facade === svelteInternalEntry) { + prodMap["svelte/internal/client"] = `/${fileName}`; + } + } + }, + }; +} + +/** Read the dep optimizer's browserHash from its on-disk metadata (dev only). */ +function readBrowserHash(): string | null { + try { + const meta = JSON.parse( + readFileSync(new URL("./node_modules/.vite/deps/_metadata.json", import.meta.url), "utf8"), + ) as { browserHash?: string }; + return meta.browserHash ?? null; + } catch { + return null; + } +} // 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({ +const config: UserConfig = { // 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 }, + plugins: [tailwindcss(), svelte(), svelteTesting(), svelteRuntimeImportMap()], + // Vite has one bind: destination-interface guards restrict HTTP + HMR to + // loopback / detected Tailscale IPs despite the dual-stack wildcard listener. + // Env host extras are additive and DISPATCH_BIND cannot widen this guard. + server: { port: 24204, host: true, allowedHosts: ["localhost", "127.0.0.1", "::1"] }, + // SPIKE (plan 03): resolve the bare Svelte specifiers to svelte's SOURCE + // files — stable dev URLs the import map points the plugin's bare imports + // at (one shared runtime instance; no prebundle copy of svelte). + resolve: { + alias: [ + { + find: /^svelte$/, + replacement: fileURLToPath( + new URL("./node_modules/svelte/src/index-client.js", import.meta.url), + ), + }, + { + find: /^svelte\/internal\/client$/, + replacement: fileURLToPath( + new URL("./node_modules/svelte/src/internal/client/index.js", import.meta.url), + ), + }, + { + find: /^svelte\/internal\/disclose-version$/, + replacement: fileURLToPath( + new URL("./node_modules/svelte/src/internal/disclose-version.js", import.meta.url), + ), + }, + { + find: /^svelte\/internal\/flags\/legacy$/, + replacement: fileURLToPath( + new URL("./node_modules/svelte/src/internal/flags/legacy.js", import.meta.url), + ), + }, + { + find: /^svelte\/store$/, + replacement: fileURLToPath( + new URL("./node_modules/svelte/src/store/index-client.js", import.meta.url), + ), + }, + ], + }, + // SPIKE (plan 03): never prebundle svelte — the app must resolve it to the + // SAME source URLs the import map hands the plugin's bare imports. + optimizeDeps: { + exclude: [ + "svelte", + "svelte/internal/client", + "svelte/internal/disclose-version", + "svelte/internal/flags/legacy", + "svelte/store", + ], + }, + build: { + rollupOptions: { + // plan 03: keep ENTRY exports in full — the plugin-runtime entries + // (`svelte-api` / `svelte-internal-client`) are the import map's + // targets, and vite's default `preserveEntrySignatures: false` lets + // rollup tree-shake entry exports no other chunk imports (leaving only + // the bindings the app happens to use). Their exports must be the FULL + // svelte API: a plugin may import bindings the app never uses. + preserveEntrySignatures: "strict", + input: { + main: fileURLToPath(new URL("./index.html", import.meta.url)), + "svelte-api": svelteApiEntry, + "svelte-internal-client": svelteInternalEntry, + }, + }, + }, 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. @@ -26,7 +244,11 @@ export default defineConfig({ globals: true, setupFiles: ["./vitest-setup.ts"], }, -}); +}; + +export default defineConfig(async ({ command }) => + secureDevConfig(config, command === "serve" && !process.env.VITEST), +); function getGitShortHash(): string { try { diff --git a/vitest-setup.ts b/vitest-setup.ts index 143ac96..94aa5e8 100644 --- a/vitest-setup.ts +++ b/vitest-setup.ts @@ -14,3 +14,43 @@ if (typeof globalThis.ResizeObserver === "undefined") { disconnect(): void {} }; } +// jsdom implements no Element.animate (Web Animations API — svelte transitions +// and the grid's FLIP reflow use it); stub a no-op so components render in tests. +if (typeof Element !== "undefined" && typeof Element.prototype.animate !== "function") { + Element.prototype.animate = () => { + const noop = () => {}; + return { + cancel: noop, + finish: noop, + pause: noop, + play: noop, + onfinish: null, + } as unknown as Animation; + }; +} +// jsdom implements no PointerEvent constructor — testing-library's +// fireEvent.pointerDown falls back to a plain Event and drops the pointer +// properties (clientX/pointerType/…). Polyfill over jsdom's MouseEvent (the +// drag/press logic is pointer-driven; a real browser always has PointerEvent). +if (typeof globalThis.PointerEvent === "undefined") { + class PointerEventPolyfill extends MouseEvent { + readonly pointerType: string; + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerType = init.pointerType ?? ""; + } + } + globalThis.PointerEvent = PointerEventPolyfill as unknown as typeof PointerEvent; +} + +// jsdom has no native modal dialog lifecycle. Fake only this browser edge; +// real-browser checks cover top-layer rendering and native focus containment. +if (typeof HTMLDialogElement !== "undefined" && !HTMLDialogElement.prototype.showModal) { + HTMLDialogElement.prototype.showModal = function () { + this.open = true; + this.querySelector<HTMLElement>("button")?.focus(); + }; + HTMLDialogElement.prototype.close = function () { + this.open = false; + }; +} |
