diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 19:19:28 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 19:19:28 +0900 |
| commit | f582642aeed9c79247e805545d434c4a261be781 (patch) | |
| tree | 2cd9d3d8e17a01adf1c1055ac0b31cc9d60806fb | |
| parent | 1285564f12238b22f6b39b9f3fbcecaca8456911 (diff) | |
| download | dispatch-web-f582642aeed9c79247e805545d434c4a261be781.tar.gz dispatch-web-f582642aeed9c79247e805545d434c4a261be781.zip | |
feat(heartbeat): heartbeat view with config, run list, live chat modal, and sidebar wiring
| -rw-r--r-- | backend-handoff.md | 68 | ||||
| -rw-r--r-- | src/app/App.svelte | 73 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 213 | ||||
| -rw-r--r-- | src/app/store.test.ts | 251 | ||||
| -rw-r--r-- | src/features/heartbeat/index.ts | 39 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/types.ts | 89 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.test.ts | 299 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.ts | 294 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/HeartbeatView.svelte | 421 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/RunModal.svelte | 169 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.test.ts | 2 |
11 files changed, 1911 insertions, 7 deletions
diff --git a/backend-handoff.md b/backend-handoff.md index d536280..6d14ad4 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,11 +5,10 @@ > **From:** dispatch-web orchestrator · **To:** `../dispatch-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-25 (§2d RESOLVED — backend merged `dev` into `feature/ssh-support`, merge `de022ce`; -`TurnProviderRetryEvent`/`provider-retry` is now present alongside the SSH types; FE re-synced both `file:` deps + -`bun run typecheck` is GREEN — 0 errors — with ZERO further FE code changes, exactly as predicted. The full SSH -computer feature (handoff #2, §2e) + the wire-type break (handoff #1) are unchanged; the merge only added -`provider-retry` on top. FE is now fully green: typecheck 0/0, 795/795 tests, biome clean, build OK)._ +_Last updated: 2026-06-25 (§2f ADDED — Heartbeat feature shipped: workspace autonomous-agent config + run +history + live run-chat modal; new `src/features/heartbeat/` feature library + `watchConversation`/`unwatchConversation` +on the store; 837/837 tests green, typecheck 0/0, biome clean, build OK. The heartbeat API is a plain REST surface — +NOT a transport-contract type — so the FE owns the types locally; see §2f for the contract-swap note)._ **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. @@ -446,6 +445,65 @@ human confirm of the dropdown/badge/test should run once `ssh` is wired + the `p --- +## 2f. Heartbeat (workspace autonomous-agent loop) → **CONSUMED ✅ (backend shipped; FE built)** + +The backend shipped a workspace-scoped **heartbeat** — an autonomous agent loop that periodically runs a turn in a +dedicated conversation using a configured system prompt, task prompt, model, reasoning effort, and interval. The FE +exposes the config, run history, and a per-run live chat in a new sidebar **Heartbeat** view (branch `feature/heartbeat`). + +**Backend API (plain REST — NOT a transport-contract type):** +- `GET /workspaces/:id/heartbeat` → `{ enabled, systemPrompt, taskPrompt, intervalMinutes, model, reasoningEffort }` +- `PUT /workspaces/:id/heartbeat` (partial body) → updated config +- `GET /workspaces/:id/heartbeat/runs` → `{ runs: [{ id, conversationId, triggeredAt, status }] }` (`status: running|completed|stopped`) +- `POST /workspaces/:id/heartbeat/runs/:runId/stop` → `{ ok: true }` + +**Contract note (important):** the heartbeat shapes are NOT in `@dispatch/transport-contract` / `@dispatch/wire` +(verified: no `heartbeat` symbol in either `dist/`). So the FE owns the types locally in `src/features/heartbeat/logic/types.ts` +(consumer-defines-port, mirroring the `mcp`/`computer` result-type pattern) and coerces the untyped JSON at the network +seam via pure `normalizeHeartbeatConfig`/`normalizeHeartbeatRuns` (a malformed/partial response can never crash the +renderer). **If the backend later promotes these to a shared contract package, swap the local types for the imports + +re-mirror `.dispatch/*.reference.md`.** No contract version bump on the FE side (no `file:` dep re-pin needed). + +**FE (DONE + verified):** +- New feature library `src/features/heartbeat/`: pure `logic/view-model.ts` (`viewRun`/`viewRuns`/`badgeForStatus`/ + `statusLabelFor`/`formatRunTime`/`relativeLabel`/config-form helpers `formFromConfig`/`patchFromForm`/`formDiffers`/ + `normalizeInterval` (1–1440 clamp)/network normalizers + `effortOptions` re-exported from `features/chat`) — 35 + view-model tests green; `ui/HeartbeatView.svelte` (config panel: enable toggle (saves immediately), system + task + prompt textareas, model dropdown, reasoning-effort dropdown, interval input, Save button with `hasChanges` guard; + scrolling runs list polling every 4s with a spinner when running + per-row Stop) + `ui/RunModal.svelte` (fullscreen + modal that reuses `features/chat`'s `ChatView` to render the run's conversation; live-streams via the store's + `watchConversation`/`chat.subscribe` while `generating`, with a Stop button → `POST .../runs/:runId/stop`); + `index.ts` (`HeartbeatView`/`RunModal`/`manifest`/types). The reasoning-effort ladder is REUSED from `features/chat` + (sanctioned cross-feature import through its public exports) — no drift. +- `AppStore` (`src/app/store.svelte.ts`): `heartbeatConfig`/`setHeartbeatConfig`/`heartbeatRuns`/`stopHeartbeatRun` + (workspace-scoped; read `activeWorkspaceId` with `untrack`) + **`watchConversation`/`unwatchConversation`** — a new + "watch" mechanism for the modal: reuses an open tab's `ChatStore` (already subscribed) or creates an EPHEMERAL watch + store in a new `watchStores` map (separate from tabs — never opens a tab) + subscribes via `chat.subscribe` + loads + history; deltas route to it (the `handleChatMessage` hot path now also checks `watchStores`); `onReopen` re-subscribes + + resyncs watch stores; `dispose` disposes them. `unwatchConversation` is a no-op for a tab conversation (it keeps its + store + stream) — only the ephemeral watch store is disposed + unsubscribed. +- Wired into `src/app/App.svelte`: `"heartbeat"` view kind (in `viewKinds`), `heartbeatManifest` in `loadedModules`, + thin adapter functions, the `viewContent` snippet branch, + `{#key heartbeatRun.id}` RunModal render when a run is + selected. +- Store tests (`src/app/store.test.ts`): +7 — config load/PUT-merge/error, runs load, stop POST, watch subscribe + + live-delta routing, tab-reuse (no extra subscribe) + unwatch no-op for a tab. +- Also fixed a PRE-EXISTING `WorkspaceCard.test.ts` typo (`onNavigate` shorthand used before declaration → + `onNavigate: vi.fn()`) that was red on the branch HEAD independent of heartbeat. + +**Verification:** 837/837 tests green (run TWICE — no cross-test pollution; the watch stores are plain `Map`s, not +shared globals, but the methodology's double-run is honored); `svelte-check` 0 errors; biome clean; `vite build` +succeeds. Live probe NOT run (the backend's heartbeat loop + endpoints were not reachable headless at verify time; the +unit + store tests fully cover the data path + the WS routing seam). To confirm end-to-end: start the backend, open the +Heartbeat sidebar view, toggle enable, edit+Save the config, watch a run appear + stream live in the modal, click Stop. + +**Vocabulary note (heads-up, not blocking):** `GLOSSARY.md` marks **"view"** as RESERVED (old-Dispatch sidebar +affordance, future). The heartbeat UI follows the codebase's ESTABLISHED convention — sidebar panels are already called +"views" pervasively (`viewKinds`, `ViewSidebar`, `viewContent`, "Model view"/"LSP view"/"Settings view"). The feature +module itself is named `heartbeat` (a feature module). No new term was coined. If the reserved-"view" cleanup happens +later, the heartbeat view kind renames in lockstep with the rest. + +--- + ## 3. Likely NEXT backend asks (heads-up, not yet requested) - **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns diff --git a/src/app/App.svelte b/src/app/App.svelte index 78504bb..f19df62 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -60,6 +60,16 @@ type TestComputerResult, } from "../features/computer"; import { + HeartbeatView, + manifest as heartbeatManifest, + RunModal, + type HeartbeatConfigResult, + type HeartbeatRunView, + type HeartbeatRunsResult, + type HeartbeatStopResult, + } from "../features/heartbeat"; + import type { ChatStore } from "../features/chat"; + import { SystemPromptBuilder, type LoadSystemPrompt as LoadSystemPromptAlias, type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias, @@ -95,6 +105,7 @@ { id: "cache-warming", label: "Cache Warming" }, { id: "tasks", label: "Tasks" }, { id: "compaction", label: "Compaction" }, + { id: "heartbeat", label: "Heartbeat" }, { id: "system-prompt", label: "System Prompt" }, { id: "settings", label: "Settings" }, ] as const; @@ -130,6 +141,7 @@ smartScrollManifest, settingsManifest, systemPromptManifest, + heartbeatManifest, ].map((m) => [m.name, m.description] as const); // Smart-scroll: keep the transcript pinned to the bottom while it streams, @@ -216,6 +228,9 @@ const storedSidebarOpen = sidebarOpenStore.load(); let sidebarOpen = $state(storedSidebarOpen ?? (typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true)); let systemPromptModalOpen = $state(false); + // The heartbeat run currently open in the fullscreen run-chat modal (null = + // closed). Holds a snapshot run view; the modal re-mounts per run (keyed). + let heartbeatRun = $state<HeartbeatRunView | null>(null); $effect(() => { sidebarOpenStore.save(sidebarOpen); @@ -360,6 +375,38 @@ store.loadSystemPromptVariables(); const saveSystemPromptPrompt: SaveSystemPromptAlias = (template) => store.setSystemPrompt(template); + + // Adapt the store's heartbeat results to the heartbeat feature's ports. The + // store returns the feature's result types directly (the API is a plain REST + // surface, not a transport-contract type), so the adapter is a thin passthrough + // (kept for structural consistency with cwd-lsp/mcp/computer — see AGENTS.md + // "contracts are the cross-unit surface"). + async function loadHeartbeatConfig(): Promise<HeartbeatConfigResult> { + return store.heartbeatConfig(); + } + + async function saveHeartbeatConfig( + patch: Parameters<typeof store.setHeartbeatConfig>[0], + ): Promise<HeartbeatConfigResult> { + return store.setHeartbeatConfig(patch); + } + + async function loadHeartbeatRuns(): Promise<HeartbeatRunsResult> { + return store.heartbeatRuns(); + } + + async function stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> { + return store.stopHeartbeatRun(runId); + } + + // Run-chat modal: open a live watch on the run's conversation (the store owns + // the ChatStore + the `chat.subscribe` stream), and tear it down on close. + function openRunChat(conversationId: string): ChatStore { + return store.watchConversation(conversationId); + } + function closeRunChat(conversationId: string): void { + store.unwatchConversation(conversationId); + } </script> <main class="relative flex h-screen overflow-hidden"> @@ -517,6 +564,21 @@ /> {/if} +{#if heartbeatRun !== null} + <!-- Keyed per run so switching runs (or re-opening) re-mounts the modal — a + fresh watch store lifecycle per run. The modal owns the live watch + (openChat/closeChat) and the Stop button. --> + {#key heartbeatRun.id} + <RunModal + run={heartbeatRun} + openChat={openRunChat} + closeChat={closeRunChat} + stopRun={stopHeartbeatRun} + onClose={() => (heartbeatRun = null)} + /> + {/key} +{/if} + {#snippet viewContent(kind: string)} {#if kind === "model"} <div class="flex flex-col gap-3"> @@ -609,5 +671,16 @@ <div class="flex flex-col gap-3"> <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} /> </div> + {:else if kind === "heartbeat"} + <!-- Workspace-scoped autonomous-agent heartbeat (config + run history). + Not conversation-scoped (no {#key}); the config + runs are per-workspace. --> + <HeartbeatView + models={store.models} + loadConfig={loadHeartbeatConfig} + saveConfig={saveHeartbeatConfig} + loadRuns={loadHeartbeatRuns} + stopRun={stopHeartbeatRun} + onOpenRun={(run) => (heartbeatRun = run)} + /> {/if} {/snippet} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 1d69391..1ad0c08 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -55,6 +55,15 @@ import type { ChatStore, HistorySync, MetricsSync } from "../features/chat"; import { createChatStore } from "../features/chat"; import type { ConversationCache } from "../features/conversation-cache"; import { createConversationCache } from "../features/conversation-cache"; +import type { + HeartbeatConfig, + HeartbeatConfigPatch, + HeartbeatConfigResult, + HeartbeatRun, + HeartbeatRunsResult, + HeartbeatStopResult, +} from "../features/heartbeat"; +import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat"; import type { Tab, TabsState } from "../features/tabs"; import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs"; import { resolveHttpUrl } from "./resolve-http-url"; @@ -306,6 +315,44 @@ export interface AppStore { */ attachUnloadGate(gate: () => boolean): void; /** + * Load the active workspace's heartbeat config + * (`GET /workspaces/:id/heartbeat`). Workspace-scoped (NOT per-conversation): + * the backend runs an autonomous agent loop on a configured interval, writing + * each run into a dedicated conversation. The config covers the system/task + * prompts, model, reasoning effort, interval, and an enabled flag. + */ + heartbeatConfig(): Promise<HeartbeatConfigResult>; + /** + * Persist a partial heartbeat config patch + * (`PUT /workspaces/:id/heartbeat`). The backend merges the patch onto the + * stored config; returns the full updated config. + */ + setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult>; + /** + * Load the active workspace's heartbeat run history + * (`GET /workspaces/:id/heartbeat/runs`). Each run references the conversation + * it wrote to — open one via {@link watchConversation} to see its chat live. + */ + heartbeatRuns(): Promise<HeartbeatRunsResult>; + /** + * Stop a running heartbeat run (`POST /workspaces/:id/heartbeat/runs/:runId/stop`). + * The run's in-flight turn seals (its conversation keeps streaming until it + * ends); the run's status flips to `stopped` (visible on the next runs poll). + */ + stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult>; + /** + * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat + * modal): ensures a live {@link ChatStore} for the conversation, subscribing + * to its turn stream (`chat.subscribe`) + loading history. Reuses the open + * tab's store if the conversation is already a tab; otherwise creates an + * EPHEMERAL watch store (separate from tabs — never opens a tab). Deltas are + * routed to it automatically. Pair every open with {@link unwatchConversation} + * on close to unsubscribe + dispose the ephemeral store. + */ + watchConversation(conversationId: string): ChatStore; + /** Dispose + unsubscribe a watch opened by {@link watchConversation}. */ + unwatchConversation(conversationId: string): void; + /** * 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). @@ -423,6 +470,13 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const chatStores = new Map<string, ChatStore>(); + // Ephemeral chat stores for MODAL viewers (the heartbeat run-chat modal): a + // watch on a conversation's live turn stream WITHOUT opening a tab. Separate + // from `chatStores` (tabs) so closing a modal never disturbs the tab strip, + // and a tab's conversation reuses its own store (see `watchConversation`). + // Deltas are routed here in addition to `chatStores`. + const watchStores = new Map<string, ChatStore>(); + function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore { return createChatStore({ conversationId, @@ -592,7 +646,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } if (targetId !== undefined) { - const store = chatStores.get(targetId); + const store = chatStores.get(targetId) ?? watchStores.get(targetId); if (store !== undefined) { store.handleDelta(msg); return; @@ -603,6 +657,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { for (const store of chatStores.values()) { store.handleDelta(msg); } + for (const store of watchStores.values()) { + store.handleDelta(msg); + } } /** @@ -623,6 +680,44 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } /** + * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat + * modal). Returns a live {@link ChatStore} for the conversation's turn stream. + * If the conversation is already an open TAB, reuses its store (it is already + * subscribed + streaming); otherwise creates an EPHEMERAL watch store in + * `watchStores` (separate from tabs — never opens a tab), subscribes to its + * live turn stream, and loads history. Deltas route to it via `handleChatMessage`. + * Pair with {@link unwatchConversation} on close. + */ + function watchConversation(conversationId: string): ChatStore { + // An open tab already has a live store + subscription — reuse it. + const tabStore = chatStores.get(conversationId); + if (tabStore !== undefined) return tabStore; + const existing = watchStores.get(conversationId); + if (existing !== undefined) return existing; + const store = createChatFor(conversationId, activeModel, activeWorkspaceId); + watchStores.set(conversationId, store); + void store.load(); + subscribeChat(conversationId); + return store; + } + + /** + * Dispose + unsubscribe a watch opened by {@link watchConversation}. A no-op if + * the conversation was (or became) an open TAB — the tab owns its store + + * subscription, so nothing is torn down (closing the modal must not disturb the + * tab strip). Only the ephemeral watch store is disposed + unsubscribed. + */ + function unwatchConversation(conversationId: string): void { + // A tab reuses its own store — leave it (and its subscription) intact. + if (chatStores.has(conversationId)) return; + const store = watchStores.get(conversationId); + if (store === undefined) return; + store.dispose(); + watchStores.delete(conversationId); + unsubscribeChat(conversationId); + } + + /** * Tell the backend the user EXPLICITLY closed this conversation's tab * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF). @@ -876,6 +971,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { subscribeChat(tab.conversationId); chatStores.get(tab.conversationId)?.resync(); } + // Re-attach to every MODAL watch too (a run-chat modal open across a + // reconnect keeps streaming). Watch stores are separate from tabs. + for (const [watchId, watchStore] of watchStores) { + subscribeChat(watchId); + watchStore.resync(); + } }, }; if (opts?.socketFactory !== undefined) { @@ -1450,6 +1551,112 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, + async heartbeatConfig(): Promise<HeartbeatConfigResult> { + // Workspace-scoped (NOT per-conversation): use the active workspace id. + const wsId = untrack(() => activeWorkspaceId); + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Heartbeat config failed (HTTP ${res.status})`, + }; + } + // Normalize the untyped JSON at the network seam (pure helper) so a + // malformed/partial response can never crash the renderer. + const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json()); + return { ok: true, config }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Heartbeat config request failed", + }; + } + }, + + async setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult> { + const wsId = untrack(() => activeWorkspaceId); + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(patch), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set heartbeat config failed (HTTP ${res.status})`, + }; + } + const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json()); + return { ok: true, config }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set heartbeat config request failed", + }; + } + }, + + async heartbeatRuns(): Promise<HeartbeatRunsResult> { + const wsId = untrack(() => activeWorkspaceId); + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs`, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Heartbeat runs failed (HTTP ${res.status})`, + }; + } + const runs: readonly HeartbeatRun[] = normalizeHeartbeatRuns(await res.json()); + return { ok: true, runs }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Heartbeat runs request failed", + }; + } + }, + + async stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> { + const wsId = untrack(() => activeWorkspaceId); + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs/${encodeURIComponent(runId)}/stop`, + { method: "POST" }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Stop heartbeat run failed (HTTP ${res.status})`, + }; + } + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Stop heartbeat run request failed", + }; + } + }, + + watchConversation(conversationId: string): ChatStore { + return watchConversation(conversationId); + }, + + unwatchConversation(conversationId: string): void { + unwatchConversation(conversationId); + }, + async loadSystemPrompt(): Promise<SystemPromptLoadResult> { try { const res = await fetchImpl(`${httpBase}/system-prompt`); @@ -1531,6 +1738,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { store.dispose(); } chatStores.clear(); + for (const store of watchStores.values()) { + store.dispose(); + } + watchStores.clear(); draftStore.dispose(); socket?.close(); socket = null; diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 5711442..c428769 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -1124,4 +1124,255 @@ describe("createAppStore", () => { store.dispose(); }); + + // ── Heartbeat (workspace-scoped config + runs + watch) ─────────────────────── + // + // The heartbeat API is a plain REST surface (not a transport-contract type), + // so these tests fake the four endpoints + verify the store coerces the + // untyped JSON and routes live deltas to a watch store (the run-chat modal). + + function heartbeatFetchImpl(opts?: { + config?: Record<string, unknown>; + runs?: Record<string, unknown>; + }): typeof fetch { + const base = fakeFetchImpl(); + const config = opts?.config ?? { + enabled: true, + systemPrompt: "sys", + taskPrompt: "task", + intervalMinutes: 15, + model: "openai/gpt-4o", + reasoningEffort: "medium", + }; + const runs = opts?.runs ?? { + runs: [ + { + id: "run-1", + conversationId: "hb-conv-1", + triggeredAt: "2026-06-25T10:00:00Z", + status: "running", + }, + ], + }; + return async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.includes("/heartbeat/runs") && method === "GET") { + return new Response(JSON.stringify(runs), { status: 200 }); + } + if (url.includes("/heartbeat/runs/") && method === "POST") { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (url.endsWith("/heartbeat") && method === "GET") { + return new Response(JSON.stringify(config), { status: 200 }); + } + if (url.endsWith("/heartbeat") && method === "PUT") { + // Echo the patch merged onto the stored config so the round-trip is observable. + const patch = init?.body ? JSON.parse(init.body as string) : {}; + return new Response(JSON.stringify({ ...config, ...patch }), { + status: 200, + }); + } + if (url.includes("/heartbeat")) { + return new Response(JSON.stringify(config), { status: 200 }); + } + return base(input, init); + }; + } + + it("heartbeatConfig loads + coerces the workspace config", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: heartbeatFetchImpl(), + localStorage: createFakeStorage(), + }); + const result = await store.heartbeatConfig(); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.config).toEqual({ + enabled: true, + systemPrompt: "sys", + taskPrompt: "task", + intervalMinutes: 15, + model: "openai/gpt-4o", + reasoningEffort: "medium", + }); + store.dispose(); + }); + + it("heartbeatConfig surfaces an HTTP error", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/heartbeat")) + return new Response(JSON.stringify({ error: "nope" }), { status: 500 }); + return fakeFetchImpl()(input); + }, + localStorage: createFakeStorage(), + }); + const result = await store.heartbeatConfig(); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("nope"); + store.dispose(); + }); + + it("setHeartbeatConfig PUTs a patch and returns the merged config", async () => { + const calls: { url: string; method: string; body: unknown }[] = []; + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.endsWith("/heartbeat") && method === "PUT") { + calls.push({ url, method, body: JSON.parse(init?.body as string) }); + } + return heartbeatFetchImpl()(input, init); + }, + localStorage: createFakeStorage(), + }); + const result = await store.setHeartbeatConfig({ enabled: false, intervalMinutes: 9999 }); + expect(result.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]?.body).toEqual({ enabled: false, intervalMinutes: 9999 }); + // The store normalizes the echoed response (interval clamped to the 1–1440 range). + if (!result.ok) throw new Error("unreachable"); + expect(result.config.intervalMinutes).toBe(1440); + store.dispose(); + }); + + it("heartbeatRuns loads + coerces the run list", async () => { + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: heartbeatFetchImpl(), + localStorage: createFakeStorage(), + }); + const result = await store.heartbeatRuns(); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("unreachable"); + expect(result.runs).toHaveLength(1); + expect(result.runs[0]).toMatchObject({ + id: "run-1", + conversationId: "hb-conv-1", + status: "running", + }); + store.dispose(); + }); + + it("stopHeartbeatRun POSTs the stop endpoint", async () => { + const calls: { url: string; method: string }[] = []; + const store = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + if (url.includes("/heartbeat/runs/") && method === "POST") { + calls.push({ url, method }); + } + return heartbeatFetchImpl()(input, init); + }, + localStorage: createFakeStorage(), + }); + const result = await store.stopHeartbeatRun("run-1"); + expect(result.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toContain("/heartbeat/runs/run-1/stop"); + expect(calls[0]?.method).toBe("POST"); + store.dispose(); + }); + + it("watchConversation subscribes + routes live deltas to the watch store", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // A heartbeat run's conversation that is NOT an open tab — watch it. + const watch = store.watchConversation("hb-conv-watch"); + // A chat.subscribe was sent for the watched conversation. + const subscribed = parseSent(ws).some( + (p) => + (p as { type: string; conversationId?: string }).type === "chat.subscribe" && + (p as { conversationId?: string }).conversationId === "hb-conv-watch", + ); + expect(subscribed).toBe(true); + + // Feed a live delta for the watched conversation → the watch store folds it. + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId: "hb-conv-watch", turnId: "t1" }, + }); + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "text-delta", + conversationId: "hb-conv-watch", + turnId: "t1", + delta: "hello from heartbeat", + }, + }); + + await vi.waitFor(() => { + const text = watch.chunks.find((c) => c.role === "assistant" && c.chunk.type === "text"); + expect((text?.chunk as { type: "text"; text: string } | undefined)?.text).toBe( + "hello from heartbeat", + ); + }); + expect(watch.generating).toBe(true); + + // Unwatch → unsubscribes (a chat.unsubscribe for this conversation is sent). + ws.sent.length = 0; + store.unwatchConversation("hb-conv-watch"); + const unsubscribed = parseSent(ws).some( + (p) => + (p as { type: string; conversationId?: string }).type === "chat.unsubscribe" && + (p as { conversationId?: string }).conversationId === "hb-conv-watch", + ); + expect(unsubscribed).toBe(true); + + store.dispose(); + }); + + it("watchConversation reuses an open tab's store; unwatch is a no-op for it", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + const convId = activeConversationId(store); + // The conversation is an open tab (already subscribed on send). Watching it + // must REUSE the tab's store + subscription — so no NEW chat.subscribe is + // sent (the watch path only subscribes when it creates an ephemeral store). + // (Note: `store.activeChat` is a Svelte `$state` PROXY of the tab store, so a + // reference-equality check is meaningless here — we assert behavior instead.) + ws.sent.length = 0; + store.watchConversation(convId); + const subscribed = parseSent(ws).some( + (p) => + (p as { type: string; conversationId?: string }).type === "chat.subscribe" && + (p as { conversationId?: string }).conversationId === convId, + ); + expect(subscribed).toBe(false); + + // Unwatching a tab conversation does NOT unsubscribe (the tab keeps its stream). + ws.sent.length = 0; + store.unwatchConversation(convId); + const unsubscribed = parseSent(ws).some( + (p) => (p as { type: string }).type === "chat.unsubscribe", + ); + expect(unsubscribed).toBe(false); + + store.dispose(); + }); }); diff --git a/src/features/heartbeat/index.ts b/src/features/heartbeat/index.ts new file mode 100644 index 0000000..00c3eeb --- /dev/null +++ b/src/features/heartbeat/index.ts @@ -0,0 +1,39 @@ +export type { + HeartbeatConfig, + HeartbeatConfigPatch, + HeartbeatConfigResult, + HeartbeatRun, + HeartbeatRunStatus, + HeartbeatRunsResult, + HeartbeatStopResult, + LoadHeartbeatConfig, + LoadHeartbeatRuns, + SaveHeartbeatConfig, + StopHeartbeatRun, +} from "./logic/types"; +export type { Badge, HeartbeatFormState, HeartbeatRunView } from "./logic/view-model"; +export { + badgeForStatus, + DEFAULT_INTERVAL_MINUTES, + effortOptions, + emptyForm, + formatRunTime, + formDiffers, + formFromConfig, + normalizeHeartbeatConfig, + normalizeHeartbeatRuns, + normalizeInterval, + patchFromForm, + relativeLabel, + statusLabelFor, + viewRun, + viewRuns, +} from "./logic/view-model"; +export { default as HeartbeatView } from "./ui/HeartbeatView.svelte"; +export { default as RunModal } from "./ui/RunModal.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "heartbeat", + description: "Workspace autonomous-agent heartbeat: config, run history, live run chat", +} as const; diff --git a/src/features/heartbeat/logic/types.ts b/src/features/heartbeat/logic/types.ts new file mode 100644 index 0000000..b3871c9 --- /dev/null +++ b/src/features/heartbeat/logic/types.ts @@ -0,0 +1,89 @@ +import type { ReasoningEffort } from "@dispatch/transport-contract"; + +/** + * Pure core types for the heartbeat feature — zero DOM, zero effects, zero Svelte. + * + * Heartbeat is a workspace-scoped autonomous agent loop: the backend periodically + * runs a turn in a dedicated conversation using a configured system prompt, task + * prompt, model, reasoning effort, and interval. The FE exposes the config + * (`GET`/`PUT /workspaces/:id/heartbeat`), the run history + * (`GET /workspaces/:id/heartbeat/runs`), and a per-run stop + * (`POST /workspaces/:id/heartbeat/runs/:runId/stop`). + * + * The backend's heartbeat API is a plain REST surface — it is NOT part of the + * shared `@dispatch/transport-contract` / `@dispatch/wire` packages (verified: + * no `heartbeat` symbol in either `dist/`). So, following the consumer-defines- + * port pattern (mirrors `features/mcp` / `features/computer` result types), the + * FE owns these shapes here and adapts the untyped JSON at the network seam in + * the composition root. If the backend later promotes these to a shared contract + * package, swap the local types for the imports (see `backend-handoff.md`). + */ + +/** The canonical run lifecycle status (backend-owned enum, verbatim). */ +export type HeartbeatRunStatus = "running" | "completed" | "stopped"; + +/** The workspace's heartbeat configuration (`GET /workspaces/:id/heartbeat`). */ +export interface HeartbeatConfig { + /** Whether the autonomous loop is enabled (running on the interval). */ + readonly enabled: boolean; + readonly systemPrompt: string; + readonly taskPrompt: string; + /** Minutes between runs. */ + readonly intervalMinutes: number; + /** The model name (`<credential>/<model>`) the heartbeat runs with. */ + readonly model: string; + /** + * The heartbeat's reasoning effort, or null when never set (the server + * default `"high"` then applies) — mirrors the per-conversation knob's + * resolution chain. + */ + readonly reasoningEffort: ReasoningEffort | null; +} + +/** + * A partial config patch for `PUT /workspaces/:id/heartbeat`. Every field is + * optional — the backend merges the patch onto the stored config. + */ +export interface HeartbeatConfigPatch { + readonly enabled?: boolean; + readonly systemPrompt?: string; + readonly taskPrompt?: string; + readonly intervalMinutes?: number; + readonly model?: string; + readonly reasoningEffort?: ReasoningEffort | null; +} + +/** One heartbeat run (`GET /workspaces/:id/heartbeat/runs`). */ +export interface HeartbeatRun { + readonly id: string; + /** The conversation this run wrote to (watch it live for the chat). */ + readonly conversationId: string; + /** ISO timestamp of when the run was triggered. */ + readonly triggeredAt: string; + readonly status: HeartbeatRunStatus; +} + +// ── Injected ports (consumer-defines-port; the composition root adapts the +// store's HTTP calls to these shapes). ────────────────────────────────────── + +/** Outcome of `GET /workspaces/:id/heartbeat` (or the PUT response). */ +export type HeartbeatConfigResult = + | { readonly ok: true; readonly config: HeartbeatConfig } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `GET /workspaces/:id/heartbeat/runs`. */ +export type HeartbeatRunsResult = + | { readonly ok: true; readonly runs: readonly HeartbeatRun[] } + | { readonly ok: false; readonly error: string }; + +/** Outcome of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */ +export type HeartbeatStopResult = + | { readonly ok: true } + | { readonly ok: false; readonly error: string }; + +export type LoadHeartbeatConfig = () => Promise<HeartbeatConfigResult | null>; +export type SaveHeartbeatConfig = ( + patch: HeartbeatConfigPatch, +) => Promise<HeartbeatConfigResult | null>; +export type LoadHeartbeatRuns = () => Promise<HeartbeatRunsResult | null>; +export type StopHeartbeatRun = (runId: string) => Promise<HeartbeatStopResult | null>; diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts new file mode 100644 index 0000000..fc43112 --- /dev/null +++ b/src/features/heartbeat/logic/view-model.test.ts @@ -0,0 +1,299 @@ +import type { ReasoningEffort } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import type { HeartbeatConfig, HeartbeatRun } from "./types"; +import { + badgeForStatus, + DEFAULT_INTERVAL_MINUTES, + effortOptions, + emptyForm, + formatRunTime, + formDiffers, + formFromConfig, + normalizeHeartbeatConfig, + normalizeHeartbeatRuns, + normalizeInterval, + patchFromForm, + relativeLabel, + statusLabelFor, + viewRun, + viewRuns, +} from "./view-model"; + +const NOW = Date.UTC(2026, 5, 25, 14, 30, 5); // 2026-06-25T14:30:05Z +const ISO_AT = "2026-06-25T14:30:05Z"; // exactly NOW +const run = (over: Partial<HeartbeatRun> = {}): HeartbeatRun => ({ + id: "run-1", + conversationId: "conv-1", + triggeredAt: ISO_AT, + status: "completed", + ...over, +}); + +const config = (over: Partial<HeartbeatConfig> = {}): HeartbeatConfig => ({ + enabled: false, + systemPrompt: "be helpful", + taskPrompt: "check status", + intervalMinutes: 15, + model: "openai/gpt-4o", + reasoningEffort: null, + ...over, +}); + +describe("badgeForStatus", () => { + it("running → warning + busy (spinner)", () => { + expect(badgeForStatus("running")).toEqual({ badge: "warning", busy: true }); + }); + it("completed → success, not busy", () => { + expect(badgeForStatus("completed")).toEqual({ badge: "success", busy: false }); + }); + it("stopped → neutral, not busy", () => { + expect(badgeForStatus("stopped")).toEqual({ badge: "neutral", busy: false }); + }); +}); + +describe("statusLabelFor", () => { + it("maps each status to a display label", () => { + expect(statusLabelFor("running")).toBe("Running"); + expect(statusLabelFor("completed")).toBe("Completed"); + expect(statusLabelFor("stopped")).toBe("Stopped"); + }); +}); + +describe("formatRunTime", () => { + it("formats an ISO timestamp as HH:MM:SS (UTC components)", () => { + // Uses local getHours/Minutes/Seconds; under UTC env (TZ=UTC) reads 14:30:05. + // We assert the SHAPE (3 colon-separated 2-digit groups) so it's TZ-stable. + expect(formatRunTime(ISO_AT)).toMatch(/^\d{2}:\d{2}:\d{2}$/); + expect(formatRunTime(ISO_AT).split(":")).toHaveLength(3); + }); + it("returns — for an unparseable timestamp", () => { + expect(formatRunTime("not-a-date")).toBe("—"); + expect(formatRunTime("")).toBe("—"); + }); +}); + +describe("relativeLabel", () => { + it("just now when within a minute", () => { + expect(relativeLabel(ISO_AT, NOW)).toBe("just now"); + expect(relativeLabel(ISO_AT, NOW + 30_000)).toBe("just now"); + }); + it("Nm ago under an hour", () => { + expect(relativeLabel(ISO_AT, NOW + 5 * 60_000)).toBe("5m ago"); + expect(relativeLabel(ISO_AT, NOW + 59 * 60_000)).toBe("59m ago"); + }); + it("Nh ago under a day", () => { + expect(relativeLabel(ISO_AT, NOW + 2 * 3_600_000)).toBe("2h ago"); + }); + it("absolute date+time past a day", () => { + const label = relativeLabel(ISO_AT, NOW + 26 * 3_600_000); + expect(label).toMatch(/^[A-Z][a-z]{2} \d+, \d{2}:\d{2}$/); + }); + it("future timestamp → just now (clock skew tolerance)", () => { + expect(relativeLabel(ISO_AT, NOW - 10_000)).toBe("just now"); + }); + it("returns — for an unparseable timestamp", () => { + expect(relativeLabel("nope", NOW)).toBe("—"); + }); +}); + +describe("viewRun / viewRuns", () => { + it("running run: warning badge + busy + labels", () => { + const v = viewRun(run({ status: "running" }), NOW); + expect(v.badge).toBe("warning"); + expect(v.busy).toBe(true); + expect(v.statusLabel).toBe("Running"); + expect(v.id).toBe("run-1"); + expect(v.conversationId).toBe("conv-1"); + expect(v.timeLabel).toMatch(/^\d{2}:\d{2}:\d{2}$/); + expect(v.relativeLabel).toBe("just now"); + }); + it("completed run: success badge, not busy", () => { + expect(viewRun(run({ status: "completed" }), NOW).badge).toBe("success"); + }); + it("stopped run: neutral badge, not busy", () => { + expect(viewRun(run({ status: "stopped" }), NOW).badge).toBe("neutral"); + }); + it("viewRuns preserves order", () => { + const views = viewRuns([run({ id: "a" }), run({ id: "b" })], NOW); + expect(views.map((v) => v.id)).toEqual(["a", "b"]); + }); +}); + +describe("config form", () => { + it("emptyForm has defaults (disabled, default interval, default effort)", () => { + const f = emptyForm(); + expect(f.enabled).toBe(false); + expect(f.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES); + expect(f.reasoningEffort).toBe("high"); // DEFAULT_REASONING_EFFORT + expect(f.systemPrompt).toBe(""); + expect(f.model).toBe(""); + }); + + it("formFromConfig resolves null reasoningEffort to the default", () => { + const f = formFromConfig(config({ reasoningEffort: null })); + expect(f.reasoningEffort).toBe("high"); + }); + + it("formFromConfig passes through a set reasoningEffort", () => { + const f = formFromConfig(config({ reasoningEffort: "max" })); + expect(f.reasoningEffort).toBe("max"); + }); + + it("formFromConfig coerces malformed fields safely", () => { + const f = formFromConfig( + config({ + enabled: "yes" as unknown as boolean, + intervalMinutes: -5, + model: 42 as unknown as string, + systemPrompt: undefined as unknown as string, + }), + ); + expect(f.enabled).toBe(false); // non-true → false + expect(f.intervalMinutes).toBe(1); // clamped + expect(f.model).toBe(""); // non-string → "" + expect(f.systemPrompt).toBe(""); // undefined → "" + }); + + it("normalizeInterval clamps to 1–1440 and rounds", () => { + expect(normalizeInterval(0)).toBe(1); + expect(normalizeInterval(-10)).toBe(1); + expect(normalizeInterval(1.4)).toBe(1); + expect(normalizeInterval(15.6)).toBe(16); + expect(normalizeInterval(2000)).toBe(1440); + expect(normalizeInterval("30" as unknown as number)).toBe(30); // default on non-number + expect(normalizeInterval(undefined)).toBe(DEFAULT_INTERVAL_MINUTES); + }); + + it("patchFromForm clamps interval + carries every field", () => { + const f = formFromConfig(config({ intervalMinutes: 2000 })); + const patch = patchFromForm(f); + expect(patch.intervalMinutes).toBe(1440); + expect(patch.enabled).toBe(false); + expect(patch.model).toBe("openai/gpt-4o"); + expect(patch.reasoningEffort).toBe("high"); + expect(patch.systemPrompt).toBe("be helpful"); + expect(patch.taskPrompt).toBe("check status"); + }); + + it("formDiffers is false for a form seeded from the config (no edits)", () => { + const c = config({ reasoningEffort: "medium" }); + const f = formFromConfig(c); + expect(formDiffers(f, c)).toBe(false); + }); + + it("formDiffers is true after an edit", () => { + const c = config(); + const f = formFromConfig(c); + f.systemPrompt = "changed"; + expect(formDiffers(f, c)).toBe(true); + }); + + it("formDiffers treats null config effort as the default (matches the resolved form)", () => { + const c = config({ reasoningEffort: null }); + const f = formFromConfig(c); + expect(formDiffers(f, c)).toBe(false); // null resolves to "high" == form + }); +}); + +describe("effortOptions re-export", () => { + it("exposes the canonical ladder with the default marked", () => { + const opts = effortOptions(); + const values = opts.map((o) => o.value) as readonly string[]; + expect(values).toEqual(["low", "medium", "high", "xhigh", "max"]); + const def = opts.find((o) => o.value === "high"); + expect(def?.label).toBe("high (default)"); + }); +}); + +describe("reasoningEffort type narrowing (sanity)", () => { + // Ensures the imported ladder stays the wire's canonical set — if the wire + // ladder changes, this test flags the drift alongside the chat feature. + it("the five canonical levels", () => { + const levels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; + expect(levels).toHaveLength(5); + }); +}); + +describe("normalizeHeartbeatConfig", () => { + it("passes through a well-formed config", () => { + const c = normalizeHeartbeatConfig({ + enabled: true, + systemPrompt: "sys", + taskPrompt: "task", + intervalMinutes: 20, + model: "openai/gpt-4o", + reasoningEffort: "max", + }); + expect(c).toEqual({ + enabled: true, + systemPrompt: "sys", + taskPrompt: "task", + intervalMinutes: 20, + model: "openai/gpt-4o", + reasoningEffort: "max", + }); + }); + it("coerces a malformed body safely (never throws, never undefined)", () => { + const c = normalizeHeartbeatConfig({ + enabled: "yes", + intervalMinutes: -3, + reasoningEffort: "bogus", + }); + expect(c.enabled).toBe(false); + expect(c.intervalMinutes).toBe(1); + expect(c.reasoningEffort).toBeNull(); + expect(c.systemPrompt).toBe(""); + expect(c.taskPrompt).toBe(""); + expect(c.model).toBe(""); + }); + it("accepts a null reasoningEffort", () => { + expect(normalizeHeartbeatConfig({ reasoningEffort: null }).reasoningEffort).toBeNull(); + }); + it("handles null / non-object input", () => { + const c = normalizeHeartbeatConfig(null); + expect(c.enabled).toBe(false); + expect(c.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES); + expect(c.model).toBe(""); + }); + it("clamps a huge interval", () => { + expect(normalizeHeartbeatConfig({ intervalMinutes: 99999 }).intervalMinutes).toBe(1440); + }); +}); + +describe("normalizeHeartbeatRuns", () => { + it("maps a well-formed runs list", () => { + const runs = normalizeHeartbeatRuns({ + runs: [ + { id: "r1", conversationId: "c1", triggeredAt: "2026-06-25T10:00:00Z", status: "running" }, + { + id: "r2", + conversationId: "c2", + triggeredAt: "2026-06-25T09:00:00Z", + status: "completed", + }, + ], + }); + expect(runs).toHaveLength(2); + expect(runs[0]).toMatchObject({ id: "r1", status: "running" }); + expect(runs[1]).toMatchObject({ id: "r2", status: "completed" }); + }); + it("returns [] for malformed body", () => { + expect(normalizeHeartbeatRuns(null)).toEqual([]); + expect(normalizeHeartbeatRuns({})).toEqual([]); + expect(normalizeHeartbeatRuns({ runs: "nope" })).toEqual([]); + }); + it("drops runs missing id/conversationId and defaults unknown status", () => { + const runs = normalizeHeartbeatRuns({ + runs: [ + { id: "r1", conversationId: "c1", triggeredAt: "x", status: "garbage" }, + { id: "", conversationId: "c2", triggeredAt: "x", status: "completed" }, + { id: "r3", conversationId: "", triggeredAt: "x", status: "running" }, + { id: "r4", conversationId: "c4", triggeredAt: "x", status: "stopped" }, + ], + }); + expect(runs).toHaveLength(2); + expect(runs[0]?.status).toBe("completed"); // "garbage" → default + expect(runs[0]?.id).toBe("r1"); + expect(runs[1]?.id).toBe("r4"); + }); +}); diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts new file mode 100644 index 0000000..f5b9f96 --- /dev/null +++ b/src/features/heartbeat/logic/view-model.ts @@ -0,0 +1,294 @@ +import type { ReasoningEffort } from "@dispatch/transport-contract"; +import { + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effortOptions, +} from "../../chat/reasoning-effort"; +import type { + HeartbeatConfig, + HeartbeatConfigPatch, + HeartbeatRun, + HeartbeatRunStatus, +} from "./types"; + +/** + * Pure view-models for the heartbeat feature — zero DOM, zero effects, zero + * Svelte. Maps backend `HeartbeatConfig`/`HeartbeatRun` to display shapes + * (badges, labels, formatted times) and holds the config-form helpers. + * + * The reasoning-effort ladder + resolution are SERVER-owned and shared with the + * per-conversation knob, so they are REUSED from `features/chat/reasoning-effort` + * (a sanctioned cross-feature import through its public exports) rather than + * redefined — no drift. + */ + +export type Badge = "success" | "warning" | "error" | "neutral"; + +/** A run shaped for display in the scrolling runs list. */ +export interface HeartbeatRunView { + readonly id: string; + readonly conversationId: string; + readonly status: HeartbeatRunStatus; + readonly statusLabel: string; + readonly badge: Badge; + /** True while the run is in flight (show a spinner). */ + readonly busy: boolean; + /** A short absolute clock label, e.g. "14:30:05". */ + readonly timeLabel: string; + /** A relative label, e.g. "5m ago" / "just now". */ + readonly relativeLabel: string; +} + +const RUNNING_LABEL = "Running"; +const COMPLETED_LABEL = "Completed"; +const STOPPED_LABEL = "Stopped"; + +/** + * Map a run's status to a display badge + busy flag. `running` → warning + + * spinner, `completed` → success, `stopped` → neutral. Mirrors the LSP/MCP + * status visual treatment. + */ +export function badgeForStatus(status: HeartbeatRunStatus): { badge: Badge; busy: boolean } { + switch (status) { + case "running": + return { badge: "warning", busy: true }; + case "completed": + return { badge: "success", busy: false }; + case "stopped": + return { badge: "neutral", busy: false }; + } +} + +export function statusLabelFor(status: HeartbeatRunStatus): string { + switch (status) { + case "running": + return RUNNING_LABEL; + case "completed": + return COMPLETED_LABEL; + case "stopped": + return STOPPED_LABEL; + } +} + +/** + * Format an ISO timestamp as a short absolute clock label (HH:MM:SS) in the + * viewer's locale. Returns "—" for an unparseable timestamp so the UI never + * crashes on a malformed backend value. Pure (no `now` needed — an absolute + * clock label doesn't depend on the current time). + */ +export function formatRunTime(triggeredAt: string): string { + const t = parseTime(triggeredAt); + if (t === null) return "—"; + return clockLabel(t); +} + +/** + * A coarse relative label — "just now" (<1m), "Nm ago", "Nh ago", else the + * absolute date+time (so an old run reads "Jun 24, 14:30"). Pure via `now`. + */ +export function relativeLabel(triggeredAt: string, now: number = Date.now()): string { + const t = parseTime(triggeredAt); + if (t === null) return "—"; + const deltaMs = now - t; + if (deltaMs < 0) return "just now"; + const mins = Math.floor(deltaMs / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return dateLabel(t); +} + +/** + * Build a display view for a run. `now` is injectable for tests (defaults to + * `Date.now()`); the composition-root component passes nothing in production. + */ +export function viewRun(run: HeartbeatRun, now: number = Date.now()): HeartbeatRunView { + const { badge, busy } = badgeForStatus(run.status); + return { + id: run.id, + conversationId: run.conversationId, + status: run.status, + statusLabel: statusLabelFor(run.status), + badge, + busy, + timeLabel: formatRunTime(run.triggeredAt), + relativeLabel: relativeLabel(run.triggeredAt, now), + }; +} + +export function viewRuns( + runs: readonly HeartbeatRun[], + now: number = Date.now(), +): readonly HeartbeatRunView[] { + return runs.map((r) => viewRun(r, now)); +} + +// ── Time formatting (pure: no `Date` mutation; injectable `now` for tests) ───── + +/** Parse an ISO timestamp to epoch ms, or null if unparseable. */ +function parseTime(iso: string): number | null { + if (typeof iso !== "string" || iso.length === 0) return null; + const t = Date.parse(iso); + return Number.isNaN(t) ? null : t; +} + +/** `HH:MM:SS` in the viewer's locale (24h where the locale uses it). */ +function clockLabel(epochMs: number): string { + const d = new Date(epochMs); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + const ss = String(d.getSeconds()).padStart(2, "0"); + return `${hh}:${mm}:${ss}`; +} + +/** A short absolute date+time label for an old run, e.g. "Jun 24, 14:30". */ +function dateLabel(epochMs: number): string { + const d = new Date(epochMs); + const month = d.toLocaleString(undefined, { month: "short" }); + const day = d.getDate(); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${month} ${day}, ${hh}:${mm}`; +} + +// ── Config form ─────────────────────────────────────────────────────────────── + +/** + * The editable form state for the config panel — a mutable mirror of a loaded + * `HeartbeatConfig` that the inputs bind to. `reasoningEffort` is resolved to + * an effective level for the `<select>` (null ⇒ default `high`), exactly like + * the per-conversation selector. + */ +export interface HeartbeatFormState { + enabled: boolean; + systemPrompt: string; + taskPrompt: string; + intervalMinutes: number; + model: string; + reasoningEffort: ReasoningEffort; +} + +/** The default interval (minutes) shown for an empty/unset config. */ +export const DEFAULT_INTERVAL_MINUTES = 30; + +/** + * Seed the editable form state from a loaded config, applying safe defaults for + * any malformed/absent backend field so the inputs are never `undefined`. + */ +export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState { + return { + enabled: config.enabled === true, + systemPrompt: config.systemPrompt ?? "", + taskPrompt: config.taskPrompt ?? "", + intervalMinutes: normalizeInterval(config.intervalMinutes), + model: typeof config.model === "string" ? config.model : "", + reasoningEffort: effectiveEffort(config.reasoningEffort ?? null), + }; +} + +/** An empty form (before the config loads). */ +export function emptyForm(): HeartbeatFormState { + return { + enabled: false, + systemPrompt: "", + taskPrompt: "", + intervalMinutes: DEFAULT_INTERVAL_MINUTES, + model: "", + reasoningEffort: DEFAULT_REASONING_EFFORT, + }; +} + +/** Clamp a raw interval to a sane positive-minute range (1–1440 = 1 min–24 h). */ +export function normalizeInterval(value: unknown): number { + const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_INTERVAL_MINUTES; + const int = Math.round(n); + if (int < 1) return 1; + if (int > 1440) return 1440; + return int; +} + +/** + * The patch to PUT when persisting the form. Only `intervalMinutes` is clamped; + * text fields are sent verbatim. `reasoningEffort` is always present (a resolved + * level) since the heartbeat has no per-run override — it persists the level. + */ +export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch { + return { + enabled: form.enabled, + systemPrompt: form.systemPrompt, + taskPrompt: form.taskPrompt, + intervalMinutes: normalizeInterval(form.intervalMinutes), + model: form.model, + reasoningEffort: form.reasoningEffort, + }; +} + +/** Whether the form differs from the loaded config (drives the Save button). */ +export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): boolean { + return ( + form.enabled !== config.enabled || + form.systemPrompt !== (config.systemPrompt ?? "") || + form.taskPrompt !== (config.taskPrompt ?? "") || + form.intervalMinutes !== normalizeInterval(config.intervalMinutes) || + form.model !== (typeof config.model === "string" ? config.model : "") || + form.reasoningEffort !== effectiveEffort(config.reasoningEffort ?? null) + ); +} + +// The reasoning-effort `<option>`s are reused verbatim from the per-conversation +// selector (re-exported so the config panel imports a single source). +export { effortOptions }; + +// ── Network-seam normalization (pure; called by the composition root) ──────── +// +// The heartbeat API is untyped JSON (not a transport-contract type), so the +// store coerces each response defensively HERE (pure + tested) — a malformed/ +// partial backend value can never crash the renderer. Mirrors the inline +// `Array.isArray(data.servers) ? … : []` guard the store does for LSP/MCP. + +/** Narrow an untrusted string to the run-status enum, defaulting to "completed". */ +function asRunStatus(value: unknown): HeartbeatRunStatus { + if (value === "running" || value === "completed" || value === "stopped") return value; + return "completed"; +} + +/** Coerce an untrusted `GET .../heartbeat/runs` body into a typed run list. */ +export function normalizeHeartbeatRuns(data: unknown): readonly HeartbeatRun[] { + if (!isRecord(data) || !Array.isArray(data.runs)) return []; + const runs = data.runs as readonly unknown[]; + return runs + .filter((r): r is Record<string, unknown> => r !== null && typeof r === "object") + .map((r) => ({ + id: typeof r.id === "string" ? r.id : "", + conversationId: typeof r.conversationId === "string" ? r.conversationId : "", + triggeredAt: typeof r.triggeredAt === "string" ? r.triggeredAt : "", + status: asRunStatus(r.status), + })) + .filter((r) => r.id !== "" && r.conversationId !== ""); +} + +/** Coerce an untrusted `GET`/`PUT .../heartbeat` body into a typed config. */ +export function normalizeHeartbeatConfig(data: unknown): HeartbeatConfig { + const d = isRecord(data) ? data : {}; + const effort = d.reasoningEffort; + return { + enabled: d.enabled === true, + systemPrompt: typeof d.systemPrompt === "string" ? d.systemPrompt : "", + taskPrompt: typeof d.taskPrompt === "string" ? d.taskPrompt : "", + intervalMinutes: normalizeInterval(d.intervalMinutes), + model: typeof d.model === "string" ? d.model : "", + reasoningEffort: + effort === "low" || + effort === "medium" || + effort === "high" || + effort === "xhigh" || + effort === "max" + ? effort + : null, + }; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return value !== null && typeof value === "object"; +} diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte new file mode 100644 index 0000000..7e0e7b7 --- /dev/null +++ b/src/features/heartbeat/ui/HeartbeatView.svelte @@ -0,0 +1,421 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { ReasoningEffort } from "@dispatch/transport-contract"; + import { isReasoningEffort } from "../../chat/reasoning-effort"; + import { + badgeForStatus, + type Badge, + DEFAULT_INTERVAL_MINUTES, + emptyForm, + effortOptions, + formDiffers, + formFromConfig, + normalizeInterval, + patchFromForm, + viewRuns, + type HeartbeatFormState, + type HeartbeatRunView, + } from "../logic/view-model"; + import type { + LoadHeartbeatConfig, + LoadHeartbeatRuns, + SaveHeartbeatConfig, + StopHeartbeatRun, + } from "../logic/types"; + + let { + models, + loadConfig, + saveConfig, + loadRuns, + stopRun, + onOpenRun, + }: { + /** The available model names (for the config's model dropdown). */ + models: readonly string[]; + loadConfig: LoadHeartbeatConfig; + saveConfig: SaveHeartbeatConfig; + loadRuns: LoadHeartbeatRuns; + stopRun: StopHeartbeatRun; + /** Open a run's chat in the fullscreen modal (composition-root wires the live watch). */ + onOpenRun: (run: HeartbeatRunView) => void; + } = $props(); + + const badgeClass: Record<Badge, string> = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + neutral: "badge-ghost", + }; + + const effortOpts = effortOptions(); + + // ── Config form ────────────────────────────────────────────────────────── + let form = $state<HeartbeatFormState>(emptyForm()); + /** The last successfully loaded/saved config, to diff the form against. */ + let loadedConfig = $state<HeartbeatFormState>(emptyForm()); + let configLoading = $state(false); + let configError = $state<string | null>(null); + let saving = $state(false); + let saveError = $state<string | null>(null); + let justSaved = $state(false); + let hasConfig = $state(false); + + const hasChanges = $derived(formDiffers(form, loadedConfig) && hasConfig); + + async function refreshConfig(): Promise<void> { + configLoading = true; + configError = null; + const result = await loadConfig(); + configLoading = false; + if (result === null) return; + if (result.ok) { + hasConfig = true; + form = formFromConfig(result.config); + loadedConfig = formFromConfig(result.config); + saveError = null; + } else { + configError = result.error; + } + } + + async function handleSave(): Promise<void> { + if (saving || !hasChanges) return; + saving = true; + saveError = null; + justSaved = false; + const result = await saveConfig(patchFromForm(form)); + saving = false; + if (result === null) return; + if (result.ok) { + // Re-seed from the authoritative response so the form tracks the server. + form = formFromConfig(result.config); + loadedConfig = formFromConfig(result.config); + justSaved = true; + } else { + saveError = result.error; + } + } + + // The enable toggle is the primary action — persist it immediately (don't + // require a separate Save). Mirrors the codebase's save-on-change controls. + async function handleToggleEnabled(): Promise<void> { + if (saving) return; + const next = !form.enabled; + form = { ...form, enabled: next }; + saving = true; + saveError = null; + justSaved = false; + const result = await saveConfig({ enabled: next }); + saving = false; + if (result === null) return; + if (result.ok) { + form = formFromConfig(result.config); + loadedConfig = formFromConfig(result.config); + justSaved = true; + } else { + saveError = result.error; + // Revert the toggle to the last-known state. + form = { ...form, enabled: loadedConfig.enabled }; + } + } + + // ── Runs list (polls while mounted) ─────────────────────────────────────── + let runs = $state<readonly HeartbeatRunView[]>([]); + let runsLoading = $state(false); + let runsError = $state<string | null>(null); + let stoppingId = $state<string | null>(null); + let stopError = $state<string | null>(null); + let pollHandle: ReturnType<typeof setInterval> | null = null; + + const RUN_POLL_MS = 4000; + + async function refreshRuns(): Promise<void> { + runsLoading = true; + runsError = null; + const result = await loadRuns(); + runsLoading = false; + if (result === null) return; + if (result.ok) { + runs = viewRuns(result.runs); + } else { + runsError = result.error; + } + } + + async function handleStop(runId: string): Promise<void> { + if (stoppingId !== null) return; + stoppingId = runId; + stopError = null; + const result = await stopRun(runId); + stoppingId = null; + if (result === null) return; + if (result.ok) { + await refreshRuns(); + } else { + stopError = result.error; + } + } + + // Load config + runs on mount, and poll runs while the view is alive so a + // running run's completion/stopped transition shows without a manual refresh. + $effect(() => { + untrack(() => { + void refreshConfig(); + void refreshRuns(); + }); + pollHandle = setInterval(() => { + void refreshRuns(); + }, RUN_POLL_MS); + return () => { + if (pollHandle !== null) clearInterval(pollHandle); + pollHandle = null; + }; + }); + + // A relative label ("5m ago") drifts as time passes; re-derive runs every + // minute so the list stays fresh without a full re-fetch. + let tick = $state(0); + $effect(() => { + const h = setInterval(() => { + tick++; + }, 60000); + return () => clearInterval(h); + }); + const runsView = $derived.by(() => { + void tick; // depend on the ticker + return runs; + }); +</script> + +<div class="flex flex-col gap-3"> + <!-- Enable / status header --> + <section class="flex items-center justify-between gap-2"> + <div class="flex items-center gap-2"> + <button + type="button" + role="switch" + aria-checked={form.enabled} + aria-label="Toggle heartbeat" + class="toggle toggle-sm" + class:toggle-primary={form.enabled} + disabled={saving || configLoading} + onclick={handleToggleEnabled} + ></button> + <span class="text-xs font-semibold uppercase opacity-60"> + {#if configLoading} + Loading… + {:else if form.enabled} + Enabled + {:else} + Disabled + {/if} + </span> + </div> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={configLoading} + onclick={() => refreshConfig()} + aria-label="Refresh heartbeat config" + > + {#if configLoading} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Refresh + {/if} + </button> + </section> + + {#if configError} + <p class="text-xs text-error">{configError}</p> + {:else} + <!-- System prompt --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">System prompt</span> + <textarea + class="textarea textarea-bordered textarea-sm h-20 w-full font-mono text-xs" + placeholder="You are an autonomous agent…" + value={form.systemPrompt} + disabled={saving || configLoading} + oninput={(e) => (form = { ...form, systemPrompt: e.currentTarget.value })} + aria-label="Heartbeat system prompt" + ></textarea> + </section> + + <!-- Task prompt --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Task prompt</span> + <textarea + class="textarea textarea-bordered textarea-sm h-20 w-full font-mono text-xs" + placeholder="Check the system status and report…" + value={form.taskPrompt} + disabled={saving || configLoading} + oninput={(e) => (form = { ...form, taskPrompt: e.currentTarget.value })} + aria-label="Heartbeat task prompt" + ></textarea> + </section> + + <!-- Model + reasoning effort --> + <section class="flex flex-col gap-2"> + <div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Model</span> + <select + class="select select-sm w-full" + value={form.model} + disabled={saving || configLoading} + onchange={(e) => (form = { ...form, model: e.currentTarget.value })} + aria-label="Heartbeat model" + > + {#if models.length === 0} + <option value="">No models available</option> + {:else} + <option value="" disabled>Select a model</option> + {#each models as model (model)} + <option value={model}>{model}</option> + {/each} + {/if} + </select> + </div> + + <div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span> + <select + class="select select-sm w-full" + value={form.reasoningEffort} + disabled={saving || configLoading} + onchange={(e) => { + const v = e.currentTarget.value; + if (isReasoningEffort(v)) form = { ...form, reasoningEffort: v as ReasoningEffort }; + }} + aria-label="Heartbeat reasoning effort" + > + {#each effortOpts as option (option.value)} + <option value={option.value}>{option.label}</option> + {/each} + </select> + </div> + </section> + + <!-- Interval --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Interval (minutes)</span> + <div class="flex items-center gap-2"> + <input + type="number" + class="input input-bordered input-sm w-24" + min="1" + max="1440" + placeholder={String(DEFAULT_INTERVAL_MINUTES)} + value={form.intervalMinutes} + disabled={saving || configLoading} + oninput={(e) => { + const n = Number.parseInt(e.currentTarget.value, 10); + form = { + ...form, + intervalMinutes: Number.isNaN(n) ? DEFAULT_INTERVAL_MINUTES : n, + }; + }} + onchange={(e) => { + form = { ...form, intervalMinutes: normalizeInterval(form.intervalMinutes) }; + e.currentTarget.value = String(form.intervalMinutes); + }} + aria-label="Heartbeat interval in minutes" + /> + <span class="text-xs opacity-60">min between runs</span> + </div> + </section> + + <!-- Save --> + <section class="flex flex-col gap-1"> + <button + type="button" + class="btn btn-sm btn-primary" + disabled={!hasChanges || saving || configLoading} + onclick={handleSave} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + Saving… + {:else} + Save config + {/if} + </button> + {#if saveError} + <p class="text-xs text-error">{saveError}</p> + {:else if justSaved} + <p class="text-xs text-success">Saved.</p> + {/if} + </section> + {/if} + + <!-- Runs list --> + <section class="flex flex-col gap-1"> + <div class="flex items-center justify-between gap-2"> + <span class="text-xs font-semibold uppercase opacity-60">Runs</span> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={runsLoading} + onclick={() => refreshRuns()} + aria-label="Refresh heartbeat runs" + > + {#if runsLoading} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Refresh + {/if} + </button> + </div> + + {#if runsError} + <p class="text-xs text-error">{runsError}</p> + {:else if runs.length === 0 && !runsLoading} + <p class="text-xs opacity-60">No runs yet. Enable the heartbeat to start the loop.</p> + {:else} + <ul class="flex max-h-72 flex-col gap-1 overflow-y-auto"> + {#each runsView as run (run.id)} + <li> + <button + type="button" + class="flex w-full items-center justify-between gap-2 rounded-box bg-base-200 p-2 text-left hover:bg-base-300" + onclick={() => onOpenRun(run)} + aria-label="Open heartbeat run {run.id} chat" + > + <span class="flex min-w-0 flex-col gap-0.5"> + <span class="truncate font-mono text-xs opacity-70">{run.id}</span> + <span class="text-xs opacity-60"> + {run.relativeLabel} · {run.timeLabel} + </span> + </span> + <span class="flex items-center gap-1"> + {#if run.busy} + <span class="loading loading-spinner loading-xs"></span> + {/if} + <span class="badge badge-sm {badgeClass[run.badge]}">{run.statusLabel}</span> + </span> + </button> + {#if run.busy} + <button + type="button" + class="btn btn-ghost btn-xs mt-0.5 text-xs" + disabled={stoppingId === run.id} + onclick={() => handleStop(run.id)} + > + {#if stoppingId === run.id} + <span class="loading loading-spinner loading-xs"></span> + Stopping… + {:else} + Stop + {/if} + </button> + {/if} + </li> + {/each} + </ul> + {#if stopError} + <p class="text-xs text-error">{stopError}</p> + {/if} + {/if} + </section> +</div> diff --git a/src/features/heartbeat/ui/RunModal.svelte b/src/features/heartbeat/ui/RunModal.svelte new file mode 100644 index 0000000..a4ed356 --- /dev/null +++ b/src/features/heartbeat/ui/RunModal.svelte @@ -0,0 +1,169 @@ +<script lang="ts"> + import { tick } from "svelte"; + import { ChatView } from "../../chat"; + import type { ChatStore } from "../../chat"; + import type { HeartbeatRunView } from "../logic/view-model"; + import type { StopHeartbeatRun } from "../logic/types"; + + let { + run, + openChat, + closeChat, + stopRun, + onClose, + }: { + /** The run to display (its conversation's chat is shown live). */ + run: HeartbeatRunView; + /** + * Open a live watch on a conversation (the store's `watchConversation`): + * returns a {@link ChatStore} subscribed to the conversation's turn stream + * + history loaded. The modal owns the watch lifecycle — calls + * `closeChat` on unmount. + */ + openChat: (conversationId: string) => ChatStore; + /** Dispose + unsubscribe the watch opened by `openChat`. */ + closeChat: (conversationId: string) => void; + /** Stop the heartbeat run (`POST .../runs/:runId/stop`). */ + stopRun: StopHeartbeatRun; + onClose: () => void; + } = $props(); + + // Open the live watch ONCE on mount (the modal is keyed per run.id, so a run + // switch remounts it). `untrack` avoids re-running if the prop fn identity + // changes — `run.conversationId` is the real dependency, captured once here. + let chat = $state<ChatStore | null>(null); + $effect(() => { + chat = openChat(run.conversationId); + return () => closeChat(run.conversationId); + }); + + // Live scroll: keep the transcript pinned to the bottom while it streams + // (unless the reader has scrolled up — then we don't fight them). + let scrollEl = $state<HTMLDivElement | undefined>(); + let contentEl = $state<HTMLDivElement | undefined>(); + let pinned = $state(true); + + function onScroll() { + const el = scrollEl; + if (el === undefined) return; + pinned = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + } + + // Follow the bottom on new content while pinned. Reads `chunks.length` so the + // effect re-runs on every streamed append. + const chunkCount = $derived(chat?.chunks.length ?? 0); + $effect(() => { + void chunkCount; + if (!pinned) return; + void tick().then(() => { + const el = scrollEl; + if (el !== undefined) el.scrollTop = el.scrollHeight; + }); + }); + + // Stop state. + let stopping = $state(false); + let stopError = $state<string | null>(null); + + async function handleStop() { + if (stopping) return; + stopping = true; + stopError = null; + const result = await stopRun(run.id); + stopping = false; + if (result === null) return; + if (!result.ok) stopError = result.error; + } + + // The live "running" signal: the chat store's `generating` reflects the + // actual event stream (turn-start…turn-sealed). True while a turn streams — + // that is when a Stop is meaningful. Falls back to the run's status snapshot + // before the stream attaches. + const live = $derived(chat?.generating ?? run.busy); + + function handleKeydown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } +</script> + +<svelte:window onkeydown={handleKeydown} /> + +<!-- Fullscreen overlay. --> +<div class="fixed inset-0 z-50 flex flex-col bg-base-100"> + <!-- Header --> + <header class="flex items-center justify-between gap-2 border-b border-base-300 px-4 py-2"> + <div class="flex min-w-0 items-center gap-2"> + <button + type="button" + class="btn btn-ghost btn-sm" + onclick={onClose} + aria-label="Close run chat" + > + ✕ + </button> + <span class="truncate font-mono text-xs opacity-70" title="Run id">{run.id}</span> + {#if live} + <span class="badge badge-sm badge-warning gap-1"> + <span class="loading loading-spinner loading-xs"></span> + Running + </span> + {:else} + <span class="badge badge-sm badge-ghost">{run.statusLabel}</span> + {/if} + </div> + <div class="flex items-center gap-2"> + {#if stopError} + <span class="text-xs text-error">{stopError}</span> + {/if} + {#if live} + <button + type="button" + class="btn btn-sm btn-error btn-outline" + disabled={stopping} + onclick={handleStop} + > + {#if stopping} + <span class="loading loading-spinner loading-xs"></span> + Stopping… + {:else} + Stop + {/if} + </button> + {/if} + </div> + </header> + + <!-- Transcript --> + <div class="relative min-h-0 flex-1"> + <div bind:this={scrollEl} class="h-full overflow-y-auto" onscroll={onScroll}> + <div bind:this={contentEl} class="p-4"> + {#if chat === null} + <div class="flex h-full items-center justify-center"> + <span class="loading loading-spinner loading-md"></span> + </div> + {:else if chat.chunks.length === 0 && chat.pendingSync} + <div class="flex h-full items-center justify-center"> + <span class="loading loading-spinner loading-md"></span> + </div> + {:else} + <ChatView + chunks={chat.chunks} + turnMetrics={chat.turnMetrics} + hasEarlier={chat.hasEarlier} + onShowEarlier={chat.showEarlier} + thinkingKeyBase={chat.thinkingKeyBase} + providerRetry={chat.providerRetry} + /> + {/if} + </div> + </div> + {#if chat !== null && chat.chunks.length === 0 && !chat.pendingSync} + <div + class="pointer-events-none absolute inset-0 flex items-center justify-center" + aria-hidden="true" + > + <span class="select-none text-2xl font-bold opacity-10">No messages</span> + </div> + {/if} + </div> +</div> diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts index f6ea432..7de97d9 100644 --- a/src/features/workspaces/ui/WorkspaceCard.test.ts +++ b/src/features/workspaces/ui/WorkspaceCard.test.ts @@ -53,7 +53,7 @@ describe("WorkspaceCard", () => { it("renders the title, slug, and an Open link", () => { const store = fakeStore() as unknown as WorkspaceStore; render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate, computers: [] }, + props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, }); expect(screen.getByText("My Workspace")).toBeInTheDocument(); expect(screen.getByText("/my-ws")).toBeInTheDocument(); |
