import type { ChatDeltaMessage, ChatErrorMessage, CompactPercentResponse, CompactResponse, ConversationCompactedMessage, ConversationHistoryResponse, ConversationListResponse, ConversationMetricsResponse, ConversationOpenMessage, ConversationStatusChangedMessage, CwdResponse, LspStatusResponse, ModelMetadata, ModelsResponse, ReasoningEffort, ReasoningEffortResponse, SetCompactPercentRequest, SetCwdRequest, SetReasoningEffortRequest, SetTitleRequest, WarmRequest, WarmResponse, } from "@dispatch/transport-contract"; import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; import type { ConversationStatus } from "@dispatch/wire"; import { createIdbChunkStore } from "../adapters/idb"; import { createLocalStore } from "../adapters/local-storage"; import type { WebSocketLike } from "../adapters/ws"; import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws"; import { normalizeChatLimit } from "../core/chunks"; import { applyServerMessage, getSurfaceSpec, type ProtocolState, initialState as protocolInitialState, invoke as protocolInvoke, subscribe as protocolSubscribe, unsubscribe as protocolUnsubscribe, } from "../core/protocol"; import type { ChatStore, HistorySync, MetricsSync } from "../features/chat"; import { createChatStore } from "../features/chat"; import type { ConversationCache } from "../features/conversation-cache"; import { createConversationCache } from "../features/conversation-cache"; import type { Tab, TabsState } from "../features/tabs"; import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs"; import { resolveHttpUrl } from "./resolve-http-url"; import { resolveWsUrl } from "./resolve-ws-url"; import { randomId } from "./uuid"; const DEFAULT_MODEL = "opencode/deepseek-v4-flash"; /** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */ export type WarmResult = | { readonly ok: true; readonly response: WarmResponse } | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/cwd`. */ export type CwdResult = | { readonly ok: true; readonly cwd: string | null } | { readonly ok: false; readonly error: string }; /** Outcome of `GET /conversations/:id/lsp`. */ export type LspResult = | { readonly ok: true; readonly response: LspStatusResponse } | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/reasoning-effort`. */ export type ReasoningEffortResult = | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } | { readonly ok: false; readonly error: string }; /** Outcome of `POST /conversations/:id/compact` (manual compaction). */ export type CompactResult = | { readonly ok: true; readonly response: CompactResponse } | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/compact-percent`. */ export type CompactPercentResult = | { readonly ok: true; readonly percent: number } | { readonly ok: false; readonly error: string }; /** Outcome of persisting a chat-limit setting (localStorage; FE-local). */ export type ChatLimitResult = | { readonly ok: true; readonly chatLimit: number } | { readonly ok: false; readonly error: string }; export interface AppStore { readonly tabs: readonly Tab[]; readonly activeConversationId: string | null; readonly activeChat: ChatStore; readonly models: readonly string[]; /** Per-model metadata (contextWindow, etc.) from `GET /models`. */ readonly modelInfo: Readonly>; readonly activeModel: string; readonly catalog: ProtocolState["catalog"]; /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */ readonly surfaces: readonly SurfaceSpec[]; readonly lastError: ProtocolState["lastError"]; /** The localStorage instance the store uses for persistence (tabs, chatLimit). * Exposed so the shell can persist sidebar layout via the same adapter. */ readonly storage: Storage | undefined; /** The current spec for one surface by id (discovery-by-id), or null if absent. */ surface(surfaceId: string): SurfaceSpec | null; send(text: string): void; /** * Enqueue a steering message onto the focused conversation's queue * (`chat.queue` WS op). While a turn is generating, the message is delivered * mid-turn at the next tool-result boundary; when idle, the server * auto-starts a turn (equivalent to `send`). Safe to offer whenever the user * wants to add input — the server owns the idle-vs-generating decision. */ queueMessage(text: string): void; selectModel(model: string): void; newDraft(): void; selectTab(conversationId: string): void; closeTab(conversationId: string): void; renameTab(conversationId: string, title: string): void; invoke(surfaceId: string, actionId: string, payload?: unknown): void; /** * Manually warm the focused conversation's prompt cache (`POST /chat/warm`). * Returns null when no conversation is focused (a draft has nothing to warm). */ warmNow(): Promise; /** The workspace conversation's persisted working directory, or null when unset. */ readonly cwd: string | null; /** The conversation workspace settings target: the active tab, or the pending draft's id. */ readonly currentConversationId: string; /** * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`). * Works for a draft too (its id survives promotion), so the first turn runs in it. */ setCwd(cwd: string): Promise; /** * The workspace conversation's persisted reasoning effort, or null when never * set (the server then resolves turns at the default, `"high"`). */ readonly reasoningEffort: ReasoningEffort | null; /** * Persist the workspace conversation's reasoning effort * (`PUT /conversations/:id/reasoning-effort`). Works for a draft too (its id * survives promotion), so the first turn already runs at the chosen level. * Takes effect from the NEXT turn; resolution stays server-owned. */ setReasoningEffort(level: ReasoningEffort): Promise; /** * Manually trigger conversation compaction (`POST /conversations/:id/compact`). * Summarizes old messages + retains the most recent N. Returns null when no * conversation is focused (a draft has nothing to compact). */ compactNow(keepLastN?: number): Promise; /** * Stop an in-flight generation (`POST /conversations/:id/stop`). Aborts the * turn without closing the conversation — partial messages are persisted, the * turn seals with `reason: "aborted"`, and the conversation goes `active → idle`. * Returns null when no conversation is focused. */ stopGeneration(): void; /** * The workspace conversation's auto-compact percent (0-100). `0` = disabled * (manual only); a positive number = auto-compact triggers when the last * turn's input tokens exceed it. Seeded from the backend on focus change. */ readonly compactPercent: number | null; /** * Persist the workspace conversation's auto-compact percent * (`PUT /conversations/:id/compact-percent`). `0` disables; 1-100 sets the * trigger percentage of the model's context window. Default (null) is 85. * number enables. Works for a draft too (its id survives promotion). */ setCompactPercent(percent: number): Promise; /** * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`). * The backend lazily spawns servers, so this may take a moment on the first call for a cwd. */ lspStatus(): Promise; /** The persisted chat limit (max loaded chunks per conversation). */ readonly chatLimit: number; /** * A conversation's backend lifecycle status (`active`/`idle`/`closed`), or * `undefined` when unknown. Drives the tab-bar generating indicator * (cross-device: a tab spinning because another device's turn is running). */ conversationStatus(conversationId: string): ConversationStatus | undefined; /** * Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to * localStorage and propagates to every live chat store (trim if lower, * deferred via the unload gate while a reader is scrolled up; no-op if * higher — page unloaded history back in via "Show earlier"). Stores created * afterwards pick the new limit up at creation. Always succeeds (FE-local). */ setChatLimit(limit: number): Promise; /** * Wire the chat-limit unload gate (composition-root injection, called once by * the shell after it owns the scroll region): unloading old chunks is allowed * only while the gate returns true — i.e. the reader is stuck to the bottom — * so a trim never yanks content out from under someone reading history. * Before attachment unloading is allowed (the initial view starts at the * bottom). */ attachUnloadGate(gate: () => boolean): void; dispose(): void; } export interface CreateAppStoreOptions { url?: string; httpUrl?: string; socketFactory?: (url: string) => WebSocketLike; fetchImpl?: typeof fetch; indexedDB?: IDBFactory; conversationId?: string; localStorage?: Storage; } function createHistorySync(httpBase: string, fetchImpl: typeof fetch): HistorySync { return async (conversationId, sinceSeq, window) => { let url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}?sinceSeq=${sinceSeq}`; // CR-5 windowing (transport-contract@0.10.0): both must be positive // integers when present (the server 400s otherwise; callers guarantee it). if (window?.limit !== undefined) url += `&limit=${window.limit}`; if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`; const res = await fetchImpl(url); if (!res.ok) { throw new Error(`History sync failed: ${res.status}`); } return (await res.json()) as ConversationHistoryResponse; }; } function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSync { return async (conversationId: string) => { const url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}/metrics`; const res = await fetchImpl(url); if (!res.ok) return { turns: [] }; return (await res.json()) as ConversationMetricsResponse; }; } export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let protocol = $state(protocolInitialState()); let models = $state([]); let modelInfo = $state>>({}); let activeModel = $state(DEFAULT_MODEL); const wsLocation = typeof location !== "undefined" ? location : undefined; const wsUrl = opts?.url ?? resolveWsUrl( { VITE_WS_URL: import.meta.env.VITE_WS_URL, VITE_WS_PORT: import.meta.env.VITE_WS_PORT }, wsLocation, ); const httpLocation = typeof location !== "undefined" ? location : undefined; const httpBase = opts?.httpUrl ?? resolveHttpUrl( { VITE_HTTP_URL: import.meta.env.VITE_HTTP_URL, VITE_HTTP_PORT: import.meta.env.VITE_HTTP_PORT, }, httpLocation, ); const fetchImpl = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis); const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB; const localStorageOpt = opts?.localStorage ?? globalThis.localStorage; const storageAdapter = createLocalStore("dispatch.tabs", { storage: localStorageOpt, }); const tabsStore: TabsStore = createTabsStore(storageAdapter); // The chat limit (max loaded chunks per conversation) — a persisted local // setting surfaced in the sidebar's Settings view. Reactive so the field + // any live-apply re-trim update together. The default is written back on // first run so the knob is discoverable in localStorage too. const chatLimitStore = createLocalStore("dispatch.chatLimit", { storage: localStorageOpt, }); const storedChatLimit = chatLimitStore.load(); const normalizedChatLimit = normalizeChatLimit(storedChatLimit); let chatLimit = $state(normalizedChatLimit); if (storedChatLimit === null) { chatLimitStore.save(normalizedChatLimit); } // Unload gate — attached by the shell once it owns the scroll region (see // `AppStore.attachUnloadGate`). Until then, unloading is allowed. let unloadGate: (() => boolean) | null = null; const cache: ConversationCache = createConversationCache( createIdbChunkStore({ indexedDB: indexedDBFactory }), ); const historySync = createHistorySync(httpBase, fetchImpl); const metricsSync = createMetricsSync(httpBase, fetchImpl); const chatStores = new Map(); function createChatFor(conversationId: string, model: string): ChatStore { return createChatStore({ conversationId, model, transport: { send(msg) { socket?.send(msg); }, }, historySync, metricsSync, cache, // Read from the persisted store (kept in sync with the reactive `chatLimit` // by `setChatLimit` + boot) so this snapshot doesn't reference the `$state` // — each store captures its limit at creation; live updates go through // `setChatLimit`. chatLimit: normalizeChatLimit(chatLimitStore.load()), canUnload: () => (unloadGate === null ? true : unloadGate()), }); } const initialDraftId = randomId(); let draftStore: ChatStore = createChatFor(initialDraftId, DEFAULT_MODEL); let draftConversationId: string = initialDraftId; let activeChat = $state(draftStore as ChatStore); // The active conversation's persisted working directory (per-tab). Seeded from // the backend on focus change; null for a draft / when unset. let cwd = $state(null); /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */ async function refreshCwd(): Promise { const id = workspaceConversationId(); try { const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`); if (!res.ok) return; const data = (await res.json()) as CwdResponse; // Guard a slow response losing a race with a conversation switch. if (workspaceConversationId() === id) cwd = data.cwd ?? null; } catch { // Non-fatal: a cwd fetch failure just leaves the prior value. } } // The workspace conversation's persisted reasoning effort. Seeded from the // backend on focus change; null = never set (the server default applies). let reasoningEffort = $state(null); /** Refetch the workspace conversation's reasoning effort (works for a draft too). */ async function refreshReasoningEffort(): Promise { const id = workspaceConversationId(); // Clear immediately so a switch never shows the PREVIOUS conversation's level // while the fetch is in flight (null renders as the server default). reasoningEffort = null; try { const res = await fetchImpl( `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`, ); if (!res.ok) return; const data = (await res.json()) as ReasoningEffortResponse; // Guard a slow response losing a race with a conversation switch. if (workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null; } catch { // Non-fatal: an effort fetch failure just leaves the default rendering. } } // The workspace conversation's auto-compact percent. Seeded from the // backend on focus change; null = not yet fetched. 0 = disabled. let compactPercent = $state(null); /** Refetch the workspace conversation's compact percent (works for a draft too). */ async function refreshCompactPercent(): Promise { const id = workspaceConversationId(); compactPercent = null; try { const res = await fetchImpl( `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`, ); if (!res.ok) return; const data = (await res.json()) as CompactPercentResponse; if (workspaceConversationId() === id) compactPercent = data.threshold; } catch { // Non-fatal: a percent fetch failure just leaves null. } } function getActiveChat(): ChatStore { const activeId = tabsStore.activeConversationId; if (activeId === null) { return draftStore; } return chatStores.get(activeId) ?? draftStore; } function refreshActiveChat(): void { activeChat = getActiveChat(); } function handleChatMessage(msg: ChatDeltaMessage | ChatErrorMessage): void { let targetId: string | undefined; if (msg.type === "chat.delta") { targetId = msg.event.conversationId; } else { targetId = msg.conversationId; } if (targetId !== undefined) { const store = chatStores.get(targetId); if (store !== undefined) { store.handleDelta(msg); return; } } // fallback: try all stores (chat.error without conversationId) for (const store of chatStores.values()) { store.handleDelta(msg); } } /** * Start watching a conversation's live turn events (`chat.subscribe`). Sent for * EVERY open conversation — not just the active one — so a backgrounded tab keeps * streaming a running turn, and a reloaded/second client re-attaches to an * in-flight turn (the server replays it from `turn-start`). Idempotent server-side; * the socket queues it until the connection is open. NOT needed right after * `chat.send` (that auto-subscribes the sending connection). */ function subscribeChat(conversationId: string): void { socket?.send({ type: "chat.subscribe", conversationId }); } /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */ function unsubscribeChat(conversationId: string): void { socket?.send({ type: "chat.unsubscribe", conversationId }); } /** * Tell the backend the user EXPLICITLY closed this conversation's tab * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF). * Distinct from a disconnect / `chat.unsubscribe`, which deliberately leave * both running. Fire-and-forget: a failure is non-fatal (worst case the * warming keeps running until a later close/toggle), and the endpoint is * idempotent server-side. */ function closeConversation(conversationId: string): void { void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, { method: "POST", }).catch(() => { // Non-fatal — see doc comment. }); } /** The conversation the surfaces should scope to (undefined for a draft). */ function focusedConversationId(): string | undefined { return tabsStore.activeConversationId ?? undefined; } /** * The conversation id workspace settings (cwd / LSP) target: the active tab, or * the pending draft's id when in draft mode. Unlike `focusedConversationId`, this * is NEVER undefined — the draft has a stable client-minted id that survives * promotion (first send), so a cwd set on a draft carries into the real turn. */ function workspaceConversationId(): string { return tabsStore.activeConversationId ?? draftConversationId; } function handleServerMessage(msg: SurfaceServerMessage): void { protocol = applyServerMessage(protocol, msg); // Surfaces are auto-expanded: whenever the catalog changes, subscribe to // every entry (and drop subscriptions for entries that vanished). if (msg.type === "catalog") { syncSubscriptions(); } } /** * Subscribe to every catalog entry, scoped to the focused conversation, and * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe` * emits unsubscribe-old + subscribe-new); a global surface ignores the id. */ function syncSubscriptions(): void { const cid = focusedConversationId(); for (const entry of protocol.catalog) { // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id // so a conversation switch doesn't churn a redundant unsubscribe+subscribe // round trip (ui-contract@0.2.0 catalog `scope`; ABSENT = assume // conversation-scoped, the conservative pre-0.2.0 policy). const scoped = entry.scope === "global" ? undefined : cid; const result = protocolSubscribe(protocol, entry.id, scoped); protocol = result.state; for (const msg of result.outgoing) { socket?.send(msg); } } const catalogIds = new Set(protocol.catalog.map((e) => e.id)); for (const id of [...protocol.subscriptions.keys()]) { if (!catalogIds.has(id)) { const result = protocolUnsubscribe(protocol, id); protocol = result.state; for (const msg of result.outgoing) { socket?.send(msg); } } } } let socket: ReturnType | null = null; /** * Open a conversation tab — used by the `conversation.open` WS broadcast * (CLI `--open` flag). If the conversation is already open, this is a no-op; * otherwise create a chat store, load its history, subscribe to its live * turns, and add the tab WITHOUT switching the active conversation (the user * stays on their current tab; the new tab appears in the strip). */ function openConversation(conversationId: string): void { if (chatStores.has(conversationId)) return; const store = createChatFor(conversationId, activeModel); chatStores.set(conversationId, store); void store.load(); subscribeChat(conversationId); tabsStore.openTab({ conversationId, model: activeModel, title: "Conversation", }); } /** * Remove a tab + its chat store locally (NO `POST /close` — used when the * backend already marked the conversation `closed` via `conversation.statusChanged`). */ function removeTabLocally(conversationId: string): void { unsubscribeChat(conversationId); const store = chatStores.get(conversationId); if (store !== undefined) { store.dispose(); chatStores.delete(conversationId); } void cache.delete(conversationId); tabsStore.closeTab(conversationId); conversationStatuses.delete(conversationId); refreshActiveChat(); syncSubscriptions(); void refreshCwd(); void refreshReasoningEffort(); void refreshCompactPercent(); } // Conversation lifecycle status (backend-owned, pushed via WS + // fetched on connect). Keyed by conversationId. let conversationStatuses = $state>(new Map()); /** * Fetch `GET /conversations?status=active,idle` on connect to restore the * tab bar across devices. Merges: opens tabs for conversations not already * open, removes tabs for conversations that are no longer active/idle * (closed on another device), and subscribes to `active` conversations' * live streams. */ async function fetchOpenConversations(): Promise { try { const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`); if (!res.ok) return; const data = (await res.json()) as ConversationListResponse; // Update the status map from the authoritative backend list. const newStatuses = new Map(); for (const conv of data.conversations) { newStatuses.set(conv.id, conv.status); } conversationStatuses = newStatuses; // Open tabs for conversations not already open. const existingIds = new Set(chatStores.keys()); for (const conv of data.conversations) { if (!existingIds.has(conv.id)) { const store = createChatFor(conv.id, activeModel); chatStores.set(conv.id, store); void store.load(); subscribeChat(conv.id); tabsStore.openTab({ conversationId: conv.id, model: activeModel, title: conv.title, }); } else { // Already open — update the title from the backend if it differs. tabsStore.setTitle(conv.id, conv.title); } } // Remove tabs for conversations no longer active/idle (closed elsewhere). const backendIds = new Set(data.conversations.map((c) => c.id)); for (const tab of tabsStore.tabs) { if (!backendIds.has(tab.conversationId)) { removeTabLocally(tab.conversationId); } } } catch { // Non-fatal: fall back to the localStorage-restored tabs. } } const socketOpts: SurfaceSocketOptions = { url: wsUrl, onMessage: handleServerMessage, onChat: handleChatMessage, onConversationOpen(msg: ConversationOpenMessage): void { openConversation(msg.conversationId); }, onConversationStatusChanged(msg: ConversationStatusChangedMessage): void { const { conversationId, status } = msg; if (status === "closed") { // Closed on another device (or the backend) — remove the tab locally. if (chatStores.has(conversationId)) { removeTabLocally(conversationId); } return; } // active / idle — update the status map (drives the tab spinner). conversationStatuses = new Map(conversationStatuses).set(conversationId, status); // If this is a new active conversation we don't have a tab for, open one. if (status === "active" && !chatStores.has(conversationId)) { openConversation(conversationId); } }, onConversationCompacted(msg: ConversationCompactedMessage): void { // Compaction keeps the conversation ID — the old full history is forked // to an archive (newConversationId). Just reload the same conversation's // history (dispose stale store + cache + re-fetch). const cid = msg.conversationId; const wasActive = tabsStore.activeConversationId === cid; const store = chatStores.get(cid); if (store !== undefined) { store.dispose(); } void cache.delete(cid); const fresh = createChatFor(cid, activeModel); chatStores.set(cid, fresh); void fresh.load(); if (wasActive) { refreshActiveChat(); } }, onReopen() { // The server forgot our subscriptions on reconnect; re-send each with the // conversation it was subscribed under (protocolSubscribe would no-op since // they're still in our local map, so emit the wire messages directly). for (const [surfaceId, sub] of protocol.subscriptions) { const msg: SubscribeMessage = sub.conversationId === undefined ? { type: "subscribe", surfaceId } : { type: "subscribe", surfaceId, conversationId: sub.conversationId }; socket?.send(msg); } // Re-attach to every open conversation's turn stream. A turn that kept // running while we were disconnected resumes streaming (server replays it // from `turn-start`); one that sealed while we were gone is committed from // history by `resync()` (which also clears a now-stale "generating"). for (const tab of tabsStore.tabs) { subscribeChat(tab.conversationId); chatStores.get(tab.conversationId)?.resync(); } }, }; if (opts?.socketFactory !== undefined) { socketOpts.socketFactory = opts.socketFactory; } socket = createSurfaceSocket(socketOpts); // Fetch model catalog void fetchImpl(`${httpBase}/models`) .then((res) => { if (!res.ok) return; return res.json() as Promise; }) .then((data) => { if (data === undefined) return; models = data.models; modelInfo = data.modelInfo ?? {}; if (data.models.length > 0 && !data.models.includes(activeModel)) { const first = data.models[0]; if (first !== undefined) { activeModel = first; draftStore.setModel(first); } } }) .catch(() => { // Model fetch failure is non-fatal; use defaults. }); // Restore persisted tabs const persistedState = storageAdapter.load(); if (persistedState !== null && persistedState.tabs.length > 0) { for (const tab of persistedState.tabs) { const store = createChatFor(tab.conversationId, tab.model); chatStores.set(tab.conversationId, store); void store.load(); // Watch each restored conversation's live turns: after a reload mid-turn the // server replays the in-flight turn so we keep rendering it. Queued until the // socket opens. subscribeChat(tab.conversationId); } if (persistedState.activeConversationId !== null) { const activeTab = persistedState.tabs.find( (t) => t.conversationId === persistedState.activeConversationId, ); if (activeTab !== undefined) { activeModel = activeTab.model; } } } refreshActiveChat(); void refreshCwd(); void refreshReasoningEffort(); void refreshCompactPercent(); // Fetch the authoritative open-conversation list from the backend (cross- // device tab sync). Merges with the localStorage-restored tabs: opens new // ones, removes closed ones, updates titles + statuses. void fetchOpenConversations(); return { get tabs(): readonly Tab[] { return tabsStore.tabs; }, get activeConversationId(): string | null { return tabsStore.activeConversationId; }, get activeChat(): ChatStore { return activeChat; }, get models(): readonly string[] { return models; }, get modelInfo(): Readonly> { return modelInfo; }, get activeModel(): string { return activeModel; }, get catalog() { return protocol.catalog; }, get surfaces(): readonly SurfaceSpec[] { const out: SurfaceSpec[] = []; for (const entry of protocol.catalog) { const spec = getSurfaceSpec(protocol, entry.id); if (spec) out.push(spec); } return out; }, get lastError() { return protocol.lastError; }, get storage() { return localStorageOpt; }, get cwd(): string | null { return cwd; }, get reasoningEffort(): ReasoningEffort | null { return reasoningEffort; }, get compactPercent(): number | null { return compactPercent; }, get chatLimit(): number { return chatLimit; }, conversationStatus(conversationId: string): ConversationStatus | undefined { return conversationStatuses.get(conversationId); }, get currentConversationId(): string { return workspaceConversationId(); }, surface(surfaceId: string): SurfaceSpec | null { return getSurfaceSpec(protocol, surfaceId); }, send(text: string): void { if (tabsStore.activeConversationId === null) { // Draft: promote to tab on first send const conversationId = draftConversationId; const model = activeModel; tabsStore.createTab({ conversationId, model, title: deriveTitle(text), }); chatStores.set(conversationId, draftStore); void draftStore.load(); // Prepare next draft const nextDraftId = randomId(); draftStore = createChatFor(nextDraftId, activeModel); draftConversationId = nextDraftId; refreshActiveChat(); // The draft became a real conversation: re-scope conversation-scoped // surfaces (e.g. cache-warming) to its id. syncSubscriptions(); void refreshCwd(); void refreshReasoningEffort(); void refreshCompactPercent(); // Now send on the promoted store chatStores.get(conversationId)?.send(text); } else { activeChat.send(text); } }, queueMessage(text: string): void { // Only offered while generating (Composer switches to `chat.queue` // when `status === "running"`), so a draft (never generating) never // reaches here. `chat.queue` auto-starts a turn if idle, so even a race // (turn sealed between the status read and the send) is safe — the // server starts a fresh turn with the message as its opening prompt. activeChat.queueMessage(text); }, selectModel(model: string): void { activeModel = model; const activeId = tabsStore.activeConversationId; if (activeId !== null) { tabsStore.setModel(activeId, model); chatStores.get(activeId)?.setModel(model); } else { draftStore.setModel(model); } }, newDraft(): void { tabsStore.newDraft(); const nextDraftId = randomId(); draftStore = createChatFor(nextDraftId, activeModel); draftConversationId = nextDraftId; refreshActiveChat(); syncSubscriptions(); void refreshCwd(); void refreshReasoningEffort(); void refreshCompactPercent(); }, selectTab(conversationId: string): void { tabsStore.selectTab(conversationId); const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId); if (tab !== undefined) { activeModel = tab.model; } refreshActiveChat(); syncSubscriptions(); void refreshCwd(); void refreshReasoningEffort(); void refreshCompactPercent(); }, closeTab(conversationId: string): void { // The user is DONE with this chat: abort any in-flight turn + stop/disable // its cache-warming, server-side (POST /close sets status → "closed"). closeConversation(conversationId); removeTabLocally(conversationId); }, renameTab(conversationId: string, title: string): void { tabsStore.setTitle(conversationId, title); void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/title`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title } satisfies SetTitleRequest), }).catch(() => { // Best-effort — the local tab is already renamed. }); }, invoke(surfaceId: string, actionId: string, payload?: unknown): void { const result = protocolInvoke( protocol, surfaceId, actionId, payload, focusedConversationId(), ); protocol = result.state; for (const msg of result.outgoing) { socket?.send(msg); } }, async warmNow(): Promise { const conversationId = tabsStore.activeConversationId; if (conversationId === null) return null; const body: WarmRequest = { conversationId, model: activeModel }; try { const res = await fetchImpl(`${httpBase}/chat/warm`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { const errBody = (await res.json().catch(() => null)) as { error?: string } | null; return { ok: false, error: errBody?.error ?? `Warm failed (HTTP ${res.status})` }; } return { ok: true, response: (await res.json()) as WarmResponse }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" }; } }, async setCwd(value: string): Promise { const id = workspaceConversationId(); const body: SetCwdRequest = { cwd: value }; try { const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { const errBody = (await res.json().catch(() => null)) as { error?: string } | null; return { ok: false, error: errBody?.error ?? `Set cwd failed (HTTP ${res.status})` }; } const data = (await res.json()) as CwdResponse; const next = data.cwd ?? null; if (workspaceConversationId() === id) cwd = next; return { ok: true, cwd: next }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" }; } }, async setReasoningEffort(level: ReasoningEffort): Promise { const id = workspaceConversationId(); const body: SetReasoningEffortRequest = { reasoningEffort: level }; try { const res = await fetchImpl( `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }, ); if (!res.ok) { const errBody = (await res.json().catch(() => null)) as { error?: string } | null; return { ok: false, error: errBody?.error ?? `Set reasoning effort failed (HTTP ${res.status})`, }; } const data = (await res.json()) as ReasoningEffortResponse; const next = data.reasoningEffort ?? level; if (workspaceConversationId() === id) reasoningEffort = next; return { ok: true, reasoningEffort: next }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : "Set reasoning effort request failed", }; } }, stopGeneration(): void { const conversationId = tabsStore.activeConversationId; if (conversationId === null) return; void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, { method: "POST", }).catch(() => { // Non-fatal — the existing event flow handles the turn settle. }); }, async compactNow(keepLastN?: number): Promise { const conversationId = tabsStore.activeConversationId; if (conversationId === null) return null; const body: Record = {}; if (keepLastN !== undefined) body.keepLastN = keepLastN; try { const res = await fetchImpl( `${httpBase}/conversations/${encodeURIComponent(conversationId)}/compact`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }, ); if (!res.ok) { const errBody = (await res.json().catch(() => null)) as { error?: string } | null; return { ok: false, error: errBody?.error ?? `Compact failed (HTTP ${res.status})`, }; } const data = (await res.json()) as CompactResponse; return { ok: true, response: data }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : "Compact request failed", }; } }, async setCompactPercent(percent: number): Promise { const id = workspaceConversationId(); const body: SetCompactPercentRequest = { threshold: percent }; try { const res = await fetchImpl( `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }, ); if (!res.ok) { const errBody = (await res.json().catch(() => null)) as { error?: string } | null; return { ok: false, error: errBody?.error ?? `Set compact percent failed (HTTP ${res.status})`, }; } const data = (await res.json()) as CompactPercentResponse; if (workspaceConversationId() === id) compactPercent = data.threshold; return { ok: true, percent: data.threshold }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : "Set compact percent request failed", }; } }, async setChatLimit(limit: number): Promise { const next = normalizeChatLimit(limit); chatLimitStore.save(next); chatLimit = next; // Propagate to every live chat store. The ACTIVE one is awaited so its // refill (on a raise) lands before the caller returns — letting the // shell preserve scroll over the prepended older chunks. Background // stores refill fire-and-forget. Future stores pick the new limit up at // creation (via the persisted store). const active = getActiveChat(); await active.setChatLimit(next); for (const s of chatStores.values()) { if (s !== active) void s.setChatLimit(next); } if (draftStore !== active) void draftStore.setChatLimit(next); return { ok: true, chatLimit: next }; }, async lspStatus(): Promise { const id = workspaceConversationId(); try { const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/lsp`); if (!res.ok) { const errBody = (await res.json().catch(() => null)) as { error?: string } | null; return { ok: false, error: errBody?.error ?? `LSP status failed (HTTP ${res.status})` }; } // Normalize the untyped body at this network seam so a malformed/partial // response can never crash the renderer (servers is guaranteed an array). const data = (await res.json()) as Partial; const response: LspStatusResponse = { conversationId: data.conversationId ?? id, cwd: data.cwd ?? null, servers: Array.isArray(data.servers) ? data.servers : [], }; return { ok: true, response }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : "LSP status request failed", }; } }, attachUnloadGate(gate: () => boolean): void { unloadGate = gate; }, dispose(): void { for (const store of chatStores.values()) { store.dispose(); } chatStores.clear(); draftStore.dispose(); socket?.close(); socket = null; }, }; }