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 /src/app | |
| 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
Diffstat (limited to 'src/app')
| -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 |
3 files changed, 536 insertions, 1 deletions
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(); + }); }); |
