diff options
28 files changed, 2825 insertions, 355 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 182279a..6a5a6b3 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -5,9 +5,16 @@ > permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — this file is for > READING only. > -> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers). Regenerate whenever +> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers + provider concurrency). Regenerate whenever > it changes. > +> **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/ @@ -1019,5 +1026,57 @@ export interface TestComputerResponse { readonly ok: boolean; readonly error?: string; } + +// ── Provider concurrency limits ([email protected]) ─────────────────── + +/** + * Response of `GET /concurrency/limits` — all providers with configured + * concurrency limits. Each entry pairs a provider id (e.g. "umans", + * "openai-compat") with its maximum concurrent in-flight requests. Providers + * not listed here have no limit (unlimited). + */ +export interface ConcurrencyLimitsResponse { + readonly limits: readonly { + readonly providerId: string; + readonly limit: number; + }[]; +} +/** + * Body of `PUT /concurrency/limits/:providerId` — set or update the concurrency + * limit for a provider. `limit` must be a positive integer. When a limit is + * set, requests beyond the limit queue (oldest-agent-first) rather than being + * sent immediately. + */ +export interface SetConcurrencyLimitRequest { + readonly limit: number; +} +/** Response of `GET/PUT /concurrency/limits/:providerId` — the configured limit. */ +export interface ConcurrencyLimitResponse { + readonly providerId: string; + readonly limit: number; +} +/** + * One provider's live concurrency status. + * + * - `inFlight`: how many slots are currently held (tokens being generated). + * - `queued`: how many agents are waiting for a slot. + * - `paused`: whether the queue is paused due to a 429 backoff. + * - `pausedUntil`: when the pause expires (epoch-ms), present only when paused. + */ +export interface ConcurrencyStatusEntry { + readonly providerId: string; + readonly limit: number; + readonly inFlight: number; + readonly queued: number; + readonly paused: boolean; + readonly pausedUntil?: number; +} +/** + * 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[]; +} ``` diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index 75adac5..888c160 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -4,7 +4,12 @@ > 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). Regenerate whenever `@dispatch/wire` changes. +> **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces + computers + provider-retry + concurrency-`queued` status). Regenerate whenever `@dispatch/wire` changes. +> +> **2026-06-26 delta (provider concurrency — ADDITIVE to `[email protected]`, NO version bump):** `ConversationStatus` +> widened to `"active" | "queued" | "idle" | "closed"`. `queued` = the turn is in flight but waiting for a +> per-provider concurrency slot (broadcast-only via `conversation.statusChanged`, never persisted); the FE shows +> a loading ring (vs the dots of `active`). See `backend-handoff.md` CR-13. > > **2026-06-26 delta (vision handoff — ADDITIVE to `[email protected]`, NO version bump):** adds a new > `ImageChunk` variant to the `Chunk` union (`{ type: "image", url, mimeType? }` — `url` is a base64 data @@ -622,12 +627,16 @@ export interface TurnSteeringEvent { /** * The lifecycle status of a conversation, used for tab persistence across - * devices. `active` = an agent is currently generating; `idle` = exists but not - * generating; `closed` = user dismissed the tab (hidden from the tab bar, not - * deleted). New conversations start as `idle`; transitions to `active` on - * turn-start, back to `idle` on turn done/error, and to `closed` on user close. + * devices. `active` = an agent is currently generating; `queued` = the turn is + * in flight but waiting for a per-provider concurrency slot (broadcast-only, + * never persisted — CR-13; the tab shows a ring vs the dots of `active`); + * `idle` = exists but not generating; `closed` = user dismissed the tab + * (hidden from the tab bar, not deleted). New conversations start as `idle`; + * transitions to `active` on turn-start (or `queued` when the request blocks on + * a concurrency slot before generation begins), back to `idle` on turn + * done/error, and to `closed` on user close. */ -export type ConversationStatus = "active" | "idle" | "closed"; +export type ConversationStatus = "active" | "queued" | "idle" | "closed"; /** * Metadata for a conversation, returned by `GET /conversations` (the list diff --git a/backend-handoff.md b/backend-handoff.md index 5a476be..5b41741 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,6 +5,10 @@ > **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-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)._ +**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9** _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 @@ -12,7 +16,7 @@ echo's data URL passes through unchanged; `ChatRequest.images` (send) is unchang (+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. +(§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 @@ -25,13 +29,13 @@ FE consumes the MCP status slice (`GET /conversations/:id/mcp`, mirroring `/lsp` ## 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`)/`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`) + WS chat ops + `WsClientMessage`/`WsServerMessage` | +| `@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` · @@ -45,11 +49,14 @@ 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`). +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`). 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]`). +`[email protected]` / `[email protected]`). ### FE invariants to keep (don't regress) @@ -90,6 +97,51 @@ No wire/transport-contract/ui-contract change needed — this is a backend behav 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 @@ -717,6 +769,87 @@ down, confirm it matches when a run actually fires). Until CR-HB-3 ships, the FE --- +## 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 diff --git a/src/App.svelte b/src/App.svelte index 0dbc958..713c844 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -70,5 +70,5 @@ {#if route.kind === "home"} <WorkspacesHome store={workspaceStore} onNavigate={navigate} computers={store.computers} /> {:else} - <App {store} /> + <App {store} onNavigate={navigate} /> {/if} diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index 113a731..dd2b773 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -283,6 +283,21 @@ describe("parseServerMessage", () => { }); }); + it("accepts the `queued` status (CR-13 — waiting for a concurrency slot)", () => { + const data = JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "queued", + workspaceId: "w1", + }); + expect(parseServerMessage(data)).toEqual({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "queued", + workspaceId: "w1", + }); + }); + it("returns null for conversation.statusChanged with missing workspaceId", () => { expect( parseServerMessage( diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index ba3e7ee..03ef763 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -126,7 +126,12 @@ export function parseServerMessage(data: string): WsServerMessage | null { case "conversation.statusChanged": { if (typeof parsed.conversationId !== "string") return null; if (typeof parsed.status !== "string") return null; - if (parsed.status !== "active" && parsed.status !== "idle" && parsed.status !== "closed") { + if ( + parsed.status !== "active" && + parsed.status !== "queued" && + parsed.status !== "idle" && + parsed.status !== "closed" + ) { return null; } if (typeof parsed.workspaceId !== "string") return null; diff --git a/src/app/App.svelte b/src/app/App.svelte index e521167..59f949c 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -16,6 +16,7 @@ ModelSelector, ReasoningEffortSelector, type CompactNowResult, + type ComposerStatus, type ReasoningEffortSaveResult, type SaveCompactPercentResult, } from "../features/chat"; @@ -40,7 +41,7 @@ import { parseMessageQueuePayload } from "../features/surface-host/logic/message-queue"; import { parseTodoPayload } from "../features/surface-host/logic/todo"; import TodoList from "../features/surface-host/ui/TodoList.svelte"; - import { manifest as tabsManifest, TabBar } from "../features/tabs"; + import { manifest as tabsManifest, TabList } from "../features/tabs"; import { manifest as viewsManifest, ViewSidebar } from "../features/views"; import { CwdField, @@ -69,6 +70,14 @@ type HeartbeatRunsResult, type HeartbeatStopResult, } from "../features/heartbeat"; + import { + ConcurrencyView, + manifest as concurrencyManifest, + type DeleteConcurrencyLimit, + type LoadConcurrencyLimits, + type LoadConcurrencyStatus, + type SaveConcurrencyLimit, + } from "../features/concurrency"; import type { ChatStore } from "../features/chat"; import { SystemPromptBuilder, @@ -89,7 +98,7 @@ import { createLocalStore } from "../adapters/local-storage"; import { untrack } from "svelte"; - let { store }: { store: AppStore } = $props(); + let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props(); // The backend's conversation-scoped cache-warming surface. Referenced by id at // the composition root (sanctioned discovery-by-id) to give it a dedicated view @@ -106,6 +115,7 @@ // 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" }, @@ -114,12 +124,14 @@ { id: "tasks", label: "Tasks" }, { id: "compaction", label: "Compaction" }, { id: "heartbeat", label: "Heartbeat" }, + { id: "concurrency", label: "Concurrency" }, { id: "system-prompt", label: "System Prompt" }, { id: "settings", label: "Settings" }, ] as const; - // Default sidebar layout: just the Model view. - const DEFAULT_VIEWS: readonly string[] = ["model"]; + // Default sidebar layout: the Tabs list (the moved-from-top tab bar) plus the + // Model view, so a fresh user sees their conversations + model controls. + const DEFAULT_VIEWS: readonly string[] = ["tabs", "model"]; const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", { storage: untrack(() => store.storage), }); @@ -150,6 +162,7 @@ settingsManifest, systemPromptManifest, heartbeatManifest, + concurrencyManifest, visionManifest, ].map((m) => [m.name, m.description] as const); @@ -222,6 +235,31 @@ return parseTodoPayload(field.payload); }); + // Top-bar title: the active tab's title, or "New Tab" when no tab is active + // (a fresh, unstarted draft — the conversation hasn't been sent yet, so no + // tab exists). Pure-derived from the (workspace-filtered) tab set + the + // active id; reflects whichever tab is selected in the sidebar's Tabs view. + const NEW_TAB_TITLE = "New Tab"; + const topBarTitle = $derived.by(() => { + const id = store.activeConversationId; + if (id === null) return NEW_TAB_TITLE; + const tab = store.tabs.find((t) => t.conversationId === id); + return tab?.title ?? NEW_TAB_TITLE; + }); + + // The composer status-bar status. Priority: error > queued > running > idle. + // `queued` (the turn is in flight but waiting for a concurrency slot — CR-13) + // wins over `running` so the corner shows a ring, not dots, during the wait. + // `turn-start` fires before the slot is granted, so `generating` is already + // true while `conversationStatus === "queued"`; the explicit queued check is + // what distinguishes the two. + const composerStatus = $derived.by<ComposerStatus>(() => { + if (store.activeChat.error) return "error"; + const id = store.activeConversationId; + if (id !== null && store.conversationStatus(id) === "queued") return "queued"; + return store.activeChat.generating ? "running" : "idle"; + }); + // Conversation/tab switch → snap to the bottom of the new transcript. $effect(() => { void store.activeConversationId; @@ -441,34 +479,72 @@ 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(); </script> <main class="relative flex h-screen overflow-hidden"> <!-- LEFT: everything except the sidebar. The full-height sidebar is a sibling - (below), so opening it shrinks this ENTIRE column — tab row included, which - slides the hamburger left. --> + (below), so opening it shrinks this ENTIRE column. --> <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]"> - <!-- Tab row: the tab strip fills + scrolls internally (flex-1 min-w-0), with - a permanently seated hamburger pinned to the far right. --> - <div class="flex min-w-0 items-center"> - <TabBar - tabs={store.tabs} - activeConversationId={store.activeConversationId} - statusFor={(id) => store.conversationStatus(id)} - onSelect={(id) => store.selectTab(id)} - onClose={(id) => store.closeTab(id)} - onNewDraft={() => store.newDraft()} - onRename={(id, title) => store.renameTab(id, title)} - /> + <!-- Slim header: the tab bar moved into the sidebar (the "Tabs" view), so + the top row now shows the active tab's title on the left (or "New Tab" + for an unstarted draft), with the build version + sidebar toggle on + the right. --> + <div class="flex items-center justify-between gap-2 px-2"> + <span + class="min-w-0 flex-1 shrink truncate pl-2 text-sm font-medium opacity-70" + data-testid="top-bar-title" + title={topBarTitle} + aria-label="Active conversation title" + > + {topBarTitle} + </span> + <a + href="/" + class="btn btn-ghost btn-sm shrink-0 px-2" + aria-label="Back to dashboard" + title="Back to dashboard" + onclick={(e) => { + e.preventDefault(); + onNavigate("/"); + }} + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + class="size-4" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V15a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V9.75M8.25 21h8.25" + /> + </svg> + </a> <span class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30" title="Build version (git short hash)" > - {__APP_VERSION__} + build: {__APP_VERSION__} </span> <button class="btn btn-square btn-ghost btn-sm mx-1 shrink-0" - aria-label="Toggle sidebar" + aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"} aria-expanded={sidebarOpen} onclick={() => (sidebarOpen = !sidebarOpen)} > @@ -481,11 +557,21 @@ class="size-5" aria-hidden="true" > - <path - stroke-linecap="round" - stroke-linejoin="round" - d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5" - /> + {#if sidebarOpen} + <!-- Sidebar open → chevrons point right --> + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m11.25 4.5 7.5 7.5-7.5 7.5m-7.5-15 7.5 7.5-7.5 7.5" + /> + {:else} + <!-- Sidebar closed → chevrons point left --> + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" + /> + {/if} </svg> </button> </div> @@ -504,7 +590,7 @@ </div> {/if} - <div class="relative min-h-0 min-w-0 flex-1"> + <div class="relative min-h-0 min-w-0 flex-1 pr-4"> <div bind:this={transcriptEl} class="h-full overflow-y-auto"> <div bind:this={transcriptContentEl}> {#key store.activeConversationId} @@ -546,11 +632,7 @@ onStop={handleStop} contextSize={store.activeChat.currentContextSize} contextWindow={store.modelInfo[store.activeModel]?.contextWindow} - status={store.activeChat.error - ? "error" - : store.activeChat.generating - ? "running" - : "idle"} + status={composerStatus} /> </div> @@ -563,7 +645,7 @@ class:w-0={!sidebarOpen} > <div - class="flex h-full w-80 flex-col gap-2 overflow-y-auto border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out" + class="flex h-full w-80 flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 transition-transform duration-300 ease-out" style="transform: translateX({sidebarOpen ? '0' : '100%'})" > <ViewSidebar kinds={viewKinds} initial={sidebarPanels} onChange={handleSidebarChange} content={viewContent} /> @@ -616,7 +698,22 @@ {/if} {#snippet viewContent(kind: string)} - {#if kind === "model"} + {#if kind === "tabs"} + <!-- The conversation tab list (moved out of the top bar into the sidebar). + Re-mount per workspace so the filtered tab set + scroll reset cleanly + on a workspace switch. --> + {#key store.activeWorkspaceId} + <TabList + tabs={store.tabs} + activeConversationId={store.activeConversationId} + statusFor={(id) => store.conversationStatus(id)} + onSelect={(id) => store.selectTab(id)} + onClose={(id) => store.closeTab(id)} + onNewDraft={() => store.newDraft()} + onRename={(id, title) => store.renameTab(id, title)} + /> + {/key} + {:else if kind === "model"} <div class="flex flex-col gap-3"> <ModelSelector models={store.models} @@ -735,5 +832,15 @@ 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} + /> {/if} {/snippet} diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 7c0a851..dcdcb9b 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -2,7 +2,7 @@ import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contrac import type { SurfaceServerMessage } from "@dispatch/ui-contract"; import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { WebSocketLike } from "../adapters/ws"; import App from "./App.svelte"; import { createAppStore } from "./store.svelte"; @@ -127,7 +127,7 @@ describe("App component interaction tests", () => { }); ws.resolveOpen(); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); expect(screen.getByRole("textbox", { name: "Message input" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(); @@ -136,6 +136,68 @@ describe("App component interaction tests", () => { store.dispose(); }); + it("shows 'New Tab' in the top bar for an unstarted draft (no active tab)", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + expect(store.activeConversationId).toBeNull(); + expect(screen.getByTestId("top-bar-title")).toHaveTextContent("New Tab"); + + store.dispose(); + }); + + it("shows the active tab's title in the top bar once a tab is started", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + // Promote draft → tab: the tab's title is derived from the first message. + store.send("Refactor the auth module"); + expect(store.activeConversationId).not.toBeNull(); + + // The top-bar title updates reactively; findByTestId awaits the flush. + expect(await screen.findByTestId("top-bar-title")).toHaveTextContent( + "Refactor the auth module", + ); + + store.dispose(); + }); + + it("the dashboard home button navigates to '/' (back to the workspaces home)", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const onNavigate = vi.fn(); + render(App, { props: { store, onNavigate } }); + + const homeBtn = screen.getByRole("link", { name: "Back to dashboard" }); + expect(homeBtn).toHaveAttribute("href", "/"); + await userEvent.setup().click(homeBtn); + + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate).toHaveBeenCalledWith("/"); + + store.dispose(); + }); + it("auto-subscribes to every catalog entry on render (no buttons to click)", () => { const ws = fakeSocket(); const store = createAppStore({ @@ -154,7 +216,7 @@ describe("App component interaction tests", () => { ], }); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); const subscribed = sentMessages(ws) .filter((m: { type: string }) => m.type === "subscribe") @@ -182,7 +244,7 @@ describe("App component interaction tests", () => { ], }); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); // No interaction: specs arrive and both surfaces render expanded. ws.feedSurfaceMessage({ @@ -221,7 +283,7 @@ describe("App component interaction tests", () => { message: "Something went wrong", }); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); const alert = screen.getByRole("alert"); expect(alert).toHaveTextContent("Something went wrong"); @@ -243,7 +305,7 @@ describe("App component interaction tests", () => { catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], }); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); const user = userEvent.setup(); // Surface is auto-subscribed; its spec arrives and renders expanded. @@ -290,7 +352,7 @@ describe("App component interaction tests", () => { }); ws.resolveOpen(); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); const user = userEvent.setup(); const textarea = screen.getByRole("textbox", { name: "Message input" }); @@ -323,7 +385,7 @@ describe("App component interaction tests", () => { store.send("test"); const convId = activeConversationId(store); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); ws.feedServerMessage({ type: "chat.delta", @@ -363,7 +425,7 @@ describe("App component interaction tests", () => { catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], }); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); // Auto-subscribed; the custom-table spec arrives and renders expanded. ws.feedSurfaceMessage({ @@ -401,7 +463,7 @@ describe("App component interaction tests", () => { }); ws.resolveOpen(); - render(App, { props: { store } }); + 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(); @@ -451,7 +513,7 @@ describe("App component interaction tests", () => { }); ws.resolveOpen(); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); // The modal should appear with the error text const dialog = await screen.findByRole("dialog", { name: "Error" }, { timeout: 3000 }); @@ -477,7 +539,7 @@ describe("App component interaction tests", () => { }); ws.resolveOpen(); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); // Wait a tick for boot async to settle await new Promise((resolve) => setTimeout(resolve, 200)); diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 8353820..ead27a6 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -54,6 +54,17 @@ import { } from "../core/protocol"; import type { ChatStore, HistorySync, MetricsSync } from "../features/chat"; import { createChatStore } from "../features/chat"; +import type { + ConcurrencyDeleteResult, + ConcurrencyLimitResult, + ConcurrencyLimitsResult, + ConcurrencyStatusResult, +} from "../features/concurrency"; +import { + normalizeConcurrencyLimit, + normalizeConcurrencyLimits, + normalizeConcurrencyStatus, +} from "../features/concurrency"; import type { ConversationCache } from "../features/conversation-cache"; import { createConversationCache } from "../features/conversation-cache"; import type { @@ -404,6 +415,38 @@ export interface AppStore { /** 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. Returns + * an empty list when the extension isn't loaded (`{ providers: [] }`). + */ + concurrencyStatus(): Promise<ConcurrencyStatusResult>; + /** * 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). @@ -994,10 +1037,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } return; } - // active / idle — update the status map (drives the tab spinner). + // 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 conversation we don't have a tab for, open one. - if (status === "active" && !chatStores.has(conversationId)) { + // 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); } }, @@ -1789,6 +1836,128 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { unwatchConversation(conversationId); }, + // ── Concurrency (per-provider limits + live status; GLOBAL, not workspace-scoped) + + async concurrencyLimits(): Promise<ConcurrencyLimitsResult> { + try { + const res = await fetchImpl(`${httpBase}/concurrency/limits`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Concurrency limits failed (HTTP ${res.status})`, + }; + } + // Normalize the untyped JSON at the network seam (pure helper) so a + // malformed/partial response (e.g. the extension returning `{}`) can + // never crash the renderer. + const limits = normalizeConcurrencyLimits(await res.json()); + return { ok: true, limits }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Concurrency limits request failed", + }; + } + }, + + async getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult> { + try { + const res = await fetchImpl( + `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Concurrency limit failed (HTTP ${res.status})`, + }; + } + const limit = normalizeConcurrencyLimit(await res.json()); + if (limit === null) { + return { ok: false, error: "Malformed concurrency limit response" }; + } + return { ok: true, providerId: limit.providerId, limit: limit.limit }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Concurrency limit request failed", + }; + } + }, + + async setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult> { + try { + const res = await fetchImpl( + `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ limit }), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set concurrency limit failed (HTTP ${res.status})`, + }; + } + const echoed = normalizeConcurrencyLimit(await res.json()); + if (echoed === null) { + return { ok: false, error: "Malformed concurrency limit response" }; + } + return { ok: true, providerId: echoed.providerId, limit: echoed.limit }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set concurrency limit request failed", + }; + } + }, + + async deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult> { + try { + const res = await fetchImpl( + `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`, + { method: "DELETE" }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Delete concurrency limit failed (HTTP ${res.status})`, + }; + } + return { ok: true, providerId }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Delete concurrency limit request failed", + }; + } + }, + + async concurrencyStatus(): Promise<ConcurrencyStatusResult> { + try { + const res = await fetchImpl(`${httpBase}/concurrency/status`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Concurrency status failed (HTTP ${res.status})`, + }; + } + const providers = normalizeConcurrencyStatus(await res.json()); + return { ok: true, providers }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Concurrency status request failed", + }; + } + }, + async loadSystemPrompt(): Promise<SystemPromptLoadResult> { try { const res = await fetchImpl(`${httpBase}/system-prompt`); diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 1534402..2cf473c 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -1403,6 +1403,333 @@ describe("createAppStore", () => { store.dispose(); }); + + // ── Concurrency (per-provider limits + live status; GLOBAL REST surface) ───── + // + // The concurrency API is a plain REST surface under /concurrency (provided by + // the `concurrency` extension). These tests fake all five endpoints + verify + // the store coerces the untyped JSON and routes the right method/URL. + + function concurrencyFetchImpl(opts?: { + limits?: Record<string, unknown>; + limit?: Record<string, unknown>; + status?: Record<string, unknown>; + }): typeof fetch { + const base = fakeFetchImpl(); + const limits = opts?.limits ?? { + limits: [ + { providerId: "umans", limit: 4 }, + { providerId: "openai-compat", limit: 5 }, + ], + }; + const limit = opts?.limit ?? { providerId: "umans", limit: 4 }; + const status = opts?.status ?? { + providers: [ + { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, + { + providerId: "openai-compat", + limit: 5, + inFlight: 5, + queued: 3, + paused: true, + pausedUntil: 1_719_408_000_000, + }, + ], + }; + return async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.endsWith("/concurrency/status") && method === "GET") { + return new Response(JSON.stringify(status), { status: 200 }); + } + if (url.endsWith("/concurrency/limits") && method === "GET") { + return new Response(JSON.stringify(limits), { status: 200 }); + } + if (url.includes("/concurrency/limits/") && method === "GET") { + return new Response(JSON.stringify(limit), { status: 200 }); + } + if (url.includes("/concurrency/limits/") && method === "PUT") { + const seg = url.slice( + url.lastIndexOf("/concurrency/limits/") + "/concurrency/limits/".length, + ); + const body = init?.body ? JSON.parse(init.body as string) : {}; + return new Response(JSON.stringify({ providerId: seg, ...body }), { status: 200 }); + } + if (url.includes("/concurrency/limits/") && method === "DELETE") { + return new Response(JSON.stringify({ ok: true, providerId: "umans" }), { status: 200 }); + } + return base(input, init); + }; + } + + it("concurrencyLimits loads + coerces the limits list", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: concurrencyFetchImpl(), + localStorage: createFakeStorage(), + }); + const result = await store.concurrencyLimits(); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.limits).toEqual([ + { providerId: "umans", limit: 4 }, + { providerId: "openai-compat", limit: 5 }, + ]); + store.dispose(); + }); + + it("concurrencyLimits tolerates a malformed/empty body (extension not loaded)", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: concurrencyFetchImpl({ limits: {} }), + localStorage: createFakeStorage(), + }); + const result = await store.concurrencyLimits(); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.limits).toEqual([]); + store.dispose(); + }); + + it("concurrencyLimits surfaces an HTTP error", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/concurrency/limits")) + return new Response(JSON.stringify({ error: "Concurrency service not available" }), { + status: 503, + }); + return fakeFetchImpl()(input); + }, + localStorage: createFakeStorage(), + }); + const result = await store.concurrencyLimits(); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("Concurrency service not available"); + store.dispose(); + }); + + it("getConcurrencyLimit loads one provider's limit", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: concurrencyFetchImpl(), + localStorage: createFakeStorage(), + }); + const result = await store.getConcurrencyLimit("umans"); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.providerId).toBe("umans"); + expect(result.limit).toBe(4); + store.dispose(); + }); + + it("getConcurrencyLimit surfaces a 404 (not configured)", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.includes("/concurrency/limits/") && method === "GET") + return new Response( + JSON.stringify({ error: "No concurrency limit configured for this provider" }), + { status: 404 }, + ); + return fakeFetchImpl()(input); + }, + localStorage: createFakeStorage(), + }); + const result = await store.getConcurrencyLimit("anthropic"); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("No concurrency limit configured"); + store.dispose(); + }); + + it("setConcurrencyLimit PUTs { limit } to the provider URL and returns the echoed limit", async () => { + const calls: { url: string; method: string; body: unknown }[] = []; + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.includes("/concurrency/limits/") && method === "PUT") { + calls.push({ url, method, body: JSON.parse(init?.body as string) }); + } + return concurrencyFetchImpl()(input, init); + }, + localStorage: createFakeStorage(), + }); + const result = await store.setConcurrencyLimit("anthropic", 8); + expect(result.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]?.method).toBe("PUT"); + expect(calls[0]?.url).toContain("/concurrency/limits/anthropic"); + expect(calls[0]?.body).toEqual({ limit: 8 }); + if (!result.ok) throw new Error("unreachable"); + expect(result.providerId).toBe("anthropic"); // echoed by the fake + expect(result.limit).toBe(8); + store.dispose(); + }); + + it("setConcurrencyLimit surfaces a 400 (non-positive body)", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes("/concurrency/limits/") && init?.method === "PUT") + return new Response( + JSON.stringify({ error: "Body must be { limit: <positive integer> }" }), + { + status: 400, + }, + ); + return fakeFetchImpl()(input); + }, + localStorage: createFakeStorage(), + }); + const result = await store.setConcurrencyLimit("umans", 0); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("Body must be"); + store.dispose(); + }); + + it("deleteConcurrencyLimit DELETEs the provider URL and returns ok", async () => { + const calls: { url: string; method: string }[] = []; + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.includes("/concurrency/limits/") && method === "DELETE") { + calls.push({ url, method }); + } + return concurrencyFetchImpl()(input, init); + }, + localStorage: createFakeStorage(), + }); + const result = await store.deleteConcurrencyLimit("umans"); + expect(result.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]?.method).toBe("DELETE"); + expect(calls[0]?.url).toContain("/concurrency/limits/umans"); + if (!result.ok) throw new Error("unreachable"); + expect(result.providerId).toBe("umans"); + store.dispose(); + }); + + it("concurrencyStatus loads + coerces the status list (incl. pausedUntil)", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: concurrencyFetchImpl(), + localStorage: createFakeStorage(), + }); + const result = await store.concurrencyStatus(); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.providers).toHaveLength(2); + expect(result.providers[0]).toEqual({ + providerId: "umans", + limit: 4, + inFlight: 2, + queued: 1, + paused: false, + }); + expect(result.providers[1]).toMatchObject({ + providerId: "openai-compat", + paused: true, + pausedUntil: 1_719_408_000_000, + }); + store.dispose(); + }); + + it("concurrencyStatus tolerates a malformed body (extension not loaded)", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: concurrencyFetchImpl({ status: {} }), + localStorage: createFakeStorage(), + }); + const result = await store.concurrencyStatus(); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.providers).toEqual([]); + store.dispose(); + }); + + // ── Conversation status: `queued` (CR-13 — waiting for a concurrency slot) ──── + + it("conversation.statusChanged 'queued' sets the status (tab spinner) without opening a duplicate tab", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + store.send("hello"); + const convId = activeConversationId(store); + const tabsBefore = store.tabs.length; + + // The backend broadcasts "queued" while the turn waits for a slot. + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "queued", + workspaceId: "default", + }); + + expect(store.conversationStatus(convId)).toBe("queued"); + // The tab already exists (opened on send) — no duplicate tab is opened. + expect(store.tabs.length).toBe(tabsBefore); + + // Granted → "active" (dots), then idle on turn seal. + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "active", + workspaceId: "default", + }); + expect(store.conversationStatus(convId)).toBe("active"); + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: convId, + status: "idle", + workspaceId: "default", + }); + expect(store.conversationStatus(convId)).toBe("idle"); + store.dispose(); + }); + + it("conversation.statusChanged 'queued' opens a tab for a new cross-device conversation", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + expect(store.tabs.length).toBe(0); + + // Another device's turn is waiting for a slot — broadcast "queued". + ws.feedServerMessage({ + type: "conversation.statusChanged", + conversationId: "other-device-conv", + status: "queued", + workspaceId: "default", + }); + + expect(store.conversationStatus("other-device-conv")).toBe("queued"); + // A queued conversation we had no tab for opens one (like `active`). + expect(store.tabs.some((t) => t.conversationId === "other-device-conv")).toBe(true); + store.dispose(); + }); }); describe("createAppStore — vision settings (global)", () => { diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 773cb91..cf57cea 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -25,6 +25,7 @@ export { createChatStore } from "./store.svelte"; export { default as ChatView } from "./ui/ChatView.svelte"; export type { CompactNowResult, SaveCompactPercentResult } from "./ui/CompactionView.svelte"; export { default as CompactionView } from "./ui/CompactionView.svelte"; +export type { ComposerStatus } from "./ui/Composer.svelte"; export { default as Composer } from "./ui/Composer.svelte"; export { default as ModelSelector } from "./ui/ModelSelector.svelte"; export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte"; diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index cd69071..e67ca5b 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -231,7 +231,7 @@ {/if} {/snippet} -<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite"> +<div class="flex flex-col gap-2 pt-4 pb-4 pl-6" role="log" aria-live="polite"> {#if hasEarlier && onShowEarlier} <!-- Chat limit: older chunks are unloaded; offer to page them back in. --> <div class="flex justify-center"> diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index 7898448..afe1e3c 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -44,10 +44,17 @@ contextSize?: number | undefined; /** Per-model context window (max tokens) from `GET /models` modelInfo. */ contextWindow?: number | undefined; - // Coarse agent status for the status-bar icon. - status?: "idle" | "running" | "error"; + /** + * Coarse agent status for the status-bar icon. `queued` = the turn is in + * flight but waiting for a concurrency slot (CR-13) — shown as a loading + * RING (vs the loading DOTS of `running`/actively generating). Behaves like + * `running` for the send button (steer/stop). + */ + status?: ComposerStatus; } = $props(); + export type ComposerStatus = "idle" | "running" | "queued" | "error"; + let text = $state(""); let images = $state<StagedImage[]>([]); let inputEl: HTMLTextAreaElement | undefined; @@ -63,21 +70,25 @@ // One button, three modes: // - idle → "Send" (starts a turn via chat.send) - // - running + text/images → "Queue" (steers via chat.queue — text only) - // - running + empty → "Stop" (aborts via POST /stop) + // - running/queued + text → "Queue" (steers via chat.queue — text only) + // - running/queued + empty → "Stop" (aborts via POST /stop) + // (`queued` behaves like `running` — the turn is in flight, just waiting for a + // concurrency slot; the user can still steer or stop it.) // Steering never carries images: when running with images staged but no text, - // fall back to "Send" semantics is wrong mid-turn — instead queue the text part - // only (images stay staged). Simplest correct rule: queue is text-only and only - // offered with text; images-without-text while running is an unusual case that - // still sends (the server auto-starts/resolves). Keep the three-mode logic - // driven by text presence for the queue vs stop split. + // 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 (status === "running" && !hasText && !hasImages && onStop !== undefined) return "stop"; - if (status === "running" && hasText && onQueue !== undefined) return "queue"; + if (inFlight && !hasText && !hasImages && onStop !== undefined) return "stop"; + if (inFlight && hasText && onQueue !== undefined) return "queue"; return "send"; }); const placeholder = $derived( - status === "running" ? "Steer the conversation..." : "Type a message, paste or drop an image…", + status === "queued" + ? "Queued for a slot…" + : status === "running" + ? "Steer the conversation..." + : "Type a message, paste or drop an image…", ); // As the window fills, escalate color: calm → warning → danger. @@ -331,8 +342,15 @@ <!-- Bottom status bar: status icon · context-window fill · token count --> <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50"> <span class="shrink-0"> - {#if status === "running"} - <span class="loading loading-spinner loading-xs text-primary"></span> + {#if status === "queued"} + <!-- Waiting for a concurrency slot — a ring (vs the dots of `running`). --> + <span + class="loading loading-ring loading-xs text-primary" + aria-label="Queued" + title="Waiting for a concurrency slot" + ></span> + {:else if status === "running"} + <span class="loading loading-dots loading-xs text-primary"></span> {:else if status === "error"} <svg xmlns="http://www.w3.org/2000/svg" diff --git a/src/features/concurrency/index.ts b/src/features/concurrency/index.ts new file mode 100644 index 0000000..151acb8 --- /dev/null +++ b/src/features/concurrency/index.ts @@ -0,0 +1,44 @@ +export type { + ConcurrencyDeleteResult, + ConcurrencyLimitEntry, + // Contract shapes re-exported for a single import surface. + ConcurrencyLimitResponse, + ConcurrencyLimitResult, + ConcurrencyLimitsResponse, + ConcurrencyLimitsResult, + ConcurrencyStatusEntry, + ConcurrencyStatusResponse, + ConcurrencyStatusResult, + DeleteConcurrencyLimit, + GetConcurrencyLimit, + LoadConcurrencyLimits, + LoadConcurrencyStatus, + SaveConcurrencyLimit, + SetConcurrencyLimitRequest, +} from "./logic/types"; +export type { Badge, ConcurrencyLimitView, ConcurrencyStatusView } from "./logic/view-model"; +export { + formatPauseDuration, + normalizeConcurrencyLimit, + normalizeConcurrencyLimits, + normalizeConcurrencyStatus, + normalizeLimit, + parseLimitInput, + pauseLabel, + providerFromModel, + providerOptions, + summarizeLimits, + summarizeStatus, + viewConcurrencyLimit, + viewConcurrencyLimits, + viewConcurrencyStatus, + viewConcurrencyStatuses, +} from "./logic/view-model"; +export { default as ConcurrencyLimitRow } from "./ui/ConcurrencyLimitRow.svelte"; +export { default as ConcurrencyView } from "./ui/ConcurrencyView.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "concurrency", + description: "Per-provider concurrency limits + live in-flight/queue status", +} as const; diff --git a/src/features/concurrency/logic/types.ts b/src/features/concurrency/logic/types.ts new file mode 100644 index 0000000..f05211f --- /dev/null +++ b/src/features/concurrency/logic/types.ts @@ -0,0 +1,82 @@ +import type { + ConcurrencyLimitResponse, + ConcurrencyLimitsResponse, + ConcurrencyStatusEntry, + ConcurrencyStatusResponse, + SetConcurrencyLimitRequest, +} from "@dispatch/transport-contract"; + +/** + * Pure core types for the concurrency feature — zero DOM, zero effects, zero + * Svelte. + * + * The backend tracks + limits how many concurrent token-generating API requests + * are in flight PER PROVIDER. When the cap is reached, additional requests queue + * and are granted slots oldest-agent-first; a 429 backoff PAUSES a provider's + * queue until `pausedUntil`. The cap is in-memory + per-provider (no persistence), + * managed via a plain REST surface under `/concurrency/...` provided by the + * `concurrency` extension. When the extension isn't loaded, the list + status + * endpoints return empty arrays (`{ limits: [] }` / `{ providers: [] }`); the + * single / PUT / DELETE endpoints return `503`. + * + * The data shapes ARE part of `@dispatch/transport-contract` (0.23.0), so they + * are imported directly (mirrors `mcp` / `computer`). The result types + injected + * ports below are FE-owned (the composition root adapts the store's HTTP calls to + * them). The endpoints are GLOBAL (not workspace- or conversation-scoped). + */ + +/** Re-export the contract shapes so consumers import a single surface. */ +export type { + ConcurrencyLimitResponse, + ConcurrencyLimitsResponse, + ConcurrencyStatusEntry, + ConcurrencyStatusResponse, + SetConcurrencyLimitRequest, +}; + +/** + * A configured concurrency limit — one provider's cap on in-flight requests. + * Same shape as the contract's `ConcurrencyLimitResponse`. + */ +export interface ConcurrencyLimitEntry { + readonly providerId: string; + readonly limit: number; +} + +// ── Result types (port outcomes; the store returns these directly) ────────────── + +/** Outcome of `GET /concurrency/limits` (all configured limits). */ +export type ConcurrencyLimitsResult = + | { readonly ok: true; readonly limits: readonly ConcurrencyLimitEntry[] } + | { readonly ok: false; readonly error: string }; + +/** + * Outcome of `GET`/`PUT /concurrency/limits/:providerId` — the configured limit + * for one provider. `GET` returns `404` when the provider has no limit (surfaced + * as `ok: false`); `PUT` returns `400` for a non-positive-integer body. + */ +export type ConcurrencyLimitResult = + | { readonly ok: true; readonly providerId: string; readonly limit: number } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `DELETE /concurrency/limits/:providerId` (remove → unlimited). */ +export type ConcurrencyDeleteResult = + | { readonly ok: true; readonly providerId: string } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `GET /concurrency/status` (live status for every limited provider). */ +export type ConcurrencyStatusResult = + | { readonly ok: true; readonly providers: readonly ConcurrencyStatusEntry[] } + | { readonly ok: false; readonly error: string }; + +// ── Injected ports (consumer-defines-port; the composition root adapts the +// store's HTTP calls to these shapes). ────────────────────────────────────── + +export type LoadConcurrencyLimits = () => Promise<ConcurrencyLimitsResult>; +export type GetConcurrencyLimit = (providerId: string) => Promise<ConcurrencyLimitResult>; +export type SaveConcurrencyLimit = ( + providerId: string, + limit: number, +) => Promise<ConcurrencyLimitResult>; +export type DeleteConcurrencyLimit = (providerId: string) => Promise<ConcurrencyDeleteResult>; +export type LoadConcurrencyStatus = () => Promise<ConcurrencyStatusResult>; diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts new file mode 100644 index 0000000..82b72b0 --- /dev/null +++ b/src/features/concurrency/logic/view-model.test.ts @@ -0,0 +1,405 @@ +import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + formatPauseDuration, + normalizeConcurrencyLimit, + normalizeConcurrencyLimits, + normalizeConcurrencyStatus, + parseLimitInput, + pauseLabel, + providerFromModel, + providerOptions, + summarizeLimits, + summarizeStatus, + viewConcurrencyLimit, + viewConcurrencyLimits, + viewConcurrencyStatus, + viewConcurrencyStatuses, +} from "./view-model"; + +const status = (over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEntry => ({ + providerId: "umans", + limit: 4, + inFlight: 2, + queued: 0, + paused: false, + ...over, +}); + +// ── parseLimitInput ─────────────────────────────────────────────────────────── + +describe("parseLimitInput", () => { + it("accepts positive integers", () => { + expect(parseLimitInput("4")).toBe(4); + expect(parseLimitInput(" 12 ")).toBe(12); + expect(parseLimitInput("1")).toBe(1); + }); + + it("rejects zero, negatives, non-integers, and garbage", () => { + expect(parseLimitInput("0")).toBeNull(); + expect(parseLimitInput("-1")).toBeNull(); + expect(parseLimitInput("4.5")).toBeNull(); + expect(parseLimitInput("")).toBeNull(); + expect(parseLimitInput(" ")).toBeNull(); + expect(parseLimitInput("abc")).toBeNull(); + expect(parseLimitInput("4abc")).toBeNull(); + }); +}); + +// ── providerFromModel / providerOptions ─────────────────────────────────────── + +describe("providerFromModel", () => { + it("takes the part before the first slash", () => { + expect(providerFromModel("openai/gpt-4o")).toBe("openai"); + expect(providerFromModel("openai-compat/gpt-4o-mini")).toBe("openai-compat"); + }); + it("returns the whole string when there is no slash", () => { + expect(providerFromModel("umans")).toBe("umans"); + }); +}); + +describe("providerOptions", () => { + it("derives distinct provider ids from models, first-seen order", () => { + expect( + providerOptions(["openai/gpt-4o", "umans/umans-glm-5.2", "openai/gpt-4o-mini"], []), + ).toEqual(["openai", "umans"]); + }); + it("unions with providers already carrying a configured limit", () => { + expect(providerOptions(["openai/gpt-4o"], [{ providerId: "anthropic", limit: 4 }])).toEqual([ + "openai", + "anthropic", + ]); + }); + it("does not duplicate a provider present in both models and limits", () => { + expect(providerOptions(["openai/gpt-4o"], [{ providerId: "openai", limit: 4 }])).toEqual([ + "openai", + ]); + }); + it("ignores models whose provider prefix is empty", () => { + expect(providerOptions(["/model-only", "umans/x"], [])).toEqual(["umans"]); + }); + it("returns [] when there are no models and no limits", () => { + expect(providerOptions([], [])).toEqual([]); + }); +}); + +// ── pauseLabel + formatPauseDuration ─────────────────────────────────────────── + +describe("formatPauseDuration", () => { + it("formats seconds / minutes+seconds / hours+minutes", () => { + expect(formatPauseDuration(30_000)).toBe("30s"); + expect(formatPauseDuration(65_000)).toBe("1m 05s"); + expect(formatPauseDuration(3_660_000)).toBe("1h 01m"); + }); + + it("non-positive → resuming", () => { + expect(formatPauseDuration(0)).toBe("resuming"); + expect(formatPauseDuration(-5_000)).toBe("resuming"); + }); +}); + +describe("pauseLabel", () => { + it("null when not paused", () => { + expect(pauseLabel(false, undefined, 0)).toBeNull(); + expect(pauseLabel(false, 10_000, 0)).toBeNull(); + }); + + it("'paused' (bare) when paused without a usable timestamp", () => { + expect(pauseLabel(true, undefined, 0)).toBe("paused"); + expect(pauseLabel(true, null, 0)).toBe("paused"); + expect(pauseLabel(true, Number.NaN, 0)).toBe("paused"); + }); + + it("countdown when paused with a future timestamp", () => { + const now = 1_000_000; + expect(pauseLabel(true, now + 30_000, now)).toBe("paused — resumes in 30s"); + expect(pauseLabel(true, now + 65_000, now)).toBe("paused — resumes in 1m 05s"); + }); + + it("'paused' (bare) when the timestamp is missing, non-finite, or expired", () => { + expect(pauseLabel(true, 0, 1_000)).toBe("paused"); + expect(pauseLabel(true, 1_000, 2_000)).toBe("paused"); + expect(pauseLabel(true, Number.NaN, 0)).toBe("paused"); + // The countdown prefix only appears with a FUTURE timestamp: + expect(pauseLabel(true, 2_000, 1_000)).toBe("paused — resumes in 1s"); + }); +}); + +// ── viewConcurrencyStatus ────────────────────────────────────────────────────── + +describe("viewConcurrencyStatus", () => { + it("serving under capacity → success badge, in-flight label, no queue", () => { + const v = viewConcurrencyStatus(status({ inFlight: 2, limit: 4, queued: 0 }), 0); + expect(v.inFlightLabel).toBe("2/4"); + expect(v.queuedLabel).toBe("no queue"); + expect(v.pausedLabel).toBeNull(); + expect(v.badge).toBe("success"); + expect(v.busy).toBe(false); + }); + + it("at capacity with a queue → warning badge + busy (spinner)", () => { + const v = viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 3 }), 0); + expect(v.inFlightLabel).toBe("4/4"); + expect(v.queuedLabel).toBe("3 queued"); + expect(v.badge).toBe("warning"); + expect(v.busy).toBe(true); + }); + + it("idle (no in-flight) → neutral badge, not busy", () => { + const v = viewConcurrencyStatus(status({ inFlight: 0, limit: 4, queued: 0 }), 0); + expect(v.badge).toBe("neutral"); + expect(v.busy).toBe(false); + expect(v.inFlightLabel).toBe("0/4"); + }); + + it("paused → warning badge + pause countdown label", () => { + const now = 1_000_000; + const v = viewConcurrencyStatus( + status({ paused: true, pausedUntil: now + 30_000, inFlight: 4, limit: 4, queued: 3 }), + now, + ); + expect(v.paused).toBe(true); + expect(v.pausedLabel).toBe("paused — resumes in 30s"); + expect(v.badge).toBe("warning"); + expect(v.busy).toBe(true); + }); + + it("at capacity but no queue → success (busy only when queuing)", () => { + const v = viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 0 }), 0); + expect(v.badge).toBe("success"); + expect(v.busy).toBe(false); + }); + + it("normalizes garbage counts to 0 and a malformed limit to 1", () => { + const v = viewConcurrencyStatus( + { + providerId: "x", + limit: -3, + inFlight: Number.NaN, + queued: "oops" as unknown as number, + paused: false, + }, + 0, + ); + expect(v.limit).toBe(1); + expect(v.inFlight).toBe(0); + expect(v.queued).toBe(0); + expect(v.inFlightLabel).toBe("0/1"); + }); + + it("viewConcurrencyStatuses maps a list preserving order", () => { + const views = viewConcurrencyStatuses( + [status({ providerId: "a" }), status({ providerId: "b" })], + 0, + ); + expect(views.map((v) => v.providerId)).toEqual(["a", "b"]); + }); +}); + +// ── viewConcurrencyLimit ─────────────────────────────────────────────────────── + +describe("viewConcurrencyLimit", () => { + it("passes through id + normalizes the limit", () => { + const v = viewConcurrencyLimit({ providerId: "umans", limit: 4 }); + expect(v.providerId).toBe("umans"); + expect(v.limit).toBe(4); + }); + + it("clamps a malformed limit to 1", () => { + expect(viewConcurrencyLimit({ providerId: "x", limit: 0 }).limit).toBe(1); + expect(viewConcurrencyLimit({ providerId: "x", limit: -2 }).limit).toBe(1); + expect(viewConcurrencyLimit({ providerId: "x", limit: 2.9 }).limit).toBe(2); + }); + + it("viewConcurrencyLimits maps a list preserving order", () => { + const views = viewConcurrencyLimits([ + { providerId: "a", limit: 1 }, + { providerId: "b", limit: 2 }, + ]); + expect(views.map((v) => v.providerId)).toEqual(["a", "b"]); + }); +}); + +// ── summarizeLimits / summarizeStatus ────────────────────────────────────────── + +describe("summarizeLimits", () => { + it("empty → No limits configured", () => { + expect(summarizeLimits([])).toBe("No limits configured"); + }); + it("counts limits (singular/plural)", () => { + expect(summarizeLimits([{ providerId: "a", limit: 1 }])).toBe("1 limit configured"); + expect( + summarizeLimits([ + { providerId: "a", limit: 1 }, + { providerId: "b", limit: 2 }, + ]), + ).toBe("2 limits configured"); + }); +}); + +describe("summarizeStatus", () => { + it("empty → No limits configured", () => { + expect(summarizeStatus([], 0)).toBe("No limits configured"); + }); + it("aggregates providers + in-flight totals", () => { + const s = summarizeStatus( + [ + status({ providerId: "a", limit: 4, inFlight: 2 }), + status({ providerId: "b", limit: 6, inFlight: 3 }), + ], + 0, + ); + expect(s).toBe("2 providers · 5/10 in flight"); + }); + it("includes queued + paused fragments only when non-zero", () => { + const s = summarizeStatus( + [ + status({ providerId: "a", limit: 4, inFlight: 4, queued: 2 }), + status({ + providerId: "b", + limit: 4, + inFlight: 1, + queued: 0, + paused: true, + pausedUntil: 1000, + }), + ], + 0, + ); + expect(s).toBe("2 providers · 5/8 in flight · 2 queued · 1 paused"); + }); + it("singular provider", () => { + expect(summarizeStatus([status({ providerId: "a", limit: 4, inFlight: 1 })], 0)).toBe( + "1 provider · 1/4 in flight", + ); + }); +}); + +// ── Network-seam normalizers ─────────────────────────────────────────────────── + +describe("normalizeConcurrencyLimits", () => { + it("coerces a well-formed body", () => { + const limits = normalizeConcurrencyLimits({ + limits: [ + { providerId: "umans", limit: 4 }, + { providerId: "openai-compat", limit: 5 }, + ], + }); + expect(limits).toEqual([ + { providerId: "umans", limit: 4 }, + { providerId: "openai-compat", limit: 5 }, + ]); + }); + + it("non-array / missing limits → []", () => { + expect(normalizeConcurrencyLimits({})).toEqual([]); + expect(normalizeConcurrencyLimits({ limits: "nope" })).toEqual([]); + expect(normalizeConcurrencyLimits(null)).toEqual([]); + expect(normalizeConcurrencyLimits(undefined)).toEqual([]); + }); + + it("drops entries without a provider id + clamps limits", () => { + const limits = normalizeConcurrencyLimits({ + limits: [ + { providerId: "umans", limit: 4 }, + { providerId: "", limit: 9 }, + { providerId: 123, limit: 1 }, + { limit: 2 }, + { providerId: "anthropic", limit: -5 }, + ], + }); + expect(limits).toEqual([ + { providerId: "umans", limit: 4 }, + { providerId: "anthropic", limit: 1 }, + ]); + }); +}); + +describe("normalizeConcurrencyLimit", () => { + it("coerces a well-formed single response", () => { + expect(normalizeConcurrencyLimit({ providerId: "umans", limit: 4 })).toEqual({ + providerId: "umans", + limit: 4, + }); + }); + it("null when the provider id is missing/non-string", () => { + expect(normalizeConcurrencyLimit({ limit: 4 })).toBeNull(); + expect(normalizeConcurrencyLimit({ providerId: "", limit: 4 })).toBeNull(); + expect(normalizeConcurrencyLimit(null)).toBeNull(); + }); + it("clamps a malformed limit to 1", () => { + expect(normalizeConcurrencyLimit({ providerId: "x", limit: 0 })).toEqual({ + providerId: "x", + limit: 1, + }); + }); +}); + +describe("normalizeConcurrencyStatus", () => { + it("coerces a well-formed body, preserving pausedUntil only when present", () => { + const now = Date.now(); + const providers = normalizeConcurrencyStatus({ + providers: [ + { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, + { + providerId: "openai-compat", + limit: 5, + inFlight: 5, + queued: 3, + paused: true, + pausedUntil: now, + }, + ], + }); + expect(providers).toHaveLength(2); + const [first, second] = providers; + expect(first).toEqual({ + providerId: "umans", + limit: 4, + inFlight: 2, + queued: 1, + paused: false, + }); + expect(first !== undefined && !("pausedUntil" in first)).toBe(true); + expect(second).toEqual({ + providerId: "openai-compat", + limit: 5, + inFlight: 5, + queued: 3, + paused: true, + pausedUntil: now, + }); + }); + + it("non-array / missing providers → []", () => { + expect(normalizeConcurrencyStatus({})).toEqual([]); + expect(normalizeConcurrencyStatus({ providers: 42 })).toEqual([]); + expect(normalizeConcurrencyStatus(null)).toEqual([]); + }); + + it("drops entries without a provider id + clamps counts", () => { + const providers = normalizeConcurrencyStatus({ + providers: [ + { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, + { providerId: "", inFlight: 1 }, + { limit: 2 }, + { providerId: 9, inFlight: 0 }, + { providerId: "x", limit: -1, inFlight: "bad", queued: null, paused: "yes" }, + ], + }); + expect(providers).toEqual([ + { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, + { providerId: "x", limit: 1, inFlight: 0, queued: 0, paused: false }, + ]); + }); + + it("omits pausedUntil when it is not a finite number", () => { + const providers = normalizeConcurrencyStatus({ + providers: [ + { providerId: "a", limit: 1, inFlight: 0, queued: 0, paused: true, pausedUntil: "x" }, + { providerId: "b", limit: 1, inFlight: 0, queued: 0, paused: true, pausedUntil: null }, + ], + }); + for (const p of providers) expect("pausedUntil" in p).toBe(false); + }); +}); diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts new file mode 100644 index 0000000..7a4fe0e --- /dev/null +++ b/src/features/concurrency/logic/view-model.ts @@ -0,0 +1,299 @@ +import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; +import type { ConcurrencyLimitEntry } from "./types"; + +/** + * Pure view-models for the concurrency feature — zero DOM, zero effects, zero + * Svelte. Maps backend `ConcurrencyLimitEntry` / `ConcurrencyStatusEntry` to + * display shapes (badges, "2/4" in-flight labels, pause countdowns, summaries), + * holds the limit-input parsing, and the network-seam normalizers the composition + * root coerces the untyped JSON with. + */ + +export type Badge = "success" | "warning" | "error" | "neutral"; + +/** A configured limit row shaped for display. */ +export interface ConcurrencyLimitView { + readonly providerId: string; + readonly limit: number; +} + +/** + * A live status row shaped for display. Carries the raw counts plus pre-computed + * labels so the template stays thin. + */ +export interface ConcurrencyStatusView { + readonly providerId: string; + readonly limit: number; + readonly inFlight: number; + readonly queued: number; + readonly paused: boolean; + /** "2/4" — in-flight slots held vs the cap. */ + readonly inFlightLabel: string; + /** "1 queued" / "no queue". */ + readonly queuedLabel: string; + /** A pause label when paused, e.g. "paused — resumes in 30s"; null otherwise. */ + readonly pausedLabel: string | null; + readonly badge: Badge; + /** True when paused or at capacity (show a spinner). */ + readonly busy: boolean; +} + +// ── Limit input parsing ─────────────────────────────────────────────────────── + +/** + * Parse a raw limit input into a positive integer, or `null` when it is not a + * valid positive integer. Accepts "4" → 4; rejects "0", "-1", "4.5", "", "abc". + * Drives the Add/Save button's disabled state so an invalid value never reaches + * the backend (the backend is still the authority — it 400s a non-positive body). + */ +export function parseLimitInput(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === "" || !/^[0-9]+$/.test(trimmed)) return null; + const n = Number.parseInt(trimmed, 10); + return Number.isFinite(n) && n >= 1 ? n : null; +} + +/** + * Coerce an untrusted limit value into a positive integer (default 1). Used when + * normalizing backend responses so a malformed `limit` can never be 0/negative. + */ +export function normalizeLimit(value: unknown): number { + const n = typeof value === "number" && Number.isFinite(value) ? value : 1; + const int = Math.floor(n); + return int >= 1 ? int : 1; +} + +// ── Provider options (the Add-form dropdown) ─────────────────────────────────── +// +// A concurrency `providerId` is the credential name that prefixes a model name +// (`<provider>/<model>` — the same key the model picker groups by). The dropdown +// is the UNION of providers discoverable from the available models AND providers +// already carrying a configured limit (so a limit set out-of-band but whose +// model list is empty still appears), in first-seen order. Models are the +// authority; a provider with models but no limit is still selectable (Add sets it). + +/** The provider id prefix of a `<provider>/<model>` name (the part before the first `/`, or the whole string). */ +export function providerFromModel(full: string): string { + const i = full.indexOf("/"); + return i === -1 ? full : full.slice(0, i); +} + +/** Distinct provider ids to offer in the Add dropdown, first-seen order. */ +export function providerOptions( + models: readonly string[], + limits: readonly ConcurrencyLimitEntry[], +): string[] { + const seen = new Set<string>(); + const out: string[] = []; + const add = (p: string): void => { + if (p !== "" && !seen.has(p)) { + seen.add(p); + out.push(p); + } + }; + for (const m of models) add(providerFromModel(m)); + for (const l of limits) add(l.providerId); + return out; +} + +// ── Status → display view ────────────────────────────────────────────────────── + +const NO_LIMITS = "No limits configured"; + +/** + * Format a remaining-ms delta as a short pause countdown: "30s", "1m 05s", + * "resuming" (≤ 0). Pure via the injected `remainingMs`. The component recomputes + * this on each status poll (every ~2s) — a 1s ticking timer is optional. + */ +export function formatPauseDuration(remainingMs: number): string { + if (remainingMs <= 0) return "resuming"; + const totalSec = Math.floor(remainingMs / 1000); + const hours = Math.floor(totalSec / 3600); + const mins = Math.floor((totalSec % 3600) / 60); + const secs = totalSec % 60; + if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m`; + if (mins > 0) return `${mins}m ${String(secs).padStart(2, "0")}s`; + return `${secs}s`; +} + +/** + * The pause label for a status entry, or `null` when not paused. When paused with + * a future `pausedUntil`, shows "paused — resumes in 30s"; when paused without a + * usable timestamp, shows "paused". Pure via the injectable `now`. + */ +export function pauseLabel( + paused: boolean, + pausedUntil: number | null | undefined, + now: number = Date.now(), +): string | null { + if (!paused) return null; + if (typeof pausedUntil === "number" && Number.isFinite(pausedUntil)) { + const remaining = pausedUntil - now; + if (remaining > 0) return `paused — resumes in ${formatPauseDuration(remaining)}`; + } + // Paused without a usable future timestamp (missing, non-finite, or already + // expired): the next status poll will clear `paused`. Show "paused" meanwhile. + return "paused"; +} + +/** + * Build a display view for a status entry. `now` is injectable for tests + * (defaults to `Date.now()`); the composition-root component passes nothing in + * production (it recomputes on each poll). + */ +export function viewConcurrencyStatus( + entry: ConcurrencyStatusEntry, + now: number = Date.now(), +): ConcurrencyStatusView { + const limit = normalizeLimit(entry.limit); + const inFlight = clampCount(entry.inFlight); + const queued = clampCount(entry.queued); + const paused = entry.paused === true; + const atCapacity = inFlight >= limit; + let badge: Badge; + if (paused) badge = "warning"; + else if (atCapacity && queued > 0) badge = "warning"; + else if (inFlight > 0) badge = "success"; + else badge = "neutral"; + return { + providerId: entry.providerId, + limit, + inFlight, + queued, + paused, + inFlightLabel: `${inFlight}/${limit}`, + queuedLabel: queued === 0 ? "no queue" : `${queued} queued`, + pausedLabel: pauseLabel(paused, entry.pausedUntil, now), + badge, + busy: paused || (atCapacity && queued > 0), + }; +} + +export function viewConcurrencyStatuses( + entries: readonly ConcurrencyStatusEntry[], + now: number = Date.now(), +): readonly ConcurrencyStatusView[] { + return entries.map((e) => viewConcurrencyStatus(e, now)); +} + +/** A display view for a configured limit entry. */ +export function viewConcurrencyLimit(entry: ConcurrencyLimitEntry): ConcurrencyLimitView { + return { providerId: entry.providerId, limit: normalizeLimit(entry.limit) }; +} + +export function viewConcurrencyLimits( + entries: readonly ConcurrencyLimitEntry[], +): readonly ConcurrencyLimitView[] { + return entries.map(viewConcurrencyLimit); +} + +// ── Summaries ────────────────────────────────────────────────────────────────── + +/** A one-line summary of the configured limits list, e.g. "2 limits configured". */ +export function summarizeLimits(limits: readonly ConcurrencyLimitEntry[]): string { + if (limits.length === 0) return NO_LIMITS; + return `${limits.length} limit${limits.length === 1 ? "" : "s"} configured`; +} + +/** + * A one-line summary of the live status, e.g. + * "2 providers · 6/10 in flight · 1 queued · 1 paused". Only the queued / paused + * fragments appear when non-zero. + */ +export function summarizeStatus( + providers: readonly ConcurrencyStatusEntry[], + now: number = Date.now(), +): string { + if (providers.length === 0) return NO_LIMITS; + let inFlight = 0; + let limitTotal = 0; + let queued = 0; + let paused = 0; + for (const p of providers) { + const limit = normalizeLimit(p.limit); + inFlight += clampCount(p.inFlight); + limitTotal += limit; + queued += clampCount(p.queued); + if (p.paused === true) paused += 1; + } + const parts: string[] = []; + parts.push( + `${providers.length} provider${providers.length === 1 ? "" : "s"}`, + `${inFlight}/${limitTotal} in flight`, + ); + if (queued > 0) parts.push(`${queued} queued`); + if (paused > 0) parts.push(`${paused} paused`); + // Touch `now` so the summary recomputes alongside the per-row pause countdown. + void now; + return parts.join(" · "); +} + +// ── Network-seam normalization (pure; called by the composition root) ─────────── +// +// The concurrency responses are untyped JSON at runtime. The store coerces each +// defensively HERE (pure + tested) — a malformed/partial backend value (e.g. the +// extension returning `{}`) can never crash the renderer. Mirrors the +// `normalizeHeartbeatConfig` / inline `Array.isArray(data.servers)` guards. + +function isRecord(value: unknown): value is Record<string, unknown> { + return value !== null && typeof value === "object"; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +/** Coerce a non-negative count field to a non-negative integer (0 on garbage). */ +function clampCount(value: unknown): number { + const n = typeof value === "number" && Number.isFinite(value) ? value : 0; + const int = Math.floor(n); + return int >= 0 ? int : 0; +} + +/** Coerce an untrusted `GET /concurrency/limits` body into a typed limit list. */ +export function normalizeConcurrencyLimits(data: unknown): readonly ConcurrencyLimitEntry[] { + if (!isRecord(data) || !Array.isArray(data.limits)) return []; + const limits = data.limits as readonly unknown[]; + return limits + .filter((r): r is Record<string, unknown> => isRecord(r)) + .map((r) => ({ + providerId: asString(r.providerId) ?? "", + limit: normalizeLimit(r.limit), + })) + .filter((r) => r.providerId !== ""); +} + +/** Coerce an untrusted `GET`/`PUT /concurrency/limits/:id` body into a limit, or null. */ +export function normalizeConcurrencyLimit(data: unknown): ConcurrencyLimitEntry | null { + if (!isRecord(data)) return null; + const providerId = asString(data.providerId); + if (providerId === null) return null; + return { providerId, limit: normalizeLimit(data.limit) }; +} + +/** + * Coerce an untrusted `GET /concurrency/status` body into a typed status list. + * `pausedUntil` is included only when it is a finite number (it is absent when + * not paused). + */ +export function normalizeConcurrencyStatus(data: unknown): readonly ConcurrencyStatusEntry[] { + if (!isRecord(data) || !Array.isArray(data.providers)) return []; + const providers = data.providers as readonly unknown[]; + return providers + .filter((r): r is Record<string, unknown> => isRecord(r)) + .map((r): ConcurrencyStatusEntry => { + const base = { + providerId: asString(r.providerId) ?? "", + limit: normalizeLimit(r.limit), + inFlight: clampCount(r.inFlight), + queued: clampCount(r.queued), + paused: r.paused === true, + }; + const pausedUntil = + typeof r.pausedUntil === "number" && Number.isFinite(r.pausedUntil) + ? r.pausedUntil + : undefined; + return pausedUntil !== undefined ? { ...base, pausedUntil } : base; + }) + .filter((r) => r.providerId !== ""); +} diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte new file mode 100644 index 0000000..5aacb88 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte @@ -0,0 +1,119 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { parseLimitInput, type ConcurrencyLimitView } from "../logic/view-model"; + import type { DeleteConcurrencyLimit, SaveConcurrencyLimit } from "../logic/types"; + + let { + limit, + save, + remove, + }: { + /** The configured limit row (providerId + current limit). */ + limit: ConcurrencyLimitView; + save: SaveConcurrencyLimit; + remove: DeleteConcurrencyLimit; + } = $props(); + + // Inline-edit state: the raw text bound to the limit input. Seeded from the + // row's canonical limit, but only while the field is untouched — so a save + // echo / list refresh re-syncs it without clobbering an in-flight edit. Mirrors + // the ChatLimitField seed pattern (avoids reading the prop in the $state init). + let draft = $state(""); + let lastSeed = $state(""); + let saving = $state(false); + let removing = $state(false); + let error = $state<string | null>(null); + /** Brief "Saved" confirmation after a successful save (mirrors ChatLimitField). + * Cleared when the field is edited again. */ + let justSaved = $state(false); + + $effect(() => { + const incoming = String(limit.limit); + untrack(() => { + if (draft === lastSeed) draft = incoming; + lastSeed = incoming; + }); + }); + + const parsed = $derived(parseLimitInput(draft)); + const dirty = $derived(parsed !== null && parsed !== limit.limit); + + // Clear the "Saved" hint + any error as soon as the user edits the field. + function onInput(): void { + justSaved = false; + error = null; + } + + async function handleSave(): Promise<void> { + if (parsed === null || parsed === limit.limit) return; + saving = true; + error = null; + const result = await save(limit.providerId, parsed); + saving = false; + if (result.ok) { + // Reflect the echoed limit back into the field immediately (the prop will + // also re-assert it via the seed effect above once the parent reloads). + draft = String(result.limit); + lastSeed = draft; + justSaved = true; + } else { + error = result.error; + } + } + + async function handleRemove(): Promise<void> { + removing = true; + error = null; + const result = await remove(limit.providerId); + removing = false; + if (!result.ok) { + error = result.error; + } + // On success the parent drops this row (re-loaded limits list). + } +</script> + +<div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <div class="flex items-center gap-2"> + <span class="flex-1 truncate font-medium font-mono" title={limit.providerId}>{limit.providerId}</span> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-20 font-mono" + aria-label={`Concurrency limit for ${limit.providerId}`} + bind:value={draft} + oninput={onInput} + disabled={saving || removing} + /> + <button + type="button" + class="btn btn-primary btn-xs" + disabled={!dirty || saving || removing} + onclick={handleSave} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Save + {/if} + </button> + <button + type="button" + class="btn btn-ghost btn-xs text-error" + aria-label={`Remove concurrency limit for ${limit.providerId}`} + disabled={saving || removing} + onclick={handleRemove} + > + {#if removing} + <span class="loading loading-spinner loading-xs"></span> + {:else} + ✕ + {/if} + </button> + </div> + {#if error} + <span class="font-mono text-xs text-error">{error}</span> + {:else if justSaved && !dirty} + <span class="text-xs text-success">Saved.</span> + {/if} +</div> diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte new file mode 100644 index 0000000..f8a2199 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -0,0 +1,331 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; + import { + type Badge, + parseLimitInput, + providerOptions, + summarizeLimits, + summarizeStatus, + viewConcurrencyLimits, + viewConcurrencyStatuses, + } from "../logic/view-model"; + import type { + ConcurrencyLimitEntry, + DeleteConcurrencyLimit, + LoadConcurrencyLimits, + LoadConcurrencyStatus, + SaveConcurrencyLimit, + } from "../logic/types"; + import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte"; + + let { + models, + loadLimits, + saveLimit, + deleteLimit, + loadStatus, + }: { + /** Available models (`<provider>/<model>`) — the source of provider ids for the Add dropdown. */ + models: readonly string[]; + loadLimits: LoadConcurrencyLimits; + saveLimit: SaveConcurrencyLimit; + deleteLimit: DeleteConcurrencyLimit; + loadStatus: LoadConcurrencyStatus; + } = $props(); + + const badgeClass: Record<Badge, string> = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + neutral: "badge-ghost", + }; + + // ── Limits (config: list / add / update / remove) ──────────────────────────── + let limits = $state<readonly ConcurrencyLimitEntry[]>([]); + let limitsError = $state<string | null>(null); + /** True after the first load settles (gates the empty state). */ + let hasLoadedLimits = $state(false); + /** Re-entrancy guard for background/silent refreshes (no UI — prevents + * overlapping fetches). The refresh is near-instant, so a visible loading + * indicator would flicker every poll/reload; it stays INVISIBLE (mirrors the + * heartbeat runs list). */ + let limitsInFlight = false; + + // Add-form state. The provider id is chosen from a dropdown of known providers + // (derived from the available models + any already-configured limit providers). + let newProviderId = $state(""); + let newLimitInput = $state(""); + let adding = $state(false); + let addError = $state<string | null>(null); + + const providerOpts = $derived(providerOptions(models, limits)); + const limitViews = $derived(viewConcurrencyLimits(limits)); + const limitsSummary = $derived(summarizeLimits(limits)); + const parsedNewLimit = $derived(parseLimitInput(newLimitInput)); + const canAdd = $derived( + newProviderId !== "" && + parsedNewLimit !== null && + !limits.some((l) => l.providerId === newProviderId) && + !adding, + ); + + // Keep the dropdown selection valid: default to the first option, and if the + // selected provider is removed from the options (e.g. its limit was deleted and + // it has no models), fall back to the first remaining option. Runs untracked so + // it doesn't loop on its own assignment. + $effect(() => { + const opts = providerOpts; + untrack(() => { + if (opts.length === 0) { + if (newProviderId !== "") newProviderId = ""; + return; + } + if (!opts.includes(newProviderId)) newProviderId = opts[0] ?? ""; + }); + }); + + async function refreshLimits(): Promise<void> { + if (limitsInFlight) return; + limitsInFlight = true; + const result = await loadLimits(); + limitsInFlight = false; + hasLoadedLimits = true; + if (result.ok) { + limits = result.limits; + // Clear the error only on success so it stays visible (stable, no flicker) + // during an in-flight retry rather than vanishing mid-refresh. + limitsError = null; + } else { + limitsError = result.error; + } + } + + async function handleAdd(): Promise<void> { + if (parsedNewLimit === null || newProviderId === "") return; + adding = true; + addError = null; + const result = await saveLimit(newProviderId, parsedNewLimit); + adding = false; + if (result.ok) { + newLimitInput = ""; + void refreshLimits(); + void refreshStatus(); + } else { + addError = result.error; + } + } + + // Wrap the ports so a row's save/remove reloads the authoritative list + status + // on success (the row still gets the result to drive its own UI). + async function rowSave(providerId: string, limit: number) { + const result = await saveLimit(providerId, limit); + if (result.ok) { + void refreshLimits(); + void refreshStatus(); + } + return result; + } + + async function rowRemove(providerId: string) { + const result = await deleteLimit(providerId); + if (result.ok) { + void refreshLimits(); + void refreshStatus(); + } + return result; + } + + // ── Live status (polls while mounted) ─────────────────────────────────────── + let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]); + let statusError = $state<string | null>(null); + /** True after the first load settles (gates the empty state). */ + let hasLoadedStatus = $state(false); + /** Re-entrancy guard for the 2s background poll (no UI — a visible loading + * indicator flickered every poll because the refresh is near-instant; it stays + * INVISIBLE, mirroring the heartbeat runs list). */ + let statusInFlight = false; + let now = $state(Date.now()); + + // A 1s clock so a `paused — resumes in Ns` countdown ticks live between polls. + $effect(() => { + const h = setInterval(() => { + now = Date.now(); + }, 1000); + return () => clearInterval(h); + }); + + const statusViews = $derived(viewConcurrencyStatuses(statusEntries, now)); + const statusSummary = $derived(summarizeStatus(statusEntries, now)); + + async function refreshStatus(): Promise<void> { + if (statusInFlight) return; + statusInFlight = true; + const result = await loadStatus(); + statusInFlight = false; + hasLoadedStatus = true; + if (result.ok) { + statusEntries = result.providers; + // Clear the error only on success so it stays visible (stable, no flicker) + // during an in-flight retry rather than vanishing mid-poll. + statusError = null; + } else { + statusError = result.error; + } + } + + const STATUS_POLL_MS = 2000; + + // Load limits + status on mount, and poll the live status while the view is + // alive (a running provider's in-flight/queued/paused transitions stay fresh + // without a manual refresh). Runs once — no reactive deps read inside. + $effect(() => { + untrack(() => { + void refreshLimits(); + void refreshStatus(); + }); + const h = setInterval(() => { + void refreshStatus(); + }, STATUS_POLL_MS); + return () => clearInterval(h); + }); +</script> + +<div class="flex flex-col gap-4"> + <!-- Limits (config) --> + <section class="flex flex-col gap-2"> + <div class="flex items-center justify-between gap-2"> + <h3 class="text-xs font-semibold uppercase opacity-60">Concurrency limits</h3> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => refreshLimits()} + aria-label="Refresh concurrency limits" + > + Refresh + </button> + </div> + + <!-- Add form --> + <form + class="flex flex-wrap items-end gap-2" + onsubmit={(e) => { + e.preventDefault(); + void handleAdd(); + }} + > + <label class="flex flex-col gap-1"> + <span class="text-[10px] uppercase opacity-60">Provider</span> + <select + class="select select-bordered select-xs w-40 font-mono" + aria-label="Provider" + bind:value={newProviderId} + disabled={adding || providerOpts.length === 0} + > + {#if providerOpts.length === 0} + <option value="" disabled>No providers available</option> + {:else} + {#each providerOpts as provider (provider)} + <option value={provider}>{provider}</option> + {/each} + {/if} + </select> + </label> + <label class="flex flex-col gap-1"> + <span class="text-[10px] uppercase opacity-60">Limit</span> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-20 font-mono" + placeholder="4" + bind:value={newLimitInput} + disabled={adding} + /> + </label> + <button + type="submit" + class="btn btn-primary btn-xs" + disabled={!canAdd} + > + {#if adding} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Add + {/if} + </button> + </form> + {#if addError} + <p class="font-mono text-xs text-error">{addError}</p> + {/if} + + <span class="text-xs opacity-70">{limitsSummary}</span> + + {#if limitsError} + <p class="text-xs text-error">{limitsError}</p> + {:else if hasLoadedLimits && limitViews.length === 0} + <p class="text-xs opacity-60">No limits configured — providers run unlimited.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each limitViews as limit (limit.providerId)} + <li> + <ConcurrencyLimitRow {limit} save={rowSave} remove={rowRemove} /> + </li> + {/each} + </ul> + {/if} + </section> + + <!-- Live status --> + <section class="flex flex-col gap-2"> + <div class="flex items-center justify-between gap-2"> + <h3 class="text-xs font-semibold uppercase opacity-60">Live status</h3> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => refreshStatus()} + aria-label="Refresh concurrency status" + > + Refresh + </button> + </div> + + <span class="text-xs opacity-70">{statusSummary}</span> + + {#if statusError} + <p class="text-xs text-error">{statusError}</p> + {:else if hasLoadedStatus && statusViews.length === 0} + <p class="text-xs opacity-60">No limits configured — nothing to report.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each statusViews as s (s.providerId)} + <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <div class="flex items-center justify-between gap-2"> + <span class="font-medium font-mono" title={s.providerId}>{s.providerId}</span> + <span class="badge badge-sm {badgeClass[s.badge]} gap-1"> + {#if s.busy} + <span class="loading loading-spinner loading-xs"></span> + {/if} + {#if s.paused} + Paused + {:else if s.inFlight >= s.limit && s.queued > 0} + At capacity + {:else if s.inFlight > 0} + Active + {:else} + Idle + {/if} + </span> + </div> + <div class="flex flex-wrap items-center justify-between gap-2 text-xs opacity-70"> + <span title="In-flight slots held vs cap">{s.inFlightLabel} in flight</span> + <span>{s.queuedLabel}</span> + </div> + {#if s.pausedLabel} + <span class="text-xs text-warning">{s.pausedLabel}</span> + {/if} + </li> + {/each} + </ul> + {/if} + </section> +</div> diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts new file mode 100644 index 0000000..3dc8e78 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -0,0 +1,224 @@ +import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { + ConcurrencyDeleteResult, + ConcurrencyLimitResult, + ConcurrencyLimitsResult, + ConcurrencyStatusResult, +} from "../logic/types"; +import ConcurrencyView from "./ConcurrencyView.svelte"; + +// Available models → provider ids are "umans", "anthropic", "openai-compat". +const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "openai-compat/gpt-4o"] as const; + +// Fakes for the four injected ports. Each resolves immediately so the mount +// effect's initial load settles in a microtask (assertions await via findBy*). + +function makeFakes(opts?: { + limits?: readonly { providerId: string; limit: number }[]; + status?: readonly ConcurrencyStatusEntry[]; +}) { + let limits = opts?.limits ?? [{ providerId: "umans", limit: 4 }]; + const status = opts?.status ?? [ + { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, + ]; + + const calls = { + loadLimits: 0, + loadStatus: 0, + saves: [] as { providerId: string; limit: number }[], + deletes: [] as string[], + }; + + return { + calls, + loadLimits: async (): Promise<ConcurrencyLimitsResult> => { + calls.loadLimits++; + return { ok: true, limits }; + }, + saveLimit: async (providerId: string, limit: number): Promise<ConcurrencyLimitResult> => { + calls.saves.push({ providerId, limit }); + // Reflect the new limit into the list the next load returns. + limits = [...limits.filter((l) => l.providerId !== providerId), { providerId, limit }]; + return { ok: true, providerId, limit }; + }, + deleteLimit: async (providerId: string): Promise<ConcurrencyDeleteResult> => { + calls.deletes.push(providerId); + limits = limits.filter((l) => l.providerId !== providerId); + return { ok: true, providerId }; + }, + loadStatus: async (): Promise<ConcurrencyStatusResult> => { + calls.loadStatus++; + return { ok: true, providers: status }; + }, + }; +} + +describe("ConcurrencyView", () => { + it("loads + renders the configured limits and live status on mount", async () => { + const fakes = makeFakes(); + render(ConcurrencyView, { + props: { + models: MODELS, + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + // The provider dropdown is populated from the available models' providers. + const providerSelect = await screen.findByLabelText("Provider"); + expect(providerSelect).toBeVisible(); + // Limits summary (unique to the limits section) + the row's remove control. + expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument(); + expect(await screen.findByLabelText("Remove concurrency limit for umans")).toBeVisible(); + // Status summary (unique to the status section). + expect(await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/)).toBeInTheDocument(); + expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(1); + expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1); + }); + + it("surfaces NO loading indicator during refresh (background poll is silent — no flicker)", async () => { + // The 2s status poll + post-mutation reloads are SILENT: they never toggle a + // visible loading state, so the Refresh buttons are plain-text (no spinner) + // and the list area never reflows mid-refresh. Regression guard for the + // flicker fix (mirrors the heartbeat runs list). + const fakes = makeFakes(); + render(ConcurrencyView, { + props: { + models: MODELS, + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/); + + const statusRefresh = screen.getByLabelText("Refresh concurrency status"); + expect(statusRefresh).toHaveTextContent("Refresh"); + expect(statusRefresh.querySelector(".loading-spinner")).toBeNull(); + expect(statusRefresh).not.toBeDisabled(); + + const limitsRefresh = screen.getByLabelText("Refresh concurrency limits"); + expect(limitsRefresh).toHaveTextContent("Refresh"); + expect(limitsRefresh.querySelector(".loading-spinner")).toBeNull(); + + // A manual refresh stays silent too (no spinner appears). + await fakes.loadStatus(); + expect(statusRefresh.querySelector(".loading-spinner")).toBeNull(); + }); + + it("adds a provider limit via the dropdown form (calls saveLimit + reloads)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { + props: { + models: MODELS, + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + const providerSelect = await screen.findByLabelText("Provider"); + // Choose "anthropic" from the dropdown (the list is auto-selected first). + await user.selectOptions(providerSelect, "anthropic"); + await user.type(screen.getByPlaceholderText("4"), "8"); + await user.click(screen.getByRole("button", { name: "Add" })); + + expect(fakes.calls.saves).toEqual([{ providerId: "anthropic", limit: 8 }]); + // After save the component reloads the limits list (now showing the row). + expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2); + expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument(); + expect(await screen.findByLabelText("Remove concurrency limit for anthropic")).toBeVisible(); + }); + + it("disables Add when the limit is empty/invalid (provider is auto-selected)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { + props: { + models: MODELS, + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + const providerSelect = await screen.findByLabelText("Provider"); + // A provider is auto-selected from the dropdown. + expect((providerSelect as HTMLSelectElement).value).not.toBe(""); + const addBtn = screen.getByRole("button", { name: "Add" }); + expect(addBtn).toBeDisabled(); // no limit entered yet + + // An invalid (non-numeric) limit keeps Add disabled. + await user.type(screen.getByPlaceholderText("4"), "abc"); + expect(addBtn).toBeDisabled(); + + // A valid positive-integer limit enables Add. + const limitInput = screen.getByPlaceholderText("4"); + await user.clear(limitInput); + await user.type(limitInput, "5"); + expect(addBtn).toBeEnabled(); + }); + + it("shows no-providers + disables the dropdown when there are no models", async () => { + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { + props: { + models: [], + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + const providerSelect = await screen.findByLabelText("Provider"); + expect(providerSelect).toBeDisabled(); + expect(screen.getByRole("button", { name: "Add" })).toBeDisabled(); + }); + + it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes(); + render(ConcurrencyView, { + props: { + models: MODELS, + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + // Wait for the limits to load (unique summary) before interacting. + await screen.findByText(/1 limit configured/); + await user.click(screen.getByLabelText("Remove concurrency limit for umans")); + + expect(fakes.calls.deletes).toEqual(["umans"]); + expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2); + }); + + it("surfaces a load error from the limits endpoint", async () => { + const failing = { + models: MODELS, + loadLimits: async (): Promise<ConcurrencyLimitsResult> => ({ + ok: false, + error: "Concurrency service not available", + }), + saveLimit: async (): Promise<ConcurrencyLimitResult> => ({ ok: false, error: "noop" }), + deleteLimit: async (): Promise<ConcurrencyDeleteResult> => ({ ok: false, error: "noop" }), + loadStatus: async (): Promise<ConcurrencyStatusResult> => ({ ok: true, providers: [] }), + }; + render(ConcurrencyView, { props: failing }); + expect(await screen.findByText("Concurrency service not available")).toBeVisible(); + }); +}); diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts index 6ac90a3..7520215 100644 --- a/src/features/tabs/index.ts +++ b/src/features/tabs/index.ts @@ -14,7 +14,7 @@ export { } from "./tabs"; export type { TabsStorage, TabsStore } from "./tabs-store.svelte"; export { createTabsStore } from "./tabs-store.svelte"; -export { default as TabBar } from "./ui/TabBar.svelte"; +export { default as TabList } from "./ui/TabList.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts index c31d2e7..ec93076 100644 --- a/src/features/tabs/tabs.test.ts +++ b/src/features/tabs/tabs.test.ts @@ -6,7 +6,6 @@ import { createTab, deriveTitle, initialState, - isStuckToEnd, MIN_HANDLE_LENGTH, newDraft, selectTab, @@ -194,29 +193,6 @@ describe("deriveTitle", () => { }); }); -describe("isStuckToEnd", () => { - it("is false when the strip does not overflow", () => { - expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 500 })).toBe(false); - expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 400 })).toBe(false); - }); - - it("is true when overflowing and scrolled to the left", () => { - expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 1000 })).toBe(true); - }); - - it("is true when overflowing and scrolled to the middle", () => { - expect(isStuckToEnd({ scrollLeft: 250, clientWidth: 500, scrollWidth: 1000 })).toBe(true); - }); - - it("is false when overflowing but scrolled fully to the right", () => { - expect(isStuckToEnd({ scrollLeft: 500, clientWidth: 500, scrollWidth: 1000 })).toBe(false); - }); - - it("treats a 1px subpixel gap at the end as at-rest (epsilon)", () => { - expect(isStuckToEnd({ scrollLeft: 499, clientWidth: 500, scrollWidth: 1000 })).toBe(false); - }); -}); - describe("shortHandle", () => { it("uses the minimum length when the id is unique", () => { const h = shortHandle("3f9a1b2c-aaaa", ["3f9a1b2c-aaaa", "7c2d-bbbb"]); diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts index bc7e30b..63cda35 100644 --- a/src/features/tabs/tabs.ts +++ b/src/features/tabs/tabs.ts @@ -87,26 +87,6 @@ export function activeTab(state: TabsState): Tab | null { return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null; } -export interface ScrollMetrics { - readonly scrollLeft: number; - readonly clientWidth: number; - readonly scrollWidth: number; -} - -const STUCK_EPSILON = 1; - -/** - * True when a right-pinned sticky element is floating over scrolled content — the - * strip overflows horizontally AND is not scrolled fully to the right. When it is - * at rest (no overflow, or scrolled to the end so it sits at its natural position) - * this returns false. Pure: layout measurements in, boolean out. - */ -export function isStuckToEnd(m: ScrollMetrics): boolean { - const overflows = m.scrollWidth > m.clientWidth + STUCK_EPSILON; - const notAtEnd = m.scrollLeft + m.clientWidth < m.scrollWidth - STUCK_EPSILON; - return overflows && notAtEnd; -} - export function deriveTitle(message: string, max: number = DEFAULT_MAX_TITLE_LENGTH): string { const trimmed = message.trim().replace(/\s+/g, " "); if (trimmed.length === 0) return DEFAULT_TITLE; diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index 087b28c..0dc5e15 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -1,8 +1,8 @@ -import { render, screen } from "@testing-library/svelte"; +import { fireEvent, render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { Tab } from "./tabs"; -import TabBar from "./ui/TabBar.svelte"; +import TabList from "./ui/TabList.svelte"; const sampleTabs: readonly Tab[] = [ { conversationId: "c1", model: "openai/gpt-4", title: "First", workspaceId: "default" }, @@ -10,9 +10,9 @@ const sampleTabs: readonly Tab[] = [ { conversationId: "c3", model: "google/gemini", title: "Third", workspaceId: "default" }, ]; -describe("TabBar", () => { +describe("TabList", () => { it("renders one role=tab element per tab showing each title", () => { - render(TabBar, { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c1", @@ -29,8 +29,8 @@ describe("TabBar", () => { expect(tabs[2]).toHaveTextContent("Third"); }); - it("applies tab-active to the active tab only", () => { - render(TabBar, { + it("marks the active tab as aria-selected", () => { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c2", @@ -41,24 +41,9 @@ describe("TabBar", () => { }); const tabs = screen.getAllByRole("tab"); - expect(tabs[0]).not.toHaveClass("tab-active"); - expect(tabs[1]).toHaveClass("tab-active"); - expect(tabs[2]).not.toHaveClass("tab-active"); - }); - - it("applies tab-active to New chat button when activeConversationId is null", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: null, - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - const newChat = screen.getByRole("button", { name: "New chat" }); - expect(newChat).toHaveClass("tab-active"); + expect(tabs[0]).toHaveAttribute("aria-selected", "false"); + expect(tabs[1]).toHaveAttribute("aria-selected", "true"); + expect(tabs[2]).toHaveAttribute("aria-selected", "false"); }); it("calls onSelect with the conversationId when a tab is clicked", async () => { @@ -66,7 +51,7 @@ describe("TabBar", () => { const onClose = vi.fn(); const user = userEvent.setup(); - render(TabBar, { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c1", @@ -91,7 +76,7 @@ describe("TabBar", () => { const onClose = vi.fn(); const user = userEvent.setup(); - render(TabBar, { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c1", @@ -115,7 +100,7 @@ describe("TabBar", () => { const onNewDraft = vi.fn(); const user = userEvent.setup(); - render(TabBar, { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c1", @@ -131,23 +116,8 @@ describe("TabBar", () => { expect(onNewDraft).toHaveBeenCalledTimes(1); }); - it("the New chat button has the sticky class", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - const newChat = screen.getByRole("button", { name: "New chat" }); - expect(newChat).toHaveClass("sticky"); - }); - it("shows visible 'New Chat' text when activeConversationId is null", () => { - render(TabBar, { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: null, @@ -162,7 +132,7 @@ describe("TabBar", () => { }); it("does not show 'New Chat' text when a real tab is active", () => { - render(TabBar, { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c1", @@ -181,7 +151,7 @@ describe("TabBar", () => { { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha", workspaceId: "default" }, { conversationId: "7c2db4e5-2222", model: "m", title: "Beta", workspaceId: "default" }, ]; - render(TabBar, { + render(TabList, { props: { tabs, activeConversationId: "3f9a1b2c-1111", @@ -195,19 +165,134 @@ describe("TabBar", () => { expect(screen.getByText("7c2d")).toBeInTheDocument(); }); - it("renders fixed-width tabs", () => { - render(TabBar, { + it("renders each tab as a single vertical row (flex-col list, not a horizontal strip)", () => { + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + // The scroll region containing the tab rows is a vertical flex column. + const tabs = screen.getAllByRole("tab"); + expect(tabs.length).toBeGreaterThan(0); + const region = tabs[0]?.parentElement; + expect(region).toHaveClass("flex-col"); + }); + + it("caps the tab list region at 80vh so a long set scrolls internally", () => { + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const tabs = screen.getAllByRole("tab"); + const region = tabs[0]?.parentElement; + expect(region).toHaveClass("max-h-[80vh]"); + expect(region).toHaveClass("overflow-y-auto"); + }); + + it("calls onRename when a tab title is double-clicked and committed with Enter", async () => { + const onRename = vi.fn(); + const user = userEvent.setup(); + + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + onRename, + }, + }); + + const titleButtons = screen.getAllByRole("button"); + // The inline-rename trigger is the title span (role=button) — find the one + // whose text matches the first tab's title. + const titleButton = titleButtons.find((b) => b.textContent === "First"); + if (!titleButton) throw new Error("title button not found"); + await user.dblClick(titleButton); + + const input = screen.getByRole("textbox"); + await user.clear(input); + await user.type(input, "Renamed{Enter}"); + + expect(onRename).toHaveBeenCalledTimes(1); + expect(onRename).toHaveBeenCalledWith("c1", "Renamed"); + }); + + it("copies the conversation id to the clipboard and highlights the badge text when clicked", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + const onSelect = vi.fn(); + + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c1", + onSelect, + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const idBadge = screen.getByRole("button", { name: "Copy conversation id c1" }); + await fireEvent.click(idBadge); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith("c1"); + // The badge text is NOT swapped (no width shift) — the highlight (selection) + // is the only indicator. + expect(idBadge).toHaveTextContent("c1"); + expect(idBadge).not.toHaveTextContent("Copied"); + // The badge text is selected (highlighted) as the copy indicator. + const selection = window.getSelection(); + expect(selection?.toString()).toBe("c1"); + // Clicking the ID must NOT switch tabs (the badge stops propagation). + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("shows a loading RING for a 'queued' tab and loading DOTS for an 'active' tab", () => { + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + statusFor: (id: string) => (id === "c1" ? "queued" : id === "c2" ? "active" : undefined), onSelect: vi.fn(), onClose: vi.fn(), onNewDraft: vi.fn(), }, }); - for (const t of screen.getAllByRole("tab")) { - expect(t).toHaveClass("w-48"); - } + // c1 is queued → a ring (DaisyUI `loading-ring`), labeled "Queued". + const queuedRing = screen.getByLabelText("Queued"); + expect(queuedRing.className).toContain("loading-ring"); + expect(queuedRing.closest('[role="tab"]')).toHaveTextContent("First"); + + const tabs = screen.getAllByRole("tab"); + expect(tabs).toHaveLength(3); + const activeTab = tabs[1]; + const idleTab = tabs[2]; + if (activeTab === undefined || idleTab === undefined) throw new Error("missing tabs"); + + // c2 is active → loading dots (NOT a ring). + expect(activeTab.querySelector(".loading-dots")).not.toBeNull(); + expect(activeTab.querySelector(".loading-ring")).toBeNull(); + + // c3 has no status → no spinner at all. + expect(idleTab.querySelector(".loading")).toBeNull(); }); }); diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte deleted file mode 100644 index 211fd5c..0000000 --- a/src/features/tabs/ui/TabBar.svelte +++ /dev/null @@ -1,177 +0,0 @@ -<script lang="ts"> - import type { Tab } from "../tabs"; - import { isStuckToEnd, shortHandle } from "../tabs"; - - 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(); - - // The new-chat button is `position: sticky; right: 0`. It floats over the tabs - // only while the strip overflows and isn't scrolled fully right; we square its - // right edge only in that "stuck" state. Pure decision (`isStuckToEnd`) + - // DOM-measurement at the edge here. - let scrollEl = $state<HTMLDivElement>(); - let stuck = $state(false); - - // Git-style short handle (shortest unique prefix) per open tab — the visible - // "tab ID". Derived from the set of open conversation ids; pure helper. - const handles = $derived.by(() => { - 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; - }); - - function recompute(): void { - const el = scrollEl; - if (el === undefined) { - stuck = false; - return; - } - stuck = isStuckToEnd({ - scrollLeft: el.scrollLeft, - clientWidth: el.clientWidth, - scrollWidth: el.scrollWidth, - }); - } - - $effect(() => { - const el = scrollEl; - if (el === undefined) return; - // Re-evaluate when the tab set changes (overflow may appear/disappear). - void tabs; - recompute(); - - el.addEventListener("scroll", recompute, { passive: true }); - const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(recompute) : undefined; - ro?.observe(el); - - return () => { - el.removeEventListener("scroll", recompute); - ro?.disconnect(); - }; - }); - // Inline rename: double-click a tab's title to edit, Enter/blur to save. - let editingId = $state<string | null>(null); - let editValue = $state(""); - let editEl = $state<HTMLInputElement>(); - - function startRename(tab: Tab): void { - if (onRename === undefined) return; - editingId = tab.conversationId; - editValue = tab.title; - // Focus the input after it renders. - queueMicrotask(() => editEl?.focus()); - } - - function commitRename(): void { - const id = editingId; - if (id !== null && onRename !== undefined) { - const trimmed = editValue.trim(); - if (trimmed.length > 0) onRename(id, trimmed); - } - editingId = null; - } - - function cancelRename(): void { - editingId = null; - } -</script> - -<div bind:this={scrollEl} class="min-w-0 flex-1 overflow-x-auto"> - <div class="tabs tabs-lift min-w-max"> - {#each tabs as tab (tab.conversationId)} - <div - class="tab flex w-48 shrink-0 items-center gap-1.5" - class:tab-active={tab.conversationId === activeConversationId} - role="tab" - tabindex="0" - onclick={() => onSelect(tab.conversationId)} - onkeydown={(e) => { - if (e.key === "Enter") onSelect(tab.conversationId); - }} - > - <span - class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60" - title="Tab ID" - > - {handles.get(tab.conversationId) ?? tab.conversationId} - </span> - {#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) === "active"} - <span class="loading loading-spinner 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} - <button - class="tab sticky right-0 z-10 bg-base-200 shadow-[-2px_0_4px_-1px_rgba(0,0,0,0.2)] {stuck - ? '!rounded-se-none !rounded-ee-none' - : ''}" - class:tab-active={activeConversationId === null} - aria-label="New chat" - onclick={() => onNewDraft()} - > - {#if activeConversationId === null} - <span class="max-w-[120px] truncate">New Chat</span> - <span class="btn btn-ghost btn-xs ml-1" aria-hidden="true">+</span> - {:else} - + - {/if} - </button> - </div> -</div> diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte new file mode 100644 index 0000000..08a3274 --- /dev/null +++ b/src/features/tabs/ui/TabList.svelte @@ -0,0 +1,184 @@ +<script lang="ts"> + import type { Tab } from "../tabs"; + import { shortHandle } from "../tabs"; + + 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(); + + // 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; + }); + + // 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>(); + + function startRename(tab: Tab): void { + if (onRename === undefined) return; + editingId = tab.conversationId; + editValue = tab.title; + // Focus the input after it renders. + queueMicrotask(() => editEl?.focus()); + } + + function commitRename(): void { + const id = editingId; + if (id !== null && onRename !== undefined) { + const trimmed = editValue.trim(); + if (trimmed.length > 0) onRename(id, trimmed); + } + editingId = null; + } + + function cancelRename(): void { + editingId = null; + } + + // Click-to-copy the agent (conversation) id: clicking the ID badge copies the + // FULL conversationId to the clipboard (the stable, useful id — the badge + // only shows a short prefix) and highlights the badge text as feedback. The + // highlight (text selection) is the indicator — no text is swapped, so the + // badge keeps a stable width. If the clipboard write fails, the selection is + // already in place so the user can Ctrl+C the text manually. + async function copyId(conversationId: string, el: HTMLElement): Promise<void> { + selectText(el); + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + try { + await clipboard.writeText(conversationId); + } catch { + // Selection already lets the user copy manually. + } + } + + function selectText(el: HTMLElement): void { + const range = document.createRange(); + range.selectNodeContents(el); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } +</script> + +<div class="flex flex-col gap-2"> + <!-- Single-column vertical tab list. Capped at 80% of the viewport height + so a long tab set scrolls inside this region instead of growing the + whole sidebar. --> + <div class="flex max-h-[80vh] 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-200" + 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> + + <button + type="button" + class="btn btn-ghost btn-sm w-full border border-base-300" + class:btn-primary={activeConversationId === null} + aria-label="New chat" + onclick={() => onNewDraft()} + > + {#if activeConversationId === null} + New Chat + {:else} + + New chat + {/if} + </button> +</div> diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte index 81b89e9..0ffc975 100644 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ b/src/features/workspaces/ui/WorkspaceCard.svelte @@ -188,7 +188,16 @@ </div> <div class="flex justify-start"> - <a class="btn" href={workspacePath(ws.id)} target="_blank" rel="noopener noreferrer"> Open </a> + <a + class="btn" + href={workspacePath(ws.id)} + onclick={(e) => { + e.preventDefault(); + onNavigate(workspacePath(ws.id)); + }} + > + Open + </a> </div> {#if cwdError} diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts index 0736efb..0d03b8e 100644 --- a/src/features/workspaces/ui/WorkspaceCard.test.ts +++ b/src/features/workspaces/ui/WorkspaceCard.test.ts @@ -119,16 +119,20 @@ describe("WorkspaceCard", () => { expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null); }); - it("the Open link opens the workspace in a new browser tab (no same-tab navigation)", () => { + 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" }); - // Native new-tab link: href points at the workspace, and target="_blank" - // opens it in a new browser tab rather than client-side navigating. + // Still a real link (progressive enhancement): href points at the workspace. expect(open).toHaveAttribute("href", "/my-ws"); - expect(open).toHaveAttribute("target", "_blank"); - expect(open.getAttribute("rel") ?? "").toMatch(/noopener/); + // 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"); }); }); |
