summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.svelte1041
-rw-r--r--src/app/App.test.ts345
-rw-r--r--src/app/chat-registry.svelte.ts297
-rw-r--r--src/app/conversation-settings.svelte.ts440
-rw-r--r--src/app/history-sync.ts38
-rw-r--r--src/app/http-actions.ts638
-rw-r--r--src/app/open-conversations.svelte.ts288
-rw-r--r--src/app/plugin-host.svelte.ts151
-rw-r--r--src/app/register-views.ts81
-rw-r--r--src/app/resolve-http-url.test.ts8
-rw-r--r--src/app/resolve-ws-url.test.ts8
-rw-r--r--src/app/settings-refreshers.svelte.ts257
-rw-r--r--src/app/store-types.ts614
-rw-r--r--src/app/store.svelte.ts2274
-rw-r--r--src/app/store.test.ts1193
-rw-r--r--src/app/subscription-budget.test.ts20
-rw-r--r--src/app/subscription-budget.ts15
-rw-r--r--src/app/surface-protocol.svelte.ts146
-rw-r--r--src/app/surface-protocol.test.ts96
-rw-r--r--src/app/view-ports.ts54
-rw-r--r--src/app/views/AccountUsagePane.svelte22
-rw-r--r--src/app/views/CacheWarmingPane.svelte37
-rw-r--r--src/app/views/ChatPane.svelte117
-rw-r--r--src/app/views/CompactionPane.svelte48
-rw-r--r--src/app/views/ComposerPane.svelte43
-rw-r--r--src/app/views/ConcurrencyPane.svelte39
-rw-r--r--src/app/views/ExtensionsPane.svelte82
-rw-r--r--src/app/views/HeartbeatPane.svelte56
-rw-r--r--src/app/views/LspPane.svelte24
-rw-r--r--src/app/views/McpPane.svelte24
-rw-r--r--src/app/views/MessageQueuePane.svelte40
-rw-r--r--src/app/views/ModelPane.svelte131
-rw-r--r--src/app/views/NamedAgentsPane.svelte25
-rw-r--r--src/app/views/SettingsPane.svelte39
-rw-r--r--src/app/views/SystemPromptPane.svelte21
-rw-r--r--src/app/views/TabsPane.svelte25
-rw-r--r--src/app/views/TasksPane.svelte29
-rw-r--r--src/app/views/VisionPane.svelte45
-rw-r--r--src/app/ws-handlers.ts216
39 files changed, 6345 insertions, 2722 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 7e5ac7d..d557149 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -1,847 +1,222 @@
-<script lang="ts">
- import type { ImageInput, ReasoningEffort } from "@dispatch/transport-contract";
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import { tick } from "svelte";
- import Table from "../components/Table.svelte";
- import {
- CacheWarmingView,
- manifest as cacheWarmingManifest,
- type WarmFeedback,
- } from "../features/cache-warming";
- import {
- ChatView,
- CompactionView,
- Composer,
- manifest as chatManifest,
- ModelSelector,
- ReasoningEffortSelector,
- type CompactNowResult,
- type ComposerStatus,
- type ReasoningEffortSaveResult,
- type SaveCompactPercentResult,
- } from "../features/chat";
- import { manifest as conversationCacheManifest } from "../features/conversation-cache";
- import { manifest as markdownManifest } from "../features/markdown";
- import {
- McpStatusView,
- manifest as mcpManifest,
- type McpStatusResult,
- } from "../features/mcp";
- import {
- ChatLimitField,
- manifest as settingsManifest,
- type ChatLimitSaveResult,
- } from "../features/settings";
- import {
- createSmartScrollController,
- manifest as smartScrollManifest,
- ScrollToBottom,
- } from "../features/smart-scroll";
- import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host";
- import { parseMessageQueuePayload } from "../features/surface-host/logic/message-queue";
- import { parseTodoPayload } from "../features/surface-host/logic/todo";
- import TodoList from "../features/surface-host/ui/TodoList.svelte";
- import { manifest as tabsManifest, TabList } from "../features/tabs";
- import { manifest as viewsManifest, ViewSidebar } from "../features/views";
- import {
- CwdField,
- type CwdSaveResult,
- LspStatusView,
- type LspStatusResult,
- manifest as cwdLspManifest,
- } from "../features/cwd-lsp";
- import {
- ComputerField,
- manifest as computerManifest,
- type ComputerSaveResult,
- type ComputerStatusResult,
- type LoadComputerStatus,
- type SaveComputer,
- type TestComputer,
- type TestComputerResult,
- } from "../features/computer";
- import {
- HeartbeatView,
- manifest as heartbeatManifest,
- RunModal,
- type HeartbeatConfigResult,
- type HeartbeatNextRunResult,
- type HeartbeatRunView,
- type HeartbeatRunsResult,
- type HeartbeatStopResult,
- } from "../features/heartbeat";
- import {
- ConcurrencyView,
- manifest as concurrencyManifest,
- type DeleteConcurrencyLimit,
- type LoadConcurrencyLimits,
- type LoadConcurrencyStatus,
- type SaveConcurrencyCooldown,
- type SaveConcurrencyLimit,
- } from "../features/concurrency";
- import type { ChatStore } from "../features/chat";
- import {
- SystemPromptBuilder,
- type LoadSystemPrompt as LoadSystemPromptAlias,
- type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias,
- type SaveSystemPrompt as SaveSystemPromptAlias,
- manifest as systemPromptManifest,
- } from "../features/system-prompt";
- import {
- VisionSettingsView,
- manifest as visionManifest,
- type LoadVisionSettingsResult,
- type SaveVisionSettingsResult,
- type VisionSettingsPatch,
- } from "../features/vision";
- import type { AppStore } from "./store.svelte";
- import ErrorModal from "./ErrorModal.svelte";
- import { createLocalStore } from "../adapters/local-storage";
- import { untrack } from "svelte";
-
- let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props();
-
- // The backend's conversation-scoped cache-warming surface. Referenced by id at
- // the composition root (sanctioned discovery-by-id) to give it a dedicated view
- // and keep it out of the generic Extensions surface list — SurfaceView itself
- // stays fully generic (it never switches on a surface id).
- const CACHE_WARMING_ID = "cache-warming";
- // The message-queue extension's per-conversation surface (steering). Pulled
- // out of the generic Extensions list and rendered as a compact panel above the
- // composer — pending steering messages are tied to the chat, not the sidebar.
- const MESSAGE_QUEUE_ID = "message-queue";
- // The `todo` extension's per-conversation task list surface (model-maintained).
- const TODO_ID = "todo";
-
- // The view kinds offered in the sidebar's dropdown. Generic data — the
- // `viewContent` snippet below maps each kind id to its renderer.
- const viewKinds = [
- { id: "tabs", label: "Tabs" },
- { id: "model", label: "Model" },
- { id: "lsp", label: "Language Servers" },
- { id: "mcp", label: "MCP Servers" },
- { id: "extensions", label: "Extensions" },
- { id: "cache-warming", label: "Cache Warming" },
- { id: "tasks", label: "Tasks" },
- { id: "compaction", label: "Compaction" },
- { id: "heartbeat", label: "Heartbeat" },
- { id: "concurrency", label: "Concurrency" },
- { id: "system-prompt", label: "System Prompt" },
- { id: "settings", label: "Settings" },
- ] as const;
-
- // Default sidebar layout: the Tabs list (the moved-from-top tab bar) plus the
- // Model view, so a fresh user sees their conversations + model controls.
- const DEFAULT_VIEWS: readonly string[] = ["tabs", "model"];
- const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", {
- storage: untrack(() => store.storage),
- });
- const sidebarPanels = sidebarStore.load() ?? DEFAULT_VIEWS;
-
- function handleSidebarChange(kinds: readonly (string | null)[]): void {
- sidebarStore.save(kinds.filter((k): k is string => k !== null));
- }
-
- // Frontend module list for the "Loaded Modules" view, AGGREGATED from each
- // feature's public `manifest` export so it can't drift from what's actually
- // composed. (The backend's "Loaded Extensions" surface is a SEPARATE,
- // backend-owned list.) FE features are internal units of this single repo, so
- // there is no per-module version — they all share dispatch-web's version.
- const MODULE_COLUMNS = ["Module", "Description"] as const;
- const loadedModules: readonly (readonly [string, string])[] = [
- chatManifest,
- tabsManifest,
- surfaceHostManifest,
- viewsManifest,
- conversationCacheManifest,
- markdownManifest,
- cacheWarmingManifest,
- cwdLspManifest,
- mcpManifest,
- computerManifest,
- smartScrollManifest,
- settingsManifest,
- systemPromptManifest,
- heartbeatManifest,
- concurrencyManifest,
- visionManifest,
- ].map((m) => [m.name, m.description] as const);
-
- // Smart-scroll: keep the transcript pinned to the bottom while it streams,
- // unless the reader has scrolled up (then show a "scroll to bottom" button).
- // One controller owns the chat scroll region; effects below feed it the edges.
- const smartScroll = createSmartScrollController();
- let transcriptEl = $state<HTMLElement | undefined>();
- let transcriptContentEl = $state<HTMLElement | undefined>();
-
- // Chat-limit unload gate: old chunks may be unloaded only while the reader is
- // stuck to the bottom. While stuck, a trim removes content far ABOVE the
- // viewport and the controller re-pins to the bottom — no visible jump; while
- // reading history, trimming is deferred instead of yanking the page (the old
- // Dispatch bug). In an $effect so a swapped store prop would be re-wired.
- $effect(() => {
- store.attachUnloadGate(() => smartScroll.isAtBottom());
- });
-
- // "Show earlier messages": page older history back in, preserving the reader's
- // viewport position — prepended content grows scrollHeight, so shift scrollTop
- // by the growth (the manual analogue of CSS scroll anchoring, which not every
- // engine applies here).
- async function handleShowEarlier(): Promise<void> {
- const el = transcriptEl;
- const prevHeight = el?.scrollHeight ?? 0;
- const prevTop = el?.scrollTop ?? 0;
- await store.activeChat.showEarlier();
- await tick();
- if (el) {
- const delta = el.scrollHeight - prevHeight;
- if (delta > 0) el.scrollTop = prevTop + delta;
- }
- }
-
- // Attach/detach the controller to the live scroll element + content (disposed on
- // unmount). The content element is observed (ResizeObserver) so the view follows
- // height changes that aren't a transcript append.
- $effect(() => {
- if (!transcriptEl) return;
- return smartScroll.attach(transcriptEl, transcriptContentEl);
- });
-
- // New transcript content streamed in (or messages loaded) → follow the bottom
- // while stuck. Reads `chunks.length` so the effect re-runs on every append.
- $effect(() => {
- void store.activeChat.chunks.length;
- smartScroll.contentChanged();
- });
-
- // The message-queue surface spec + whether it currently has pending messages
- // (steering). Rendered as a compact panel above the composer only when non-empty.
- const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID));
- const hasQueuedMessages = $derived.by(() => {
- const spec = messageQueueSpec;
- if (spec === null) return false;
- const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID);
- if (field === undefined || field.kind !== "custom") return false;
- const data = parseMessageQueuePayload(field.payload);
- return data !== null && data.messages.length > 0;
- });
-
- // The todo surface spec + its parsed task list (model-maintained, read-only).
- const todoSpec = $derived(store.surface(TODO_ID));
- const todoData = $derived.by(() => {
- const spec = todoSpec;
- if (spec === null) return null;
- const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === TODO_ID);
- if (field === undefined || field.kind !== "custom") return null;
- return parseTodoPayload(field.payload);
- });
-
- // Top-bar title: the active tab's title, or "New Tab" when no tab is active
- // (a fresh, unstarted draft — the conversation hasn't been sent yet, so no
- // tab exists). Pure-derived from the (workspace-filtered) tab set + the
- // active id; reflects whichever tab is selected in the sidebar's Tabs view.
- const NEW_TAB_TITLE = "New Tab";
- const topBarTitle = $derived.by(() => {
- const id = store.activeConversationId;
- if (id === null) return NEW_TAB_TITLE;
- const tab = store.tabs.find((t) => t.conversationId === id);
- return tab?.title ?? NEW_TAB_TITLE;
- });
-
- // The composer status-bar status. Priority: error > queued > running > idle.
- // `queued` (the turn is in flight but waiting for a concurrency slot — CR-13)
- // wins over `running` so the corner shows a ring, not dots, during the wait.
- // `turn-start` fires before the slot is granted, so `generating` is already
- // true while `conversationStatus === "queued"`; the explicit queued check is
- // what distinguishes the two.
- const composerStatus = $derived.by<ComposerStatus>(() => {
- if (store.activeChat.error) return "error";
- const id = store.activeConversationId;
- if (id !== null && store.conversationStatus(id) === "queued") return "queued";
- return store.activeChat.generating ? "running" : "idle";
- });
-
- // Conversation/tab switch → snap to the bottom of the new transcript.
- $effect(() => {
- void store.activeConversationId;
- smartScroll.reset();
- });
-
- // Right sidebar: persisted open/closed state. Defaults to open on wide
- // screens (first visit), then remembers the user's toggle thereafter.
- const WIDE_BREAKPOINT = 1024; // Tailwind `lg`
- const sidebarOpenStore = createLocalStore<boolean>("dispatch.sidebar.open", {
- storage: untrack(() => store.storage),
- });
- const storedSidebarOpen = sidebarOpenStore.load();
- let sidebarOpen = $state(storedSidebarOpen ?? (typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true));
- let systemPromptModalOpen = $state(false);
- // The heartbeat run currently open in the fullscreen run-chat modal (null =
- // closed). Holds a snapshot run view; the modal re-mounts per run (keyed).
- let heartbeatRun = $state<HeartbeatRunView | null>(null);
-
- $effect(() => {
- sidebarOpenStore.save(sidebarOpen);
- });
-
- function handleInvoke(msg: InvokeMessage) {
- store.invoke(msg.surfaceId, msg.actionId, msg.payload);
- }
-
- function handleSend(text: string, images?: readonly ImageInput[]): void {
- store.send(text, images);
- }
-
- function handleQueue(text: string) {
- store.queueMessage(text);
- }
-
- function handleStop() {
- store.stopGeneration();
- }
-
- function handleSelectModel(model: string) {
- store.selectModel(model);
- }
-
- // Adapt the store's WarmResult to the cache-warming feature's WarmNow port.
- async function warmNow(): Promise<WarmFeedback | null> {
- const result = await store.warmNow();
- if (result === null) return null;
- return result.ok
- ? {
- ok: true,
- cachePct: result.response.cachePct,
- expectedCacheRate: result.response.expectedCacheRate,
- }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's reasoning-effort result to the chat feature's port.
- async function saveReasoningEffort(
- level: ReasoningEffort,
- ): Promise<ReasoningEffortSaveResult | null> {
- const result = await store.setReasoningEffort(level);
- if (result === null) return null;
- return result.ok
- ? { ok: true, reasoningEffort: result.reasoningEffort }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's compact result to the compaction view's port.
- async function compactNow(): Promise<CompactNowResult | null> {
- const result = await store.compactNow();
- if (result === null) return null;
- return result.ok
- ? {
- ok: true,
- messagesSummarized: result.response.messagesSummarized,
- messagesKept: result.response.messagesKept,
- }
- : { ok: false, error: result.error };
- }
-
- async function saveCompactPercent(
- percent: number,
- ): Promise<SaveCompactPercentResult | null> {
- const result = await store.setCompactPercent(percent);
- if (result === null) return null;
- return result.ok
- ? { ok: true, percent: result.percent }
- : { ok: false, error: result.error };
- }
+<script module lang="ts">
+ import { registerBuiltinViews } from "./register-views";
- // Adapt the store's global vision-settings API to the vision feature's ports.
- async function loadVisionSettings(): Promise<LoadVisionSettingsResult> {
- // The store seeds `visionSettings` on boot; a refresh keeps it current.
- await store.refreshVisionSettings();
- const settings = store.visionSettings;
- if (settings === null) {
- return { ok: false, error: "Vision settings not available." };
- }
- return { ok: true, settings };
- }
-
- async function saveVisionSettings(
- patch: VisionSettingsPatch,
- ): Promise<SaveVisionSettingsResult> {
- const result = await store.setVisionSettings(patch);
- if (result === null) return { ok: false, error: "Vision settings not available." };
- return result.ok
- ? { ok: true, settings: result.settings }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's chat-limit result to the settings feature's port. On a
- // raise the active chat refills (prepends older history); preserve the
- // reader's viewport over the prepend (the manual analogue of CSS scroll
- // anchoring), exactly like `handleShowEarlier`.
- async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> {
- const el = transcriptEl;
- const prevHeight = el?.scrollHeight ?? 0;
- const prevTop = el?.scrollTop ?? 0;
- const result = await store.setChatLimit(value);
- await tick();
- if (el) {
- const delta = el.scrollHeight - prevHeight;
- if (delta > 0) el.scrollTop = prevTop + delta;
- }
- return result.ok
- ? { ok: true, chatLimit: result.chatLimit }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's cwd/LSP results to the cwd-lsp feature's ports.
- async function saveCwd(cwd: string): Promise<CwdSaveResult | null> {
- const result = await store.setCwd(cwd);
- if (result === null) return null;
- return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error };
- }
-
- async function loadLspStatus(): Promise<LspStatusResult | null> {
- const result = await store.lspStatus();
- if (result === null) return null;
- return result.ok
- ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's computer results to the computer feature's ports.
- async function saveComputer(computerId: string | null): Promise<ComputerSaveResult | null> {
- const result = await store.setComputer(computerId);
- if (result === null) return null;
- return result.ok ? { ok: true, computerId: result.computerId } : { ok: false, error: result.error };
- }
-
- const loadComputerStatus: LoadComputerStatus = async (
- alias: string,
- ): Promise<ComputerStatusResult | null> => {
- const result = await store.computerStatus(alias);
- if (result === null) return null;
- return result.ok ? { ok: true, status: result.response } : { ok: false, error: result.error };
- };
-
- const testComputer: TestComputer = async (
- alias: string,
- ): Promise<TestComputerResult | null> => {
- const result = await store.testComputer(alias);
- if (result === null) return null;
- return result.ok ? { ok: true, response: result.response } : { ok: false, error: result.error };
- };
-
- async function loadMcpStatus(): Promise<McpStatusResult | null> {
- const result = await store.mcpStatus();
- if (result === null) return null;
- return result.ok
- ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's system prompt results to the system-prompt feature's ports.
- const loadSystemPromptPrompt: LoadSystemPromptAlias = () => store.loadSystemPrompt();
-
- const loadSystemPromptVariablesPrompt: LoadSystemPromptVariablesAlias = () =>
- store.loadSystemPromptVariables();
-
- const saveSystemPromptPrompt: SaveSystemPromptAlias = (template) => store.setSystemPrompt(template);
-
- // Adapt the store's heartbeat results to the heartbeat feature's ports. The
- // store returns the feature's result types directly (the API is a plain REST
- // surface, not a transport-contract type), so the adapter is a thin passthrough
- // (kept for structural consistency with cwd-lsp/mcp/computer — see AGENTS.md
- // "contracts are the cross-unit surface").
- async function loadHeartbeatConfig(): Promise<HeartbeatConfigResult> {
- return store.heartbeatConfig();
- }
-
- async function saveHeartbeatConfig(
- patch: Parameters<typeof store.setHeartbeatConfig>[0],
- ): Promise<HeartbeatConfigResult> {
- return store.setHeartbeatConfig(patch);
- }
-
- async function loadHeartbeatRuns(): Promise<HeartbeatRunsResult> {
- return store.heartbeatRuns();
- }
-
- async function stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
- return store.stopHeartbeatRun(runId);
- }
-
- async function loadHeartbeatNextRun(): Promise<HeartbeatNextRunResult> {
- return store.heartbeatNextRun();
- }
-
- // Run-chat modal: open a live watch on the run's conversation (the store owns
- // the ChatStore + the `chat.subscribe` stream), and tear it down on close.
- function openRunChat(conversationId: string): ChatStore {
- return store.watchConversation(conversationId);
- }
- function closeRunChat(conversationId: string): void {
- store.unwatchConversation(conversationId);
- }
-
- // Adapt the store's concurrency results to the feature's ports. The store
- // returns the feature's result types directly (the API is a plain REST surface
- // under /concurrency, not a workspace/conversation-scoped one), so the adapter
- // is a thin passthrough (kept for structural consistency — AGENTS.md "contracts
- // are the cross-unit surface").
- const loadConcurrencyLimits: LoadConcurrencyLimits = () => store.concurrencyLimits();
- const saveConcurrencyLimit: SaveConcurrencyLimit = (providerId, limit) =>
- store.setConcurrencyLimit(providerId, limit);
- const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) =>
- store.deleteConcurrencyLimit(providerId);
- const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus();
- const saveConcurrencyCooldown: SaveConcurrencyCooldown = (providerId, cooldownMs) =>
- store.setConcurrencyCooldown(providerId, cooldownMs);
+ // Register every built-in view ONCE per app load, BEFORE any tile renders.
+ // The composition root stays the only place store ports are wired: the
+ // wrappers receive them through `ports` (see `view-ports.ts`).
+ registerBuiltinViews();
</script>
-<main class="relative flex h-screen overflow-hidden">
- <!-- LEFT: everything except the sidebar. The full-height sidebar is a sibling
- (below), so opening it shrinks this ENTIRE column. -->
- <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]">
- <!-- Slim header: the tab bar moved into the sidebar (the "Tabs" view), so
- the top row now shows the active tab's title on the left (or "New Tab"
- for an unstarted draft), with the build version + sidebar toggle on
- the right. -->
- <div class="flex items-center justify-between gap-2 px-2 py-2">
- <span
- class="min-w-0 flex-1 shrink truncate pl-2 text-sm font-medium opacity-70"
- data-testid="top-bar-title"
- title={topBarTitle}
- aria-label="Active conversation title"
- >
- {topBarTitle}
- </span>
- <a
- href="/"
- class="btn btn-ghost btn-sm shrink-0 px-2"
- aria-label="Back to dashboard"
- title="Back to dashboard"
- onclick={(e) => {
- e.preventDefault();
- onNavigate("/");
- }}
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-4"
- aria-hidden="true"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V15a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V9.75M8.25 21h8.25"
- />
- </svg>
- </a>
- <span
- class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30"
- title="Build version (git short hash)"
- >
- build: {__APP_VERSION__}
- </span>
- <button
- class="btn btn-square btn-ghost btn-sm mx-1 shrink-0"
- aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
- aria-expanded={sidebarOpen}
- onclick={() => (sidebarOpen = !sidebarOpen)}
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-5"
- aria-hidden="true"
- >
- {#if sidebarOpen}
- <!-- Sidebar open → chevrons point right -->
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="m11.25 4.5 7.5 7.5-7.5 7.5m-7.5-15 7.5 7.5-7.5 7.5"
- />
- {:else}
- <!-- Sidebar closed → chevrons point left -->
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5"
- />
- {/if}
- </svg>
- </button>
- </div>
-
- {#if store.lastError}
- <div role="alert" class="alert alert-error mx-4 mt-2">
- <strong>Error:</strong>
- {store.lastError.message}
- </div>
- {/if}
-
- {#if store.activeChat.error}
- <div role="alert" class="alert alert-warning mx-4 mt-2">
- <strong>Chat error:</strong>
- {store.activeChat.error}
- </div>
- {/if}
-
- <div class="relative min-h-0 min-w-0 flex-1 pr-4">
- <div bind:this={transcriptEl} class="h-full overflow-y-auto">
- <div bind:this={transcriptContentEl}>
- {#key store.activeConversationId}
- <ChatView
- chunks={store.activeChat.chunks}
- turnMetrics={store.activeChat.turnMetrics}
- hasEarlier={store.activeChat.hasEarlier}
- onShowEarlier={handleShowEarlier}
- thinkingKeyBase={store.activeChat.thinkingKeyBase}
- providerRetry={store.activeChat.providerRetry}
- apiBaseUrl={store.httpBase}
- />
- {/key}
- </div>
- </div>
- {#if store.activeChat.chunks.length === 0}
- <div
- class="pointer-events-none absolute inset-0 flex items-center justify-center"
- aria-hidden="true"
- >
- <span class="select-none text-4xl font-bold opacity-10">Dispatch</span>
- </div>
- {/if}
- <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} />
- </div>
-
- {#if hasQueuedMessages && messageQueueSpec !== null}
- <!-- Pending steering messages (the message-queue surface). Rendered via
- the generic SurfaceView (dispatches on rendererId, never surface id);
- only shown when the queue is non-empty — an idle queue is hidden. -->
- <div class="px-4 pt-2">
- <SurfaceView spec={messageQueueSpec} onInvoke={handleInvoke} />
- </div>
- {/if}
-
- <Composer
- onSend={handleSend}
- onQueue={handleQueue}
- onStop={handleStop}
- contextSize={store.activeChat.currentContextSize}
- contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
- status={composerStatus}
- />
- </div>
-
- <!-- Full-height right sidebar. On wide screens (`lg:relative`) it is in-flow, so
- opening it shrinks the whole left column (push). Below `lg` it overlays
- (`max-lg:absolute`, full height) with a backdrop. -->
- <aside
- class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative"
- class:w-80={sidebarOpen}
- class:w-0={!sidebarOpen}
- >
- <div
- class="flex h-full w-80 flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 transition-transform duration-300 ease-out"
- style="transform: translateX({sidebarOpen ? '0' : '100%'})"
- >
- <ViewSidebar kinds={viewKinds} initial={sidebarPanels} onChange={handleSidebarChange} content={viewContent} />
- </div>
- </aside>
+<script lang="ts">
+ import type { LayoutTree } from "@dispatch/layout-contract";
+ import { untrack } from "svelte";
+ import { createCollapseStore, firstCollapsibleNodeId, TileRoot } from "../features/layout";
+ import { RunModal, type HeartbeatRunView } from "../features/heartbeat";
+ import { SystemPromptBuilder } from "../features/system-prompt";
+ import type { AppStore } from "./store.svelte";
+ import type { AppViewPorts, ChatViewportBridge } from "./view-ports";
+ import ErrorModal from "./ErrorModal.svelte";
+
+ let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props();
+
+ // Modal chrome — app shell state, OUTSIDE the tree (layout-00 decision 2).
+ let systemPromptModalOpen = $state(false);
+ // The heartbeat run currently open in the fullscreen run-chat modal (null =
+ // closed). Holds a snapshot run view; the modal re-mounts per run (keyed).
+ let heartbeatRun = $state<HeartbeatRunView | null>(null);
+
+ // --- The layout tree (plan 02): the ACTIVE workspace's tile tree, served
+ // by the backend's layout provider plugin when one is active (probed on
+ // boot, on workspace switch, and on extensions-changed; `layout` WS pushes
+ // re-render live) and the built-in DEFAULT_LAYOUT otherwise (no-provider
+ // mode: edits are ephemeral, logged once, no error UI). The store owns the
+ // whole lifecycle; the shell just renders `tree` and forwards edits.
+ // `dispatch.sidebar.views` is RETIRED (D-L4): its only reader is the store's
+ // one-way §2.7 migration.
+ const tree = $derived(store.layout.tree);
+
+ function handleTreeChange(next: LayoutTree): void {
+ store.layout.onTreeChange(next);
+ }
+
+ // --- Sidebar collapse: renderer chrome state (per-node-id open/closed),
+ // persisted with the SAME key/semantics as today (`dispatch.sidebar.open`, a
+ // bare boolean, default open on wide screens). The toggle drives the tree's
+ // single collapsible node ("side" on the default tree; generically the first
+ // collapsible split child).
+ const collapse = createCollapseStore({
+ storage: untrack(() => store.storage),
+ viewportWidth: typeof window !== "undefined" ? window.innerWidth : undefined,
+ });
+ const collapseState = $derived($collapse);
+ const collapseKey = $derived(firstCollapsibleNodeId(tree));
+ const sidebarOpen = $derived(
+ collapseKey !== null ? (collapseState.openByNode[collapseKey] ?? collapseState.defaultOpen) : false,
+ );
+
+ function toggleSidebar(): void {
+ if (collapseKey !== null) collapse.toggle(collapseKey);
+ }
+
+ // --- The port bag every view wrapper receives. This is the ONE place the
+ // store's ports are wired (AGENTS.md: composition root). `store` is read
+ // through a getter so a swapped store prop stays reactive through it.
+ const chatViewport: ChatViewportBridge = { el: null };
+ const viewPorts: AppViewPorts = {
+ get store() {
+ return store;
+ },
+ chatViewport,
+ openSystemPromptBuilder: () => (systemPromptModalOpen = true),
+ openHeartbeatRun: (run) => (heartbeatRun = run),
+ };
+
+ // Top-bar title: the active tab's title, or "New Tab" when no tab is active
+ // (a fresh, unstarted draft — the conversation hasn't been sent yet, so no
+ // tab exists). Pure-derived from the (workspace-filtered) tab set + the
+ // active id; reflects whichever tab is selected in the sidebar's Tabs view.
+ const NEW_TAB_TITLE = "New Tab";
+ const topBarTitle = $derived.by(() => {
+ const id = store.activeConversationId;
+ if (id === null) return NEW_TAB_TITLE;
+ const tab = store.tabs.find((t) => t.conversationId === id);
+ return tab?.title ?? NEW_TAB_TITLE;
+ });
+
+ function handleInvokeHome(): void {
+ onNavigate("/");
+ }
+</script>
- <!-- Backdrop: only on narrow screens (overlay mode), click to close. -->
- {#if sidebarOpen}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="fixed inset-0 z-20 bg-black/30 lg:hidden"
- role="button"
- tabindex="0"
- aria-label="Close sidebar"
- onclick={() => (sidebarOpen = false)}
- onkeydown={(e) => {
- if (e.key === "Escape" || e.key === "Enter") sidebarOpen = false;
- }}
- ></div>
- {/if}
+<main class="h-app relative flex flex-col overflow-hidden">
+ <!-- Slim header: the tab bar moved into the sidebar (the "Tabs" view), so
+ the top row now shows the active tab's title on the left (or "New Tab"
+ for an unstarted draft), with the build version + sidebar toggle on
+ the right. CHROME — stays outside the tree (layout-00 decision 2).
+ `.app-navbar` (app.css): the Artix wiki's darkstrap navbar look. -->
+ <div class="app-navbar flex items-center justify-between gap-2 px-2 pt-[calc(0.5rem+5px)] pb-2">
+ <span
+ class="app-navbar-engraved min-w-0 flex-1 shrink truncate pl-2 text-sm font-semibold text-primary"
+ data-testid="top-bar-title"
+ title={topBarTitle}
+ aria-label="Active conversation title"
+ >
+ {topBarTitle}
+ </span>
+ <span
+ class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-primary/70"
+ title="Build version (git short hash)"
+ >
+ build: {__APP_VERSION__}
+ </span>
+ <a
+ href="/"
+ class="btn btn-ghost btn-sm shrink-0 px-2 text-primary"
+ aria-label="Back to dashboard"
+ title="Back to dashboard"
+ onclick={(e) => {
+ e.preventDefault();
+ handleInvokeHome();
+ }}
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-4"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V15a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V9.75M8.25 21h8.25"
+ />
+ </svg>
+ </a>
+ {#if collapseKey !== null}
+ <button
+ class="btn btn-square btn-ghost btn-sm mx-1 shrink-0 text-primary"
+ aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
+ aria-expanded={sidebarOpen}
+ onclick={toggleSidebar}
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-5"
+ aria-hidden="true"
+ >
+ {#if sidebarOpen}
+ <!-- Sidebar open → chevrons point right -->
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m11.25 4.5 7.5 7.5-7.5 7.5m-7.5-15 7.5 7.5-7.5 7.5"
+ />
+ {:else}
+ <!-- Sidebar closed → chevrons point left -->
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5"
+ />
+ {/if}
+ </svg>
+ </button>
+ {/if}
+ </div>
+
+ {#if store.lastError}
+ <div role="alert" class="alert alert-error mx-4 mt-2">
+ <strong>Error:</strong>
+ {store.lastError.message}
+ </div>
+ {/if}
+
+ {#if store.activeChat.error}
+ <div role="alert" class="alert alert-warning mx-4 mt-2">
+ <strong>Chat error:</strong>
+ {store.activeChat.error}
+ </div>
+ {/if}
+
+ <!-- THE TREE: the tile renderer owns the content region. Tile edits are
+ applied here and persisted to dispatch.sidebar.views (above). -->
+ <div class="min-h-0 min-w-0 flex-1">
+ <TileRoot {tree} {collapse} ports={viewPorts} onTreeChange={handleTreeChange} />
+ </div>
</main>
{#if store.fatalError}
- <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} />
+ <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} />
{/if}
{#if systemPromptModalOpen}
- <SystemPromptBuilder
- loadPrompt={loadSystemPromptPrompt}
- savePrompt={saveSystemPromptPrompt}
- loadVariables={loadSystemPromptVariablesPrompt}
- onClose={() => (systemPromptModalOpen = false)}
- />
+ <SystemPromptBuilder
+ loadPrompt={() => store.loadSystemPrompt()}
+ savePrompt={(template) => store.setSystemPrompt(template)}
+ loadVariables={() => store.loadSystemPromptVariables()}
+ onClose={() => (systemPromptModalOpen = false)}
+ />
{/if}
{#if heartbeatRun !== null}
- <!-- Keyed per run so switching runs (or re-opening) re-mounts the modal — a
- fresh watch store lifecycle per run. The modal owns the live watch
- (openChat/closeChat) and the Stop button. -->
- {#key heartbeatRun.id}
- <RunModal
- run={heartbeatRun}
- openChat={openRunChat}
- closeChat={closeRunChat}
- stopRun={stopHeartbeatRun}
- onClose={() => (heartbeatRun = null)}
- apiBaseUrl={store.httpBase}
- />
- {/key}
+ <!-- Keyed per run so switching runs (or re-opening) re-mounts the modal — a
+ fresh watch store lifecycle per run. The modal owns the live watch
+ (openChat/closeChat) and the Stop button. -->
+ {#key heartbeatRun.id}
+ <RunModal
+ run={heartbeatRun}
+ openChat={(conversationId) => store.watchConversation(conversationId)}
+ closeChat={(conversationId) => store.unwatchConversation(conversationId)}
+ stopRun={(runId) => store.stopHeartbeatRun(runId)}
+ onClose={() => (heartbeatRun = null)}
+ apiBaseUrl={store.httpBase}
+ />
+ {/key}
{/if}
-
-{#snippet viewContent(kind: string)}
- {#if kind === "tabs"}
- <!-- The conversation tab list (moved out of the top bar into the sidebar).
- Re-mount per workspace so the filtered tab set + scroll reset cleanly
- on a workspace switch. -->
- {#key store.activeWorkspaceId}
- <TabList
- tabs={store.tabs}
- activeConversationId={store.activeConversationId}
- statusFor={(id) => store.conversationStatus(id)}
- onSelect={(id) => store.selectTab(id)}
- onClose={(id) => store.closeTab(id)}
- onNewDraft={() => store.newDraft()}
- onRename={(id, title) => store.renameTab(id, title)}
- />
- {/key}
- {:else if kind === "model"}
- <div class="flex flex-col gap-3">
- <ModelSelector
- models={store.models}
- selected={store.activeModel}
- onSelect={handleSelectModel}
- modelInfo={store.modelInfo}
- />
- <!-- Keyed on the workspace conversation (active tab OR draft) so the inputs
- re-mount per conversation — incl. switching between drafts — and can't
- bleed across tabs. Editable for a draft too (cwd + effort apply from turn 1). -->
- {#key store.currentConversationId}
- <ReasoningEffortSelector persisted={store.reasoningEffort} save={saveReasoningEffort} />
- <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} />
- <ComputerField
- computerId={store.computerId}
- canEdit={true}
- computers={store.computers}
- save={saveComputer}
- loadStatus={loadComputerStatus}
- test={testComputer}
- />
- {/key}
- </div>
- {:else if kind === "lsp"}
- <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. -->
- {#key store.currentConversationId}
- <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} />
- {/key}
- {:else if kind === "mcp"}
- <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. -->
- {#key store.currentConversationId}
- <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} />
- {/key}
- {:else if kind === "extensions"}
- <section>
- <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3>
- <Table columns={MODULE_COLUMNS} rows={loadedModules} />
- </section>
- <section class="mt-4 flex flex-col gap-3">
- <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3>
- {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)}
- <SurfaceView {spec} onInvoke={handleInvoke} />
- {/each}
- </section>
- {:else if kind === "cache-warming"}
- <!-- Re-mount per conversation (like ChatView) so the view's local warming
- history / manual-warm feedback can't bleed across tabs. -->
- {#key store.activeConversationId}
- <CacheWarmingView
- spec={store.surface(CACHE_WARMING_ID)}
- canWarm={store.activeConversationId !== null}
- onInvoke={handleInvoke}
- {warmNow}
- />
- {/key}
- {:else if kind === "tasks"}
- <!-- Re-mount per conversation so the task list is isolated per conversation.
- TodoList always reserves its fixed 60vh height (empty or full). -->
- {#key store.activeConversationId}
- <TodoList payload={todoData} />
- {/key}
- {:else if kind === "compaction"}
- <!-- Message compaction is per-conversation (keyed so the percent + feedback
- can't bleed across tabs). Vision (image-compaction) settings are GLOBAL,
- so they stay mounted across conversation switches (no {#key}). -->
- {#key store.currentConversationId}
- <CompactionView
- percent={store.compactPercent}
- canCompact={store.activeConversationId !== null}
- {compactNow}
- savePercent={saveCompactPercent}
- />
- {/key}
- <div class="divider my-1 text-xs text-base-content/40">Image compaction</div>
- <VisionSettingsView
- models={store.models}
- modelInfo={store.modelInfo}
- load={loadVisionSettings}
- save={saveVisionSettings}
- />
- {:else if kind === "system-prompt"}
- <!-- Global system prompt template. Opens a full-page modal editor (half
- template / half variable palette). Not conversation-scoped (no {#key}). -->
- <div class="flex flex-col gap-2">
- <p class="text-xs opacity-60">
- Edit the global system prompt template with variable placeholders. Opens a full-page editor.
- </p>
- <button
- type="button"
- class="btn btn-primary btn-sm"
- onclick={() => (systemPromptModalOpen = true)}
- >
- Open builder
- </button>
- </div>
- {:else if kind === "settings"}
- <!-- FE-local settings. Not conversation-scoped (no {#key}: the chat limit is
- global), so the field stays mounted across tab switches. -->
- <div class="flex flex-col gap-3">
- <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} />
- </div>
- {:else if kind === "heartbeat"}
- <!-- Workspace-scoped autonomous-agent heartbeat (config + run history).
- Not conversation-scoped (no {#key}); the config + runs are per-workspace. -->
- <HeartbeatView
- models={store.models}
- loadConfig={loadHeartbeatConfig}
- saveConfig={saveHeartbeatConfig}
- loadRuns={loadHeartbeatRuns}
- stopRun={stopHeartbeatRun}
- loadVariables={loadSystemPromptVariablesPrompt}
- loadDefaultPrompt={loadSystemPromptPrompt}
- loadNextRun={loadHeartbeatNextRun}
- onOpenRun={(run) => (heartbeatRun = run)}
- />
- {:else if kind === "concurrency"}
- <!-- Per-provider concurrency limits + live status. GLOBAL (not workspace- or
- conversation-scoped), so the panel stays mounted across tab switches. -->
- <ConcurrencyView
- models={store.models}
- loadLimits={loadConcurrencyLimits}
- saveLimit={saveConcurrencyLimit}
- deleteLimit={deleteConcurrencyLimit}
- loadStatus={loadConcurrencyStatus}
- saveCooldown={saveConcurrencyCooldown}
- />
- {/if}
-{/snippet}
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index dcdcb9b..ebc8004 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -1,9 +1,12 @@
+import type { LayoutTree } from "@dispatch/layout-contract";
+import { DEFAULT_LAYOUT } from "@dispatch/layout-contract";
import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
-import { render, screen } from "@testing-library/svelte";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
+import { overlaySidePanels } from "../features/layout";
import App from "./App.svelte";
import { createAppStore } from "./store.svelte";
@@ -57,11 +60,17 @@ function fakeSocket(): FakeSocket {
function fakeFetchImpl(): typeof fetch {
return async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
+ if (url.endsWith("/layout")) {
+ // No layout provider in these tests: the plan-02 no-provider signal.
+ return new Response(JSON.stringify({ error: "No layout provider is active" }), {
+ status: 404,
+ });
+ }
if (url.includes("/conversations?status=")) {
return new Response(JSON.stringify({ conversations: [] }), { status: 200 });
}
@@ -73,7 +82,60 @@ function fakeFetchImpl(): typeof fetch {
status: 200,
});
}
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ if (url.endsWith("/settings/vision")) {
+ return new Response(JSON.stringify({ imageLimit: 10, compactionModel: null }), {
+ status: 200,
+ });
+ }
+ if (/\/plugins\/[^/]+\/frontend/.test(url)) {
+ // plan 03 frontend module (no fixtures declare one) — the plain 404
+ // the route returns for unknown/unloaded/frontend-less plugins.
+ return new Response(JSON.stringify({ error: "No frontend module for this plugin" }), {
+ status: 404,
+ });
+ }
+ if (/\/plugins(\?.*)?$/.test(url)) {
+ // Valid EMPTY list (plan 03): the plugin host's boot
+ // `extensions-changed`/workspace syncs read this — a shapeless
+ // fallback body would be cast to PluginListResponse and crash the
+ // diff (`list.loaded` not iterable).
+ return new Response(
+ JSON.stringify({ loaded: [], disabledWithReason: [], discovered: [], missing: [] }),
+ { status: 200 },
+ );
+ }
+ return new Response(JSON.stringify({ chunks: [], historyEpoch: 0, latestSeq: 0 }), {
+ status: 200,
+ });
+ };
+}
+
+/**
+ * A fetch whose layout provider is ACTIVE and serves the given sidebar
+ * panels (plan 02): `GET /workspaces/:id/layout` returns DEFAULT_LAYOUT with
+ * the views overlaid; `PUT` echoes the tree (200). Tests that need specific
+ * sidebar views visible drive the layout through THIS edge — the pre-seeded
+ * `dispatch.sidebar.views` localStorage is only read by the one-way §2.7
+ * migration now.
+ */
+function fakeFetchWithLayout(views: readonly string[]): typeof fetch {
+ const inner = fakeFetchImpl();
+ return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/layout")) {
+ const method = init?.method ?? "GET";
+ if (method === "PUT" && typeof init?.body === "string") {
+ const body = JSON.parse(init.body) as { tree: LayoutTree };
+ return new Response(JSON.stringify({ tree: body.tree }), { status: 200 });
+ }
+ if (method === "GET") {
+ const tree = overlaySidePanels(DEFAULT_LAYOUT, views);
+ return new Response(JSON.stringify({ tree }), { status: 200 });
+ }
+ const tree = overlaySidePanels(DEFAULT_LAYOUT, views);
+ return new Response(JSON.stringify({ tree }), { status: 200 });
+ }
+ return inner(input, init);
};
}
@@ -118,6 +180,119 @@ function activeConversationId(store: ReturnType<typeof createAppStore>): string
}
describe("App component interaction tests", () => {
+ it.each([
+ "all",
+ "parent",
+ "cancel",
+ ] as const)("group close %s flows through the real store, persistence and HTTP close port", async (choice) => {
+ const ws = fakeSocket();
+ const storage = createFakeStorage();
+ const closed: string[] = [];
+ const base = fakeFetchWithLayout(["tabs"]);
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/conversations?status=")) {
+ return new Response(
+ JSON.stringify({
+ conversations: [
+ { id: "p", workspaceId: "default" },
+ { id: "c1", workspaceId: "default", tabGroupParentId: "p" },
+ { id: "c2", workspaceId: "default", tabGroupParentId: "p" },
+ { id: "g", workspaceId: "default", tabGroupParentId: "c1" },
+ { id: "solo", workspaceId: "default" },
+ { id: "other-workspace", workspaceId: "other", tabGroupParentId: "p" },
+ ].map((conversation) => ({
+ ...conversation,
+ title: conversation.id,
+ status: "idle",
+ lastActivityAt: 0,
+ })),
+ }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/close")) {
+ expect(init?.method).toBe("POST");
+ const id = url.split("/").at(-2) as string;
+ closed.push(id);
+ return new Response(JSON.stringify({ conversationId: id, abortedTurn: false }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ },
+ localStorage: storage,
+ });
+ try {
+ ws.resolveOpen();
+ render(App, { props: { store, onNavigate: vi.fn() } });
+ await screen.findByRole("tablist");
+ for (const [id, parent] of [
+ ["p", null],
+ ["c1", "p"],
+ ["c2", "p"],
+ ["g", "c1"],
+ ["solo", null],
+ ] as const) {
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: id,
+ workspaceId: "default",
+ ...(parent === null ? {} : { tabGroupParentId: parent }),
+ });
+ }
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "other-workspace",
+ workspaceId: "other",
+ tabGroupParentId: "p",
+ });
+ await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(5));
+ store.selectTab("p");
+ const parent = await waitFor(() => {
+ const tab = document.querySelector<HTMLElement>('[role="tab"][data-conversation-id="p"]');
+ expect(tab).not.toBeNull();
+ return tab;
+ });
+ if (parent === null) throw new Error("Parent tab missing");
+ await fireEvent.click(within(parent).getByRole("button", { name: "Close tab" }));
+ const dialog = screen.getByRole("dialog", { name: "Close subagents too?" });
+ expect(dialog).toHaveTextContent("3 subagent tabs");
+ expect(closed).toEqual([]);
+ await fireEvent.click(
+ within(dialog).getByRole("button", {
+ name:
+ choice === "all"
+ ? /Yes, close all 4/
+ : choice === "parent"
+ ? /No, keep them open/
+ : "Cancel",
+ }),
+ );
+ const expected = choice === "all" ? ["p", "c1", "c2", "g"] : choice === "parent" ? ["p"] : [];
+ expect(closed).toEqual(expected);
+ expect(store.tabs.map((tab) => tab.conversationId)).toEqual(
+ ["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)),
+ );
+ expect(screen.queryByRole("dialog")).toBeNull();
+ if (choice !== "cancel") {
+ const persisted = JSON.parse(storage.getItem("dispatch.tabs") as string) as {
+ tabs: { conversationId: string }[];
+ };
+ expect(persisted.tabs.map((tab) => tab.conversationId)).toEqual([
+ ...["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)),
+ "other-workspace",
+ ]);
+ expect(store.activeConversationId).not.toBe("p");
+ }
+ } finally {
+ store.dispose();
+ }
+ });
+
it("renders the model selector and composer in draft mode", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -231,7 +406,7 @@ describe("App component interaction tests", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
+ fetchImpl: fakeFetchWithLayout(["extensions"]),
localStorage: createFakeStorageWithViews(),
});
ws.resolveOpen();
@@ -295,7 +470,7 @@ describe("App component interaction tests", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
+ fetchImpl: fakeFetchWithLayout(["extensions"]),
localStorage: createFakeStorageWithViews(),
});
ws.resolveOpen();
@@ -372,12 +547,133 @@ describe("App component interaction tests", () => {
store.dispose();
});
+ it.each([
+ "look at this",
+ "",
+ ])("queues staged images through the full UI with text %j", async (text) => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ ws.resolveOpen();
+ const { container } = render(App, { props: { store, onNavigate: vi.fn() } });
+ const user = userEvent.setup();
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "start");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const conversationId = activeConversationId(store);
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId, turnId: "t1" },
+ });
+ if (text) await user.type(textarea, text);
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+ ws.sent.length = 0;
+
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+
+ expect(sentMessages(ws).filter((m: { type: string }) => m.type.startsWith("chat."))).toEqual([
+ {
+ type: "chat.queue",
+ requestId: expect.any(String),
+ conversationId,
+ workspaceId: "default",
+ text,
+ images: [{ url: "data:image/gif;base64,R0lG", mimeType: "image/gif" }],
+ },
+ ]);
+ expect(textarea).toHaveValue("");
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ store.dispose();
+ });
+
+ it("uses only the focused conversation's queue snapshot for the Flush label and POSTs stop", async () => {
+ const ws = fakeSocket();
+ const fetchImpl = vi.fn(fakeFetchImpl());
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ ws.resolveOpen();
+ render(App, { props: { store, onNavigate: vi.fn() } });
+ const user = userEvent.setup();
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "start");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const firstId = activeConversationId(store);
+ const startTurn = (conversationId: string) =>
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId, turnId: `turn-${conversationId}` },
+ });
+ const pushQueue = (conversationId: string, payload: unknown) =>
+ ws.feedSurfaceMessage({
+ type: "surface",
+ conversationId,
+ spec: {
+ id: "message-queue",
+ region: "inline",
+ title: "Message queue",
+ fields: [{ kind: "custom", rendererId: "message-queue", payload }],
+ },
+ });
+ startTurn(firstId);
+ await screen.findByRole("button", { name: "Stop generation" });
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "message-queue", region: "inline", title: "Message queue", scope: "conversation" },
+ ],
+ });
+ const pending = { messages: [{ id: "m1", text: "next", queuedAt: 1 }] };
+ pushQueue(firstId, pending);
+ const flush = await screen.findByRole("button", { name: "Flush" });
+ ws.sent.length = 0;
+ fetchImpl.mockClear();
+ await user.click(flush);
+ expect(fetchImpl.mock.calls.filter(([, init]) => init?.method === "POST")).toEqual([
+ [expect.stringContaining(`/conversations/${firstId}/stop`), { method: "POST" }],
+ ]);
+ expect(sentMessages(ws)).toEqual([]); // No new send, queue, or cancel operation.
+
+ store.newDraft();
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "second");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const secondId = activeConversationId(store);
+ expect(secondId).not.toBe(firstId);
+ startTurn(secondId);
+ await screen.findByRole("button", { name: "Stop generation" });
+ pushQueue(firstId, pending); // Late old-scope update must not relabel the new conversation.
+ await vi.waitFor(() =>
+ expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument(),
+ );
+ pushQueue(secondId, pending);
+ await screen.findByRole("button", { name: "Flush" });
+ pushQueue(secondId, { messages: [] });
+ await screen.findByRole("button", { name: "Stop generation" });
+ pushQueue(secondId, { messages: "malformed" });
+ await vi.waitFor(() =>
+ expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument(),
+ );
+ store.dispose();
+ });
+
it("incoming chat.delta renders text in the chat transcript", async () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
});
ws.resolveOpen();
@@ -415,7 +711,7 @@ describe("App component interaction tests", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
+ fetchImpl: fakeFetchWithLayout(["extensions"]),
localStorage: createFakeStorageWithViews(),
});
ws.resolveOpen();
@@ -454,19 +750,20 @@ describe("App component interaction tests", () => {
store.dispose();
});
- it("the Extensions view lists frontend modules aggregated from feature manifests", () => {
+ it("the Extensions view lists frontend modules aggregated from feature manifests", async () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
+ fetchImpl: fakeFetchWithLayout(["extensions"]),
localStorage: createFakeStorageWithViews(),
});
ws.resolveOpen();
render(App, { props: { store, onNavigate: vi.fn() } });
- // Extensions view is pre-populated in the fake storage, so the modules table renders immediately.
- expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument();
+ // The Extensions view arrives with the provider's tree (an async GET —
+ // plan 02), so await the modules table rendering.
+ expect(await screen.findByRole("columnheader", { name: "Module" })).toBeInTheDocument();
for (const name of [
"chat",
"tabs",
@@ -486,8 +783,8 @@ describe("App component interaction tests", () => {
// network failure / unreachable backend on a new device).
const failingFetch = async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
@@ -502,7 +799,9 @@ describe("App component interaction tests", () => {
status: 200,
});
}
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ return new Response(JSON.stringify({ chunks: [], historyEpoch: 0, latestSeq: 0 }), {
+ status: 200,
+ });
};
const ws = fakeSocket();
@@ -583,4 +882,22 @@ describe("App component interaction tests", () => {
store.dispose();
});
+
+ it("the Vision view renders the vision settings UI (image limit + compaction model)", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchWithLayout(["vision"]),
+ localStorage: createFakeStorageWithViews(["vision"]),
+ });
+ ws.resolveOpen();
+
+ render(App, { props: { store, onNavigate: vi.fn() } });
+
+ // The vision settings load (`GET /settings/vision`) seeds the inputs.
+ expect(await screen.findByLabelText(/Image limit/)).toBeInTheDocument();
+ expect(await screen.findByLabelText(/Compaction model/)).toBeInTheDocument();
+
+ store.dispose();
+ });
});
diff --git a/src/app/chat-registry.svelte.ts b/src/app/chat-registry.svelte.ts
new file mode 100644
index 0000000..63f58ad
--- /dev/null
+++ b/src/app/chat-registry.svelte.ts
@@ -0,0 +1,297 @@
+import { untrack } from "svelte";
+import { createLocalStore } from "../adapters/local-storage";
+import type { SurfaceSocketHandle } from "../adapters/ws";
+import { normalizeChatLimit } from "../core/chunks";
+import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
+import { createChatStore, createPendingInputStorage } from "../features/chat";
+import type { ConversationCache } from "../features/conversation-cache";
+import type { TabsStore } from "../features/tabs";
+import type { ChatLimitResult, CreateAppStoreOptions } from "./store-types";
+import { liveConversationIds } from "./subscription-budget";
+import { randomId } from "./uuid";
+
+/** App-owned tab/watch stores and idempotent transcript loading; no singleton state. */
+export function createChatRegistry(deps: {
+ storage: Storage | undefined;
+ tabsStore: TabsStore;
+ createCache: () => ConversationCache;
+ historySync: HistorySync;
+ metricsSync: MetricsSync;
+ defaultModel: string;
+ activeModel: () => string;
+ activeWorkspaceId: () => string;
+ send: SurfaceSocketHandle["send"];
+ reportError: (context: string, err: unknown) => void;
+ chatFlushScheduler: CreateAppStoreOptions["chatFlushScheduler"];
+}) {
+ // The chat limit (max loaded chunks per conversation) — a persisted local
+ // setting surfaced in the sidebar's Settings view. Reactive so the field +
+ // any live-apply re-trim update together. The default is written back on
+ // first run so the knob is discoverable in localStorage too.
+ const chatLimitStore = createLocalStore<number>("dispatch.chatLimit", {
+ storage: deps.storage,
+ });
+ const storedChatLimit = chatLimitStore.load();
+ const normalizedChatLimit = normalizeChatLimit(storedChatLimit);
+ let chatLimit = $state(normalizedChatLimit);
+ if (storedChatLimit === null) {
+ chatLimitStore.save(normalizedChatLimit);
+ }
+
+ // Unload gate — attached by the shell once it owns the scroll region (see
+ // `AppStore.attachUnloadGate`). Until then, unloading is allowed.
+ let unloadGate: (() => boolean) | null = null;
+
+ const cache = deps.createCache();
+
+ const chatStores = new Map<string, ChatStore>();
+ // Includes in-flight loads: repeated activation must not start a second load.
+ const loadedConversationIds = new Set<string>();
+ const loadingConversationIds = new Set<string>();
+
+ // Ephemeral chat stores for MODAL viewers (the heartbeat run-chat modal): a
+ // watch on a conversation's live turn stream WITHOUT opening a tab. Separate
+ // from `chatStores` (tabs) so closing a modal never disturbs the tab strip,
+ // and a tab's conversation reuses its own store (see `watchConversation`).
+ // Deltas are routed here in addition to `chatStores`.
+ const watchStores = new Map<string, ChatStore>();
+ const watchedIds = new Set<string>();
+ const subscribedIds = new Set<string>();
+
+ function syncChatSubscriptions(): void {
+ const desired = liveConversationIds(deps.tabsStore.activeConversationId, watchedIds);
+ for (const id of subscribedIds) {
+ if (!desired.has(id)) {
+ deps.send({ type: "chat.unsubscribe", conversationId: id });
+ subscribedIds.delete(id);
+ }
+ }
+ for (const id of desired) {
+ if (subscribedIds.has(id)) continue;
+ subscribedIds.add(id);
+ deps.send({ type: "chat.subscribe", conversationId: id });
+ if (loadedConversationIds.has(id) && !loadingConversationIds.has(id)) {
+ (chatStores.get(id) ?? watchStores.get(id))?.resync();
+ }
+ }
+ }
+
+ /**
+ * Coalesce live chat events to at most one fold + render per animation
+ * frame (a late-join replay of a long turn arrives as a burst of WS frames;
+ * folding each separately re-derives the whole transcript per frame). A
+ * timeout backs up rAF, which browsers pause in hidden tabs — a backgrounded
+ * tab still applies its events (just less often).
+ */
+ function scheduleChatFlush(flush: () => void): void {
+ let ran = false;
+ const run = (): void => {
+ if (ran) return;
+ ran = true;
+ flush();
+ };
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
+ setTimeout(run, 250);
+ }
+
+ const chatFlushScheduler =
+ deps.chatFlushScheduler === undefined ? scheduleChatFlush : deps.chatFlushScheduler;
+
+ function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore {
+ return createChatStore({
+ ...(deps.storage === undefined
+ ? {}
+ : {
+ pendingInputStorage: createPendingInputStorage(deps.storage, conversationId),
+ }),
+ conversationId,
+ createRequestId: randomId,
+ model,
+ workspaceId,
+ transport: {
+ send(msg) {
+ deps.send(msg);
+ },
+ },
+ historySync: deps.historySync,
+ metricsSync: deps.metricsSync,
+ cache,
+ // Read from the persisted store (kept in sync with the reactive `chatLimit`
+ // by `setChatLimit` + boot) so this snapshot doesn't reference the `$state`
+ // — each store captures its limit at creation; live updates go through
+ // `setChatLimit`.
+ chatLimit: normalizeChatLimit(chatLimitStore.load()),
+ canUnload: () => (unloadGate === null ? true : unloadGate()),
+ ...(chatFlushScheduler !== null ? { scheduleFlush: chatFlushScheduler } : {}),
+ onError: (context, err) => {
+ deps.reportError(`${context} (conversation: ${conversationId})`, err);
+ },
+ });
+ }
+
+ const initialDraftId = randomId();
+ // Read `activeWorkspaceId` with untrack to suppress Svelte's
+ // `state_referenced_locally` warning — this intentionally captures the
+ // INITIAL workspace for the boot draft. When the workspace changes later,
+ // `setActiveWorkspace` creates a fresh draft store with the new id.
+ let draftStore: ChatStore = createChatFor(
+ initialDraftId,
+ deps.defaultModel,
+ untrack(deps.activeWorkspaceId),
+ );
+ let draftConversationId: string = initialDraftId;
+
+ let activeChat = $state<ChatStore>(draftStore as ChatStore);
+ function getActiveChat(): ChatStore {
+ const activeId = deps.tabsStore.activeConversationId;
+ if (activeId === null) {
+ return draftStore;
+ }
+ return chatStores.get(activeId) ?? draftStore;
+ }
+
+ function ensureLoaded(conversationId: string): void {
+ if (loadedConversationIds.has(conversationId)) return;
+ const store = chatStores.get(conversationId) ?? watchStores.get(conversationId);
+ if (store === undefined) return;
+ loadedConversationIds.add(conversationId);
+ loadingConversationIds.add(conversationId);
+ void store.load().finally(() => loadingConversationIds.delete(conversationId));
+ }
+
+ function refreshActiveChat(): void {
+ syncChatSubscriptions();
+ const activeId = deps.tabsStore.activeConversationId;
+ if (activeId !== null) ensureLoaded(activeId);
+ activeChat = getActiveChat();
+ }
+
+ /** Discovery creates metadata; only the focused chat and explicit viewers watch live. */
+ function subscribeChat(conversationId: string): void {
+ void conversationId;
+ syncChatSubscriptions();
+ }
+
+ /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */
+ function unsubscribeChat(conversationId: string): void {
+ if (subscribedIds.delete(conversationId)) {
+ deps.send({ type: "chat.unsubscribe", conversationId });
+ }
+ }
+
+ /**
+ * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
+ * modal). Returns a live {@link ChatStore} for the conversation's turn stream.
+ * If the conversation is already an open TAB, reuses its store (it is already
+ * subscribed + streaming); otherwise creates an EPHEMERAL watch store in
+ * `watchStores` (separate from tabs — never opens a tab), subscribes to its
+ * live turn stream, and loads history. Deltas route to it via `handleChatMessage`.
+ * Pair with {@link unwatchConversation} on close.
+ */
+ function watchConversation(conversationId: string): ChatStore {
+ watchedIds.add(conversationId);
+ // An open tab already has a live store + subscription — reuse it.
+ const tabStore = chatStores.get(conversationId);
+ if (tabStore !== undefined) {
+ syncChatSubscriptions();
+ ensureLoaded(conversationId);
+ return tabStore;
+ }
+ const existing = watchStores.get(conversationId);
+ if (existing !== undefined) return existing;
+ const store = createChatFor(conversationId, deps.activeModel(), deps.activeWorkspaceId());
+ watchStores.set(conversationId, store);
+ subscribeChat(conversationId);
+ ensureLoaded(conversationId);
+ return store;
+ }
+
+ /**
+ * Dispose + unsubscribe a watch opened by {@link watchConversation}. A no-op if
+ * the conversation was (or became) an open TAB — the tab owns its store +
+ * subscription, so nothing is torn down (closing the modal must not disturb the
+ * tab strip). Only the ephemeral watch store is disposed + unsubscribed.
+ */
+ function unwatchConversation(conversationId: string): void {
+ watchedIds.delete(conversationId);
+ syncChatSubscriptions();
+ // A tab reuses its own store — leave it (and its subscription) intact.
+ if (chatStores.has(conversationId)) return;
+ const store = watchStores.get(conversationId);
+ if (store === undefined) return;
+ store.dispose();
+ watchStores.delete(conversationId);
+ loadedConversationIds.delete(conversationId);
+ unsubscribeChat(conversationId);
+ }
+
+ return {
+ cache,
+ chatStores,
+ watchStores,
+ loadedConversationIds,
+ createChatFor,
+ ensureLoaded,
+ getActiveChat,
+ refreshActiveChat,
+ subscribeChat,
+ unsubscribeChat,
+ reconnect(): void {
+ subscribedIds.clear();
+ syncChatSubscriptions();
+ },
+ watchConversation,
+ unwatchConversation,
+ get draftStore() {
+ return draftStore;
+ },
+ set draftStore(value: ChatStore) {
+ draftStore = value;
+ },
+ get draftConversationId() {
+ return draftConversationId;
+ },
+ set draftConversationId(value: string) {
+ draftConversationId = value;
+ },
+ get activeChat() {
+ return activeChat;
+ },
+ get chatLimit() {
+ return chatLimit;
+ },
+ async setChatLimit(limit: number): Promise<ChatLimitResult> {
+ const next = normalizeChatLimit(limit);
+ chatLimitStore.save(next);
+ chatLimit = next;
+ // Propagate to every live chat store. The ACTIVE one is awaited so its
+ // refill (on a raise) lands before the caller returns — letting the
+ // shell preserve scroll over the prepended older chunks. Background
+ // stores refill fire-and-forget. Future stores pick the new limit up at
+ // creation (via the persisted store).
+ const active = getActiveChat();
+ await active.setChatLimit(next);
+ for (const s of chatStores.values()) {
+ if (s !== active) void s.setChatLimit(next);
+ }
+ if (draftStore !== active) void draftStore.setChatLimit(next);
+ return { ok: true, chatLimit: next };
+ },
+ attachUnloadGate(gate: () => boolean): void {
+ unloadGate = gate;
+ },
+ dispose(): void {
+ for (const store of chatStores.values()) {
+ store.dispose();
+ }
+ chatStores.clear();
+ for (const store of watchStores.values()) {
+ store.dispose();
+ }
+ watchStores.clear();
+ loadedConversationIds.clear();
+ draftStore.dispose();
+ },
+ };
+}
+export type ChatRegistry = ReturnType<typeof createChatRegistry>;
diff --git a/src/app/conversation-settings.svelte.ts b/src/app/conversation-settings.svelte.ts
new file mode 100644
index 0000000..b853a8f
--- /dev/null
+++ b/src/app/conversation-settings.svelte.ts
@@ -0,0 +1,440 @@
+import type {
+ CompactPercentResponse,
+ ConversationComputerResponse,
+ CwdResponse,
+ LspStatusResponse,
+ McpStatusResponse,
+ ReasoningEffort,
+ ReasoningEffortResponse,
+ SetCompactPercentRequest,
+ SetConversationComputerRequest,
+ SetCwdRequest,
+ SetReasoningEffortRequest,
+ SetVisionSettingsRequest,
+} from "@dispatch/transport-contract";
+import { untrack } from "svelte";
+import type { SetThinkingRequest, ThinkingResponse } from "../features/chat/reasoning-effort";
+import {
+ normalizeVisionSettings,
+ type VisionSettings,
+ type VisionSettingsPatch,
+} from "../features/vision";
+
+import type {
+ CompactPercentResult,
+ ComputerResult,
+ CwdResult,
+ LspResult,
+ McpResult,
+ ReasoningEffortResult,
+ ThinkingResult,
+ VisionSettingsResult,
+} from "./store-types";
+
+/** Conversation settings keep their original reactive cells and response guards. */
+export function createConversationSettings(deps: {
+ httpBase: string;
+ fetchImpl: typeof fetch;
+ workspaceConversationId: () => string;
+ activeWorkspaceId: () => string;
+ reportError: (context: string, err: unknown) => void;
+}) {
+ const { httpBase, fetchImpl } = deps;
+ // The active conversation's persisted working directory (per-tab). Seeded from
+ // the backend on focus change; null for a draft / when unset.
+ let cwd = $state<string | null>(null);
+ let textFileContextRevision = $state(0);
+
+ /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */
+ async function refreshCwd(): Promise<void> {
+ textFileContextRevision++;
+ const id = deps.workspaceConversationId();
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`);
+ if (!res.ok) return;
+ const data = (await res.json()) as CwdResponse;
+ // Guard a slow response losing a race with a conversation switch.
+ if (deps.workspaceConversationId() === id) cwd = data.cwd ?? null;
+ } catch (err) {
+ deps.reportError("Failed to load working directory", err);
+ }
+ }
+
+ // The active conversation's persisted computer (SSH Host alias). Seeded on
+ // focus change; null = local / never set (inherits the workspace default).
+ let computerId = $state<string | null>(null);
+
+ /**
+ * Refetch the workspace conversation's persisted computer into reactive state
+ * (works for a draft too). A draft's id 404s until promoted; `res.ok` is false
+ * so it is a silent no-op (mirrors `refreshCwd` for a draft).
+ */
+ async function refreshComputer(): Promise<void> {
+ textFileContextRevision++;
+ const id = deps.workspaceConversationId();
+ // Clear immediately so a switch never shows the PREVIOUS conversation's
+ // computer while the fetch is in flight (null renders as "Local").
+ computerId = null;
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/computer`);
+ if (!res.ok) return;
+ const data = (await res.json()) as ConversationComputerResponse;
+ // Guard a slow response losing a race with a conversation switch.
+ if (deps.workspaceConversationId() === id) computerId = data.computerId ?? null;
+ } catch (err) {
+ deps.reportError("Failed to load computer", err);
+ }
+ }
+
+ // The workspace conversation's persisted reasoning effort. Seeded from the
+ // backend on focus change; null = never set (the server default applies).
+ let reasoningEffort = $state<ReasoningEffort | null>(null);
+
+ /** Refetch the workspace conversation's reasoning effort (works for a draft too). */
+ async function refreshReasoningEffort(): Promise<void> {
+ const id = deps.workspaceConversationId();
+ // Clear immediately so a switch never shows the PREVIOUS conversation's level
+ // while the fetch is in flight (null renders as the server default).
+ reasoningEffort = null;
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`,
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as ReasoningEffortResponse;
+ // Guard a slow response losing a race with a conversation switch.
+ if (deps.workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null;
+ } catch (err) {
+ deps.reportError("Failed to load reasoning effort", err);
+ }
+ }
+
+ // The workspace conversation's persisted thinking flag (SEPARATE from the
+ // effort level). Seeded from the backend on focus change; null = never set
+ // (thinking ON — the default). PROPOSED endpoint (see backend-handoff.md):
+ // a 404 (endpoint not yet shipped) leaves `thinking` null ⇒ ON (default), so
+ // the selector simply shows the effort level until the backend ships it.
+ let thinking = $state<boolean | null>(null);
+
+ /** Refetch the workspace conversation's thinking flag (works for a draft too). */
+ async function refreshThinking(): Promise<void> {
+ const id = deps.workspaceConversationId();
+ // Clear immediately so a switch never shows the PREVIOUS conversation's
+ // setting while the fetch is in flight (null ⇒ ON, the default).
+ thinking = null;
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/thinking`);
+ if (!res.ok) return;
+ // Guard against the SPA fallback: when DISPATCH_WEB_DIR is set (production),
+ // unknown API routes get index.html with HTTP 200 + text/html instead of a
+ // 404. Treat any non-JSON 200 as "endpoint not shipped" → leave null (ON).
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) return;
+ const data = (await res.json()) as ThinkingResponse;
+ // Guard a slow response losing a race with a conversation switch.
+ if (deps.workspaceConversationId() === id) thinking = data.thinking ?? null;
+ } catch (err) {
+ deps.reportError("Failed to load thinking setting", err);
+ }
+ }
+
+ // The workspace conversation's auto-compact percent. Seeded from the
+ // backend on focus change; null = not yet fetched. 0 = disabled.
+ let compactPercent = $state<number | null>(null);
+
+ // The GLOBAL vision settings (shared across all conversations). Seeded on
+ // boot; null = not yet fetched.
+ let visionSettings = $state<VisionSettings | null>(null);
+
+ /** Refetch the global vision settings (`GET /settings/vision`). */
+ async function refreshVisionSettings(): Promise<void> {
+ try {
+ const res = await fetchImpl(`${httpBase}/settings/vision`);
+ if (!res.ok) return;
+ const data = normalizeVisionSettings(await res.json());
+ visionSettings = data;
+ } catch (err) {
+ deps.reportError("Failed to load vision settings", err);
+ }
+ }
+
+ /** Refetch the workspace conversation's compact percent (works for a draft too). */
+ async function refreshCompactPercent(): Promise<void> {
+ const id = deps.workspaceConversationId();
+ compactPercent = null;
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`,
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as CompactPercentResponse;
+ if (deps.workspaceConversationId() === id) compactPercent = data.threshold;
+ } catch (err) {
+ deps.reportError("Failed to load compact percent", err);
+ }
+ }
+ return {
+ refreshCwd,
+ refreshComputer,
+ refreshReasoningEffort,
+ refreshThinking,
+ refreshCompactPercent,
+ refreshVisionSettings,
+ get textFileContextRevision() {
+ return textFileContextRevision;
+ },
+ get cwd() {
+ return cwd;
+ },
+ get computerId() {
+ return computerId;
+ },
+ get reasoningEffort() {
+ return reasoningEffort;
+ },
+ get thinking() {
+ return thinking;
+ },
+ get compactPercent() {
+ return compactPercent;
+ },
+ get visionSettings() {
+ return visionSettings;
+ },
+ async setCwd(value: string): Promise<CwdResult | null> {
+ const id = deps.workspaceConversationId();
+ const body: SetCwdRequest = {
+ cwd: value,
+ workspaceId: untrack(deps.activeWorkspaceId),
+ };
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: errBody?.error ?? `Set cwd failed (HTTP ${res.status})` };
+ }
+ const data = (await res.json()) as CwdResponse;
+ const next = data.cwd ?? null;
+ if (deps.workspaceConversationId() === id) {
+ cwd = next;
+ textFileContextRevision++;
+ }
+ return { ok: true, cwd: next };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" };
+ }
+ },
+
+ async setComputer(computerIdValue: string | null): Promise<ComputerResult | null> {
+ const id = deps.workspaceConversationId();
+ const body: SetConversationComputerRequest = { computerId: computerIdValue };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/computer`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set computer failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as ConversationComputerResponse;
+ const next = data.computerId ?? null;
+ if (deps.workspaceConversationId() === id) {
+ computerId = next;
+ textFileContextRevision++;
+ }
+ return { ok: true, computerId: next };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set computer request failed",
+ };
+ }
+ },
+
+ async setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null> {
+ const id = deps.workspaceConversationId();
+ const body: SetReasoningEffortRequest = { reasoningEffort: level };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set reasoning effort failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as ReasoningEffortResponse;
+ const next = data.reasoningEffort ?? level;
+ if (deps.workspaceConversationId() === id) reasoningEffort = next;
+ return { ok: true, reasoningEffort: next };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set reasoning effort request failed",
+ };
+ }
+ },
+
+ async setThinking(enabled: boolean): Promise<ThinkingResult | null> {
+ const id = deps.workspaceConversationId();
+ const body: SetThinkingRequest = { thinking: enabled };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/thinking`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set thinking failed (HTTP ${res.status})`,
+ };
+ }
+ // Guard against the SPA fallback (200 + text/html when the endpoint
+ // is not shipped). Optimistically apply locally — the backend can't
+ // persist it, but the UI reflects the user's intent for this session.
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) {
+ if (deps.workspaceConversationId() === id) thinking = enabled;
+ return { ok: true, thinking: enabled };
+ }
+ const data = (await res.json()) as ThinkingResponse;
+ const next = data.thinking ?? enabled;
+ if (deps.workspaceConversationId() === id) thinking = next;
+ return { ok: true, thinking: next };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set thinking request failed",
+ };
+ }
+ },
+ async setCompactPercent(percent: number): Promise<CompactPercentResult | null> {
+ const id = deps.workspaceConversationId();
+ const body: SetCompactPercentRequest = { threshold: percent };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set compact percent failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as CompactPercentResponse;
+ if (deps.workspaceConversationId() === id) compactPercent = data.threshold;
+ return { ok: true, percent: data.threshold };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set compact percent request failed",
+ };
+ }
+ },
+
+ async setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | null> {
+ const body: SetVisionSettingsRequest = patch;
+ try {
+ const res = await fetchImpl(`${httpBase}/settings/vision`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set vision settings failed (HTTP ${res.status})`,
+ };
+ }
+ const data = normalizeVisionSettings(await res.json());
+ visionSettings = data;
+ return { ok: true, settings: data };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set vision settings request failed",
+ };
+ }
+ },
+ async lspStatus(): Promise<LspResult | null> {
+ const id = deps.workspaceConversationId();
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/lsp`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: errBody?.error ?? `LSP status failed (HTTP ${res.status})` };
+ }
+ // Normalize the untyped body at this network seam so a malformed/partial
+ // response can never crash the renderer (servers is guaranteed an array).
+ const data = (await res.json()) as Partial<LspStatusResponse>;
+ const response: LspStatusResponse = {
+ conversationId: data.conversationId ?? id,
+ cwd: data.cwd ?? null,
+ servers: Array.isArray(data.servers) ? data.servers : [],
+ };
+ return { ok: true, response };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "LSP status request failed",
+ };
+ }
+ },
+
+ async mcpStatus(): Promise<McpResult | null> {
+ const id = deps.workspaceConversationId();
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/mcp`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: errBody?.error ?? `MCP status failed (HTTP ${res.status})` };
+ }
+ // Normalize the untyped body at this network seam so a malformed/partial
+ // response can never crash the renderer (servers is guaranteed an array).
+ const data = (await res.json()) as Partial<McpStatusResponse>;
+ const response: McpStatusResponse = {
+ conversationId: data.conversationId ?? id,
+ cwd: data.cwd ?? null,
+ servers: Array.isArray(data.servers) ? data.servers : [],
+ };
+ return { ok: true, response };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "MCP status request failed",
+ };
+ }
+ },
+ };
+}
diff --git a/src/app/history-sync.ts b/src/app/history-sync.ts
new file mode 100644
index 0000000..ed5d889
--- /dev/null
+++ b/src/app/history-sync.ts
@@ -0,0 +1,38 @@
+import type {
+ ConversationHistoryResponse,
+ ConversationMetricsResponse,
+} from "@dispatch/transport-contract";
+import type { HistorySync, MetricsSync } from "../features/chat";
+
+export function createHistorySync(httpBase: string, fetchImpl: typeof fetch): HistorySync {
+ return async (conversationId, sinceSeq, window) => {
+ let url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}?sinceSeq=${sinceSeq}`;
+ // CR-5 windowing ([email protected]): both must be positive
+ // integers when present (the server 400s otherwise; callers guarantee it).
+ if (window?.limit !== undefined) url += `&limit=${window.limit}`;
+ if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`;
+ const res = await fetchImpl(url);
+ if (!res.ok) {
+ throw new Error(`History sync failed: ${res.status}`);
+ }
+ const data = (await res.json()) as ConversationHistoryResponse;
+ if (
+ !data ||
+ !Array.isArray(data.chunks) ||
+ !Number.isSafeInteger(data.historyEpoch) ||
+ data.historyEpoch < 0
+ ) {
+ throw new Error("Invalid history response");
+ }
+ return data;
+ };
+}
+
+export function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSync {
+ return async (conversationId: string) => {
+ const url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}/metrics`;
+ const res = await fetchImpl(url);
+ if (!res.ok) return { turns: [] };
+ return (await res.json()) as ConversationMetricsResponse;
+ };
+}
diff --git a/src/app/http-actions.ts b/src/app/http-actions.ts
new file mode 100644
index 0000000..55407b4
--- /dev/null
+++ b/src/app/http-actions.ts
@@ -0,0 +1,638 @@
+import type {
+ CompactResponse,
+ ComputerStatusResponse,
+ SetSystemPromptTemplateRequest,
+ SystemPromptTemplateResponse,
+ SystemPromptVariablesResponse,
+ TestComputerResponse,
+ WarmRequest,
+ WarmResponse,
+} from "@dispatch/transport-contract";
+import { untrack } from "svelte";
+import type {
+ AccountUsageProviderId,
+ AccountUsageResult,
+ AccountUsageSettingsResult,
+ UpdateAccountUsageSettingsRequest,
+} from "../features/account-usage";
+import { normalizeAccountUsage, normalizeAccountUsageReport } from "../features/account-usage";
+import type {
+ ConcurrencyCooldownResult,
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusResult,
+} from "../features/concurrency";
+import {
+ normalizeConcurrencyCooldown,
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+} from "../features/concurrency";
+import type {
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatRun,
+ HeartbeatRunsResult,
+ HeartbeatStopResult,
+} from "../features/heartbeat";
+import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat";
+import {
+ normalizeSearchBackendConfig,
+ type SearchBackendConfigResult,
+} from "../features/search-settings";
+
+import type {
+ CompactResult,
+ ComputerStatusResult,
+ SystemPromptLoadResult,
+ SystemPromptSaveResult,
+ SystemPromptVariablesResult,
+ TestComputerResult,
+ WarmResult,
+} from "./store-types";
+
+/** Stateless app HTTP actions; callers inject the current focus/workspace. */
+export function createHttpActions(deps: {
+ httpBase: string;
+ fetchImpl: typeof fetch;
+ activeConversationId: () => string | null;
+ activeModel: () => string;
+ activeWorkspaceId: () => string;
+ reportError: (context: string, err: unknown) => void;
+}) {
+ const { httpBase, fetchImpl } = deps;
+ return {
+ async warmNow(): Promise<WarmResult | null> {
+ const conversationId = deps.activeConversationId();
+ if (conversationId === null) return null;
+ const body: WarmRequest = { conversationId, model: deps.activeModel() };
+ try {
+ const res = await fetchImpl(`${httpBase}/chat/warm`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: errBody?.error ?? `Warm failed (HTTP ${res.status})` };
+ }
+ return { ok: true, response: (await res.json()) as WarmResponse };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" };
+ }
+ },
+ async computerStatus(alias: string): Promise<ComputerStatusResult | null> {
+ if (alias === "") return null;
+ try {
+ const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/status`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Computer status failed (HTTP ${res.status})`,
+ };
+ }
+ const status = (await res.json()) as ComputerStatusResponse;
+ return { ok: true, response: status };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Computer status request failed",
+ };
+ }
+ },
+
+ async testComputer(alias: string): Promise<TestComputerResult | null> {
+ if (alias === "") return null;
+ try {
+ const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/test`, {
+ method: "POST",
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Test computer failed (HTTP ${res.status})`,
+ };
+ }
+ const response = (await res.json()) as TestComputerResponse;
+ return { ok: true, response };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Test computer request failed",
+ };
+ }
+ },
+ stopGeneration(): void {
+ const conversationId = deps.activeConversationId();
+ if (conversationId === null) return;
+ void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, {
+ method: "POST",
+ }).catch((err) => {
+ deps.reportError("Failed to stop generation", err);
+ });
+ },
+
+ async compactNow(keepLastN?: number): Promise<CompactResult | null> {
+ const conversationId = deps.activeConversationId();
+ if (conversationId === null) return null;
+ const body: Record<string, unknown> = {};
+ if (keepLastN !== undefined) body.keepLastN = keepLastN;
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(conversationId)}/compact`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Compact failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as CompactResponse;
+ return { ok: true, response: data };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Compact request failed",
+ };
+ }
+ },
+ async heartbeatConfig(): Promise<HeartbeatConfigResult> {
+ // Workspace-scoped (NOT per-conversation): use the active workspace id.
+ const wsId = untrack(deps.activeWorkspaceId);
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat config failed (HTTP ${res.status})`,
+ };
+ }
+ // Normalize the untyped JSON at the network seam (pure helper) so a
+ // malformed/partial response can never crash the renderer.
+ const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat config request failed",
+ };
+ }
+ },
+
+ async setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult> {
+ const wsId = untrack(deps.activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(patch),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set heartbeat config failed (HTTP ${res.status})`,
+ };
+ }
+ const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set heartbeat config request failed",
+ };
+ }
+ },
+
+ async heartbeatRuns(): Promise<HeartbeatRunsResult> {
+ const wsId = untrack(deps.activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat runs failed (HTTP ${res.status})`,
+ };
+ }
+ const runs: readonly HeartbeatRun[] = normalizeHeartbeatRuns(await res.json());
+ return { ok: true, runs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat runs request failed",
+ };
+ }
+ },
+
+ async stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
+ const wsId = untrack(deps.activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs/${encodeURIComponent(runId)}/stop`,
+ { method: "POST" },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Stop heartbeat run failed (HTTP ${res.status})`,
+ };
+ }
+ return { ok: true };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Stop heartbeat run request failed",
+ };
+ }
+ },
+
+ async heartbeatNextRun(): Promise<HeartbeatNextRunResult> {
+ const wsId = untrack(deps.activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/next-run`,
+ );
+ if (!res.ok) {
+ // 404 = the backend hasn't shipped CR-HB-3 yet → the FE falls back to
+ // an approximation. Surface as ok:false (non-fatal).
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat next-run failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json().catch(() => null)) as { nextRunAt?: string | null } | null;
+ // `null` (disabled / no run scheduled) passes through; anything non-string
+ // also becomes null so a malformed body can't crash the countdown.
+ const raw = data?.nextRunAt;
+ const nextRunAt = typeof raw === "string" ? raw : null;
+ return { ok: true, nextRunAt };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat next-run request failed",
+ };
+ }
+ },
+ async concurrencyLimits(): Promise<ConcurrencyLimitsResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/concurrency/limits`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency limits failed (HTTP ${res.status})`,
+ };
+ }
+ // Normalize the untyped JSON at the network seam (pure helper) so a
+ // malformed/partial response (e.g. the extension returning `{}`) can
+ // never crash the renderer.
+ const limits = normalizeConcurrencyLimits(await res.json());
+ return { ok: true, limits };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency limits request failed",
+ };
+ }
+ },
+
+ async getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ const limit = normalizeConcurrencyLimit(await res.json());
+ if (limit === null) {
+ return { ok: false, error: "Malformed concurrency limit response" };
+ }
+ return { ok: true, providerId: limit.providerId, limit: limit.limit };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency limit request failed",
+ };
+ }
+ },
+
+ async setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ limit }),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ const echoed = normalizeConcurrencyLimit(await res.json());
+ if (echoed === null) {
+ return { ok: false, error: "Malformed concurrency limit response" };
+ }
+ return { ok: true, providerId: echoed.providerId, limit: echoed.limit };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set concurrency limit request failed",
+ };
+ }
+ },
+
+ async deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ { method: "DELETE" },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Delete concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ return { ok: true, providerId };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Delete concurrency limit request failed",
+ };
+ }
+ },
+
+ async accountUsage(opts?: { readonly refresh?: boolean }): Promise<AccountUsageResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/usage${opts?.refresh ? "?refresh=1" : ""}`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Account usage failed (HTTP ${res.status})`,
+ ...(res.status === 503 ? { unavailable: true } : {}),
+ };
+ }
+ return { ok: true, usage: normalizeAccountUsage(await res.json(), Date.now()) };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Account usage request failed",
+ };
+ }
+ },
+
+ async setAccountUsageSettings(
+ provider: AccountUsageProviderId,
+ settings: UpdateAccountUsageSettingsRequest,
+ ): Promise<AccountUsageSettingsResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/usage/${encodeURIComponent(provider)}/settings`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(settings),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Save usage settings failed (HTTP ${res.status})`,
+ };
+ }
+ const report = normalizeAccountUsageReport(await res.json());
+ return report
+ ? { ok: true, report }
+ : { ok: false, error: "Malformed usage settings response" };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Save usage settings request failed",
+ };
+ }
+ },
+
+ async concurrencyStatus(): Promise<ConcurrencyStatusResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/concurrency/status`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency status failed (HTTP ${res.status})`,
+ };
+ }
+ const providers = normalizeConcurrencyStatus(await res.json());
+ return { ok: true, providers };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency status request failed",
+ };
+ }
+ },
+
+ async searchBackendConfig(): Promise<SearchBackendConfigResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/search/config`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Search config failed (HTTP ${res.status})`,
+ // The 503 degrade = the backend's search-config service not loaded
+ // (the tool-web-search extension absent) — a distinct UI state.
+ ...(res.status === 503 ? { unavailable: true } : {}),
+ };
+ }
+ // Normalize the untyped JSON at this network seam (pure helper) so a
+ // malformed/partial response can never crash the renderer.
+ const config = normalizeSearchBackendConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Search config request failed",
+ };
+ }
+ },
+
+ async setSearchBackend(backend: string): Promise<SearchBackendConfigResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/search/config`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ backend }),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set search backend failed (HTTP ${res.status})`,
+ ...(res.status === 503 ? { unavailable: true } : {}),
+ };
+ }
+ const config = normalizeSearchBackendConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set search backend request failed",
+ };
+ }
+ },
+
+ async getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Get concurrency cooldown failed (HTTP ${res.status})`,
+ };
+ }
+ const data = normalizeConcurrencyCooldown(await res.json());
+ if (data === null) {
+ return { ok: false, error: "Malformed concurrency cooldown response" };
+ }
+ return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Get concurrency cooldown request failed",
+ };
+ }
+ },
+
+ async setConcurrencyCooldown(
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ cooldownMs }),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set concurrency cooldown failed (HTTP ${res.status})`,
+ };
+ }
+ const data = normalizeConcurrencyCooldown(await res.json());
+ if (data === null) {
+ return { ok: false, error: "Malformed concurrency cooldown response" };
+ }
+ return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set concurrency cooldown request failed",
+ };
+ }
+ },
+
+ async loadSystemPrompt(): Promise<SystemPromptLoadResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/system-prompt`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Load system prompt failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as SystemPromptTemplateResponse;
+ return { ok: true, template: data.template ?? "" };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Load system prompt request failed",
+ };
+ }
+ },
+
+ async setSystemPrompt(template: string): Promise<SystemPromptSaveResult> {
+ try {
+ const body: SetSystemPromptTemplateRequest = { template };
+ const res = await fetchImpl(`${httpBase}/system-prompt`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set system prompt failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as SystemPromptTemplateResponse;
+ return { ok: true, template: data.template };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set system prompt request failed",
+ };
+ }
+ },
+
+ async loadSystemPromptVariables(): Promise<SystemPromptVariablesResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/system-prompt/variables`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Load system prompt variables failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as Partial<SystemPromptVariablesResponse>;
+ return { ok: true, variables: Array.isArray(data.variables) ? data.variables : [] };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Load system prompt variables request failed",
+ };
+ }
+ },
+ };
+}
diff --git a/src/app/open-conversations.svelte.ts b/src/app/open-conversations.svelte.ts
new file mode 100644
index 0000000..9c8c25b
--- /dev/null
+++ b/src/app/open-conversations.svelte.ts
@@ -0,0 +1,288 @@
+import type {
+ ConversationListResponse,
+ SetTabGroupRequest,
+ SetTitleRequest,
+ TabGroupResponse,
+} from "@dispatch/transport-contract";
+import type { ConversationStatus } from "@dispatch/wire";
+import type { ConversationCache } from "../features/conversation-cache";
+import type { TabsStore } from "../features/tabs";
+
+import type { ChatRegistry } from "./chat-registry.svelte";
+import type { createConversationSettings } from "./conversation-settings.svelte";
+
+/** Cross-device tab discovery/reconciliation and backend lifecycle status. */
+export function createOpenConversations(deps: {
+ httpBase: string;
+ fetchImpl: typeof fetch;
+ tabsStore: TabsStore;
+ registry: ChatRegistry;
+ cache: ConversationCache;
+ activeModel: () => string;
+ settings: ReturnType<typeof createConversationSettings>;
+ syncSubscriptions: () => void;
+ reportError: (context: string, err: unknown) => void;
+}) {
+ const { httpBase, fetchImpl, tabsStore, registry, cache } = deps;
+ /**
+ * Tell the backend the user EXPLICITLY closed this conversation's tab
+ * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with
+ * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF).
+ * Distinct from a disconnect / `chat.unsubscribe`, which deliberately leave
+ * both running. Fire-and-forget: a failure is non-fatal (worst case the
+ * warming keeps running until a later close/toggle), and the endpoint is
+ * idempotent server-side.
+ */
+ function closeConversation(conversationId: string): void {
+ void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, {
+ method: "POST",
+ }).catch((err) => {
+ deps.reportError("Failed to close conversation", err);
+ });
+ }
+
+ /**
+ * Open a conversation tab — used by the `conversation.open` WS broadcast
+ * (CLI `--open` flag) and by `conversation.statusChanged` when a new active
+ * conversation is discovered. If the conversation is already open, this is a
+ * no-op; otherwise create a chat store, subscribe to its live
+ * turns, and add the tab WITHOUT switching the active conversation (the user
+ * stays on their current tab; the new tab appears in the strip). The tab is
+ * stamped with the conversation's actual `workspaceId`, NOT the viewer's
+ * currently active workspace.
+ */
+ function openConversation(
+ conversationId: string,
+ workspaceId: string,
+ model?: string,
+ tabGroupParentId?: string,
+ ): void {
+ if (registry.chatStores.has(conversationId)) return;
+ // Stamp the tab with the conversation's PERSISTED model when the open
+ // broadcast carries it (falling back to the current global selection) —
+ // never let the globally-active model overwrite a conversation's real one
+ // (fix-toolcall-duplicate-keys plan Part 4).
+ const tabModel = model ?? deps.activeModel();
+ const store =
+ registry.watchStores.get(conversationId) ??
+ registry.createChatFor(conversationId, tabModel, workspaceId);
+ registry.watchStores.delete(conversationId);
+ store.setModel(tabModel);
+ registry.chatStores.set(conversationId, store);
+ registry.subscribeChat(conversationId);
+ tabsStore.openTab({
+ conversationId,
+ model: tabModel,
+ title: "Conversation",
+ workspaceId,
+ ...(tabGroupParentId !== undefined ? { tabGroupParentId } : {}),
+ });
+ }
+
+ /**
+ * Remove a tab + its chat store locally (NO `POST /close` — used when the
+ * backend already marked the conversation `closed` via `conversation.statusChanged`).
+ */
+ function removeTabLocally(conversationId: string): void {
+ registry.unsubscribeChat(conversationId);
+ const store = registry.chatStores.get(conversationId);
+ if (store !== undefined) {
+ store.dispose();
+ registry.chatStores.delete(conversationId);
+ }
+ registry.loadedConversationIds.delete(conversationId);
+ void cache.delete(conversationId);
+ tabsStore.closeTab(conversationId);
+ conversationStatuses.delete(conversationId);
+ registry.refreshActiveChat();
+ deps.syncSubscriptions();
+ void deps.settings.refreshCwd();
+ void deps.settings.refreshComputer();
+ void deps.settings.refreshReasoningEffort();
+ void deps.settings.refreshThinking();
+ void deps.settings.refreshCompactPercent();
+ }
+
+ // Conversation lifecycle status (backend-owned, pushed via WS +
+ // fetched on connect). Keyed by conversationId.
+ let conversationStatuses = $state<Map<string, ConversationStatus>>(new Map());
+
+ // The set of workspaces with ≥1 active/queued conversation, derived ONCE
+ // (not recomputed per card). Every active/queued conversation has an open
+ // tab stamped with its workspace, so the tabs are the conversation→workspace
+ // map; cross-reference with the lifecycle statuses. `$derived` recomputes
+ // lazily when the tab set or status map changes, so each
+ // `workspaceHasActiveConversations` call is an O(1) lookup instead of a scan
+ // of the full tab list per card.
+ const activeWorkspaces = $derived.by(() => {
+ const out = new Set<string>();
+ for (const tab of tabsStore.tabs) {
+ const status = conversationStatuses.get(tab.conversationId);
+ if (status === "active" || status === "queued") out.add(tab.workspaceId);
+ }
+ return out;
+ });
+
+ /**
+ * Fetch `GET /conversations?status=active,idle` on connect to restore the
+ * tab bar across devices. Merges: opens tabs for conversations not already
+ * open, removes tabs for conversations that are no longer active/idle
+ * (closed on another device), and subscribes to `active` conversations'
+ * live streams.
+ */
+ let pendingFetch: Promise<void> | undefined;
+ function fetchOpenConversations(): Promise<void> {
+ pendingFetch ??= loadOpenConversations().finally(() => {
+ pendingFetch = undefined;
+ });
+ return pendingFetch;
+ }
+
+ async function loadOpenConversations(): Promise<void> {
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`);
+ if (!res.ok) return;
+ const data = (await res.json()) as ConversationListResponse;
+
+ if (!data || !Array.isArray(data.conversations)) {
+ deps.reportError(
+ "Failed to load open conversations",
+ new Error("Invalid conversations response"),
+ );
+ return;
+ }
+ // Update the status map from the authoritative backend list.
+ const newStatuses = new Map<string, ConversationStatus>();
+ for (const conv of data.conversations) {
+ newStatuses.set(conv.id, conv.status);
+ }
+ conversationStatuses = newStatuses;
+
+ // Open tabs for conversations not already open.
+ const existingIds = new Set(registry.chatStores.keys());
+ for (const conv of data.conversations) {
+ if (!existingIds.has(conv.id)) {
+ // Stamp the tab with the conversation's PERSISTED model (when the
+ // list carries it) instead of the global active model — the fix for
+ // tabs showing/overwriting the wrong model
+ // (fix-toolcall-duplicate-keys plan Part 4).
+ const model = conv.model ?? deps.activeModel();
+ const store =
+ registry.watchStores.get(conv.id) ??
+ registry.createChatFor(conv.id, model, conv.workspaceId);
+ registry.watchStores.delete(conv.id);
+ store.setModel(model);
+ registry.chatStores.set(conv.id, store);
+ // Seed the cache timer's turn gap (only an idle conversation is waiting on the user).
+ if (conv.status === "idle") store.noteLastActivity(conv.lastActivityAt);
+ registry.subscribeChat(conv.id);
+ tabsStore.openTab({
+ conversationId: conv.id,
+ model,
+ title: conv.title,
+ workspaceId: conv.workspaceId,
+ ...(conv.tabGroupParentId !== undefined
+ ? { tabGroupParentId: conv.tabGroupParentId }
+ : {}),
+ });
+ } else {
+ // Already open — reconcile title + workspace from the authoritative
+ // backend list. A raced `conversation.open` (summon) may have stamped
+ // the tab with the wrong workspace ("default"); the backend's
+ // authoritative conversation record is the source of truth, so a
+ // differing tab workspace is corrected here.
+ tabsStore.setTitle(conv.id, conv.title);
+ // Seed the cache timer's turn gap from the backend (a tab restored
+ // from localStorage at boot has no live `done` to go by). Only an
+ // idle conversation is waiting on the user.
+ if (conv.status === "idle")
+ registry.chatStores.get(conv.id)?.noteLastActivity(conv.lastActivityAt);
+ const tab = tabsStore.tabs.find((t) => t.conversationId === conv.id);
+ if (tab?.workspaceId !== conv.workspaceId) {
+ tabsStore.setWorkspaceId(conv.id, conv.workspaceId);
+ }
+ // The backend's EFFECTIVE tab group parent is authoritative too.
+ if (tab?.tabGroupParentId !== conv.tabGroupParentId) {
+ tabsStore.setTabGroupParent(conv.id, conv.tabGroupParentId ?? null);
+ }
+ }
+ }
+
+ // Remove tabs for conversations no longer active/idle (closed elsewhere).
+ const backendIds = new Set(data.conversations.map((c) => c.id));
+ for (const tab of tabsStore.tabs) {
+ if (
+ !backendIds.has(tab.conversationId) &&
+ !registry.chatStores.get(tab.conversationId)?.pendingInputs.length
+ ) {
+ removeTabLocally(tab.conversationId);
+ }
+ }
+ registry.refreshActiveChat();
+ } catch (err) {
+ deps.reportError(
+ `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`,
+ err,
+ );
+ }
+ }
+
+ return {
+ closeConversation,
+ openConversation,
+ removeTabLocally,
+ fetchOpenConversations,
+ renameTab(conversationId: string, title: string): void {
+ tabsStore.setTitle(conversationId, title);
+ void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/title`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title } satisfies SetTitleRequest),
+ }).catch((err) => {
+ deps.reportError("Failed to rename conversation", err);
+ });
+ },
+
+ setTabGroup(conversationId: string, parentId: string | null): void {
+ const previous =
+ tabsStore.tabs.find((t) => t.conversationId === conversationId)?.tabGroupParentId ?? null;
+ tabsStore.setTabGroupParent(conversationId, parentId);
+ void (async () => {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(conversationId)}/tab-group`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ parentId } satisfies SetTabGroupRequest),
+ },
+ );
+ if (!res.ok) {
+ const err = (await res.json().catch(() => null)) as { error?: string } | null;
+ throw new Error(err?.error ?? `HTTP ${res.status}`);
+ }
+ const body = (await res.json()) as TabGroupResponse;
+ tabsStore.setTabGroupParent(conversationId, body.tabGroupParentId);
+ } catch (err) {
+ // Revert the optimistic move, then surface the failure.
+ tabsStore.setTabGroupParent(conversationId, previous);
+ deps.reportError("Failed to update the tab's subagent group", err);
+ }
+ })();
+ },
+
+ get conversationStatuses() {
+ return conversationStatuses;
+ },
+ set conversationStatuses(value: Map<string, ConversationStatus>) {
+ conversationStatuses = value;
+ },
+ conversationStatus(conversationId: string): ConversationStatus | undefined {
+ return conversationStatuses.get(conversationId);
+ },
+ workspaceHasActiveConversations(workspaceId: string): boolean {
+ return activeWorkspaces.has(workspaceId);
+ },
+ };
+}
+export type OpenConversations = ReturnType<typeof createOpenConversations>;
diff --git a/src/app/plugin-host.svelte.ts b/src/app/plugin-host.svelte.ts
new file mode 100644
index 0000000..8054005
--- /dev/null
+++ b/src/app/plugin-host.svelte.ts
@@ -0,0 +1,151 @@
+/**
+ * The frontend-plugin HOST wiring (plan 03 §3.2) — the composition-root glue
+ * between the ABI driver (`core/plugin-abi`), the view registry, the
+ * surfaces ports, and the plugins HTTP edge.
+ *
+ * The sync triggers (boot, `extensions-changed`, `workspace.set` → the app
+ * store calls `sync()` on exactly those events) each fetch the plugin list +
+ * the ACTIVE workspace's enablement rows, derive the desired candidate set
+ * (pure), and run one driver pass. The driver's effects are injected HERE at
+ * the edge:
+ * - `importModule` — the dynamic `import()` (the only module-system touch);
+ * - `buildHostApi` — the per-plugin `FrontendHostAPI` with FORCED pluginId
+ * attribution into the layout registry, app-store-backed surfaces ports,
+ * and a plugin-prefixed logger;
+ * - `unregisterViewsByPlugin` — the registry's unload sweep.
+ *
+ * After every pass the per-plugin FE states are reported to the plugins
+ * store (the manager UI renders "incompatible frontend plugin" and
+ * activation failures).
+ */
+import {
+ createFrontendDriver,
+ FRONTEND_ABI_VERSION,
+ type FrontendDriver,
+ type FrontendHostAPI,
+ type FrontendPluginState,
+ frontendCandidates,
+ usablePluginIds,
+} from "../core/plugin-abi";
+import {
+ type LayoutAbiPort,
+ registerPluginView,
+ unregisterViewsByPlugin,
+ type ViewComponent,
+} from "../features/layout";
+import type { PluginsHttp } from "../features/plugins";
+
+/** The surfaces ports the ABI hands plugin views — backed by the app store. */
+export interface FrontendSurfacesPorts {
+ readonly subscribe: (surfaceId: string, conversationId?: string) => void;
+ readonly unsubscribe: (surfaceId: string, conversationId?: string) => void;
+ readonly invoke: (
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+ conversationId?: string,
+ ) => void;
+ readonly getSpec: (surfaceId: string) => unknown;
+}
+
+/** The shape of the plugins store the FE states are reported to. */
+export interface FrontendStateReporter {
+ setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void;
+}
+
+export interface PluginHostOptions {
+ readonly http: PluginsHttp;
+ /** The backend HTTP base (import-URL origin). */
+ readonly httpBase: string;
+ readonly reporter: FrontendStateReporter;
+ /** The ACTIVE workspace provider (read per sync — workspace.set trigger). */
+ readonly activeWorkspaceId: () => string;
+ readonly surfaces: FrontendSurfacesPorts;
+ /**
+ * The layout port (plan 04 §4.5) — the ABI's `api.layout` (applyOps to the
+ * ACTIVE workspace's provider + getTree, null in no-provider mode). Built
+ * by the composition root over the layout store + ops HTTP edge.
+ */
+ readonly layout: LayoutAbiPort;
+}
+
+export interface PluginHost {
+ /**
+ * Run one sync pass (all three triggers share it — the active workspace
+ * is read per call, which is what makes `workspace.set` a distinct
+ * trigger). Never throws: a failed list fetch keeps the current set.
+ */
+ sync(): Promise<void>;
+}
+
+export function createPluginHost(opts: PluginHostOptions): PluginHost {
+ const driver: FrontendDriver = createFrontendDriver({
+ importModule: (url) => import(/* @vite-ignore */ url),
+ buildHostApi: (pluginId) => buildHostApi(pluginId),
+ unregisterViewsByPlugin,
+ logger: console,
+ });
+
+ function buildHostApi(pluginId: string): FrontendHostAPI {
+ return {
+ abiVersion: FRONTEND_ABI_VERSION,
+ registerView: (reg) =>
+ registerPluginView(
+ {
+ id: reg.id,
+ label: reg.label,
+ // ONE controlled cast at the ABI seam — the registry is a
+ // heterogeneous component bag (register-views.ts precedent);
+ // the plugin's own build already vetted its component.
+ component: reg.component as ViewComponent,
+ ...(reg.panelEligible !== undefined ? { panelEligible: reg.panelEligible } : {}),
+ },
+ pluginId,
+ ),
+ surfaces: {
+ subscribe: (surfaceId, conversationId) =>
+ opts.surfaces.subscribe(surfaceId, conversationId),
+ unsubscribe: (surfaceId, conversationId) =>
+ opts.surfaces.unsubscribe(surfaceId, conversationId),
+ invoke: (surfaceId, actionId, payload, conversationId) =>
+ opts.surfaces.invoke(surfaceId, actionId, payload, conversationId),
+ getSpec: (surfaceId) => opts.surfaces.getSpec(surfaceId) as never,
+ },
+ logger: {
+ info: (msg) => console.info(`[frontend-plugin:${pluginId}] ${msg}`),
+ warn: (msg) => console.warn(`[frontend-plugin:${pluginId}] ${msg}`),
+ },
+ // plan 04 §4.5: the layout port — the composition-root-built adapter
+ // over the layout store + the ops HTTP edge (the plugin never sees
+ // either; only this typed port).
+ layout: opts.layout,
+ };
+ }
+
+ return {
+ async sync(): Promise<void> {
+ // Both reads are fail-soft (null on network failure) — a down backend
+ // keeps the CURRENT imported set untouched (no unload on a flaky
+ // fetch, and the manager UI's last-known states persist).
+ const workspaceId = opts.activeWorkspaceId();
+ const [list, workspace] = await Promise.all([
+ opts.http.list(),
+ opts.http.workspacePlugins(workspaceId),
+ ]);
+ if (list === null) return;
+ // Shape guard at the outermost edge (asymmetric testing: the shell
+ // guards, the pure diff stays trusting): a malformed-but-200
+ // `/plugins` response (wrong shape, contract drift, a test fake that
+ // doesn't model the route) must fail SOFT exactly like `null` — keep
+ // the current imported set, never throw from the fire-and-forget sync.
+ if (!Array.isArray(list.loaded)) return;
+ const candidates = frontendCandidates(list, {
+ workspaceId,
+ usableIds: usablePluginIds(workspace?.plugins ?? []),
+ httpBase: opts.httpBase,
+ });
+ await driver.sync(candidates);
+ opts.reporter.setFrontendStates(driver.states());
+ },
+ };
+}
diff --git a/src/app/register-views.ts b/src/app/register-views.ts
new file mode 100644
index 0000000..05afd89
--- /dev/null
+++ b/src/app/register-views.ts
@@ -0,0 +1,81 @@
+/**
+ * Built-in view registration — the composition root's view vocabulary.
+ *
+ * One thin wrapper component per existing view (src/app/views/*.svelte), each
+ * closing over exactly the props/ports App.svelte's `viewContent` snippet used
+ * to pass — INCLUDING each view's `{#key}` remount semantics (moved INSIDE the
+ * wrappers). Registry ids match the old `viewKinds` ids exactly: no
+ * user-facing renaming, no persistence-key changes.
+ *
+ * `chat` / `composer` / `message-queue` are CONTENT views (decision 5): they
+ * register with `panelEligible: false` so they never appear in the panel
+ * dropdowns. All 15 sidebar views keep the eligible default.
+ *
+ * Imported ONCE by App.svelte's module script — before any tile renders.
+ */
+
+import type { Component } from "svelte";
+import { registerView, type ViewComponent, type ViewRegistration } from "../features/layout";
+import type { AppViewPorts } from "./view-ports";
+import AccountUsagePane from "./views/AccountUsagePane.svelte";
+import CacheWarmingPane from "./views/CacheWarmingPane.svelte";
+import ChatPane from "./views/ChatPane.svelte";
+import CompactionPane from "./views/CompactionPane.svelte";
+import ComposerPane from "./views/ComposerPane.svelte";
+import ConcurrencyPane from "./views/ConcurrencyPane.svelte";
+import ExtensionsPane from "./views/ExtensionsPane.svelte";
+import HeartbeatPane from "./views/HeartbeatPane.svelte";
+import LspPane from "./views/LspPane.svelte";
+import McpPane from "./views/McpPane.svelte";
+import MessageQueuePane from "./views/MessageQueuePane.svelte";
+import ModelPane from "./views/ModelPane.svelte";
+import NamedAgentsPane from "./views/NamedAgentsPane.svelte";
+import SettingsPane from "./views/SettingsPane.svelte";
+import SystemPromptPane from "./views/SystemPromptPane.svelte";
+import TabsPane from "./views/TabsPane.svelte";
+import TasksPane from "./views/TasksPane.svelte";
+import VisionPane from "./views/VisionPane.svelte";
+
+/**
+ * Every wrapper takes `{ ports: AppViewPorts }` and TileLeaf supplies it — but
+ * the registry is a HETEROGENEOUS bag of components (generic `Component`), so
+ * the composition root bridges each wrapper's concrete props type to the
+ * registry's opaque one. ONE controlled cast at this seam (the renderer always
+ * passes the port bag; the wrappers re-narrow it) instead of `any` in the
+ * contract.
+ */
+type WrapperComponent = Component<{ ports: AppViewPorts }>;
+function asView(component: WrapperComponent): ViewComponent {
+ return component as unknown as ViewComponent;
+}
+
+export function registerBuiltinViews(): void {
+ const views: readonly ViewRegistration[] = [
+ // --- sidebar views (panel-eligible by default — the 14 old viewKinds + account-usage)
+ { id: "tabs", label: "Tabs", component: asView(TabsPane) },
+ { id: "model", label: "Model", component: asView(ModelPane) },
+ { id: "named-agents", label: "Named Agents", component: asView(NamedAgentsPane) },
+ { id: "lsp", label: "Language Servers", component: asView(LspPane) },
+ { id: "mcp", label: "MCP Servers", component: asView(McpPane) },
+ { id: "extensions", label: "Extensions", component: asView(ExtensionsPane) },
+ { id: "cache-warming", label: "Cache Warming", component: asView(CacheWarmingPane) },
+ { id: "tasks", label: "Tasks", component: asView(TasksPane) },
+ { id: "compaction", label: "Compaction", component: asView(CompactionPane) },
+ { id: "vision", label: "Vision", component: asView(VisionPane) },
+ { id: "heartbeat", label: "Heartbeat", component: asView(HeartbeatPane) },
+ { id: "concurrency", label: "Concurrency", component: asView(ConcurrencyPane) },
+ { id: "account-usage", label: "Usage", component: asView(AccountUsagePane) },
+ { id: "system-prompt", label: "System Prompt", component: asView(SystemPromptPane) },
+ { id: "settings", label: "Settings", component: asView(SettingsPane) },
+ // --- content views (never in the panel dropdowns — decision 5)
+ { id: "chat", label: "Chat", component: asView(ChatPane), panelEligible: false },
+ { id: "composer", label: "Composer", component: asView(ComposerPane), panelEligible: false },
+ {
+ id: "message-queue",
+ label: "Message Queue",
+ component: asView(MessageQueuePane),
+ panelEligible: false,
+ },
+ ];
+ for (const view of views) registerView(view);
+}
diff --git a/src/app/resolve-http-url.test.ts b/src/app/resolve-http-url.test.ts
index 0da2591..c556812 100644
--- a/src/app/resolve-http-url.test.ts
+++ b/src/app/resolve-http-url.test.ts
@@ -19,13 +19,13 @@ describe("resolveHttpUrl", () => {
});
it("derives http://<hostname>:24203 from http location", () => {
- const result = resolveHttpUrl({}, { protocol: "http:", hostname: "100.126.75.103" });
- expect(result).toBe("http://100.126.75.103:24203");
+ const result = resolveHttpUrl({}, { protocol: "http:", hostname: "192.0.2.10" });
+ expect(result).toBe("http://192.0.2.10:24203");
});
it("derives https://<hostname>:24203 from https location", () => {
- const result = resolveHttpUrl({}, { protocol: "https:", hostname: "arch-razer" });
- expect(result).toBe("https://arch-razer:24203");
+ const result = resolveHttpUrl({}, { protocol: "https:", hostname: "dev-node" });
+ expect(result).toBe("https://dev-node:24203");
});
it("uses VITE_HTTP_PORT when set", () => {
diff --git a/src/app/resolve-ws-url.test.ts b/src/app/resolve-ws-url.test.ts
index b5ba6c3..be1ab12 100644
--- a/src/app/resolve-ws-url.test.ts
+++ b/src/app/resolve-ws-url.test.ts
@@ -19,13 +19,13 @@ describe("resolveWsUrl", () => {
});
it("derives ws://<hostname>:24205 from http location", () => {
- const result = resolveWsUrl({}, { protocol: "http:", hostname: "100.126.75.103" });
- expect(result).toBe("ws://100.126.75.103:24205");
+ const result = resolveWsUrl({}, { protocol: "http:", hostname: "192.0.2.10" });
+ expect(result).toBe("ws://192.0.2.10:24205");
});
it("derives wss://<hostname>:24205 from https location", () => {
- const result = resolveWsUrl({}, { protocol: "https:", hostname: "arch-razer" });
- expect(result).toBe("wss://arch-razer:24205");
+ const result = resolveWsUrl({}, { protocol: "https:", hostname: "dev-node" });
+ expect(result).toBe("wss://dev-node:24205");
});
it("uses VITE_WS_PORT when set", () => {
diff --git a/src/app/settings-refreshers.svelte.ts b/src/app/settings-refreshers.svelte.ts
new file mode 100644
index 0000000..fb68895
--- /dev/null
+++ b/src/app/settings-refreshers.svelte.ts
@@ -0,0 +1,257 @@
+import type {
+ ComputerListResponse,
+ ModelMetadata,
+ ModelsResponse,
+} from "@dispatch/transport-contract";
+import type { ComputerEntry } from "@dispatch/wire";
+import type {
+ NamedAgent,
+ NamedAgentDeleteResult,
+ NamedAgentInput,
+ NamedAgentSaveResult,
+ NamedAgentsResult,
+} from "../features/named-agents";
+import { normalizeNamedAgentRecord, normalizeNamedAgents } from "../features/named-agents";
+
+/** Replace-or-append a Named Agent into a list (immutably), keyed by id. */
+function upsertNamedAgentList(
+ agents: readonly NamedAgent[],
+ agent: NamedAgent,
+): readonly NamedAgent[] {
+ const idx = agents.findIndex((a) => a.id === agent.id);
+ if (idx === -1) return [...agents, agent];
+ return agents.map((a) => (a.id === agent.id ? agent : a));
+}
+
+/** Workspace catalogs, global settings discovery and Named Agent records. */
+export function createSettingsRefreshers(deps: {
+ httpBase: string;
+ fetchImpl: typeof fetch;
+ activeWorkspaceId: () => string;
+ reportError: (context: string, err: unknown) => void;
+}) {
+ const { httpBase, fetchImpl } = deps;
+ let models = $state<readonly string[]>([]);
+ let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({});
+ // Discovered SSH computers (`GET /computers`). Global (like `models`); empty
+ // until the `ssh` extension lands. Read-only — no CRUD (the user edits their
+ // `~/.ssh/config`).
+ let computers = $state<readonly ComputerEntry[]>([]);
+ // The persisted Named Agents (global user config). Seeded from the backend on
+ // boot + kept in sync by the CRUD methods; drives both the sidebar editor and
+ // the synthetic model-selector provider.
+ let namedAgents = $state<readonly NamedAgent[]>([]);
+ /**
+ * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace
+ * (`GET /workspaces/:id/plugins/providers-status`, plan 06 §6.5). `null` =
+ * unknown (not fetched yet / endpoint missing) — the first-boot guardrail
+ * shows only on a confident `0` (§7).
+ */
+ let enabledProviderCount = $state<number | null>(null);
+
+ /** Replace-or-append a Named Agent into the reactive list (immutably). */
+ function upsertNamedAgent(agent: NamedAgent): void {
+ namedAgents = upsertNamedAgentList(namedAgents, agent);
+ }
+
+ /**
+ * Refetch the model catalog for the ACTIVE workspace
+ * (`GET /models?workspaceId=`, plan 06 §6.5 — workspace-aware; a backend
+ * without the param returns the global catalog, which stays correct).
+ * Re-run on boot, on every workspace switch, and on `extensions-changed`
+ * (a provider toggled in another client updates the picker here too).
+ */
+ async function refreshModels(): Promise<void> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/models?workspaceId=${encodeURIComponent(deps.activeWorkspaceId())}`,
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as ModelsResponse;
+ // Defensive: a malformed/SPA-fallback response must never poison the
+ // picker with a non-array (the old boot chain had the same exposure).
+ if (!Array.isArray(data.models)) return;
+ models = data.models;
+ modelInfo = data.modelInfo ?? {};
+ } catch (err) {
+ deps.reportError("Failed to load model list", err);
+ }
+ }
+
+ /**
+ * Refetch the active workspace's provider status (the §6.5 first-boot
+ * guardrail signal). Guarded against a slow response losing a race with a
+ * workspace switch (only the CURRENT workspace's result lands). Non-JSON
+ * 200 (SPA fallback — endpoint not shipped) leaves it null (no prompt).
+ */
+ async function refreshProviderStatus(): Promise<void> {
+ const workspaceId = deps.activeWorkspaceId();
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`,
+ );
+ if (!res.ok) {
+ if (workspaceId === deps.activeWorkspaceId()) enabledProviderCount = null;
+ return;
+ }
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) return;
+ const data = (await res.json()) as { enabledProviderCount?: number };
+ if (workspaceId === deps.activeWorkspaceId()) {
+ enabledProviderCount =
+ typeof data.enabledProviderCount === "number" ? data.enabledProviderCount : null;
+ }
+ } catch {
+ // Non-fatal: the guardrail just stays hidden (null) when the endpoint
+ // is unreachable.
+ if (workspaceId === deps.activeWorkspaceId()) enabledProviderCount = null;
+ }
+ }
+
+ function loadGlobalCatalogs(): void {
+ // Fetch the discovered-computer catalog (global, like models). Empty until
+ // the `ssh` extension lands — a safe no-op until then (the selector shows
+ // "Local (none)" only). Non-fatal: a failure leaves an empty list.
+ void fetchImpl(`${httpBase}/computers`)
+ .then((res) => {
+ if (!res.ok) return { computers: [] } as ComputerListResponse;
+ return res.json() as Promise<ComputerListResponse>;
+ })
+ .then((data) => {
+ computers = data?.computers ?? [];
+ })
+ .catch((err) => {
+ deps.reportError("Failed to load computer list", err);
+ });
+
+ // Fetch the persisted Named Agents (global user config). Seeded on boot so the
+ // sidebar editor + the synthetic model-selector provider have data immediately.
+ // Non-fatal: a failure leaves an empty list (the user can add agents).
+ void fetchImpl(`${httpBase}/named-agents`)
+ .then((res) => {
+ if (!res.ok) return { namedAgents: [] } as { namedAgents: NamedAgent[] };
+ return res.json() as Promise<{ namedAgents: NamedAgent[] }>;
+ })
+ .then((data) => {
+ namedAgents = normalizeNamedAgents(data);
+ })
+ .catch((err) => {
+ deps.reportError("Failed to load named agents", err);
+ });
+ }
+
+ return {
+ refreshModels,
+ refreshProviderStatus,
+ loadGlobalCatalogs,
+ get models() {
+ return models;
+ },
+ get modelInfo() {
+ return modelInfo;
+ },
+ get computers() {
+ return computers;
+ },
+ get namedAgents() {
+ return namedAgents;
+ },
+ get enabledProviderCount() {
+ return enabledProviderCount;
+ },
+ async refreshNamedAgents(): Promise<NamedAgentsResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Named agents failed (HTTP ${res.status})`,
+ };
+ }
+ const agents = normalizeNamedAgents(await res.json());
+ namedAgents = agents;
+ return { ok: true, agents };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Named agents request failed",
+ };
+ }
+ },
+
+ async createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(input),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Create named agent failed (HTTP ${res.status})`,
+ };
+ }
+ const agent = normalizeNamedAgentRecord(await res.json());
+ if (agent === null) return { ok: false, error: "Malformed named agent response" };
+ upsertNamedAgent(agent);
+ return { ok: true, agent };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Create named agent request failed",
+ };
+ }
+ },
+
+ async updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(input),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Update named agent failed (HTTP ${res.status})`,
+ };
+ }
+ const agent = normalizeNamedAgentRecord(await res.json());
+ if (agent === null) return { ok: false, error: "Malformed named agent response" };
+ upsertNamedAgent(agent);
+ return { ok: true, agent };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Update named agent request failed",
+ };
+ }
+ },
+
+ async deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Delete named agent failed (HTTP ${res.status})`,
+ };
+ }
+ namedAgents = namedAgents.filter((a) => a.id !== id);
+ return { ok: true };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Delete named agent request failed",
+ };
+ }
+ },
+ };
+}
diff --git a/src/app/store-types.ts b/src/app/store-types.ts
new file mode 100644
index 0000000..6835166
--- /dev/null
+++ b/src/app/store-types.ts
@@ -0,0 +1,614 @@
+import type {
+ CompactResponse,
+ ComputerStatusResponse,
+ LspStatusResponse,
+ McpStatusResponse,
+ ModelMetadata,
+ ReasoningEffort,
+ SystemPromptVariable,
+ TestComputerResponse,
+ WarmResponse,
+} from "@dispatch/transport-contract";
+import type { SurfaceSpec } from "@dispatch/ui-contract";
+import type { ComputerEntry, ConversationStatus, ImageInput } from "@dispatch/wire";
+import type { WebSocketLike } from "../adapters/ws";
+import type { ProtocolState } from "../core/protocol";
+import type {
+ AccountUsageProviderId,
+ AccountUsageResult,
+ AccountUsageSettingsResult,
+ UpdateAccountUsageSettingsRequest,
+} from "../features/account-usage";
+import type { ChatStore } from "../features/chat";
+import type {
+ ConcurrencyCooldownResult,
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusResult,
+} from "../features/concurrency";
+import type {
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatRunsResult,
+ HeartbeatStopResult,
+} from "../features/heartbeat";
+import type { LayoutStore } from "../features/layout";
+import type { TextFileClient } from "../features/markdown";
+import type {
+ NamedAgent,
+ NamedAgentDeleteResult,
+ NamedAgentInput,
+ NamedAgentSaveResult,
+ NamedAgentSelectionSnapshot,
+ NamedAgentsResult,
+} from "../features/named-agents";
+import type { PluginsStore } from "../features/plugins";
+import type { SearchBackendConfigResult } from "../features/search-settings";
+import type { Tab } from "../features/tabs";
+import type { VisionSettings, VisionSettingsPatch } from "../features/vision";
+import type { TileLayoutStore } from "../features/workspaces";
+
+/** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */
+export type WarmResult =
+ | { readonly ok: true; readonly response: WarmResponse }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `PUT /conversations/:id/cwd`. */
+export type CwdResult =
+ | { readonly ok: true; readonly cwd: string | null }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `PUT /conversations/:id/computer` (set/clear the per-conversation computer). */
+export type ComputerResult =
+ | { readonly ok: true; readonly computerId: string | null }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /computers/:alias/status` (the live connection state). */
+export type ComputerStatusResult =
+ | { readonly ok: true; readonly response: ComputerStatusResponse }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `POST /computers/:alias/test` (one-shot connectivity probe). */
+export type TestComputerResult =
+ | { readonly ok: true; readonly response: TestComputerResponse }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /conversations/:id/lsp`. */
+export type LspResult =
+ | { readonly ok: true; readonly response: LspStatusResponse }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /conversations/:id/mcp`. */
+export type McpResult =
+ | { readonly ok: true; readonly response: McpStatusResponse }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `PUT /conversations/:id/reasoning-effort`. */
+export type ReasoningEffortResult =
+ | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Outcome of `PUT /conversations/:id/thinking` (PROPOSED — see
+ * `backend-handoff.md`; the endpoint is not yet shipped by the backend).
+ */
+export type ThinkingResult =
+ | { readonly ok: true; readonly thinking: boolean }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `POST /conversations/:id/compact` (manual compaction). */
+export type CompactResult =
+ | { readonly ok: true; readonly response: CompactResponse }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `PUT /conversations/:id/compact-percent`. */
+export type CompactPercentResult =
+ | { readonly ok: true; readonly percent: number }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `PUT /settings/vision` (global vision-settings save). */
+export type VisionSettingsResult =
+ | { readonly ok: true; readonly settings: VisionSettings }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /system-prompt` (global template load). */
+export type SystemPromptLoadResult =
+ | { readonly ok: true; readonly template: string }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `PUT /system-prompt` (global template save). */
+export type SystemPromptSaveResult = SystemPromptLoadResult;
+
+/** Outcome of `GET /system-prompt/variables` (variable catalog). */
+export type SystemPromptVariablesResult =
+ | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of persisting a chat-limit setting (localStorage; FE-local). */
+export type ChatLimitResult =
+ | { readonly ok: true; readonly chatLimit: number }
+ | { readonly ok: false; readonly error: string };
+
+export interface AppStore {
+ readonly tabs: readonly Tab[];
+ readonly activeConversationId: string | null;
+ /** The workspace currently in view (URL slug); tabs are filtered to it. */
+ readonly activeWorkspaceId: string;
+ /**
+ * The resolved HTTP API base URL (e.g. `http://localhost:24203`). Used to
+ * resolve relative image URLs served by the backend (`/images/…`) into
+ * absolute URLs for `<img src>`.
+ */
+ readonly httpBase: string;
+ readonly activeChat: ChatStore;
+ readonly models: readonly string[];
+ /** Per-model metadata (contextWindow, etc.) from `GET /models`. */
+ readonly modelInfo: Readonly<Record<string, ModelMetadata>>;
+ readonly activeModel: string;
+ readonly catalog: ProtocolState["catalog"];
+ /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */
+ readonly surfaces: readonly SurfaceSpec[];
+ readonly lastError: ProtocolState["lastError"];
+ /** The localStorage instance the store uses for persistence (tabs, chatLimit).
+ * Exposed so the shell can persist sidebar layout via the same adapter. */
+ readonly storage: Storage | undefined;
+ /**
+ * The current spec by id, or null if absent. With conversationId, require a
+ * snapshot received for that conversation (not a retained prior-scope spec).
+ */
+ surface(surfaceId: string, conversationId?: string): SurfaceSpec | null;
+ /**
+ * Send a user message (start a turn). Forwards any staged `images`
+ * (`ImageInput[]` — base64 data URLs / https URLs) on the `chat.send` op;
+ * the server passes them to a vision-capable model natively or transcribes
+ * them via vision handoff for a non-vision model. Omitted on the wire when
+ * none are staged. On a draft, promotes to a tab first.
+ */
+ send(text: string, images?: readonly ImageInput[]): void;
+ /**
+ * Enqueue a steering message onto the focused conversation's queue
+ * (`chat.queue` WS op). While a turn is generating, the message is delivered
+ * mid-turn at the next tool-result boundary; when idle, the server
+ * auto-starts a turn (equivalent to `send`). Safe to offer whenever the user
+ * wants to add input — the server owns the idle-vs-generating decision.
+ */
+ queueMessage(text: string, images?: readonly ImageInput[]): void;
+ /**
+ * Cancel (remove) a single queued steering message by id so it never runs
+ * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: the
+ * message-queue surface update reconciles the queue UI (the cancelled message
+ * leaves the snapshot). Targets the focused conversation's queue. The caller
+ * optimistically hides the row; a cancel of an already-drained / unknown
+ * message is a silent server no-op (nothing to roll back).
+ */
+ cancelQueuedMessage(messageId: string): void;
+ /** Select a raw model for the active conversation. Pass `snapshot` to record a Named Agent selection (plan §7). */
+ selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void;
+ newDraft(): void;
+ /** Switch the active workspace (on route change) + reset to a fresh draft in it. */
+ setActiveWorkspace(workspaceId: string): void;
+ selectTab(conversationId: string): void;
+ closeTab(conversationId: string): void;
+ renameTab(conversationId: string, title: string): void;
+ /**
+ * Move a tab into a subagent group (the tab group parent's id) or out of
+ * any group (null). Purely presentational (NOT lineage): applied
+ * optimistically, persisted via `PUT /conversations/:id/tab-group` (synced
+ * to other clients by `conversation.tabGroupChanged`), reverted on failure.
+ */
+ setTabGroup(conversationId: string, parentId: string | null): void;
+ invoke(surfaceId: string, actionId: string, payload?: unknown): void;
+ /**
+ * Explicitly (re-)subscribe one surface, scoped to `conversationId` (or the
+ * focused conversation when absent) — the frontend-plugin ABI's
+ * `surfaces.subscribe` path (plan 03). The app auto-subscribes every
+ * catalog entry; this is the re-assertion port (same wire semantics).
+ */
+ subscribeSurface(surfaceId: string, conversationId?: string): void;
+ /** Drop one surface's subscription (the ABI's `surfaces.unsubscribe`). */
+ unsubscribeSurface(surfaceId: string, conversationId?: string): void;
+ /**
+ * Manually warm the focused conversation's prompt cache (`POST /chat/warm`).
+ * Returns null when no conversation is focused (a draft has nothing to warm).
+ */
+ warmNow(): Promise<WarmResult | null>;
+ /** The workspace conversation's persisted working directory, or null when unset. */
+ readonly cwd: string | null;
+ /** Invalidates live-file routing after known settings changes. */
+ readonly textFileContextRevision: number;
+ createTextFileClient(conversationId: string): TextFileClient;
+ /** The conversation workspace settings target: the active tab, or the pending draft's id. */
+ readonly currentConversationId: string;
+ /**
+ * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`).
+ * Works for a draft too (its id survives promotion), so the first turn runs in it.
+ */
+ setCwd(cwd: string): Promise<CwdResult | null>;
+ /**
+ * The workspace conversation's persisted computer (an SSH `Host` alias), or
+ * null when never set / local. Seeded from the backend on focus change.
+ */
+ readonly computerId: string | null;
+ /**
+ * Persist the workspace conversation's computer (`PUT /conversations/:id/computer`).
+ * Pass null to clear → the conversation inherits the workspace default → local.
+ * Works for a draft too (its id survives promotion). Not seen by the agent — a
+ * user-facing tool-execution target only.
+ */
+ setComputer(computerId: string | null): Promise<ComputerResult | null>;
+ /**
+ * Every remote computer discovered from the user's `~/.ssh/config`
+ * (`GET /computers`), fetched on boot. Read-only — there is no Computer CRUD
+ * (the user edits their ssh config to add one). Empty until the `ssh`
+ * extension lands.
+ */
+ readonly computers: readonly ComputerEntry[];
+ /**
+ * The live connection state of a computer (`GET /computers/:alias/status`):
+ * whether Dispatch currently holds an open SSH session to it. Returns null
+ * only if no alias is given (the focused conversation is local). Polled by the
+ * `ComputerField` while a computer is selected.
+ */
+ computerStatus(alias: string): Promise<ComputerStatusResult | null>;
+ /**
+ * One-shot connectivity probe (`POST /computers/:alias/test`): Dispatch opens
+ * an SSH connection to the alias, runs a trivial command, then closes. `ok` is
+ * true on success; `error` carries the failure reason otherwise.
+ */
+ testComputer(alias: string): Promise<TestComputerResult | null>;
+ /**
+ * The workspace conversation's persisted reasoning effort, or null when never
+ * set (the server then resolves turns at the default, `"high"`).
+ */
+ readonly reasoningEffort: ReasoningEffort | null;
+ /**
+ * Persist the workspace conversation's reasoning effort
+ * (`PUT /conversations/:id/reasoning-effort`). Works for a draft too (its id
+ * survives promotion), so the first turn already runs at the chosen level.
+ * Takes effect from the NEXT turn; resolution stays server-owned.
+ */
+ setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null>;
+ /**
+ * The workspace conversation's persisted thinking flag, or null when never
+ * set (the server then resolves turns with thinking ON — the default).
+ * `false` ⇒ thinking disabled entirely (the SEPARATE "off" axis — NOT a
+ * zero-effort level; the umans route maps it to `reasoning_effort: "none"`).
+ * PROPOSED backend contract — see `backend-handoff.md`.
+ */
+ readonly thinking: boolean | null;
+ /**
+ * Persist the workspace conversation's thinking flag
+ * (`PUT /conversations/:id/thinking`). Works for a draft too (its id survives
+ * promotion), so the first turn already runs with the chosen setting. Takes
+ * effect from the NEXT turn; resolution stays server-owned.
+ * PROPOSED backend contract — see `backend-handoff.md`.
+ */
+ setThinking(enabled: boolean): Promise<ThinkingResult | null>;
+ /**
+ * Manually trigger conversation compaction (`POST /conversations/:id/compact`).
+ * Summarizes old messages + retains the most recent N. Returns null when no
+ * conversation is focused (a draft has nothing to compact).
+ */
+ compactNow(keepLastN?: number): Promise<CompactResult | null>;
+ /**
+ * Stop an in-flight generation (`POST /conversations/:id/stop`). Aborts the
+ * turn without closing the conversation — partial messages are persisted, the
+ * turn seals with `reason: "aborted"`, and the conversation goes `active → idle`.
+ * Returns null when no conversation is focused.
+ */
+ stopGeneration(): void;
+ /**
+ * The workspace conversation's auto-compact percent (0-100). `0` = disabled
+ * (manual only); a positive number = auto-compact triggers when the last
+ * turn's input tokens exceed it. Seeded from the backend on focus change.
+ */
+ readonly compactPercent: number | null;
+ /**
+ * Persist the workspace conversation's auto-compact percent
+ * (`PUT /conversations/:id/compact-percent`). `0` disables; 1-100 sets the
+ * trigger percentage of the model's context window. Default (null) is 85.
+ * number enables. Works for a draft too (its id survives promotion).
+ */
+ setCompactPercent(percent: number): Promise<CompactPercentResult | null>;
+ /**
+ * The GLOBAL vision settings (`GET /settings/vision`): `imageLimit` (max
+ * native images per turn before compaction; 0 = disabled) + `compactionModel`
+ * (which vision model transcribes old images; null = auto). Shared across all
+ * conversations. Seeded on boot; `null` = not yet fetched.
+ */
+ readonly visionSettings: VisionSettings | null;
+ /**
+ * Refetch the global vision settings (`GET /settings/vision`). Called by the
+ * vision-settings view on mount; also seeded on boot.
+ */
+ refreshVisionSettings(): Promise<void>;
+ /**
+ * Save a PARTIAL vision-settings update (`PUT /settings/vision`). Either
+ * field may be omitted. Returns the merged settings on success.
+ */
+ setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | null>;
+ /**
+ * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`).
+ * The backend lazily spawns servers, so this may take a moment on the first call for a cwd.
+ */
+ lspStatus(): Promise<LspResult | null>;
+ /**
+ * Fetch the workspace conversation's MCP server status (`GET /conversations/:id/mcp`).
+ * Mirrors the LSP status endpoint: returns `{cwd, servers}` with empty `servers`
+ * when no cwd is set; the backend lazily connects servers, so this may take a
+ * moment on the first call for a cwd.
+ */
+ mcpStatus(): Promise<McpResult | null>;
+ /**
+ * Load the global system prompt template (`GET /system-prompt`). The template is
+ * conversation-agnostic; it is resolved once per conversation on first turn and
+ * persisted for prompt-cache safety.
+ */
+ loadSystemPrompt(): Promise<SystemPromptLoadResult>;
+ /**
+ * Persist the global system prompt template (`PUT /system-prompt`). Changes apply
+ * to new conversations on their first turn; existing conversations keep their
+ * resolved system prompt until compaction.
+ */
+ setSystemPrompt(template: string): Promise<SystemPromptSaveResult>;
+ /**
+ * Load the static catalog of available system prompt variables (`GET /system-prompt/variables`).
+ * Used by the builder to render the variable selector buttons.
+ */
+ loadSystemPromptVariables(): Promise<SystemPromptVariablesResult>;
+ /** The persisted chat limit (max loaded chunks per conversation). */
+ readonly chatLimit: number;
+ /**
+ * A conversation's backend lifecycle status (`active`/`idle`/`closed`), or
+ * `undefined` when unknown. Drives the tab-bar generating indicator
+ * (cross-device: a tab spinning because another device's turn is running).
+ */
+ conversationStatus(conversationId: string): ConversationStatus | undefined;
+ /**
+ * Whether at least one conversation in the given workspace is currently
+ * active or queued (generating / waiting for a concurrency slot) — drives
+ * the loading-dots indicator on workspace cards. Backed by a once-derived
+ * `activeWorkspaces` set (the open-tab set × the backend lifecycle statuses)
+ * so this is an O(1) lookup, not a per-card scan of the full tab list.
+ * Reactive: the set is a `$derived`, so a Svelte template expression calling
+ * this re-runs when the tab set or status map changes.
+ */
+ workspaceHasActiveConversations(workspaceId: string): boolean;
+ /**
+ * Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to
+ * localStorage and propagates to every live chat store (trim if lower,
+ * deferred via the unload gate while a reader is scrolled up; no-op if
+ * higher — page unloaded history back in via "Show earlier"). Stores created
+ * afterwards pick the new limit up at creation. Always succeeds (FE-local).
+ */
+ setChatLimit(limit: number): Promise<ChatLimitResult>;
+ /**
+ * Wire the chat-limit unload gate (composition-root injection, called once by
+ * the shell after it owns the scroll region): unloading old chunks is allowed
+ * only while the gate returns true — i.e. the reader is stuck to the bottom —
+ * so a trim never yanks content out from under someone reading history.
+ * Before attachment unloading is allowed (the initial view starts at the
+ * bottom).
+ */
+ attachUnloadGate(gate: () => boolean): void;
+ /**
+ * Load the active workspace's heartbeat config
+ * (`GET /workspaces/:id/heartbeat`). Workspace-scoped (NOT per-conversation):
+ * the backend runs an autonomous agent loop on a configured interval, writing
+ * each run into a dedicated conversation. The config covers the system/task
+ * prompts, model, reasoning effort, interval, and an enabled flag.
+ */
+ heartbeatConfig(): Promise<HeartbeatConfigResult>;
+ /**
+ * Persist a partial heartbeat config patch
+ * (`PUT /workspaces/:id/heartbeat`). The backend merges the patch onto the
+ * stored config; returns the full updated config.
+ */
+ setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult>;
+ /**
+ * Load the active workspace's heartbeat run history
+ * (`GET /workspaces/:id/heartbeat/runs`). Each run references the conversation
+ * it wrote to — open one via {@link watchConversation} to see its chat live.
+ */
+ heartbeatRuns(): Promise<HeartbeatRunsResult>;
+ /**
+ * Stop a running heartbeat run (`POST /workspaces/:id/heartbeat/runs/:runId/stop`).
+ * The run's in-flight turn seals (its conversation keeps streaming until it
+ * ends); the run's status flips to `stopped` (visible on the next runs poll).
+ */
+ stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult>;
+ /**
+ * Fetch the server-authoritative next-run timestamp
+ * (`GET /workspaces/:id/heartbeat/next-run`) — when the next heartbeat run
+ * will fire (ISO 8601), or null when disabled / no run scheduled. The FE shows
+ * a live countdown from this. When the endpoint is absent (404 — backend
+ * hasn't shipped CR-HB-3 yet) it returns `ok: false` so the FE falls back to
+ * an approximation from the runs + config.
+ */
+ heartbeatNextRun(): Promise<HeartbeatNextRunResult>;
+ /**
+ * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
+ * modal): ensures a live {@link ChatStore} for the conversation, subscribing
+ * to its turn stream (`chat.subscribe`) + loading history. Reuses the open
+ * tab's store if the conversation is already a tab; otherwise creates an
+ * EPHEMERAL watch store (separate from tabs — never opens a tab). Deltas are
+ * routed to it automatically. Pair every open with {@link unwatchConversation}
+ * on close to unsubscribe + dispose the ephemeral store.
+ */
+ watchConversation(conversationId: string): ChatStore;
+ /** Dispose + unsubscribe a watch opened by {@link watchConversation}. */
+ unwatchConversation(conversationId: string): void;
+ /**
+ * Load all configured per-provider concurrency limits
+ * (`GET /concurrency/limits`). Global (not workspace-scoped). Returns an empty
+ * list when the concurrency extension isn't loaded (`{ limits: [] }`).
+ */
+ concurrencyLimits(): Promise<ConcurrencyLimitsResult>;
+ /**
+ * Fetch the configured limit for one provider
+ * (`GET /concurrency/limits/:providerId`). `404` (no limit configured) and
+ * `503` (extension not loaded) both surface as `ok: false`.
+ */
+ getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult>;
+ /**
+ * Set or update a provider's concurrency limit
+ * (`PUT /concurrency/limits/:providerId`, body `{ limit }`). `limit` must be a
+ * positive integer (a non-positive body is `400`). At the cap, further requests
+ * queue oldest-agent-first rather than being sent immediately.
+ */
+ setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult>;
+ /**
+ * Remove a provider's concurrency limit (`DELETE /concurrency/limits/:providerId`),
+ * making it unlimited. `404` (not configured) and `503` (extension not loaded)
+ * both surface as `ok: false`.
+ */
+ deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult>;
+ /**
+ * Fetch live concurrency status for every provider with a configured limit
+ * (`GET /concurrency/status`): in-flight slots held, agents queued, and a paused
+ * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Each
+ * entry also carries the per-slot release `cooldownMs` + an `autoReduced` flag
+ * (true when a 429 auto-reduced the limit by 1; the FE renders a banner). Returns
+ * an empty list when the extension isn't loaded (`{ providers: [] }`).
+ */
+ concurrencyStatus(): Promise<ConcurrencyStatusResult>;
+ /**
+ * Load Claude + Codex subscription usage (`GET /usage`). Served from the
+ * backend's cache (5 min TTL); `refresh: true` sends `?refresh=1`, which the
+ * server still rate-limits (min interval + failure cooldown). A 503 (the
+ * `account-usage` extension isn't loaded) returns `unavailable: true`.
+ */
+ accountUsage(opts?: { readonly refresh?: boolean }): Promise<AccountUsageResult>;
+ /**
+ * Update a provider's usage settings (`PUT /usage/:provider/settings`; today
+ * only `hyper`: `dailyAllowance` + `resetAnchor` epoch-ms or null). Returns
+ * the provider's refreshed report.
+ */
+ setAccountUsageSettings(
+ provider: AccountUsageProviderId,
+ settings: UpdateAccountUsageSettingsRequest,
+ ): Promise<AccountUsageSettingsResult>;
+ /**
+ * Load the GLOBAL search-backend selection
+ * (`GET /search/config`): which engine backs the `web_search` tool's search
+ * mode. Not workspace-scoped — the selection is global. `unavailable: true`
+ * on the failure variant marks the 503 degrade (the backend's
+ * search-config service not loaded) so the UI can disable the field.
+ */
+ searchBackendConfig(): Promise<SearchBackendConfigResult>;
+ /**
+ * Persist a new search-backend selection
+ * (`PUT /search/config`, body `{ backend }`). `400` (unknown/unconfigured
+ * backend) and `503` (service not loaded) both surface as `ok: false`.
+ */
+ setSearchBackend(backend: string): Promise<SearchBackendConfigResult>;
+ /**
+ * Fetch the per-slot release cooldown (ms) for one provider
+ * (`GET /concurrency/cooldown/:providerId`). `404` (no concurrency config at
+ * all) and `503` (extension not loaded) both surface as `ok: false`.
+ */
+ getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult>;
+ /**
+ * Set the per-slot release cooldown (ms) for one provider
+ * (`PUT /concurrency/cooldown/:providerId`, body `{ cooldownMs }`). `cooldownMs`
+ * must be a non-negative integer (0 = no cooldown / instant re-admission); an
+ * invalid body is `400`. Persists + applies to subsequently recycled slots.
+ */
+ setConcurrencyCooldown(
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult>;
+ /**
+ * A critical error that blocks normal operation (e.g. the cross-device tab
+ * restore fetch failed). When non-null, a full-screen modal is shown with the
+ * error details. Cleared by `clearFatalError` (the modal's dismiss button).
+ */
+ readonly fatalError: string | null;
+ /** Dismiss the fatal error (called by the error modal's X button). */
+ clearFatalError(): void;
+ // ── Named Agents (global config; plan §9) ──────────────────────────────────
+ /**
+ * The persisted Named Agents (from `GET /named-agents`). Global user config
+ * (not per-workspace); seeded on boot. Drives the sidebar editor AND the
+ * synthetic "Named Agents" provider in the model selector (plan §6).
+ */
+ readonly namedAgents: readonly NamedAgent[];
+ /**
+ * The active conversation's Named Agent selection snapshot (plan §7), or null
+ * when it was selected via a raw model. Survives the Named Agent being renamed
+ * / removed (the snapshot retains the friendly label + captured raw model).
+ */
+ readonly namedAgentSnapshot: NamedAgentSelectionSnapshot | null;
+ /** Refetch the Named Agents (`GET /named-agents`). */
+ refreshNamedAgents(): Promise<NamedAgentsResult>;
+ // ── Plugins (plan 07) ──────────────────────────────────────────────────────
+ /**
+ * The plugin manager + per-workspace enablement store (one instance per
+ * app store). The workspaces page's manager/modal read + mutate through
+ * it; the app store feeds its `onExtensionsChanged` hook.
+ */
+ readonly plugins: PluginsStore;
+ /**
+ * The ACTIVE workspace's tile tree (plan 02): served by the backend's
+ * layout provider when one is active, the local default otherwise. The
+ * shell reads `tree` for the renderer and feeds edits back through
+ * `onTreeChange` (debounced PUT + rollback); the store owns the probes,
+ * the WS-push filter, and the no-provider degrade.
+ */
+ readonly layout: LayoutStore;
+ /**
+ * The home grid's synced tile layout (ONE shared document per device
+ * orientation — `wide` = landscape/desktop, `tall` = portrait/mobile),
+ * served by the backend's home-tile-layout plugin. The home page locks the
+ * active orientation to the resolved grid geometry; this store owns the
+ * probes, the WS-push filter, the debounced PUT + rollback, and the
+ * offline degrade.
+ */
+ readonly tileLayout: TileLayoutStore;
+ /**
+ * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace
+ * (`GET /workspaces/:id/plugins/providers-status`). `null` = unknown — the
+ * first-boot guardrail renders only on a confident `0` (§7).
+ */
+ readonly enabledProviderCount: number | null;
+ /**
+ * Create a Named Agent (`POST /named-agents`). On success the store's
+ * `namedAgents` list is updated (append/replace) so every consumer — the
+ * sidebar editor AND the model selector — reflects it without a reload.
+ */
+ createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult>;
+ /**
+ * Update a Named Agent (`PUT /named-agents/:id`). On success the store list is
+ * updated in place. Returns the canonical saved record.
+ */
+ updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult>;
+ /**
+ * Remove a Named Agent (`DELETE /named-agents/:id`). On success it leaves the
+ * store list immediately (removing it from the sidebar + model selector), and
+ * does NOT alter conversations that previously selected it (those keep their
+ * snapshot — plan §7).
+ */
+ deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult>;
+ dispose(): void;
+}
+
+export interface CreateAppStoreOptions {
+ url?: string;
+ httpUrl?: string;
+ socketFactory?: (url: string) => WebSocketLike;
+ fetchImpl?: typeof fetch;
+ indexedDB?: IDBFactory;
+ conversationId?: string;
+ localStorage?: Storage;
+ /** The workspace to scope to at boot (its URL slug); "default" if absent. */
+ workspaceId?: string;
+ /**
+ * How live chat events are batched before folding into a chat store.
+ * Default: once per animation frame (with a timeout fallback for hidden
+ * tabs). `null` = fold every event synchronously (tests that assert on the
+ * transcript immediately after feeding a frame).
+ */
+ chatFlushScheduler?: ((flush: () => void) => void) | null;
+}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 22b0a25..8fcc4cc 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -1,537 +1,81 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- CompactPercentResponse,
- CompactResponse,
- ComputerListResponse,
- ComputerStatusResponse,
- ConversationCompactedMessage,
- ConversationComputerResponse,
- ConversationHistoryResponse,
- ConversationListResponse,
- ConversationMetricsResponse,
- ConversationOpenMessage,
- ConversationStatusChangedMessage,
- CwdResponse,
- LspStatusResponse,
- McpStatusResponse,
ModelMetadata,
ModelResponse,
- ModelsResponse,
ReasoningEffort,
- ReasoningEffortResponse,
- SetCompactPercentRequest,
- SetConversationComputerRequest,
- SetCwdRequest,
SetModelRequest,
- SetReasoningEffortRequest,
- SetSystemPromptTemplateRequest,
- SetTitleRequest,
- SetVisionSettingsRequest,
- SystemPromptTemplateResponse,
- SystemPromptVariable,
- SystemPromptVariablesResponse,
- TestComputerResponse,
- WarmRequest,
- WarmResponse,
+ WorkspaceSetMessage,
} from "@dispatch/transport-contract";
-import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { SurfaceSpec } from "@dispatch/ui-contract";
import type { ComputerEntry, ConversationStatus, ImageInput } from "@dispatch/wire";
-import { untrack } from "svelte";
import { createIdbChunkStore } from "../adapters/idb";
import { createLocalStore } from "../adapters/local-storage";
-import type { WebSocketLike } from "../adapters/ws";
-import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws";
-import { normalizeChatLimit } from "../core/chunks";
-import {
- applyServerMessage,
- getSurfaceSpec,
- type ProtocolState,
- initialState as protocolInitialState,
- invoke as protocolInvoke,
- subscribe as protocolSubscribe,
- unsubscribe as protocolUnsubscribe,
-} from "../core/protocol";
-import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
-import { createChatStore } from "../features/chat";
-import type {
- ConcurrencyCooldownResult,
- ConcurrencyDeleteResult,
- ConcurrencyLimitResult,
- ConcurrencyLimitsResult,
- ConcurrencyStatusResult,
-} from "../features/concurrency";
-import {
- normalizeConcurrencyCooldown,
- normalizeConcurrencyLimit,
- normalizeConcurrencyLimits,
- normalizeConcurrencyStatus,
-} from "../features/concurrency";
-import type { ConversationCache } from "../features/conversation-cache";
+import { createSurfaceSocket } from "../adapters/ws";
+import { getSurfaceSpec } from "../core/protocol";
+import type { ChatStore } from "../features/chat";
+import { migrateLegacyModelName } from "../features/chat";
import { createConversationCache } from "../features/conversation-cache";
-import type {
- HeartbeatConfig,
- HeartbeatConfigPatch,
- HeartbeatConfigResult,
- HeartbeatNextRunResult,
- HeartbeatRun,
- HeartbeatRunsResult,
- HeartbeatStopResult,
-} from "../features/heartbeat";
-import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat";
+import type { LayoutStore } from "../features/layout";
+import {
+ createLayoutAbiPort,
+ createLayoutHttp,
+ createLayoutStore,
+ listPanelViews,
+} from "../features/layout";
+import { createTextFileClient } from "../features/markdown";
+import type { NamedAgent, NamedAgentSelectionSnapshot } from "../features/named-agents";
+import type { PluginsStore } from "../features/plugins";
+import { createPluginsHttp, createPluginsStore } from "../features/plugins";
import type { Tab, TabsState } from "../features/tabs";
import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs";
+import type { VisionSettings } from "../features/vision";
import {
- normalizeVisionSettings,
- type VisionSettings,
- type VisionSettingsPatch,
-} from "../features/vision";
+ createTileLayoutHttp,
+ createTileLayoutStore,
+ geometryFor,
+ type TileLayoutStore,
+} from "../features/workspaces";
+import { createChatRegistry } from "./chat-registry.svelte";
+import { createConversationSettings } from "./conversation-settings.svelte";
+import { createHistorySync, createMetricsSync } from "./history-sync";
+import { createHttpActions } from "./http-actions";
+import { createOpenConversations } from "./open-conversations.svelte";
+import { createPluginHost } from "./plugin-host.svelte";
import { resolveHttpUrl } from "./resolve-http-url";
import { resolveWsUrl } from "./resolve-ws-url";
+import { createSettingsRefreshers } from "./settings-refreshers.svelte";
+import type { AppStore, CreateAppStoreOptions } from "./store-types";
+import { createSurfaceProtocol } from "./surface-protocol.svelte";
import { randomId } from "./uuid";
-
-const DEFAULT_MODEL = "opencode/deepseek-v4-flash";
-
-/** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */
-export type WarmResult =
- | { readonly ok: true; readonly response: WarmResponse }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `PUT /conversations/:id/cwd`. */
-export type CwdResult =
- | { readonly ok: true; readonly cwd: string | null }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `PUT /conversations/:id/computer` (set/clear the per-conversation computer). */
-export type ComputerResult =
- | { readonly ok: true; readonly computerId: string | null }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `GET /computers/:alias/status` (the live connection state). */
-export type ComputerStatusResult =
- | { readonly ok: true; readonly response: ComputerStatusResponse }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `POST /computers/:alias/test` (one-shot connectivity probe). */
-export type TestComputerResult =
- | { readonly ok: true; readonly response: TestComputerResponse }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `GET /conversations/:id/lsp`. */
-export type LspResult =
- | { readonly ok: true; readonly response: LspStatusResponse }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `GET /conversations/:id/mcp`. */
-export type McpResult =
- | { readonly ok: true; readonly response: McpStatusResponse }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `PUT /conversations/:id/reasoning-effort`. */
-export type ReasoningEffortResult =
- | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `POST /conversations/:id/compact` (manual compaction). */
-export type CompactResult =
- | { readonly ok: true; readonly response: CompactResponse }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `PUT /conversations/:id/compact-percent`. */
-export type CompactPercentResult =
- | { readonly ok: true; readonly percent: number }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `PUT /settings/vision` (global vision-settings save). */
-export type VisionSettingsResult =
- | { readonly ok: true; readonly settings: VisionSettings }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `GET /system-prompt` (global template load). */
-export type SystemPromptLoadResult =
- | { readonly ok: true; readonly template: string }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `PUT /system-prompt` (global template save). */
-export type SystemPromptSaveResult = SystemPromptLoadResult;
-
-/** Outcome of `GET /system-prompt/variables` (variable catalog). */
-export type SystemPromptVariablesResult =
- | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of persisting a chat-limit setting (localStorage; FE-local). */
-export type ChatLimitResult =
- | { readonly ok: true; readonly chatLimit: number }
- | { readonly ok: false; readonly error: string };
-
-export interface AppStore {
- readonly tabs: readonly Tab[];
- readonly activeConversationId: string | null;
- /** The workspace currently in view (URL slug); tabs are filtered to it. */
- readonly activeWorkspaceId: string;
- /**
- * The resolved HTTP API base URL (e.g. `http://localhost:24203`). Used to
- * resolve relative image URLs served by the backend (`/images/…`) into
- * absolute URLs for `<img src>`.
- */
- readonly httpBase: string;
- readonly activeChat: ChatStore;
- readonly models: readonly string[];
- /** Per-model metadata (contextWindow, etc.) from `GET /models`. */
- readonly modelInfo: Readonly<Record<string, ModelMetadata>>;
- readonly activeModel: string;
- readonly catalog: ProtocolState["catalog"];
- /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */
- readonly surfaces: readonly SurfaceSpec[];
- readonly lastError: ProtocolState["lastError"];
- /** The localStorage instance the store uses for persistence (tabs, chatLimit).
- * Exposed so the shell can persist sidebar layout via the same adapter. */
- readonly storage: Storage | undefined;
- /** The current spec for one surface by id (discovery-by-id), or null if absent. */
- surface(surfaceId: string): SurfaceSpec | null;
- /**
- * Send a user message (start a turn). Forwards any staged `images`
- * (`ImageInput[]` — base64 data URLs / https URLs) on the `chat.send` op;
- * the server passes them to a vision-capable model natively or transcribes
- * them via vision handoff for a non-vision model. Omitted on the wire when
- * none are staged. On a draft, promotes to a tab first.
- */
- send(text: string, images?: readonly ImageInput[]): void;
- /**
- * Enqueue a steering message onto the focused conversation's queue
- * (`chat.queue` WS op). While a turn is generating, the message is delivered
- * mid-turn at the next tool-result boundary; when idle, the server
- * auto-starts a turn (equivalent to `send`). Safe to offer whenever the user
- * wants to add input — the server owns the idle-vs-generating decision.
- */
- queueMessage(text: string): void;
- selectModel(model: string): void;
- newDraft(): void;
- /** Switch the active workspace (on route change) + reset to a fresh draft in it. */
- setActiveWorkspace(workspaceId: string): void;
- selectTab(conversationId: string): void;
- closeTab(conversationId: string): void;
- renameTab(conversationId: string, title: string): void;
- invoke(surfaceId: string, actionId: string, payload?: unknown): void;
- /**
- * Manually warm the focused conversation's prompt cache (`POST /chat/warm`).
- * Returns null when no conversation is focused (a draft has nothing to warm).
- */
- warmNow(): Promise<WarmResult | null>;
- /** The workspace conversation's persisted working directory, or null when unset. */
- readonly cwd: string | null;
- /** The conversation workspace settings target: the active tab, or the pending draft's id. */
- readonly currentConversationId: string;
- /**
- * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`).
- * Works for a draft too (its id survives promotion), so the first turn runs in it.
- */
- setCwd(cwd: string): Promise<CwdResult | null>;
- /**
- * The workspace conversation's persisted computer (an SSH `Host` alias), or
- * null when never set / local. Seeded from the backend on focus change.
- */
- readonly computerId: string | null;
- /**
- * Persist the workspace conversation's computer (`PUT /conversations/:id/computer`).
- * Pass null to clear → the conversation inherits the workspace default → local.
- * Works for a draft too (its id survives promotion). Not seen by the agent — a
- * user-facing tool-execution target only.
- */
- setComputer(computerId: string | null): Promise<ComputerResult | null>;
- /**
- * Every remote computer discovered from the user's `~/.ssh/config`
- * (`GET /computers`), fetched on boot. Read-only — there is no Computer CRUD
- * (the user edits their ssh config to add one). Empty until the `ssh`
- * extension lands.
- */
- readonly computers: readonly ComputerEntry[];
- /**
- * The live connection state of a computer (`GET /computers/:alias/status`):
- * whether Dispatch currently holds an open SSH session to it. Returns null
- * only if no alias is given (the focused conversation is local). Polled by the
- * `ComputerField` while a computer is selected.
- */
- computerStatus(alias: string): Promise<ComputerStatusResult | null>;
- /**
- * One-shot connectivity probe (`POST /computers/:alias/test`): Dispatch opens
- * an SSH connection to the alias, runs a trivial command, then closes. `ok` is
- * true on success; `error` carries the failure reason otherwise.
- */
- testComputer(alias: string): Promise<TestComputerResult | null>;
- /**
- * The workspace conversation's persisted reasoning effort, or null when never
- * set (the server then resolves turns at the default, `"high"`).
- */
- readonly reasoningEffort: ReasoningEffort | null;
- /**
- * Persist the workspace conversation's reasoning effort
- * (`PUT /conversations/:id/reasoning-effort`). Works for a draft too (its id
- * survives promotion), so the first turn already runs at the chosen level.
- * Takes effect from the NEXT turn; resolution stays server-owned.
- */
- setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null>;
- /**
- * Manually trigger conversation compaction (`POST /conversations/:id/compact`).
- * Summarizes old messages + retains the most recent N. Returns null when no
- * conversation is focused (a draft has nothing to compact).
- */
- compactNow(keepLastN?: number): Promise<CompactResult | null>;
- /**
- * Stop an in-flight generation (`POST /conversations/:id/stop`). Aborts the
- * turn without closing the conversation — partial messages are persisted, the
- * turn seals with `reason: "aborted"`, and the conversation goes `active → idle`.
- * Returns null when no conversation is focused.
- */
- stopGeneration(): void;
- /**
- * The workspace conversation's auto-compact percent (0-100). `0` = disabled
- * (manual only); a positive number = auto-compact triggers when the last
- * turn's input tokens exceed it. Seeded from the backend on focus change.
- */
- readonly compactPercent: number | null;
- /**
- * Persist the workspace conversation's auto-compact percent
- * (`PUT /conversations/:id/compact-percent`). `0` disables; 1-100 sets the
- * trigger percentage of the model's context window. Default (null) is 85.
- * number enables. Works for a draft too (its id survives promotion).
- */
- setCompactPercent(percent: number): Promise<CompactPercentResult | null>;
- /**
- * The GLOBAL vision settings (`GET /settings/vision`): `imageLimit` (max
- * native images per turn before compaction; 0 = disabled) + `compactionModel`
- * (which vision model transcribes old images; null = auto). Shared across all
- * conversations. Seeded on boot; `null` = not yet fetched.
- */
- readonly visionSettings: VisionSettings | null;
- /**
- * Refetch the global vision settings (`GET /settings/vision`). Called by the
- * vision-settings view on mount; also seeded on boot.
- */
- refreshVisionSettings(): Promise<void>;
- /**
- * Save a PARTIAL vision-settings update (`PUT /settings/vision`). Either
- * field may be omitted. Returns the merged settings on success.
- */
- setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | null>;
- /**
- * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`).
- * The backend lazily spawns servers, so this may take a moment on the first call for a cwd.
- */
- lspStatus(): Promise<LspResult | null>;
- /**
- * Fetch the workspace conversation's MCP server status (`GET /conversations/:id/mcp`).
- * Mirrors the LSP status endpoint: returns `{cwd, servers}` with empty `servers`
- * when no cwd is set; the backend lazily connects servers, so this may take a
- * moment on the first call for a cwd.
- */
- mcpStatus(): Promise<McpResult | null>;
- /**
- * Load the global system prompt template (`GET /system-prompt`). The template is
- * conversation-agnostic; it is resolved once per conversation on first turn and
- * persisted for prompt-cache safety.
- */
- loadSystemPrompt(): Promise<SystemPromptLoadResult>;
- /**
- * Persist the global system prompt template (`PUT /system-prompt`). Changes apply
- * to new conversations on their first turn; existing conversations keep their
- * resolved system prompt until compaction.
- */
- setSystemPrompt(template: string): Promise<SystemPromptSaveResult>;
- /**
- * Load the static catalog of available system prompt variables (`GET /system-prompt/variables`).
- * Used by the builder to render the variable selector buttons.
- */
- loadSystemPromptVariables(): Promise<SystemPromptVariablesResult>;
- /** The persisted chat limit (max loaded chunks per conversation). */
- readonly chatLimit: number;
- /**
- * A conversation's backend lifecycle status (`active`/`idle`/`closed`), or
- * `undefined` when unknown. Drives the tab-bar generating indicator
- * (cross-device: a tab spinning because another device's turn is running).
- */
- conversationStatus(conversationId: string): ConversationStatus | undefined;
- /**
- * Whether at least one conversation in the given workspace is currently
- * active or queued (generating / waiting for a concurrency slot) — drives
- * the loading-dots indicator on workspace cards. Backed by a once-derived
- * `activeWorkspaces` set (the open-tab set × the backend lifecycle statuses)
- * so this is an O(1) lookup, not a per-card scan of the full tab list.
- * Reactive: the set is a `$derived`, so a Svelte template expression calling
- * this re-runs when the tab set or status map changes.
- */
- workspaceHasActiveConversations(workspaceId: string): boolean;
- /**
- * Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to
- * localStorage and propagates to every live chat store (trim if lower,
- * deferred via the unload gate while a reader is scrolled up; no-op if
- * higher — page unloaded history back in via "Show earlier"). Stores created
- * afterwards pick the new limit up at creation. Always succeeds (FE-local).
- */
- setChatLimit(limit: number): Promise<ChatLimitResult>;
- /**
- * Wire the chat-limit unload gate (composition-root injection, called once by
- * the shell after it owns the scroll region): unloading old chunks is allowed
- * only while the gate returns true — i.e. the reader is stuck to the bottom —
- * so a trim never yanks content out from under someone reading history.
- * Before attachment unloading is allowed (the initial view starts at the
- * bottom).
- */
- attachUnloadGate(gate: () => boolean): void;
- /**
- * Load the active workspace's heartbeat config
- * (`GET /workspaces/:id/heartbeat`). Workspace-scoped (NOT per-conversation):
- * the backend runs an autonomous agent loop on a configured interval, writing
- * each run into a dedicated conversation. The config covers the system/task
- * prompts, model, reasoning effort, interval, and an enabled flag.
- */
- heartbeatConfig(): Promise<HeartbeatConfigResult>;
- /**
- * Persist a partial heartbeat config patch
- * (`PUT /workspaces/:id/heartbeat`). The backend merges the patch onto the
- * stored config; returns the full updated config.
- */
- setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult>;
- /**
- * Load the active workspace's heartbeat run history
- * (`GET /workspaces/:id/heartbeat/runs`). Each run references the conversation
- * it wrote to — open one via {@link watchConversation} to see its chat live.
- */
- heartbeatRuns(): Promise<HeartbeatRunsResult>;
- /**
- * Stop a running heartbeat run (`POST /workspaces/:id/heartbeat/runs/:runId/stop`).
- * The run's in-flight turn seals (its conversation keeps streaming until it
- * ends); the run's status flips to `stopped` (visible on the next runs poll).
- */
- stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult>;
- /**
- * Fetch the server-authoritative next-run timestamp
- * (`GET /workspaces/:id/heartbeat/next-run`) — when the next heartbeat run
- * will fire (ISO 8601), or null when disabled / no run scheduled. The FE shows
- * a live countdown from this. When the endpoint is absent (404 — backend
- * hasn't shipped CR-HB-3 yet) it returns `ok: false` so the FE falls back to
- * an approximation from the runs + config.
- */
- heartbeatNextRun(): Promise<HeartbeatNextRunResult>;
- /**
- * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
- * modal): ensures a live {@link ChatStore} for the conversation, subscribing
- * to its turn stream (`chat.subscribe`) + loading history. Reuses the open
- * tab's store if the conversation is already a tab; otherwise creates an
- * EPHEMERAL watch store (separate from tabs — never opens a tab). Deltas are
- * routed to it automatically. Pair every open with {@link unwatchConversation}
- * on close to unsubscribe + dispose the ephemeral store.
- */
- watchConversation(conversationId: string): ChatStore;
- /** Dispose + unsubscribe a watch opened by {@link watchConversation}. */
- unwatchConversation(conversationId: string): void;
- /**
- * Load all configured per-provider concurrency limits
- * (`GET /concurrency/limits`). Global (not workspace-scoped). Returns an empty
- * list when the concurrency extension isn't loaded (`{ limits: [] }`).
- */
- concurrencyLimits(): Promise<ConcurrencyLimitsResult>;
- /**
- * Fetch the configured limit for one provider
- * (`GET /concurrency/limits/:providerId`). `404` (no limit configured) and
- * `503` (extension not loaded) both surface as `ok: false`.
- */
- getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult>;
- /**
- * Set or update a provider's concurrency limit
- * (`PUT /concurrency/limits/:providerId`, body `{ limit }`). `limit` must be a
- * positive integer (a non-positive body is `400`). At the cap, further requests
- * queue oldest-agent-first rather than being sent immediately.
- */
- setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult>;
- /**
- * Remove a provider's concurrency limit (`DELETE /concurrency/limits/:providerId`),
- * making it unlimited. `404` (not configured) and `503` (extension not loaded)
- * both surface as `ok: false`.
- */
- deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult>;
- /**
- * Fetch live concurrency status for every provider with a configured limit
- * (`GET /concurrency/status`): in-flight slots held, agents queued, and a paused
- * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Each
- * entry also carries the per-slot release `cooldownMs` + an `autoReduced` flag
- * (true when a 429 auto-reduced the limit by 1; the FE renders a banner). Returns
- * an empty list when the extension isn't loaded (`{ providers: [] }`).
- */
- concurrencyStatus(): Promise<ConcurrencyStatusResult>;
- /**
- * Fetch the per-slot release cooldown (ms) for one provider
- * (`GET /concurrency/cooldown/:providerId`). `404` (no concurrency config at
- * all) and `503` (extension not loaded) both surface as `ok: false`.
- */
- getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult>;
- /**
- * Set the per-slot release cooldown (ms) for one provider
- * (`PUT /concurrency/cooldown/:providerId`, body `{ cooldownMs }`). `cooldownMs`
- * must be a non-negative integer (0 = no cooldown / instant re-admission); an
- * invalid body is `400`. Persists + applies to subsequently recycled slots.
- */
- setConcurrencyCooldown(
- providerId: string,
- cooldownMs: number,
- ): Promise<ConcurrencyCooldownResult>;
- /**
- * A critical error that blocks normal operation (e.g. the cross-device tab
- * restore fetch failed). When non-null, a full-screen modal is shown with the
- * error details. Cleared by `clearFatalError` (the modal's dismiss button).
- */
- readonly fatalError: string | null;
- /** Dismiss the fatal error (called by the error modal's X button). */
- clearFatalError(): void;
- dispose(): void;
-}
-
-export interface CreateAppStoreOptions {
- url?: string;
- httpUrl?: string;
- socketFactory?: (url: string) => WebSocketLike;
- fetchImpl?: typeof fetch;
- indexedDB?: IDBFactory;
- conversationId?: string;
- localStorage?: Storage;
- /** The workspace to scope to at boot (its URL slug); "default" if absent. */
- workspaceId?: string;
-}
-
-function createHistorySync(httpBase: string, fetchImpl: typeof fetch): HistorySync {
- return async (conversationId, sinceSeq, window) => {
- let url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}?sinceSeq=${sinceSeq}`;
- // CR-5 windowing ([email protected]): both must be positive
- // integers when present (the server 400s otherwise; callers guarantee it).
- if (window?.limit !== undefined) url += `&limit=${window.limit}`;
- if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`;
- const res = await fetchImpl(url);
- if (!res.ok) {
- throw new Error(`History sync failed: ${res.status}`);
- }
- return (await res.json()) as ConversationHistoryResponse;
- };
-}
-
-function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSync {
- return async (conversationId: string) => {
- const url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}/metrics`;
- const res = await fetchImpl(url);
- if (!res.ok) return { turns: [] };
- return (await res.json()) as ConversationMetricsResponse;
- };
-}
+import { createWsHandlers } from "./ws-handlers";
+
+const DEFAULT_MODEL = "opencode-1/deepseek-v4-flash";
+
+export type {
+ AppStore,
+ ChatLimitResult,
+ CompactPercentResult,
+ CompactResult,
+ ComputerResult,
+ ComputerStatusResult,
+ CreateAppStoreOptions,
+ CwdResult,
+ LspResult,
+ McpResult,
+ ReasoningEffortResult,
+ SystemPromptLoadResult,
+ SystemPromptSaveResult,
+ SystemPromptVariablesResult,
+ TestComputerResult,
+ ThinkingResult,
+ VisionSettingsResult,
+ WarmResult,
+} from "./store-types";
export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
- let protocol = $state<ProtocolState>(protocolInitialState());
- let models = $state<readonly string[]>([]);
- let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({});
- // Discovered SSH computers (`GET /computers`). Global (like `models`); empty
- // until the `ssh` extension lands. Read-only — no CRUD (the user edits their
- // `~/.ssh/config`).
- let computers = $state<readonly ComputerEntry[]>([]);
let activeModel = $state(DEFAULT_MODEL);
+ // The active conversation's Named Agent snapshot (null when selected via a raw
+ // model). Derived from the active tab; survives a Named Agent being edited.
+ let namedAgentSnapshot = $state<NamedAgentSelectionSnapshot | null>(null);
let fatalError = $state<string | null>(null);
// The workspace currently in view (its URL slug); "default" until routing
@@ -562,127 +106,82 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB;
const localStorageOpt = opts?.localStorage ?? globalThis.localStorage;
- const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", {
+ // ── Plugins (plan 07) ──────────────────────────────────────────────────────
+ // The plugin-manager + per-workspace enablement state lives in its own
+ // feature store (one instance per app store, passed explicitly to the UI).
+ // The app store wires the server's `extensions-changed` broadcast into it
+ // (§11 multi-client coherence) and refreshes the workspace-scoped model
+ // picker + guardrail on the same event.
+ const pluginsHttp = createPluginsHttp(httpBase, fetchImpl);
+ const pluginsStore = createPluginsStore(pluginsHttp);
+
+ // ── Layout (plan 02) ─────────────────────────────────────────────────────────
+ // The ACTIVE workspace's tile tree, owned by the backend's layout provider
+ // plugin when one is active (its well-known routes + the `layout` WS push)
+ // and rendered from the local default otherwise (no-provider mode: edits
+ // ephemeral, logged once, no error UI). Probes run on boot, on every
+ // workspace switch, and on `extensions-changed` (§2.6); the one-way
+ // localStorage migration (§2.7) arms on the boot probe.
+ const layoutHttp = createLayoutHttp(httpBase, fetchImpl);
+ const layoutStore = createLayoutStore({
+ http: layoutHttp,
+ workspaceId: activeWorkspaceId,
storage: localStorageOpt,
+ knownViews: listPanelViews().map((v) => v.id),
});
- const tabsStore: TabsStore = createTabsStore(storageAdapter);
- // The chat limit (max loaded chunks per conversation) — a persisted local
- // setting surfaced in the sidebar's Settings view. Reactive so the field +
- // any live-apply re-trim update together. The default is written back on
- // first run so the knob is discoverable in localStorage too.
- const chatLimitStore = createLocalStore<number>("dispatch.chatLimit", {
+ // ── Home tile-layout (the synced Win8-Start grid) ─────────────────────────
+ // ONE shared document PER device orientation (`wide` = landscape/desktop,
+ // `tall` = portrait/mobile), persisted by the backend's home-tile-layout
+ // plugin and broadcast over WS (`tile-layout`). The home page locks the
+ // active orientation to the resolved grid geometry and renders
+ // `tileLayout.entries`; this store owns the probes, the WS-push filter,
+ // the debounced PUT + rollback, and the offline degrade.
+ const tileLayoutStore = createTileLayoutStore({
+ http: createTileLayoutHttp(httpBase, fetchImpl),
+ initialMode:
+ typeof window === "undefined"
+ ? "wide"
+ : geometryFor(window.innerWidth, window.innerHeight).mode,
storage: localStorageOpt,
});
- const storedChatLimit = chatLimitStore.load();
- const normalizedChatLimit = normalizeChatLimit(storedChatLimit);
- let chatLimit = $state(normalizedChatLimit);
- if (storedChatLimit === null) {
- chatLimitStore.save(normalizedChatLimit);
- }
- // Unload gate — attached by the shell once it owns the scroll region (see
- // `AppStore.attachUnloadGate`). Until then, unloading is allowed.
- let unloadGate: (() => boolean) | null = null;
-
- const cache: ConversationCache = createConversationCache(
- createIdbChunkStore({ indexedDB: indexedDBFactory }),
- );
-
- const historySync = createHistorySync(httpBase, fetchImpl);
- const metricsSync = createMetricsSync(httpBase, fetchImpl);
-
- const chatStores = new Map<string, ChatStore>();
-
- // Ephemeral chat stores for MODAL viewers (the heartbeat run-chat modal): a
- // watch on a conversation's live turn stream WITHOUT opening a tab. Separate
- // from `chatStores` (tabs) so closing a modal never disturbs the tab strip,
- // and a tab's conversation reuses its own store (see `watchConversation`).
- // Deltas are routed here in addition to `chatStores`.
- const watchStores = new Map<string, ChatStore>();
-
- function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore {
- return createChatStore({
- conversationId,
- model,
- workspaceId,
- transport: {
- send(msg) {
- socket?.send(msg);
- },
- },
- historySync,
- metricsSync,
- cache,
- // Read from the persisted store (kept in sync with the reactive `chatLimit`
- // by `setChatLimit` + boot) so this snapshot doesn't reference the `$state`
- // — each store captures its limit at creation; live updates go through
- // `setChatLimit`.
- chatLimit: normalizeChatLimit(chatLimitStore.load()),
- canUnload: () => (unloadGate === null ? true : unloadGate()),
- onError: (context, err) => {
- reportError(`${context} (conversation: ${conversationId})`, err);
- },
- });
- }
+ const refreshers = createSettingsRefreshers({
+ httpBase,
+ fetchImpl,
+ activeWorkspaceId: () => activeWorkspaceId,
+ reportError,
+ });
- const initialDraftId = randomId();
- // Read `activeWorkspaceId` with untrack to suppress Svelte's
- // `state_referenced_locally` warning — this intentionally captures the
- // INITIAL workspace for the boot draft. When the workspace changes later,
- // `setActiveWorkspace` creates a fresh draft store with the new id.
- let draftStore: ChatStore = createChatFor(
- initialDraftId,
- DEFAULT_MODEL,
- untrack(() => activeWorkspaceId),
- );
- let draftConversationId: string = initialDraftId;
-
- let activeChat = $state<ChatStore>(draftStore as ChatStore);
-
- // The active conversation's persisted working directory (per-tab). Seeded from
- // the backend on focus change; null for a draft / when unset.
- let cwd = $state<string | null>(null);
-
- /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */
- async function refreshCwd(): Promise<void> {
- const id = workspaceConversationId();
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`);
- if (!res.ok) return;
- const data = (await res.json()) as CwdResponse;
- // Guard a slow response losing a race with a conversation switch.
- if (workspaceConversationId() === id) cwd = data.cwd ?? null;
- } catch (err) {
- reportError("Failed to load working directory", err);
- }
- }
+ const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", {
+ storage: localStorageOpt,
+ });
+ const tabsStore: TabsStore = createTabsStore(storageAdapter);
- // The active conversation's persisted computer (SSH Host alias). Seeded on
- // focus change; null = local / never set (inherits the workspace default).
- let computerId = $state<string | null>(null);
+ const registry = createChatRegistry({
+ storage: localStorageOpt,
+ tabsStore,
+ createCache: () =>
+ createConversationCache(createIdbChunkStore({ indexedDB: indexedDBFactory })),
+ historySync: createHistorySync(httpBase, fetchImpl),
+ metricsSync: createMetricsSync(httpBase, fetchImpl),
+ defaultModel: DEFAULT_MODEL,
+ activeModel: () => activeModel,
+ activeWorkspaceId: () => activeWorkspaceId,
+ send: (msg) => socket?.send(msg),
+ reportError,
+ chatFlushScheduler: opts?.chatFlushScheduler,
+ });
- /**
- * Refetch the workspace conversation's persisted computer into reactive state
- * (works for a draft too). A draft's id 404s until promoted; `res.ok` is false
- * so it is a silent no-op (mirrors `refreshCwd` for a draft).
- */
- async function refreshComputer(): Promise<void> {
- const id = workspaceConversationId();
- // Clear immediately so a switch never shows the PREVIOUS conversation's
- // computer while the fetch is in flight (null renders as "Local").
- computerId = null;
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/computer`);
- if (!res.ok) return;
- const data = (await res.json()) as ConversationComputerResponse;
- // Guard a slow response losing a race with a conversation switch.
- if (workspaceConversationId() === id) computerId = data.computerId ?? null;
- } catch (err) {
- reportError("Failed to load computer", err);
- }
- }
+ const cache = registry.cache;
+ const settings = createConversationSettings({
+ httpBase,
+ fetchImpl,
+ workspaceConversationId,
+ activeWorkspaceId: () => activeWorkspaceId,
+ reportError,
+ });
/** Refetch the workspace conversation's persisted model (works for a draft too). */
async function refreshModel(): Promise<void> {
const id = workspaceConversationId();
@@ -691,14 +190,21 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
if (!res.ok) return;
const data = (await res.json()) as ModelResponse;
if (workspaceConversationId() !== id) return;
+ // The backend is authoritative for the persisted snapshot: restore how the
+ // conversation was selected (a Named Agent, or none). Falls back to the tab's
+ // local snapshot when the response omits it (older backend).
+ namedAgentSnapshot = data.namedAgent ?? namedAgentSnapshot;
if (typeof data.model === "string" && data.model.length > 0) {
activeModel = data.model;
const activeId = tabsStore.activeConversationId;
if (activeId !== null) {
tabsStore.setModel(activeId, data.model);
- chatStores.get(activeId)?.setModel(data.model);
+ tabsStore.setSnapshot(activeId, namedAgentSnapshot);
+ registry.chatStores.get(activeId)?.setModel(data.model);
+ registry.chatStores.get(activeId)?.setNamedAgent(namedAgentSnapshot);
} else {
- draftStore.setModel(data.model);
+ registry.draftStore.setModel(data.model);
+ registry.draftStore.setNamedAgent(namedAgentSnapshot);
}
}
} catch (err) {
@@ -706,174 +212,6 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
}
- // The workspace conversation's persisted reasoning effort. Seeded from the
- // backend on focus change; null = never set (the server default applies).
- let reasoningEffort = $state<ReasoningEffort | null>(null);
-
- /** Refetch the workspace conversation's reasoning effort (works for a draft too). */
- async function refreshReasoningEffort(): Promise<void> {
- const id = workspaceConversationId();
- // Clear immediately so a switch never shows the PREVIOUS conversation's level
- // while the fetch is in flight (null renders as the server default).
- reasoningEffort = null;
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`,
- );
- if (!res.ok) return;
- const data = (await res.json()) as ReasoningEffortResponse;
- // Guard a slow response losing a race with a conversation switch.
- if (workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null;
- } catch (err) {
- reportError("Failed to load reasoning effort", err);
- }
- }
-
- // The workspace conversation's auto-compact percent. Seeded from the
- // backend on focus change; null = not yet fetched. 0 = disabled.
- let compactPercent = $state<number | null>(null);
-
- // The GLOBAL vision settings (shared across all conversations). Seeded on
- // boot; null = not yet fetched.
- let visionSettings = $state<VisionSettings | null>(null);
-
- /** Refetch the global vision settings (`GET /settings/vision`). */
- async function refreshVisionSettings(): Promise<void> {
- try {
- const res = await fetchImpl(`${httpBase}/settings/vision`);
- if (!res.ok) return;
- const data = normalizeVisionSettings(await res.json());
- visionSettings = data;
- } catch (err) {
- reportError("Failed to load vision settings", err);
- }
- }
-
- /** Refetch the workspace conversation's compact percent (works for a draft too). */
- async function refreshCompactPercent(): Promise<void> {
- const id = workspaceConversationId();
- compactPercent = null;
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`,
- );
- if (!res.ok) return;
- const data = (await res.json()) as CompactPercentResponse;
- if (workspaceConversationId() === id) compactPercent = data.threshold;
- } catch (err) {
- reportError("Failed to load compact percent", err);
- }
- }
-
- function getActiveChat(): ChatStore {
- const activeId = tabsStore.activeConversationId;
- if (activeId === null) {
- return draftStore;
- }
- return chatStores.get(activeId) ?? draftStore;
- }
-
- function refreshActiveChat(): void {
- activeChat = getActiveChat();
- }
-
- function handleChatMessage(msg: ChatDeltaMessage | ChatErrorMessage): void {
- let targetId: string | undefined;
- if (msg.type === "chat.delta") {
- targetId = msg.event.conversationId;
- } else {
- targetId = msg.conversationId;
- }
-
- if (targetId !== undefined) {
- const store = chatStores.get(targetId) ?? watchStores.get(targetId);
- if (store !== undefined) {
- store.handleDelta(msg);
- return;
- }
- }
-
- // fallback: try all stores (chat.error without conversationId)
- for (const store of chatStores.values()) {
- store.handleDelta(msg);
- }
- for (const store of watchStores.values()) {
- store.handleDelta(msg);
- }
- }
-
- /**
- * Start watching a conversation's live turn events (`chat.subscribe`). Sent for
- * EVERY open conversation — not just the active one — so a backgrounded tab keeps
- * streaming a running turn, and a reloaded/second client re-attaches to an
- * in-flight turn (the server replays it from `turn-start`). Idempotent server-side;
- * the socket queues it until the connection is open. NOT needed right after
- * `chat.send` (that auto-subscribes the sending connection).
- */
- function subscribeChat(conversationId: string): void {
- socket?.send({ type: "chat.subscribe", conversationId });
- }
-
- /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */
- function unsubscribeChat(conversationId: string): void {
- socket?.send({ type: "chat.unsubscribe", conversationId });
- }
-
- /**
- * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
- * modal). Returns a live {@link ChatStore} for the conversation's turn stream.
- * If the conversation is already an open TAB, reuses its store (it is already
- * subscribed + streaming); otherwise creates an EPHEMERAL watch store in
- * `watchStores` (separate from tabs — never opens a tab), subscribes to its
- * live turn stream, and loads history. Deltas route to it via `handleChatMessage`.
- * Pair with {@link unwatchConversation} on close.
- */
- function watchConversation(conversationId: string): ChatStore {
- // An open tab already has a live store + subscription — reuse it.
- const tabStore = chatStores.get(conversationId);
- if (tabStore !== undefined) return tabStore;
- const existing = watchStores.get(conversationId);
- if (existing !== undefined) return existing;
- const store = createChatFor(conversationId, activeModel, activeWorkspaceId);
- watchStores.set(conversationId, store);
- void store.load();
- subscribeChat(conversationId);
- return store;
- }
-
- /**
- * Dispose + unsubscribe a watch opened by {@link watchConversation}. A no-op if
- * the conversation was (or became) an open TAB — the tab owns its store +
- * subscription, so nothing is torn down (closing the modal must not disturb the
- * tab strip). Only the ephemeral watch store is disposed + unsubscribed.
- */
- function unwatchConversation(conversationId: string): void {
- // A tab reuses its own store — leave it (and its subscription) intact.
- if (chatStores.has(conversationId)) return;
- const store = watchStores.get(conversationId);
- if (store === undefined) return;
- store.dispose();
- watchStores.delete(conversationId);
- unsubscribeChat(conversationId);
- }
-
- /**
- * Tell the backend the user EXPLICITLY closed this conversation's tab
- * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with
- * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF).
- * Distinct from a disconnect / `chat.unsubscribe`, which deliberately leave
- * both running. Fire-and-forget: a failure is non-fatal (worst case the
- * warming keeps running until a later close/toggle), and the endpoint is
- * idempotent server-side.
- */
- function closeConversation(conversationId: string): void {
- void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, {
- method: "POST",
- }).catch((err) => {
- reportError("Failed to close conversation", err);
- });
- }
-
/** The conversation the surfaces should scope to (undefined for a draft). */
function focusedConversationId(): string | undefined {
return tabsStore.activeConversationId ?? undefined;
@@ -886,97 +224,35 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
* promotion (first send), so a cwd set on a draft carries into the real turn.
*/
function workspaceConversationId(): string {
- return tabsStore.activeConversationId ?? draftConversationId;
+ return tabsStore.activeConversationId ?? registry.draftConversationId;
}
- function handleServerMessage(msg: SurfaceServerMessage): void {
- protocol = applyServerMessage(protocol, msg);
- // Surfaces are auto-expanded: whenever the catalog changes, subscribe to
- // every entry (and drop subscriptions for entries that vanished).
- if (msg.type === "catalog") {
- syncSubscriptions();
- }
- }
-
- /**
- * Subscribe to every catalog entry, scoped to the focused conversation, and
- * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped
- * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe`
- * emits unsubscribe-old + subscribe-new); a global surface ignores the id.
- */
- function syncSubscriptions(): void {
- const cid = focusedConversationId();
- for (const entry of protocol.catalog) {
- // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id
- // so a conversation switch doesn't churn a redundant unsubscribe+subscribe
- // round trip ([email protected] catalog `scope`; ABSENT = assume
- // conversation-scoped, the conservative pre-0.2.0 policy).
- const scoped = entry.scope === "global" ? undefined : cid;
- const result = protocolSubscribe(protocol, entry.id, scoped);
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.send(msg);
- }
- }
- const catalogIds = new Set(protocol.catalog.map((e) => e.id));
- for (const id of [...protocol.subscriptions.keys()]) {
- if (!catalogIds.has(id)) {
- const result = protocolUnsubscribe(protocol, id);
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.send(msg);
- }
- }
- }
- }
+ const surfaces = createSurfaceProtocol({
+ focusedConversationId,
+ send: (msg) => socket?.send(msg),
+ });
let socket: ReturnType<typeof createSurfaceSocket> | null = null;
- /**
- * Open a conversation tab — used by the `conversation.open` WS broadcast
- * (CLI `--open` flag) and by `conversation.statusChanged` when a new active
- * conversation is discovered. If the conversation is already open, this is a
- * no-op; otherwise create a chat store, load its history, subscribe to its live
- * turns, and add the tab WITHOUT switching the active conversation (the user
- * stays on their current tab; the new tab appears in the strip). The tab is
- * stamped with the conversation's actual `workspaceId`, NOT the viewer's
- * currently active workspace.
- */
- function openConversation(conversationId: string, workspaceId: string): void {
- if (chatStores.has(conversationId)) return;
- const store = createChatFor(conversationId, activeModel, workspaceId);
- chatStores.set(conversationId, store);
- void store.load();
- subscribeChat(conversationId);
- tabsStore.openTab({
- conversationId,
- model: activeModel,
- title: "Conversation",
- workspaceId,
- });
- }
-
- /**
- * Remove a tab + its chat store locally (NO `POST /close` — used when the
- * backend already marked the conversation `closed` via `conversation.statusChanged`).
- */
- function removeTabLocally(conversationId: string): void {
- unsubscribeChat(conversationId);
- const store = chatStores.get(conversationId);
- if (store !== undefined) {
- store.dispose();
- chatStores.delete(conversationId);
- }
- void cache.delete(conversationId);
- tabsStore.closeTab(conversationId);
- conversationStatuses.delete(conversationId);
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
- void refreshReasoningEffort();
- void refreshCompactPercent();
- }
+ const conversations = createOpenConversations({
+ httpBase,
+ fetchImpl,
+ tabsStore,
+ registry,
+ cache,
+ activeModel: () => activeModel,
+ settings,
+ syncSubscriptions: surfaces.syncSubscriptions,
+ reportError,
+ });
+ const httpActions = createHttpActions({
+ httpBase,
+ fetchImpl,
+ activeConversationId: () => tabsStore.activeConversationId,
+ activeModel: () => activeModel,
+ activeWorkspaceId: () => activeWorkspaceId,
+ reportError,
+ });
/**
* Surface a swallowed error to the user via the full-screen error modal
@@ -992,229 +268,133 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
fatalError = `${context}\n\n${detail}`;
}
- // Conversation lifecycle status (backend-owned, pushed via WS +
- // fetched on connect). Keyed by conversationId.
- let conversationStatuses = $state<Map<string, ConversationStatus>>(new Map());
-
- // The set of workspaces with ≥1 active/queued conversation, derived ONCE
- // (not recomputed per card). Every active/queued conversation has an open
- // tab stamped with its workspace, so the tabs are the conversation→workspace
- // map; cross-reference with the lifecycle statuses. `$derived` recomputes
- // lazily when the tab set or status map changes, so each
- // `workspaceHasActiveConversations` call is an O(1) lookup instead of a scan
- // of the full tab list per card.
- const activeWorkspaces = $derived.by(() => {
- const out = new Set<string>();
- for (const tab of tabsStore.tabs) {
- const status = conversationStatuses.get(tab.conversationId);
- if (status === "active" || status === "queued") out.add(tab.workspaceId);
- }
- return out;
+ // ── Frontend plugins (plan 03) ─────────────────────────────────────────────
+ // The ABI driver's composition-root wiring: one host instance per app
+ // store. `sync()` runs on boot, on `extensions-changed`, and on every
+ // workspace switch (the three load-lifecycle triggers — the active
+ // workspace is read per call, which makes workspace.set meaningful).
+ // The surfaces ports delegate to the SAME protocol/socket machinery the
+ // app store's own surface methods use (closure functions below, shared
+ // with the returned object — no second protocol state).
+ const pluginHost = createPluginHost({
+ http: pluginsHttp,
+ httpBase,
+ reporter: pluginsStore,
+ activeWorkspaceId: () => activeWorkspaceId,
+ surfaces: {
+ subscribe: (surfaceId, conversationId) =>
+ surfaces.subscribeSurfaceOp(surfaceId, conversationId),
+ unsubscribe: (surfaceId, conversationId) =>
+ surfaces.unsubscribeSurfaceOp(surfaceId, conversationId),
+ invoke: (surfaceId, actionId, payload, conversationId) =>
+ surfaces.invokeSurfaceOp(surfaceId, actionId, payload, conversationId),
+ getSpec: (surfaceId) => surfaces.surfaceSpecOp(surfaceId),
+ },
+ // plan 04 §4.5: the plugins' `api.layout` port — applyOps posts to the
+ // ACTIVE workspace's provider (the ops route) and feeds the returned
+ // tree back through the store (instant local echo; the WS broadcast is
+ // every client's authoritative copy); getTree exposes the active tree
+ // only while a provider is PRESENT (null = no-provider mode).
+ layout: createLayoutAbiPort({
+ store: layoutStore,
+ postOps: (workspaceId, ops) => layoutHttp.postOps(workspaceId, ops),
+ }),
});
- /**
- * Fetch `GET /conversations?status=active,idle` on connect to restore the
- * tab bar across devices. Merges: opens tabs for conversations not already
- * open, removes tabs for conversations that are no longer active/idle
- * (closed on another device), and subscribes to `active` conversations'
- * live streams.
- */
- async function fetchOpenConversations(): Promise<void> {
- try {
- const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`);
- if (!res.ok) return;
- const data = (await res.json()) as ConversationListResponse;
-
- // Update the status map from the authoritative backend list.
- const newStatuses = new Map<string, ConversationStatus>();
- for (const conv of data.conversations) {
- newStatuses.set(conv.id, conv.status);
- }
- conversationStatuses = newStatuses;
-
- // Open tabs for conversations not already open.
- const existingIds = new Set(chatStores.keys());
- for (const conv of data.conversations) {
- if (!existingIds.has(conv.id)) {
- const store = createChatFor(conv.id, activeModel, conv.workspaceId);
- chatStores.set(conv.id, store);
- void store.load();
- subscribeChat(conv.id);
- tabsStore.openTab({
- conversationId: conv.id,
- model: activeModel,
- title: conv.title,
- workspaceId: conv.workspaceId,
- });
- } else {
- // Already open — update the title from the backend if it differs.
- tabsStore.setTitle(conv.id, conv.title);
- }
- }
-
- // Remove tabs for conversations no longer active/idle (closed elsewhere).
- const backendIds = new Set(data.conversations.map((c) => c.id));
- for (const tab of tabsStore.tabs) {
- if (!backendIds.has(tab.conversationId)) {
- removeTabLocally(tab.conversationId);
- }
- }
- } catch (err) {
- reportError(
- `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`,
- err,
- );
- }
- }
-
- const socketOpts: SurfaceSocketOptions = {
+ const socketOpts = createWsHandlers({
url: wsUrl,
- onMessage: handleServerMessage,
- onChat: handleChatMessage,
- onConversationOpen(msg: ConversationOpenMessage): void {
- openConversation(msg.conversationId, msg.workspaceId);
- },
- onConversationStatusChanged(msg: ConversationStatusChangedMessage): void {
- const { conversationId, status, workspaceId } = msg;
- if (status === "closed") {
- // Closed on another device (or the backend) — remove the tab locally.
- if (chatStores.has(conversationId)) {
- removeTabLocally(conversationId);
- }
- return;
- }
- // active / queued / idle — update the status map (drives the tab spinner).
- // `queued` = the turn is in flight but waiting for a concurrency slot
- // (broadcast-only, never persisted — CR-13); the tab shows a ring.
- conversationStatuses = new Map(conversationStatuses).set(conversationId, status);
- // If this is a new active OR queued conversation we don't have a tab for,
- // open one — so a cross-device turn (incl. one waiting in the concurrency
- // queue) is visible. `idle` never opens a tab.
- if ((status === "active" || status === "queued") && !chatStores.has(conversationId)) {
- openConversation(conversationId, workspaceId);
- }
- },
- onConversationCompacted(msg: ConversationCompactedMessage): void {
- // Compaction keeps the conversation ID — the old full history is forked
- // to an archive (newConversationId). Just reload the same conversation's
- // history (dispose stale store + cache + re-fetch).
- const cid = msg.conversationId;
- const wasActive = tabsStore.activeConversationId === cid;
- const store = chatStores.get(cid);
- if (store !== undefined) {
- store.dispose();
- }
- void cache.delete(cid);
- const fresh = createChatFor(cid, activeModel, activeWorkspaceId);
- chatStores.set(cid, fresh);
- void fresh.load();
- if (wasActive) {
- refreshActiveChat();
- }
- },
- onReopen() {
- // The server forgot our subscriptions on reconnect; re-send each with the
- // conversation it was subscribed under (protocolSubscribe would no-op since
- // they're still in our local map, so emit the wire messages directly).
- for (const [surfaceId, sub] of protocol.subscriptions) {
- const msg: SubscribeMessage =
- sub.conversationId === undefined
- ? { type: "subscribe", surfaceId }
- : { type: "subscribe", surfaceId, conversationId: sub.conversationId };
- socket?.send(msg);
- }
- // Re-attach to every open conversation's turn stream. A turn that kept
- // running while we were disconnected resumes streaming (server replays it
- // from `turn-start`); one that sealed while we were gone is committed from
- // history by `resync()` (which also clears a now-stale "generating").
- for (const tab of tabsStore.tabs) {
- subscribeChat(tab.conversationId);
- chatStores.get(tab.conversationId)?.resync();
- }
- // Re-attach to every MODAL watch too (a run-chat modal open across a
- // reconnect keeps streaming). Watch stores are separate from tabs.
- for (const [watchId, watchStore] of watchStores) {
- subscribeChat(watchId);
- watchStore.resync();
- }
- },
- };
+ send: (msg) => socket?.send(msg),
+ registry,
+ conversations,
+ surfaces,
+ cache,
+ tabsStore,
+ activeWorkspaceId: () => activeWorkspaceId,
+ workspaceSetMsg: (id) => workspaceSetMsg(id),
+ pluginsStore,
+ pluginHost,
+ layoutStore,
+ tileLayoutStore,
+ refreshers,
+ reportError,
+ });
if (opts?.socketFactory !== undefined) {
socketOpts.socketFactory = opts.socketFactory;
}
socket = createSurfaceSocket(socketOpts);
- // Fetch model catalog
- void fetchImpl(`${httpBase}/models`)
- .then((res) => {
- if (!res.ok) return;
- return res.json() as Promise<ModelsResponse>;
- })
- .then((data) => {
- if (data === undefined) return;
- models = data.models;
- modelInfo = data.modelInfo ?? {};
- if (data.models.length > 0 && !data.models.includes(activeModel)) {
- const first = data.models[0];
- if (first !== undefined) {
- activeModel = first;
- draftStore.setModel(first);
- }
- }
- })
- .catch((err) => {
- reportError("Failed to load model list", err);
- });
-
- // Fetch the discovered-computer catalog (global, like models). Empty until
- // the `ssh` extension lands — a safe no-op until then (the selector shows
- // "Local (none)" only). Non-fatal: a failure leaves an empty list.
- void fetchImpl(`${httpBase}/computers`)
- .then((res) => {
- if (!res.ok) return { computers: [] } as ComputerListResponse;
- return res.json() as Promise<ComputerListResponse>;
- })
- .then((data) => {
- computers = data?.computers ?? [];
- })
- .catch((err) => {
- reportError("Failed to load computer list", err);
- });
+ // Tell the backend THIS connection's active workspace (plan 06 §6.4). The
+ // socket queues the send until the connection opens; on receipt the server
+ // re-pushes the workspace-filtered surface catalog. Also refreshed on
+ // reconnect (onReopen — a new connection is anonymous again).
+ const workspaceSetMsg = (workspaceId: string): WorkspaceSetMessage => ({
+ type: "workspace.set",
+ workspaceId,
+ });
+ socket.send(workspaceSetMsg(activeWorkspaceId));
+
+ // Fetch the model catalog (workspace-scoped — plan 06 §6.5).
+ void refreshers.refreshModels();
+ // Fetch the active workspace's provider status (§6.5 first-boot guardrail).
+ void refreshers.refreshProviderStatus();
+ // Probe the ACTIVE layout provider (plan 02 §2.6 boot trigger): the tree is
+ // served by the backend's layout plugin when one is active. `boot: true`
+ // arms the one-way §2.7 localStorage migration on the first success.
+ void layoutStore.probe(activeWorkspaceId, { boot: true });
+ // Frontend plugins (plan 03): the load lifecycle's boot trigger — every
+ // loaded ∧ enabled ∧ frontend-declaring ∧ handshake-passing plugin is
+ // imported + activated (after the first list fetch lands).
+ void pluginHost.sync();
+ // One-time legacy model-name migration on boot (independent of the fetch;
+ // preserved from the pre-refreshModels boot chain).
+ if (migrateLegacyModelName(activeModel) !== activeModel) {
+ activeModel = migrateLegacyModelName(activeModel);
+ }
+
+ refreshers.loadGlobalCatalogs();
// Restore persisted tabs
const persistedState = storageAdapter.load();
if (persistedState !== null && persistedState.tabs.length > 0) {
for (const tab of persistedState.tabs) {
- const store = createChatFor(tab.conversationId, tab.model, tab.workspaceId);
- chatStores.set(tab.conversationId, store);
- void store.load();
+ // One-time persisted-state migration: rewrite a legacy `opencode/<model>`
+ // tab model to the numbered `opencode-1/<model>` namespace, and persist the
+ // migrated value back so a re-load is a no-op (idempotent).
+ const model = migrateLegacyModelName(tab.model);
+ if (model !== tab.model) {
+ tabsStore.setModel(tab.conversationId, model);
+ }
+ const store = registry.createChatFor(tab.conversationId, model, tab.workspaceId);
+ registry.chatStores.set(tab.conversationId, store);
// Watch each restored conversation's live turns: after a reload mid-turn the
// server replays the in-flight turn so we keep rendering it. Queued until the
// socket opens.
- subscribeChat(tab.conversationId);
+ registry.subscribeChat(tab.conversationId);
}
if (persistedState.activeConversationId !== null) {
const activeTab = persistedState.tabs.find(
(t) => t.conversationId === persistedState.activeConversationId,
);
if (activeTab !== undefined) {
- activeModel = activeTab.model;
+ activeModel = migrateLegacyModelName(activeTab.model);
+ namedAgentSnapshot = activeTab.snapshot ?? null;
}
}
}
- refreshActiveChat();
- void refreshCwd();
- void refreshComputer();
+ registry.refreshActiveChat();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
void refreshModel();
- void refreshReasoningEffort();
- void refreshCompactPercent();
- void refreshVisionSettings();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
+ void settings.refreshVisionSettings();
// Fetch the authoritative open-conversation list from the backend (cross-
// device tab sync). Merges with the localStorage-restored tabs: opens new
// ones, removes closed ones, updates titles + statuses.
- void fetchOpenConversations();
+ void conversations.fetchOpenConversations();
return {
get tabs(): readonly Tab[] {
@@ -1231,162 +411,209 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
},
setActiveWorkspace(workspaceId: string): void {
activeWorkspaceId = workspaceId;
+ // Tell the backend the switch (plan 06 §6.4): workspace switching is
+ // instant + client-side, so the server must be told explicitly — it
+ // re-pushes the workspace-filtered surface catalog on receipt. The send
+ // is idempotent + queued until the socket opens.
+ socket?.send(workspaceSetMsg(workspaceId));
// Reset to a fresh draft scoped to the new workspace so a new chat is
// stamped with the right `workspaceId` on `chat.send`.
const nextDraftId = randomId();
- draftStore = createChatFor(nextDraftId, activeModel, workspaceId);
- draftConversationId = nextDraftId;
+ registry.draftStore = registry.createChatFor(nextDraftId, activeModel, workspaceId);
+ registry.draftConversationId = nextDraftId;
tabsStore.newDraft();
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
+ namedAgentSnapshot = null;
+ registry.refreshActiveChat();
+ surfaces.syncSubscriptions();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
void refreshModel();
- void refreshReasoningEffort();
- void refreshCompactPercent();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
+ // The model picker + provider guardrail are WORKSPACE-SCOPED now
+ // (plan 06 §6.5): refetch both for the new workspace.
+ void refreshers.refreshModels();
+ void refreshers.refreshProviderStatus();
+ // The tile tree is WORKSPACE-SCOPED too (plan 02): reset to the local
+ // default for the new workspace and probe its provider (§2.6 — every
+ // workspace switch is a probe, which is also the missed-push catch-up).
+ layoutStore.onWorkspaceChanged(workspaceId);
+ // Frontend plugins (plan 03): the load lifecycle's workspace.set
+ // trigger — the enablement set is WORKSPACE-SCOPED, so a switch may
+ // load and unload frontend plugins.
+ void pluginHost.sync();
},
get activeChat(): ChatStore {
- return activeChat;
+ return registry.activeChat;
},
get models(): readonly string[] {
- return models;
+ return refreshers.models;
},
get modelInfo(): Readonly<Record<string, ModelMetadata>> {
- return modelInfo;
+ return refreshers.modelInfo;
},
get activeModel(): string {
return activeModel;
},
get catalog() {
- return protocol.catalog;
+ return surfaces.protocol.catalog;
},
get surfaces(): readonly SurfaceSpec[] {
const out: SurfaceSpec[] = [];
- for (const entry of protocol.catalog) {
- const spec = getSurfaceSpec(protocol, entry.id);
+ for (const entry of surfaces.protocol.catalog) {
+ const spec = getSurfaceSpec(surfaces.protocol, entry.id);
if (spec) out.push(spec);
}
return out;
},
get lastError() {
- return protocol.lastError;
+ return surfaces.protocol.lastError;
},
get storage() {
return localStorageOpt;
},
+ get textFileContextRevision(): number {
+ return settings.textFileContextRevision;
+ },
+ createTextFileClient(conversationId) {
+ return createTextFileClient(httpBase, conversationId, fetchImpl);
+ },
get cwd(): string | null {
- return cwd;
+ return settings.cwd;
},
get computerId(): string | null {
- return computerId;
+ return settings.computerId;
},
get computers(): readonly ComputerEntry[] {
- return computers;
+ return refreshers.computers;
},
get reasoningEffort(): ReasoningEffort | null {
- return reasoningEffort;
+ return settings.reasoningEffort;
+ },
+ get thinking(): boolean | null {
+ return settings.thinking;
},
get compactPercent(): number | null {
- return compactPercent;
+ return settings.compactPercent;
},
get visionSettings(): VisionSettings | null {
- return visionSettings;
+ return settings.visionSettings;
},
async refreshVisionSettings(): Promise<void> {
- await refreshVisionSettings();
+ await settings.refreshVisionSettings();
},
get chatLimit(): number {
- return chatLimit;
+ return registry.chatLimit;
},
conversationStatus(conversationId: string): ConversationStatus | undefined {
- return conversationStatuses.get(conversationId);
+ return conversations.conversationStatuses.get(conversationId);
},
workspaceHasActiveConversations(workspaceId: string): boolean {
// O(1) lookup into the once-derived `activeWorkspaces` set; false when the
// workspace has no active/queued conversation (or none at all).
- return activeWorkspaces.has(workspaceId);
+ return conversations.workspaceHasActiveConversations(workspaceId);
},
get currentConversationId(): string {
return workspaceConversationId();
},
- surface(surfaceId: string): SurfaceSpec | null {
- return getSurfaceSpec(protocol, surfaceId);
+ surface(surfaceId: string, conversationId?: string): SurfaceSpec | null {
+ return surfaces.surfaceSpecOp(surfaceId, conversationId);
},
send(text: string, images?: readonly ImageInput[]): void {
if (tabsStore.activeConversationId === null) {
- // Draft: promote to tab on first send
- const conversationId = draftConversationId;
+ // Draft: promote to tab on first send. Carry a Named Agent selection's
+ // snapshot so the promoted conversation retains how it was selected (§7).
+ const conversationId = registry.draftConversationId;
const model = activeModel;
+ const snapshot = namedAgentSnapshot ?? undefined;
tabsStore.createTab({
conversationId,
model,
title: deriveTitle(text),
workspaceId: activeWorkspaceId,
+ ...(snapshot ? { snapshot } : {}),
});
- chatStores.set(conversationId, draftStore);
- void draftStore.load();
+ registry.chatStores.set(conversationId, registry.draftStore);
// Prepare next draft
const nextDraftId = randomId();
- draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId);
- draftConversationId = nextDraftId;
+ registry.draftStore = registry.createChatFor(nextDraftId, activeModel, activeWorkspaceId);
+ registry.draftConversationId = nextDraftId;
- refreshActiveChat();
+ registry.refreshActiveChat();
// The draft became a real conversation: re-scope conversation-scoped
// surfaces (e.g. cache-warming) to its id.
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
- void refreshReasoningEffort();
- void refreshCompactPercent();
+ surfaces.syncSubscriptions();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
// Now send on the promoted store
- chatStores.get(conversationId)?.send(text, images);
+ registry.chatStores.get(conversationId)?.send(text, images);
} else {
- activeChat.send(text, images);
+ registry.activeChat.send(text, images);
}
},
- queueMessage(text: string): void {
+ queueMessage(text: string, images?: readonly ImageInput[]): void {
// Only offered while generating (Composer switches to `chat.queue`
// when `status === "running"`), so a draft (never generating) never
// reaches here. `chat.queue` auto-starts a turn if idle, so even a race
// (turn sealed between the status read and the send) is safe — the
// server starts a fresh turn with the message as its opening prompt.
- activeChat.queueMessage(text);
+ registry.activeChat.queueMessage(text, images);
},
- selectModel(model: string): void {
+ cancelQueuedMessage(messageId: string): void {
+ // Fire-and-forget + idempotent. The message-queue surface (conversation-
+ // scoped) reconciles the queue UI — the cancelled row leaves the snapshot.
+ // A cancel of an already-drained / unknown message is a silent no-op, so
+ // there is no local state to roll back. Delegates to the focused
+ // conversation's chat store, which owns the conversationId + transport.
+ registry.activeChat.cancelQueuedMessage(messageId);
+ },
+
+ selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void {
activeModel = model;
+ namedAgentSnapshot = snapshot ?? null;
const activeId = tabsStore.activeConversationId;
if (activeId !== null) {
tabsStore.setModel(activeId, model);
- chatStores.get(activeId)?.setModel(model);
+ tabsStore.setSnapshot(activeId, snapshot ?? null);
+ registry.chatStores.get(activeId)?.setModel(model);
+ registry.chatStores.get(activeId)?.setNamedAgent(snapshot ?? null);
+ const body: SetModelRequest = { model, ...(snapshot ? { namedAgent: snapshot } : {}) };
void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(activeId)}/model`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model } satisfies SetModelRequest),
+ body: JSON.stringify(body),
}).catch((err) => {
reportError("Failed to persist model", err);
});
} else {
- draftStore.setModel(model);
+ registry.draftStore.setModel(model);
+ registry.draftStore.setNamedAgent(snapshot ?? null);
}
},
newDraft(): void {
tabsStore.newDraft();
const nextDraftId = randomId();
- draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId);
- draftConversationId = nextDraftId;
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
+ registry.draftStore = registry.createChatFor(nextDraftId, activeModel, activeWorkspaceId);
+ registry.draftConversationId = nextDraftId;
+ namedAgentSnapshot = null;
+ registry.refreshActiveChat();
+ surfaces.syncSubscriptions();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
void refreshModel();
- void refreshReasoningEffort();
- void refreshCompactPercent();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
},
selectTab(conversationId: string): void {
@@ -1394,747 +621,150 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId);
if (tab !== undefined) {
activeModel = tab.model;
+ namedAgentSnapshot = tab.snapshot ?? null;
+ } else {
+ namedAgentSnapshot = null;
}
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
+ registry.refreshActiveChat();
+ surfaces.syncSubscriptions();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
void refreshModel();
- void refreshReasoningEffort();
- void refreshCompactPercent();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
},
closeTab(conversationId: string): void {
// The user is DONE with this chat: abort any in-flight turn + stop/disable
// its cache-warming, server-side (POST /close sets status → "closed").
- closeConversation(conversationId);
- removeTabLocally(conversationId);
+ conversations.closeConversation(conversationId);
+ conversations.removeTabLocally(conversationId);
},
- renameTab(conversationId: string, title: string): void {
- tabsStore.setTitle(conversationId, title);
- void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/title`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title } satisfies SetTitleRequest),
- }).catch((err) => {
- reportError("Failed to rename conversation", err);
- });
- },
+ renameTab: conversations.renameTab,
+ setTabGroup: conversations.setTabGroup,
invoke(surfaceId: string, actionId: string, payload?: unknown): void {
- const result = protocolInvoke(
- protocol,
- surfaceId,
- actionId,
- payload,
- focusedConversationId(),
- );
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.send(msg);
- }
+ surfaces.invokeSurfaceOp(surfaceId, actionId, payload);
},
- async warmNow(): Promise<WarmResult | null> {
- const conversationId = tabsStore.activeConversationId;
- if (conversationId === null) return null;
- const body: WarmRequest = { conversationId, model: activeModel };
- try {
- const res = await fetchImpl(`${httpBase}/chat/warm`, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return { ok: false, error: errBody?.error ?? `Warm failed (HTTP ${res.status})` };
- }
- return { ok: true, response: (await res.json()) as WarmResponse };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" };
- }
+ subscribeSurface(surfaceId: string, conversationId?: string): void {
+ surfaces.subscribeSurfaceOp(surfaceId, conversationId);
},
- async setCwd(value: string): Promise<CwdResult | null> {
- const id = workspaceConversationId();
- const body: SetCwdRequest = {
- cwd: value,
- workspaceId: untrack(() => activeWorkspaceId),
- };
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return { ok: false, error: errBody?.error ?? `Set cwd failed (HTTP ${res.status})` };
- }
- const data = (await res.json()) as CwdResponse;
- const next = data.cwd ?? null;
- if (workspaceConversationId() === id) cwd = next;
- return { ok: true, cwd: next };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" };
- }
+ unsubscribeSurface(surfaceId: string, conversationId?: string): void {
+ surfaces.unsubscribeSurfaceOp(surfaceId, conversationId);
},
- async setComputer(computerIdValue: string | null): Promise<ComputerResult | null> {
- const id = workspaceConversationId();
- const body: SetConversationComputerRequest = { computerId: computerIdValue };
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/computer`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set computer failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as ConversationComputerResponse;
- const next = data.computerId ?? null;
- if (workspaceConversationId() === id) computerId = next;
- return { ok: true, computerId: next };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set computer request failed",
- };
- }
- },
+ warmNow: httpActions.warmNow,
- async computerStatus(alias: string): Promise<ComputerStatusResult | null> {
- if (alias === "") return null;
- try {
- const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/status`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Computer status failed (HTTP ${res.status})`,
- };
- }
- const status = (await res.json()) as ComputerStatusResponse;
- return { ok: true, response: status };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Computer status request failed",
- };
- }
- },
+ setCwd: settings.setCwd,
- async testComputer(alias: string): Promise<TestComputerResult | null> {
- if (alias === "") return null;
- try {
- const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/test`, {
- method: "POST",
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Test computer failed (HTTP ${res.status})`,
- };
- }
- const response = (await res.json()) as TestComputerResponse;
- return { ok: true, response };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Test computer request failed",
- };
- }
- },
+ setComputer: settings.setComputer,
- async setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null> {
- const id = workspaceConversationId();
- const body: SetReasoningEffortRequest = { reasoningEffort: level };
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set reasoning effort failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as ReasoningEffortResponse;
- const next = data.reasoningEffort ?? level;
- if (workspaceConversationId() === id) reasoningEffort = next;
- return { ok: true, reasoningEffort: next };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set reasoning effort request failed",
- };
- }
- },
+ computerStatus: httpActions.computerStatus,
- stopGeneration(): void {
- const conversationId = tabsStore.activeConversationId;
- if (conversationId === null) return;
- void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, {
- method: "POST",
- }).catch((err) => {
- reportError("Failed to stop generation", err);
- });
- },
+ testComputer: httpActions.testComputer,
- async compactNow(keepLastN?: number): Promise<CompactResult | null> {
- const conversationId = tabsStore.activeConversationId;
- if (conversationId === null) return null;
- const body: Record<string, unknown> = {};
- if (keepLastN !== undefined) body.keepLastN = keepLastN;
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(conversationId)}/compact`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Compact failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as CompactResponse;
- return { ok: true, response: data };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Compact request failed",
- };
- }
- },
+ setReasoningEffort: settings.setReasoningEffort,
- async setCompactPercent(percent: number): Promise<CompactPercentResult | null> {
- const id = workspaceConversationId();
- const body: SetCompactPercentRequest = { threshold: percent };
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set compact percent failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as CompactPercentResponse;
- if (workspaceConversationId() === id) compactPercent = data.threshold;
- return { ok: true, percent: data.threshold };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set compact percent request failed",
- };
- }
- },
+ setThinking: settings.setThinking,
- async setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | null> {
- const body: SetVisionSettingsRequest = patch;
- try {
- const res = await fetchImpl(`${httpBase}/settings/vision`, {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set vision settings failed (HTTP ${res.status})`,
- };
- }
- const data = normalizeVisionSettings(await res.json());
- visionSettings = data;
- return { ok: true, settings: data };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set vision settings request failed",
- };
- }
- },
+ stopGeneration: httpActions.stopGeneration,
- async setChatLimit(limit: number): Promise<ChatLimitResult> {
- const next = normalizeChatLimit(limit);
- chatLimitStore.save(next);
- chatLimit = next;
- // Propagate to every live chat store. The ACTIVE one is awaited so its
- // refill (on a raise) lands before the caller returns — letting the
- // shell preserve scroll over the prepended older chunks. Background
- // stores refill fire-and-forget. Future stores pick the new limit up at
- // creation (via the persisted store).
- const active = getActiveChat();
- await active.setChatLimit(next);
- for (const s of chatStores.values()) {
- if (s !== active) void s.setChatLimit(next);
- }
- if (draftStore !== active) void draftStore.setChatLimit(next);
- return { ok: true, chatLimit: next };
- },
+ compactNow: httpActions.compactNow,
- async lspStatus(): Promise<LspResult | null> {
- const id = workspaceConversationId();
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/lsp`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return { ok: false, error: errBody?.error ?? `LSP status failed (HTTP ${res.status})` };
- }
- // Normalize the untyped body at this network seam so a malformed/partial
- // response can never crash the renderer (servers is guaranteed an array).
- const data = (await res.json()) as Partial<LspStatusResponse>;
- const response: LspStatusResponse = {
- conversationId: data.conversationId ?? id,
- cwd: data.cwd ?? null,
- servers: Array.isArray(data.servers) ? data.servers : [],
- };
- return { ok: true, response };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "LSP status request failed",
- };
- }
- },
+ setCompactPercent: settings.setCompactPercent,
- async mcpStatus(): Promise<McpResult | null> {
- const id = workspaceConversationId();
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/mcp`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return { ok: false, error: errBody?.error ?? `MCP status failed (HTTP ${res.status})` };
- }
- // Normalize the untyped body at this network seam so a malformed/partial
- // response can never crash the renderer (servers is guaranteed an array).
- const data = (await res.json()) as Partial<McpStatusResponse>;
- const response: McpStatusResponse = {
- conversationId: data.conversationId ?? id,
- cwd: data.cwd ?? null,
- servers: Array.isArray(data.servers) ? data.servers : [],
- };
- return { ok: true, response };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "MCP status request failed",
- };
- }
- },
+ setVisionSettings: settings.setVisionSettings,
- async heartbeatConfig(): Promise<HeartbeatConfigResult> {
- // Workspace-scoped (NOT per-conversation): use the active workspace id.
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Heartbeat config failed (HTTP ${res.status})`,
- };
- }
- // Normalize the untyped JSON at the network seam (pure helper) so a
- // malformed/partial response can never crash the renderer.
- const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
- return { ok: true, config };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Heartbeat config request failed",
- };
- }
- },
+ setChatLimit: registry.setChatLimit,
- async setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult> {
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(patch),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set heartbeat config failed (HTTP ${res.status})`,
- };
- }
- const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
- return { ok: true, config };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set heartbeat config request failed",
- };
- }
- },
+ lspStatus: settings.lspStatus,
- async heartbeatRuns(): Promise<HeartbeatRunsResult> {
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs`,
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Heartbeat runs failed (HTTP ${res.status})`,
- };
- }
- const runs: readonly HeartbeatRun[] = normalizeHeartbeatRuns(await res.json());
- return { ok: true, runs };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Heartbeat runs request failed",
- };
- }
- },
+ mcpStatus: settings.mcpStatus,
- async stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs/${encodeURIComponent(runId)}/stop`,
- { method: "POST" },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Stop heartbeat run failed (HTTP ${res.status})`,
- };
- }
- return { ok: true };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Stop heartbeat run request failed",
- };
- }
- },
+ heartbeatConfig: httpActions.heartbeatConfig,
- async heartbeatNextRun(): Promise<HeartbeatNextRunResult> {
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/next-run`,
- );
- if (!res.ok) {
- // 404 = the backend hasn't shipped CR-HB-3 yet → the FE falls back to
- // an approximation. Surface as ok:false (non-fatal).
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Heartbeat next-run failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json().catch(() => null)) as { nextRunAt?: string | null } | null;
- // `null` (disabled / no run scheduled) passes through; anything non-string
- // also becomes null so a malformed body can't crash the countdown.
- const raw = data?.nextRunAt;
- const nextRunAt = typeof raw === "string" ? raw : null;
- return { ok: true, nextRunAt };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Heartbeat next-run request failed",
- };
- }
- },
+ setHeartbeatConfig: httpActions.setHeartbeatConfig,
+
+ heartbeatRuns: httpActions.heartbeatRuns,
+
+ stopHeartbeatRun: httpActions.stopHeartbeatRun,
+
+ heartbeatNextRun: httpActions.heartbeatNextRun,
watchConversation(conversationId: string): ChatStore {
- return watchConversation(conversationId);
+ return registry.watchConversation(conversationId);
},
unwatchConversation(conversationId: string): void {
- unwatchConversation(conversationId);
+ registry.unwatchConversation(conversationId);
},
// ── Concurrency (per-provider limits + live status; GLOBAL, not workspace-scoped)
- async concurrencyLimits(): Promise<ConcurrencyLimitsResult> {
- try {
- const res = await fetchImpl(`${httpBase}/concurrency/limits`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Concurrency limits failed (HTTP ${res.status})`,
- };
- }
- // Normalize the untyped JSON at the network seam (pure helper) so a
- // malformed/partial response (e.g. the extension returning `{}`) can
- // never crash the renderer.
- const limits = normalizeConcurrencyLimits(await res.json());
- return { ok: true, limits };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Concurrency limits request failed",
- };
- }
- },
+ concurrencyLimits: httpActions.concurrencyLimits,
- async getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult> {
- try {
- const res = await fetchImpl(
- `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Concurrency limit failed (HTTP ${res.status})`,
- };
- }
- const limit = normalizeConcurrencyLimit(await res.json());
- if (limit === null) {
- return { ok: false, error: "Malformed concurrency limit response" };
- }
- return { ok: true, providerId: limit.providerId, limit: limit.limit };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Concurrency limit request failed",
- };
- }
- },
+ getConcurrencyLimit: httpActions.getConcurrencyLimit,
- async setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult> {
- try {
- const res = await fetchImpl(
- `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ limit }),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set concurrency limit failed (HTTP ${res.status})`,
- };
- }
- const echoed = normalizeConcurrencyLimit(await res.json());
- if (echoed === null) {
- return { ok: false, error: "Malformed concurrency limit response" };
- }
- return { ok: true, providerId: echoed.providerId, limit: echoed.limit };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set concurrency limit request failed",
- };
- }
- },
+ setConcurrencyLimit: httpActions.setConcurrencyLimit,
- async deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult> {
- try {
- const res = await fetchImpl(
- `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
- { method: "DELETE" },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Delete concurrency limit failed (HTTP ${res.status})`,
- };
- }
- return { ok: true, providerId };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Delete concurrency limit request failed",
- };
- }
- },
+ deleteConcurrencyLimit: httpActions.deleteConcurrencyLimit,
- async concurrencyStatus(): Promise<ConcurrencyStatusResult> {
- try {
- const res = await fetchImpl(`${httpBase}/concurrency/status`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Concurrency status failed (HTTP ${res.status})`,
- };
- }
- const providers = normalizeConcurrencyStatus(await res.json());
- return { ok: true, providers };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Concurrency status request failed",
- };
- }
- },
+ accountUsage: httpActions.accountUsage,
- async getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult> {
- try {
- const res = await fetchImpl(
- `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Get concurrency cooldown failed (HTTP ${res.status})`,
- };
- }
- const data = normalizeConcurrencyCooldown(await res.json());
- if (data === null) {
- return { ok: false, error: "Malformed concurrency cooldown response" };
- }
- return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Get concurrency cooldown request failed",
- };
- }
- },
+ setAccountUsageSettings: httpActions.setAccountUsageSettings,
- async setConcurrencyCooldown(
- providerId: string,
- cooldownMs: number,
- ): Promise<ConcurrencyCooldownResult> {
- try {
- const res = await fetchImpl(
- `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ cooldownMs }),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set concurrency cooldown failed (HTTP ${res.status})`,
- };
- }
- const data = normalizeConcurrencyCooldown(await res.json());
- if (data === null) {
- return { ok: false, error: "Malformed concurrency cooldown response" };
- }
- return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set concurrency cooldown request failed",
- };
- }
- },
+ concurrencyStatus: httpActions.concurrencyStatus,
- async loadSystemPrompt(): Promise<SystemPromptLoadResult> {
- try {
- const res = await fetchImpl(`${httpBase}/system-prompt`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Load system prompt failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as SystemPromptTemplateResponse;
- return { ok: true, template: data.template ?? "" };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Load system prompt request failed",
- };
- }
- },
+ searchBackendConfig: httpActions.searchBackendConfig,
- async setSystemPrompt(template: string): Promise<SystemPromptSaveResult> {
- try {
- const body: SetSystemPromptTemplateRequest = { template };
- const res = await fetchImpl(`${httpBase}/system-prompt`, {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set system prompt failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as SystemPromptTemplateResponse;
- return { ok: true, template: data.template };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set system prompt request failed",
- };
- }
- },
+ setSearchBackend: httpActions.setSearchBackend,
- async loadSystemPromptVariables(): Promise<SystemPromptVariablesResult> {
- try {
- const res = await fetchImpl(`${httpBase}/system-prompt/variables`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Load system prompt variables failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as Partial<SystemPromptVariablesResponse>;
- return { ok: true, variables: Array.isArray(data.variables) ? data.variables : [] };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Load system prompt variables request failed",
- };
- }
+ getConcurrencyCooldown: httpActions.getConcurrencyCooldown,
+
+ setConcurrencyCooldown: httpActions.setConcurrencyCooldown,
+
+ loadSystemPrompt: httpActions.loadSystemPrompt,
+
+ setSystemPrompt: httpActions.setSystemPrompt,
+
+ loadSystemPromptVariables: httpActions.loadSystemPromptVariables,
+
+ attachUnloadGate: registry.attachUnloadGate,
+
+ // ── Named Agents (global config; plan §9) ────────────────────────────────
+
+ get namedAgents(): readonly NamedAgent[] {
+ return refreshers.namedAgents;
+ },
+ get namedAgentSnapshot(): NamedAgentSelectionSnapshot | null {
+ return namedAgentSnapshot;
},
- attachUnloadGate(gate: () => boolean): void {
- unloadGate = gate;
+ // ── Plugins (plan 07) ────────────────────────────────────────────────────
+ get plugins(): PluginsStore {
+ return pluginsStore;
+ },
+ get layout(): LayoutStore {
+ return layoutStore;
},
+ get tileLayout(): TileLayoutStore {
+ return tileLayoutStore;
+ },
+ get enabledProviderCount(): number | null {
+ return refreshers.enabledProviderCount;
+ },
+
+ refreshNamedAgents: refreshers.refreshNamedAgents,
+
+ createNamedAgent: refreshers.createNamedAgent,
+
+ updateNamedAgent: refreshers.updateNamedAgent,
+
+ deleteNamedAgent: refreshers.deleteNamedAgent,
get fatalError(): string | null {
return fatalError;
@@ -2144,15 +774,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
},
dispose(): void {
- for (const store of chatStores.values()) {
- store.dispose();
- }
- chatStores.clear();
- for (const store of watchStores.values()) {
- store.dispose();
- }
- watchStores.clear();
- draftStore.dispose();
+ registry.dispose();
socket?.close();
socket = null;
},
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index a945ea7..90c11b3 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -1,9 +1,29 @@
import type { ConversationHistoryResponse, WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
-import { describe, expect, it, vi } from "vitest";
+import type { NamedAgent } from "@dispatch/wire";
+import { IDBFactory } from "fake-indexeddb";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
import { createAppStore } from "./store.svelte";
+// Never let an omitted injection reach a real backend. Track rejected requests
+// too: the app deliberately catches network errors, which must not hide a bad fake.
+const unexpectedFetch = vi.fn<typeof fetch>(async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? (input instanceof Request ? input.method : "GET");
+ throw new Error(`Unexpected fetch: ${method} ${url}`);
+});
+
+beforeEach(() => {
+ unexpectedFetch.mockClear();
+ vi.stubGlobal("fetch", unexpectedFetch);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ expect(unexpectedFetch).not.toHaveBeenCalled();
+});
+
interface FakeSocket extends WebSocketLike {
sent: string[];
resolveOpen(): void;
@@ -104,17 +124,124 @@ interface FakeFetchOptions {
models?: readonly string[];
history?: Record<string, ConversationHistoryResponse>;
model?: string | null;
+ /** Persisted Named Agents (GET /named-agents + CRUD in-memory). */
+ namedAgents?: readonly NamedAgent[];
}
function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
- const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"];
+ const models = opts?.models ?? ["opencode-1/deepseek-v4-flash", "openai/gpt-4o"];
const history = opts?.history ?? {};
- return async (input: string | URL | Request): Promise<Response> => {
+ // In-memory Named Agent store for the CRUD routes (the fake fetch mimics the
+ // backend's /named-agents surface).
+ let namedAgents: NamedAgent[] = opts?.namedAgents ? [...opts.namedAgents] : [];
+ return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models }), { status: 200 });
+ const method =
+ init?.method ?? (typeof input !== "string" && !(input instanceof URL) ? input.method : "GET");
+ const bodyText =
+ typeof init?.body === "string"
+ ? init.body
+ : typeof input !== "string" &&
+ !(input instanceof URL) &&
+ typeof input.body !== "undefined" &&
+ "body" in input
+ ? await input.clone().text()
+ : null;
+ const { pathname, searchParams } = new URL(url);
+ if (pathname === "/models" && method === "GET") {
+ return Response.json({ models });
+ }
+ if (method === "GET") {
+ if (pathname === "/conversations" && searchParams.get("status") === "active,idle") {
+ // Most tests drive tabs via WS/local actions. Disable boot reconciliation
+ // so an in-flight empty list cannot remove a tab created by the test.
+ // Discovery tests override this route with their authoritative lists.
+ return Response.json({ error: "Discovery unavailable in this fixture" }, { status: 503 });
+ }
+ if (pathname === "/plugins") return Response.json({ plugins: [] });
+ if (pathname === "/computers") return Response.json({ computers: [] });
+ if (/^\/workspaces\/[^/]+\/plugins$/.test(pathname)) {
+ return Response.json({ workspaceId: pathname.split("/")[2], plugins: [] });
+ }
+ if (/^\/workspaces\/[^/]+\/plugins\/providers-status$/.test(pathname)) {
+ return Response.json({ enabledProviderCount: 1 });
+ }
+ // Explicitly simulate absent layout providers; retain local cached layouts.
+ if (
+ /^\/workspaces\/[^/]+\/layout$/.test(pathname) ||
+ /^\/home\/tile-layout\/(wide|tall)$/.test(pathname)
+ ) {
+ return Response.json({ error: "No layout provider" }, { status: 404 });
+ }
+ if (pathname === "/settings/vision") {
+ return Response.json({ imageLimit: 10, compactionModel: null });
+ }
+ const setting =
+ /^\/conversations\/[^/]+\/(cwd|computer|reasoning-effort|thinking|compact-percent)$/.exec(
+ pathname,
+ )?.[1];
+ if (setting !== undefined) {
+ const key = {
+ cwd: "cwd",
+ computer: "computerId",
+ "reasoning-effort": "reasoningEffort",
+ thinking: "thinking",
+ "compact-percent": "compactPercent",
+ }[setting];
+ if (key !== undefined) return Response.json({ [key]: null });
+ }
+ if (/^\/conversations\/[^/]+\/metrics$/.test(pathname)) {
+ return Response.json({ turns: [] });
+ }
+ if (/^\/conversations\/[^/]+$/.test(pathname) && searchParams.has("sinceSeq")) {
+ return Response.json(
+ history[url] ??
+ ({ chunks: [], historyEpoch: 0, latestSeq: 0 } satisfies ConversationHistoryResponse),
+ );
+ }
+ }
+ if (/^\/conversations\/[^/]+\/close$/.test(pathname) && method === "POST") {
+ return Response.json({ conversationId: pathname.split("/")[2], abortedTurn: false });
+ }
+ if (pathname === "/named-agents" && method === "GET") {
+ return new Response(JSON.stringify({ namedAgents }), { status: 200 });
+ }
+ if (pathname === "/named-agents" && method === "POST") {
+ const payload =
+ bodyText === null
+ ? null
+ : (JSON.parse(bodyText) as { name?: string; model?: string } | null);
+ const agent: NamedAgent = {
+ id: `na-${namedAgents.length + 1}`,
+ name: payload?.name ?? "",
+ model: payload?.model ?? "",
+ };
+ namedAgents = [...namedAgents, agent];
+ return new Response(JSON.stringify({ namedAgent: agent }), { status: 201 });
}
- if (url.endsWith("/model")) {
+ if (/^\/named-agents\/[^/]+$/.test(pathname) && method === "PUT") {
+ const id = url.split("/named-agents/")[1] ?? "";
+ const payload =
+ bodyText === null
+ ? null
+ : (JSON.parse(bodyText) as { name?: string; model?: string } | null);
+ const updated: NamedAgent = {
+ id,
+ name: payload?.name ?? "",
+ model: payload?.model ?? "",
+ };
+ namedAgents = namedAgents.map((a) => (a.id === id ? updated : a));
+ return new Response(JSON.stringify({ namedAgent: updated }), { status: 200 });
+ }
+ if (/^\/named-agents\/[^/]+$/.test(pathname) && method === "DELETE") {
+ const id = url.split("/named-agents/")[1] ?? "";
+ namedAgents = namedAgents.filter((a) => a.id !== id);
+ return new Response(JSON.stringify({ id }), { status: 200 });
+ }
+ if (
+ /^\/conversations\/[^/]+\/model$/.test(pathname) &&
+ (method === "GET" || method === "PUT")
+ ) {
return new Response(
JSON.stringify({
conversationId: "ignored",
@@ -123,9 +250,7 @@ function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
{ status: 200 },
);
}
- const body =
- history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse);
- return new Response(JSON.stringify(body), { status: 200 });
+ return unexpectedFetch(input, init);
};
}
@@ -145,8 +270,8 @@ function createFakeStorage(): Storage {
getItem(key: string): string | null {
return map.get(key) ?? null;
},
- key(_index: number): string | null {
- return null;
+ key(index: number): string | null {
+ return [...map.keys()][index] ?? null;
},
removeItem(key: string) {
map.delete(key);
@@ -163,6 +288,22 @@ function activeConversationId(store: ReturnType<typeof createAppStore>): string
return id as string;
}
+describe("fakeFetchImpl", () => {
+ it.each([
+ "http://localhost:24203/unexpected",
+ new URL("http://localhost:24203/conversations/c/unknown-setting"),
+ new Request("http://localhost:24203/models", { method: "DELETE" }),
+ ])("rejects an unexpected URL or method: %s", async (input) => {
+ try {
+ await expect(fakeFetchImpl()(input)).rejects.toThrow("Unexpected fetch:");
+ expect(unexpectedFetch).toHaveBeenCalledOnce();
+ } finally {
+ // This test deliberately exercises the guard; all store tests require zero calls.
+ unexpectedFetch.mockClear();
+ }
+ });
+});
+
describe("createAppStore", () => {
it("starts with empty catalog and no surfaces", () => {
const ws = fakeSocket();
@@ -181,6 +322,29 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("probes only the portrait tile layout at boot and injects its cached document", () => {
+ const width = vi.spyOn(window, "innerWidth", "get").mockReturnValue(390);
+ const height = vi.spyOn(window, "innerHeight", "get").mockReturnValue(844);
+ const storage = createFakeStorage();
+ const cached = { version: 2, tiles: [{ id: "a", order: 0, colSpan: 2, rowSpan: 2 }] };
+ storage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached));
+ const fetchImpl = vi.fn(fakeFetchImpl());
+ const store = createAppStore({ socketFactory: fakeSocket, fetchImpl, localStorage: storage });
+ try {
+ expect(store.tileLayout.mode).toBe("tall");
+ expect(store.tileLayout.entries).toEqual(cached.tiles);
+ expect(
+ fetchImpl.mock.calls
+ .map(([url]) => String(url))
+ .filter((url) => url.includes("/home/tile-layout/")),
+ ).toEqual([expect.stringMatching(/\/home\/tile-layout\/tall$/)]);
+ } finally {
+ store.dispose();
+ width.mockRestore();
+ height.mockRestore();
+ }
+ });
+
it("updates catalog when catalog message arrives", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -457,6 +621,7 @@ describe("createAppStore", () => {
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
});
ws.resolveOpen();
@@ -518,17 +683,25 @@ describe("createAppStore", () => {
{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
{ seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
],
+ historyEpoch: 0,
latestSeq: 2,
};
- const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
fetchedUrls.push(url);
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
- return new Response(JSON.stringify(historyResponse), { status: 200 });
+ if (/\/conversations\/[^/?]+\?sinceSeq=/.test(url)) {
+ return Response.json(historyResponse);
+ }
+ return base(input, init);
};
const ws = fakeSocket();
@@ -573,19 +746,19 @@ describe("createAppStore", () => {
const store = createAppStore({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl({
- models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"],
+ models: ["opencode-1/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"],
}),
localStorage: createFakeStorage(),
});
ws.resolveOpen();
- await new Promise((r) => setTimeout(r, 50));
-
- expect(store.models).toEqual([
- "opencode/deepseek-v4-flash",
- "openai/gpt-4o",
- "anthropic/claude-3",
- ]);
+ await vi.waitFor(() => {
+ expect(store.models).toEqual([
+ "opencode-1/deepseek-v4-flash",
+ "openai/gpt-4o",
+ "anthropic/claude-3",
+ ]);
+ });
store.dispose();
});
@@ -599,11 +772,68 @@ describe("createAppStore", () => {
});
ws.resolveOpen();
- expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
store.dispose();
});
+ it("does not silently switch the active model when the selected one is absent from the catalog", async () => {
+ // The catalog only has umans; the active (default/selected) OpenCode model is
+ // NOT available. The FE must NOT auto-fall back to the first catalog model —
+ // it keeps the selection and lets the model selector surface it as unavailable.
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ models: ["umans/umans-glm-5.2"] }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ await vi.waitFor(() => expect(store.models).toEqual(["umans/umans-glm-5.2"]));
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
+
+ store.dispose();
+ });
+
+ it("migrates a legacy persisted opencode/<model> tab model to opencode-1/<model>", async () => {
+ const ws = fakeSocket();
+ // Seed localStorage with a persisted tab whose model is the OBSOLETE
+ // `opencode/deepseek-v4-flash` namespace.
+ const storage = createFakeStorage();
+ storage.setItem(
+ "dispatch.tabs",
+ JSON.stringify({
+ tabs: [
+ {
+ conversationId: "c-legacy",
+ title: "Legacy",
+ model: "opencode/deepseek-v4-flash",
+ workspaceId: "default",
+ },
+ ],
+ activeConversationId: null,
+ }),
+ );
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws.resolveOpen();
+ // After restore, the restored tab's model + the persisted store are migrated.
+ const restored = store.tabs.find((t) => t.conversationId === "c-legacy");
+ expect(restored?.model).toBe("opencode-1/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
+ // Issue 7: the migrated tab state is ACTUALLY written back to the storage
+ // adapter immediately (not merely kept in memory) — a re-load is now a no-op.
+ const persisted = JSON.parse(storage.getItem("dispatch.tabs") ?? "") as {
+ tabs: Array<{ conversationId: string; model: string }>;
+ };
+ const persistedModel = persisted.tabs.find((t) => t.conversationId === "c-legacy")?.model;
+ expect(persistedModel).toBe("opencode-1/deepseek-v4-flash");
+ expect(persisted.tabs.some((t) => t.model.startsWith("opencode/"))).toBe(false);
+ store.dispose();
+ });
+
it("draft: sending the first message creates a tab titled from the message", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -679,7 +909,7 @@ describe("createAppStore", () => {
store.send("first message");
// New tab opens with the default model until the persisted-model fetch resolves.
- expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
// Wait for the persisted model fetch to resolve.
await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o"));
@@ -688,12 +918,148 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("fetchOpenConversations stamps tabs with the conversation's persisted model (not the global active model)", async () => {
+ const ws = fakeSocket();
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
+ status: 200,
+ });
+ }
+ // The authoritative open-conversation list — one conversation whose
+ // persisted model differs from the global activeModel.
+ if (url.includes("/conversations?status=")) {
+ return new Response(
+ JSON.stringify({
+ conversations: [
+ {
+ id: "conv-model",
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ title: "Model conv",
+ status: "idle",
+ workspaceId: "default",
+ model: "openrouter/stealth/ox-alpha",
+ },
+ ],
+ }),
+ { status: 200 },
+ );
+ }
+ return base(input, init);
+ };
+
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // global activeModel is the default until anything changes it
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
+
+ // fetchOpenConversations (boot reconcile) opens the conversation's tab —
+ // stamped with the PERSISTED model from the list, not activeModel.
+ await vi.waitFor(() => {
+ const tab = store.tabs.find((t) => t.conversationId === "conv-model");
+ expect(tab?.model).toBe("openrouter/stealth/ox-alpha");
+ });
+
+ store.dispose();
+ });
+
+ it("fetchOpenConversations seeds the chat store's cache-timer turn gap from an idle conversation's lastActivityAt", async () => {
+ const ws = fakeSocket();
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/conversations?status=")) {
+ return new Response(
+ JSON.stringify({
+ conversations: [
+ {
+ id: "conv-seed",
+ createdAt: 1000,
+ lastActivityAt: 7777,
+ title: "Seed conv",
+ status: "idle",
+ workspaceId: "default",
+ model: "claude/claude-opus-4-6",
+ },
+ ],
+ }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/metrics")) {
+ return new Response(JSON.stringify({ turns: [] }), { status: 200 });
+ }
+ return base(input, init);
+ };
+
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => {
+ expect(store.tabs.some((t) => t.conversationId === "conv-seed")).toBe(true);
+ });
+ store.selectTab("conv-seed");
+ expect(store.activeChat.cacheTimer.running).toEqual({
+ kind: "turn",
+ turnId: null,
+ startedAt: 7777,
+ });
+
+ store.dispose();
+ });
+
+ it("conversation.open WS message carrying a model stamps the tab with it", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // The user stays on their current (draft) tab; the backend broadcasts
+ // `conversation.open` with the conversation's persisted model.
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "conv-ws",
+ workspaceId: "default",
+ model: "openrouter/stealth/ox-alpha",
+ });
+
+ await vi.waitFor(() => {
+ const tab = store.tabs.find((t) => t.conversationId === "conv-ws");
+ expect(tab?.model).toBe("openrouter/stealth/ox-alpha");
+ expect(tab?.workspaceId).toBe("default");
+ });
+
+ store.dispose();
+ });
+
it("chat.delta routes to the matching tab only", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
});
ws.resolveOpen();
@@ -891,6 +1257,135 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("seeds thinking from GET /conversations/:id/thinking (null = never set ⇒ ON)", async () => {
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/thinking")) {
+ return new Response(JSON.stringify({ conversationId: "x", thinking: false }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => {
+ expect(store.thinking).toBe(false);
+ });
+
+ store.dispose();
+ });
+
+ it("treats a missing thinking endpoint (404) as 'never set' (null ⇒ ON)", async () => {
+ // The thinking endpoint is PROPOSED (backend-handoff.md); until the backend
+ // ships it, GET 404s and `thinking` stays null ⇒ the selector shows the
+ // effort level (thinking ON, the default) — graceful, never a crash.
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/thinking")) {
+ return new Response("not found", { status: 404 });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // give the (404ing) fetch a tick to settle
+ await vi.waitFor(() => {
+ expect(store.reasoningEffort).not.toBe(undefined);
+ });
+ expect(store.thinking).toBeNull();
+
+ store.dispose();
+ });
+
+ it("treats a SPA-fallback response (200 + text/html) as 'never set' (null ⇒ ON)", async () => {
+ // In production (DISPATCH_WEB_DIR set), unknown API routes get the SPA
+ // fallback: index.html with HTTP 200 + text/html. This must NOT crash
+ // res.json() — it should leave `thinking` null (ON, the default).
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/thinking")) {
+ return new Response("<!DOCTYPE html><html><body>SPA fallback</body></html>", {
+ status: 200,
+ headers: { "content-type": "text/html; charset=utf-8" },
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // give the (HTML) fetch a tick to settle
+ await vi.waitFor(() => {
+ expect(store.reasoningEffort).not.toBe(undefined);
+ });
+ expect(store.thinking).toBeNull();
+
+ store.dispose();
+ });
+
+ it("setThinking PUTs the flag and updates local state from the echo", async () => {
+ const calls: { url: string; method: string; body: string | undefined }[] = [];
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ calls.push({ url, method: init?.method ?? "GET", body: init?.body as string | undefined });
+ if (url.endsWith("/thinking") && init?.method === "PUT") {
+ const sent = JSON.parse(init.body as string) as { thinking: boolean };
+ return new Response(JSON.stringify({ conversationId: "x", thinking: sent.thinking }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ if (url.endsWith("/thinking")) {
+ return new Response(JSON.stringify({ conversationId: "x", thinking: null }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.setThinking(false);
+ expect(result).toEqual({ ok: true, thinking: false });
+ expect(store.thinking).toBe(false);
+
+ const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/thinking"));
+ expect(put).toBeDefined();
+ expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`);
+ expect(JSON.parse(put?.body ?? "{}")).toEqual({ thinking: false });
+
+ store.dispose();
+ });
+
it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -1112,17 +1607,21 @@ describe("createAppStore", () => {
store.dispose();
});
- it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => {
+ it("re-subscribes and resyncs a loaded conversation on reconnect", async () => {
const fetchedUrls: string[] = [];
- const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
fetchedUrls.push(url);
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ return base(input, init);
};
const ws = reconnectableSocket();
@@ -1205,9 +1704,6 @@ describe("createAppStore", () => {
status: 200,
});
}
- if (url.includes("/heartbeat")) {
- return new Response(JSON.stringify(config), { status: 200 });
- }
return base(input, init);
};
}
@@ -1223,6 +1719,7 @@ describe("createAppStore", () => {
if (!result.ok) throw new Error("unreachable");
expect(result.config).toEqual({
enabled: true,
+ inactiveOnly: true,
systemPrompt: "sys",
taskPrompt: "task",
intervalMinutes: 15,
@@ -1423,7 +1920,7 @@ describe("createAppStore", () => {
const limits = opts?.limits ?? {
limits: [
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
],
};
const limit = opts?.limit ?? { providerId: "umans", limit: 4 };
@@ -1431,7 +1928,7 @@ describe("createAppStore", () => {
providers: [
{ providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
{
- providerId: "openai-compat",
+ providerId: "opencode-1",
limit: 5,
inFlight: 5,
queued: 3,
@@ -1491,7 +1988,7 @@ describe("createAppStore", () => {
if (!result.ok) throw new Error("unreachable");
expect(result.limits).toEqual([
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
]);
store.dispose();
});
@@ -1663,7 +2160,7 @@ describe("createAppStore", () => {
autoReduced: false,
});
expect(result.providers[1]).toMatchObject({
- providerId: "openai-compat",
+ providerId: "opencode-1",
paused: true,
pausedUntil: 1_719_408_000_000,
cooldownMs: 350,
@@ -1969,6 +2466,7 @@ describe("createAppStore — vision settings (global)", () => {
fetchImpl: typeof fetch;
puts: { imageLimit?: number; compactionModel?: string | null }[];
} {
+ const base = fakeFetchImpl();
let current = initial;
const puts: { imageLimit?: number; compactionModel?: string | null }[] = [];
return {
@@ -1976,7 +2474,7 @@ describe("createAppStore — vision settings (global)", () => {
fetchImpl: async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(
JSON.stringify({
models: ["kimi/k2", "umans/glm-5.2"],
@@ -1997,8 +2495,7 @@ describe("createAppStore — vision settings (global)", () => {
}
return new Response(JSON.stringify(current), { status: 200 });
}
- // Default: empty history + no cwd for the other endpoints.
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ return base(input, init);
},
};
}
@@ -2061,6 +2558,7 @@ describe("createAppStore — vision settings (global)", () => {
});
it("surfaces a PUT error", async () => {
+ const base = fakeFetchImpl();
const fetchImpl: typeof fetch = async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.endsWith("/settings/vision")) {
@@ -2071,7 +2569,7 @@ describe("createAppStore — vision settings (global)", () => {
status: 200,
});
}
- return new Response(JSON.stringify({ models: [] }), { status: 200 });
+ return base(input, init);
};
const store = createAppStore({
socketFactory: () => fakeSocket(),
@@ -2087,3 +2585,616 @@ describe("createAppStore — vision settings (global)", () => {
store.dispose();
});
});
+
+describe("createAppStore — Named Agents", () => {
+ it("seeds namedAgents from GET /named-agents on boot", async () => {
+ const ws = fakeSocket();
+ const agents: readonly NamedAgent[] = [
+ { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ ];
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ namedAgents: agents }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => expect(store.namedAgents).toEqual(agents));
+ store.dispose();
+ });
+
+ it("createNamedAgent appends to the store list", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.createNamedAgent({ name: "Research", model: "umans/glm" });
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.agent.id).toBe("na-1");
+ expect(store.namedAgents).toHaveLength(1);
+ expect(store.namedAgents[0]?.name).toBe("Research");
+ }
+ store.dispose();
+ });
+
+ it("updateNamedAgent updates the record in place", async () => {
+ const ws = fakeSocket();
+ const agents: readonly NamedAgent[] = [
+ { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ ];
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ namedAgents: agents }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.updateNamedAgent("na-1", {
+ name: "Coding",
+ model: "opencode-1/new",
+ });
+ expect(result.ok).toBe(true);
+ expect(store.namedAgents[0]?.name).toBe("Coding");
+ expect(store.namedAgents[0]?.model).toBe("opencode-1/new");
+ store.dispose();
+ });
+
+ it("deleteNamedAgent removes the record immediately", async () => {
+ const ws = fakeSocket();
+ const agents: readonly NamedAgent[] = [
+ { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ { id: "na-2", name: "Research Agent", model: "umans/glm" },
+ ];
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ namedAgents: agents }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ await vi.waitFor(() => expect(store.namedAgents).toHaveLength(agents.length));
+
+ const result = await store.deleteNamedAgent("na-1");
+ expect(result.ok).toBe(true);
+ expect(store.namedAgents.map((a) => a.id)).toEqual(["na-2"]);
+ store.dispose();
+ });
+
+ it("selectModel records a Named Agent snapshot on the active conversation", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.selectModel("opencode-1/claude-sonnet-4.5", {
+ namedAgentId: "na-1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+
+ expect(store.activeModel).toBe("opencode-1/claude-sonnet-4.5");
+ expect(store.namedAgentSnapshot?.namedAgentName).toBe("Coding Agent");
+ store.dispose();
+ });
+
+ it("selectModel without a snapshot clears a prior Named Agent selection", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.selectModel("opencode-1/claude-sonnet-4.5", {
+ namedAgentId: "na-1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+ store.selectModel("openai/gpt-4o");
+
+ expect(store.activeModel).toBe("openai/gpt-4o");
+ expect(store.namedAgentSnapshot).toBeNull();
+ store.dispose();
+ });
+});
+
+describe("conversation load-on-demand", () => {
+ async function setup(
+ ws: WebSocketLike,
+ count = 3,
+ activeConversationId: string | null = "lazy-0",
+ workspaceCount = 1,
+ ) {
+ const storage = createFakeStorage();
+ const conversations = Array.from({ length: count }, (_, i) => ({
+ id: `lazy-${i}`,
+ title: `Conversation ${i}`,
+ model: "openai/gpt-4o",
+ workspaceId: i % workspaceCount === 0 ? "default" : `workspace-${i % workspaceCount}`,
+ status: i === 1 ? "active" : "idle",
+ lastActivityAt: 1234,
+ }));
+ storage.setItem(
+ "dispatch.tabs",
+ JSON.stringify({
+ tabs: conversations.slice(0, Math.ceil(count / 2)).map((c) => ({
+ conversationId: c.id,
+ title: c.title,
+ model: c.model,
+ workspaceId: c.workspaceId,
+ })),
+ activeConversationId,
+ }),
+ );
+ const base = fakeFetchImpl();
+ const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
+ const url = String(input);
+ if (url.includes("/conversations?status=")) {
+ return Response.json({ conversations });
+ }
+ if (/\/conversations\/[^/?]+\?sinceSeq=/.test(url)) {
+ return Response.json({
+ chunks: [
+ { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hi" } },
+ { seq: 2, role: "assistant", turnId: "t1", chunk: { type: "text", text: "hello" } },
+ ],
+ historyEpoch: 0,
+ latestSeq: 2,
+ });
+ }
+ if (url.endsWith("/metrics")) return Response.json({ turns: [] });
+ return base(input, init);
+ });
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: storage,
+ indexedDB: new IDBFactory(),
+ chatFlushScheduler: null,
+ });
+ const requests = () =>
+ fetchImpl.mock.calls
+ .map(([url]) => new URL(String(url)).pathname + new URL(String(url)).search)
+ .filter((url) => /\/conversations\/[^/?]+(?:\?sinceSeq=|\/metrics$)/.test(url));
+ await vi.waitFor(() => expect(store.tabs).toHaveLength(Math.ceil(count / workspaceCount)));
+ if (activeConversationId !== null) {
+ await vi.waitFor(() =>
+ expect(requests()).toContain(`/conversations/${activeConversationId}/metrics`),
+ );
+ }
+ return { store, requests };
+ }
+
+ // These exercise 300 real Svelte-backed tabs and count requests, not elapsed
+ // time. Allow contention without shrinking the regression's large-tab fixture.
+ it("boots 300 persisted/discovered tabs with only active history + metrics", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws, 300);
+ try {
+ ws.resolveOpen();
+ expect(requests()).toEqual([
+ "/conversations/lazy-0?sinceSeq=0&limit=192",
+ "/conversations/lazy-0/metrics",
+ ]);
+ expect(
+ parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"),
+ ).toHaveLength(1);
+ expect(store.tabs[299]?.title).toBe("Conversation 299");
+ expect(store.conversationStatus("lazy-1")).toBe("active");
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: "lazy-1",
+ workspaceId: "default",
+ status: "queued",
+ });
+ expect(store.conversationStatus("lazy-1")).toBe("queued");
+ expect(requests()).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ }, 20_000);
+
+ it("discovers 339 conversations across 24 workspaces without subscribing to all of them", async () => {
+ const ws = fakeSocket();
+ const { store } = await setup(ws, 339, "lazy-0", 24);
+ try {
+ ws.resolveOpen();
+ const subscriptions = parseSent(ws).filter(
+ (message) => (message as { type: string }).type === "chat.subscribe",
+ );
+ expect(subscriptions).toEqual([{ type: "chat.subscribe", conversationId: "lazy-0" }]);
+ store.setActiveWorkspace("workspace-1");
+ store.selectTab("lazy-1");
+ expect(parseSent(ws)).toContainEqual({ type: "chat.unsubscribe", conversationId: "lazy-0" });
+ expect(parseSent(ws)).toContainEqual({ type: "chat.subscribe", conversationId: "lazy-1" });
+ } finally {
+ store.dispose();
+ }
+ }, 20_000);
+
+ it("does not load any history while the active slot is a draft", async () => {
+ const { store, requests } = await setup(fakeSocket(), 300, null);
+ try {
+ expect(requests()).toEqual([]);
+ store.selectTab("lazy-0");
+ await vi.waitFor(() => expect(requests()).toContain("/conversations/lazy-0/metrics"));
+ expect(requests()).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ }, 20_000);
+
+ it("loads a tab once while pending and catches up a previously loaded tab on reactivation", async () => {
+ const { store, requests } = await setup(fakeSocket());
+ try {
+ store.selectTab("lazy-2");
+ store.selectTab("lazy-0");
+ store.selectTab("lazy-2");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ expect(requests().filter((u) => u.includes("lazy-2"))).toHaveLength(2);
+ expect(store.activeChat.cacheTimer.running?.startedAt).toBe(1234);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("re-subscribes and resyncs only focused tabs and watches on reconnect", async () => {
+ const ws = reconnectableSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.open();
+ store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ ws.sent.length = 0;
+ const before = requests().length;
+ ws.closeRemote();
+ await new Promise((r) => setTimeout(r, 800));
+ ws.open();
+ await vi.waitFor(() => expect(requests()).toHaveLength(before + 4));
+ expect(
+ requests()
+ .slice(before)
+ .every((u) => /lazy-(0|watch)[/?]/.test(u)),
+ ).toBe(true);
+ expect(
+ parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"),
+ ).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("loads watches, including unloaded tabs, and loads disposed watches again", async () => {
+ const { store, requests } = await setup(fakeSocket());
+ try {
+ const watch = store.watchConversation("lazy-1");
+ store.watchConversation("lazy-1");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(watch.chunks).toHaveLength(2);
+ store.selectTab("lazy-1");
+ expect(requests()).toHaveLength(4);
+ store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ store.unwatchConversation("lazy-watch");
+ const reopened = store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(8));
+ expect(reopened.chunks).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("merges pre-load live deltas with history without duplicate bubbles", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: "lazy-1", turnId: "t1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "user-message", conversationId: "lazy-1", turnId: "t1", text: "hi" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: "lazy-1", turnId: "t1", delta: "hello" },
+ });
+ expect(requests()).toHaveLength(2);
+ store.selectTab("lazy-1");
+ expect(store.activeChat.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "hi" },
+ { type: "text", text: "hello" },
+ ]);
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(store.activeChat.chunks).toHaveLength(2);
+ expect(store.activeChat.chunks.every((c) => !c.provisional)).toBe(true);
+ expect(store.activeChat.generating).toBe(true);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("compaction reloads loaded stores but does not load unseen conversations", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: "lazy-1", turnId: "old" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: "lazy-1", turnId: "old", delta: "stale" },
+ });
+ for (const id of ["lazy-1", "unknown", "lazy-0"]) {
+ ws.feedServerMessage({
+ type: "conversation.compacted",
+ conversationId: id,
+ newConversationId: `${id}-archive`,
+ messagesSummarized: 10,
+ historyEpoch: 0,
+ messagesKept: 2,
+ });
+ }
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(requests().every((u) => u.includes("lazy-0"))).toBe(true);
+ store.selectTab("lazy-1");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ expect(store.activeChat.chunks).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("keeps a watched store loaded when it becomes a tab, and reloads a closed tab", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ const watch = store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "lazy-watch",
+ workspaceId: "default",
+ });
+ store.selectTab("lazy-watch");
+ expect(store.activeChat.chunks).toEqual(watch.chunks);
+ store.unwatchConversation("lazy-watch");
+ expect(requests()).toHaveLength(4);
+ store.newDraft(); // Closing a background tab must not activate/load its neighbor.
+ store.closeTab("lazy-watch");
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "lazy-watch",
+ workspaceId: "default",
+ });
+ expect(requests()).toHaveLength(4);
+ store.selectTab("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ } finally {
+ store.dispose();
+ }
+ });
+});
+
+it.each([
+ null,
+ {},
+ { conversations: {} },
+])("reports malformed conversation lists: %j", async (body) => {
+ const ws = fakeSocket();
+ const base = fakeFetchImpl();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ indexedDB: new IDBFactory(),
+ localStorage: createFakeStorage(),
+ fetchImpl: async (input, init) =>
+ String(input).includes("/conversations?status=") ? Response.json(body) : base(input, init),
+ });
+ try {
+ ws.resolveOpen();
+ await vi.waitFor(() => expect(store.fatalError).toContain("Invalid conversations response"));
+ } finally {
+ store.dispose();
+ }
+});
+
+describe("compaction queued acknowledgement routing", () => {
+ it.each([
+ "surface-first",
+ "ack-first",
+ "drained-before-ack",
+ ])("uses only authoritative queue snapshots: %s", (order) => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ ws.resolveOpen();
+ store.send("queued text", [{ url: "data:image/png;base64,AAAA" }]);
+ const cid = activeConversationId(store);
+ const send = parseSent(ws).find((m) => (m as { type: string }).type === "chat.send") as {
+ requestId: string;
+ };
+ expect(send.requestId).toEqual(expect.any(String));
+ const sourceChat = store.activeChat;
+ const watch = store.watchConversation("other-conversation");
+ watch.send("unrelated local echo");
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [{ id: "message-queue", region: "inline", title: "Queue", scope: "conversation" }],
+ });
+ const spec = (messages: { id: string; text: string; timestamp: number }[]) => ({
+ id: "message-queue",
+ region: "inline",
+ title: "Queue",
+ fields: [{ kind: "custom" as const, rendererId: "message-queue", payload: { messages } }],
+ });
+ const queued = spec([{ id: "q1", text: "queued text", timestamp: 1 }]);
+ const drained = spec([]);
+ const ack = () =>
+ ws.feedServerMessage({
+ type: "chat.queued",
+ conversationId: cid,
+ reason: "compaction",
+ messageId: "q1",
+ requestId: send.requestId,
+ });
+ if (order === "ack-first") {
+ ack();
+ expect(store.surface("message-queue", cid)).toBeNull();
+ }
+ ws.feedSurfaceMessage({ type: "surface", conversationId: cid, spec: queued });
+ expect(store.surface("message-queue", cid)).toEqual(queued);
+ if (order === "drained-before-ack") {
+ ws.feedSurfaceMessage({
+ type: "update",
+ update: { surfaceId: "message-queue", conversationId: cid, spec: drained },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: cid, turnId: "t1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "user-message", conversationId: cid, turnId: "t1", text: "queued text" },
+ });
+ }
+ ack();
+ ack();
+ expect(store.surface("message-queue", cid)).toEqual(
+ order === "drained-before-ack" ? drained : queued,
+ );
+ expect(sourceChat.chunks.map((c) => c.chunk)).toEqual(
+ order === "drained-before-ack" ? [{ type: "text", text: "queued text" }] : [],
+ );
+ expect(sourceChat.error).toBeNull();
+ expect(watch.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "unrelated local echo" },
+ ]);
+ expect(parseSent(ws).filter((m) => (m as { type: string }).type === "chat.send")).toHaveLength(
+ 2,
+ );
+ expect(parseSent(ws).filter((m) => (m as { type: string }).type === "chat.queue")).toHaveLength(
+ 0,
+ );
+ store.dispose();
+ });
+});
+
+it("routes omitted replay only to the matching watch/tab without WS resend or an error banner", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ indexedDB: new IDBFactory(),
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ try {
+ ws.resolveOpen();
+ const tab = store.activeChat;
+ const watch = store.watchConversation("omitted-watch");
+ const sentBefore = ws.sent.length;
+ ws.feedServerMessage({
+ type: "chat.resync-required",
+ conversationId: "unknown",
+ turnId: "unknown-turn",
+ reason: "replay-limit",
+ });
+ expect(watch.recovering).toBe(false);
+ expect(tab.recovering).toBe(false);
+ ws.feedServerMessage({
+ type: "chat.resync-required",
+ conversationId: "omitted-watch",
+ turnId: "t1",
+ reason: "replay-limit",
+ });
+ expect(watch.recovering).toBe(true);
+ expect(watch.generating).toBe(true);
+ expect(tab.recovering).toBe(false);
+ expect(watch.error).toBeNull();
+ expect(tab.error).toBeNull();
+ expect(store.fatalError).toBeNull();
+ expect(ws.sent).toHaveLength(sentBefore);
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: {
+ type: "turn-ended",
+ conversationId: "omitted-watch",
+ turnId: "t1",
+ reason: "aborted",
+ },
+ });
+ await vi.waitFor(() => expect(watch.pendingSync).toBe(false));
+ expect(watch.recovering).toBe(false);
+ expect(watch.generating).toBe(false);
+ expect(ws.sent).toHaveLength(sentBefore);
+ } finally {
+ store.dispose();
+ }
+});
+
+describe("unconfirmed first-message recovery", () => {
+ it("retains a local-only tab and input after reload and authoritative discovery, without resending", async () => {
+ const storage = createFakeStorage();
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ if (String(input).endsWith("/conversations?status=active,idle"))
+ return Response.json({ conversations: [] });
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const first = createAppStore({
+ localStorage: storage,
+ indexedDB: new IDBFactory(),
+ fetchImpl,
+ socketFactory: () => ws,
+ });
+ ws.resolveOpen();
+ first.send("must survive", [{ url: "data:image/png;base64,AAAA" }]);
+ const id = first.activeConversationId;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(first.activeChat.pendingInputs).toHaveLength(1);
+ first.dispose();
+ const nextSocket = fakeSocket();
+ const next = createAppStore({
+ localStorage: storage,
+ indexedDB: new IDBFactory(),
+ fetchImpl,
+ socketFactory: () => nextSocket,
+ });
+ try {
+ nextSocket.resolveOpen();
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(next.tabs.some((tab) => tab.conversationId === id)).toBe(true);
+ expect(next.activeChat.pendingInputs).toMatchObject([
+ { request: { message: "must survive" } },
+ ]);
+ const requestId = next.activeChat.pendingInputs[0]?.request.requestId ?? "";
+ nextSocket.feedServerMessage({ type: "chat.accepted", conversationId: id ?? "", requestId });
+ expect(next.activeChat.pendingInputs).toEqual([]);
+ expect(
+ parseSent(nextSocket).some((message) =>
+ ["chat.send", "chat.queue"].includes((message as { type: string }).type),
+ ),
+ ).toBe(false);
+ } finally {
+ next.dispose();
+ }
+ });
+});
diff --git a/src/app/subscription-budget.test.ts b/src/app/subscription-budget.test.ts
new file mode 100644
index 0000000..03cdaa1
--- /dev/null
+++ b/src/app/subscription-budget.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import {
+ liveConversationIds,
+ MAX_CHAT_SUBSCRIPTIONS,
+ MAX_SURFACE_SUBSCRIPTIONS,
+} from "./subscription-budget";
+
+describe("live subscription budget", () => {
+ it("reserves the focused conversation even when hundreds of viewers exist", () => {
+ const viewers = Array.from({ length: 339 }, (_, i) => `viewer-${i}`);
+ const selected = liveConversationIds("focused", viewers);
+ expect(selected.size).toBe(MAX_CHAT_SUBSCRIPTIONS);
+ expect(selected.has("focused")).toBe(true);
+ expect(selected.size + MAX_SURFACE_SUBSCRIPTIONS).toBeLessThanOrEqual(256);
+ });
+ it("deduplicates a modal viewing the focused chat and permits drafts", () => {
+ expect([...liveConversationIds("same", ["same", "other"])]).toEqual(["same", "other"]);
+ expect([...liveConversationIds(null, [])]).toEqual([]);
+ });
+});
diff --git a/src/app/subscription-budget.ts b/src/app/subscription-budget.ts
new file mode 100644
index 0000000..fc0ccb2
--- /dev/null
+++ b/src/app/subscription-budget.ts
@@ -0,0 +1,15 @@
+/** Split the server's 256 shared slots so catalog growth cannot evict chat watchers. */
+export const MAX_CHAT_SUBSCRIPTIONS = 128;
+export const MAX_SURFACE_SUBSCRIPTIONS = 128;
+
+export function liveConversationIds(
+ activeId: string | null,
+ watchedIds: Iterable<string>,
+): Set<string> {
+ return new Set(
+ [...new Set([...(activeId === null ? [] : [activeId]), ...watchedIds])].slice(
+ 0,
+ MAX_CHAT_SUBSCRIPTIONS,
+ ),
+ );
+}
diff --git a/src/app/surface-protocol.svelte.ts b/src/app/surface-protocol.svelte.ts
new file mode 100644
index 0000000..c79c8b6
--- /dev/null
+++ b/src/app/surface-protocol.svelte.ts
@@ -0,0 +1,146 @@
+import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { SurfaceSocketHandle } from "../adapters/ws";
+import {
+ applyServerMessage,
+ getSurfaceSpec,
+ type ProtocolState,
+ initialState as protocolInitialState,
+ invoke as protocolInvoke,
+ subscribe as protocolSubscribe,
+ unsubscribe as protocolUnsubscribe,
+} from "../core/protocol";
+import { MAX_SURFACE_SUBSCRIPTIONS } from "./subscription-budget";
+
+/** Shared app/plugin surface operations over the single app protocol state. */
+export function createSurfaceProtocol(deps: {
+ focusedConversationId: () => string | undefined;
+ send: SurfaceSocketHandle["send"];
+}) {
+ let protocol = $state<ProtocolState>(protocolInitialState());
+ // Rendering may retain the prior spec during re-scope. Consumers making
+ // conversation-specific decisions can opt into a snapshot with a known scope.
+ let receivedScopes = $state<ReadonlyMap<string, string | undefined>>(new Map());
+ function handleServerMessage(msg: SurfaceServerMessage): void {
+ const next = applyServerMessage(protocol, msg);
+ if (next !== protocol && (msg.type === "surface" || msg.type === "update")) {
+ const snapshot = msg.type === "surface" ? msg : msg.update;
+ const scopes = new Map(receivedScopes);
+ scopes.set(snapshot.spec.id, snapshot.conversationId);
+ receivedScopes = scopes;
+ }
+ protocol = next;
+ // Surfaces are auto-expanded: whenever the catalog changes, subscribe to
+ // every entry (and drop subscriptions for entries that vanished).
+ if (msg.type === "catalog") {
+ syncSubscriptions();
+ }
+ }
+
+ /**
+ * Subscribe to every catalog entry, scoped to the focused conversation, and
+ * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped
+ * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe`
+ * emits unsubscribe-old + subscribe-new); a global surface ignores the id.
+ */
+ function syncSubscriptions(): void {
+ const cid = deps.focusedConversationId();
+ const catalogIds = new Set(
+ protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS).map((e) => e.id),
+ );
+ for (const id of [...protocol.subscriptions.keys()]) {
+ if (!catalogIds.has(id)) {
+ const result = protocolUnsubscribe(protocol, id);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+ }
+ for (const entry of protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS)) {
+ // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id
+ // so a conversation switch doesn't churn a redundant unsubscribe+subscribe
+ // round trip ([email protected] catalog `scope`; ABSENT = assume
+ // conversation-scoped, the conservative pre-0.2.0 policy).
+ const scoped = entry.scope === "global" ? undefined : cid;
+ const result = protocolSubscribe(protocol, entry.id, scoped);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+ receivedScopes = new Map([...receivedScopes].filter(([id]) => protocol.subscriptions.has(id)));
+ }
+
+ /**
+ * The surface OP implementations — closure functions shared by the app
+ * store's public methods AND the frontend-plugin ABI's surfaces ports (the
+ * plugin host binds exactly these; there is no second protocol state).
+ */
+ function invokeSurfaceOp(
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+ conversationId?: string,
+ ): void {
+ const result = protocolInvoke(
+ protocol,
+ surfaceId,
+ actionId,
+ payload,
+ conversationId ?? deps.focusedConversationId(),
+ );
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+
+ function subscribeSurfaceOp(surfaceId: string, conversationId?: string): void {
+ if (
+ !protocol.subscriptions.has(surfaceId) &&
+ protocol.subscriptions.size >= MAX_SURFACE_SUBSCRIPTIONS
+ )
+ return;
+ const result = protocolSubscribe(
+ protocol,
+ surfaceId,
+ conversationId ?? deps.focusedConversationId(),
+ );
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+
+ function unsubscribeSurfaceOp(surfaceId: string, conversationId?: string): void {
+ // protocolUnsubscribe keys on the surface alone (it drops the pair it
+ // was subscribed under); the optional id documents the intent.
+ void conversationId;
+ const result = protocolUnsubscribe(protocol, surfaceId);
+ protocol = result.state;
+ const scopes = new Map(receivedScopes);
+ scopes.delete(surfaceId);
+ receivedScopes = scopes;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+
+ function surfaceSpecOp(surfaceId: string, conversationId?: string): SurfaceSpec | null {
+ if (conversationId !== undefined && receivedScopes.get(surfaceId) !== conversationId)
+ return null;
+ return getSurfaceSpec(protocol, surfaceId);
+ }
+
+ return {
+ handleServerMessage,
+ syncSubscriptions,
+ invokeSurfaceOp,
+ subscribeSurfaceOp,
+ unsubscribeSurfaceOp,
+ surfaceSpecOp,
+ get protocol() {
+ return protocol;
+ },
+ };
+}
diff --git a/src/app/surface-protocol.test.ts b/src/app/surface-protocol.test.ts
new file mode 100644
index 0000000..405e561
--- /dev/null
+++ b/src/app/surface-protocol.test.ts
@@ -0,0 +1,96 @@
+import type { SurfaceSpec } from "@dispatch/ui-contract";
+import { describe, expect, it, vi } from "vitest";
+import { createSurfaceProtocol } from "./surface-protocol.svelte";
+
+const spec: SurfaceSpec = { id: "s1", region: "inline", title: "Snapshot", fields: [] };
+
+function fixture() {
+ let focusedId = "c1";
+ const surfaces = createSurfaceProtocol({
+ focusedConversationId: () => focusedId,
+ send: vi.fn(),
+ });
+ surfaces.handleServerMessage({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "inline", title: "Snapshot", scope: "conversation" }],
+ });
+ return {
+ surfaces,
+ focus(id: string) {
+ focusedId = id;
+ surfaces.syncSubscriptions();
+ },
+ };
+}
+
+describe("conversation-scoped surface lookup", () => {
+ it("keeps retained rendering but withholds another conversation's snapshot from decisions", () => {
+ const { surfaces, focus } = fixture();
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toEqual(spec);
+ focus("c2");
+ expect(surfaces.surfaceSpecOp("s1")).toEqual(spec);
+ expect(surfaces.surfaceSpecOp("s1", "c2")).toBeNull();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ expect(surfaces.surfaceSpecOp("s1", "c2")).toBeNull();
+ const updated = { ...spec, title: "Current" };
+ surfaces.handleServerMessage({
+ type: "update",
+ update: { surfaceId: "s1", conversationId: "c2", spec: updated },
+ });
+ expect(surfaces.surfaceSpecOp("s1", "c2")).toEqual(updated);
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ });
+
+ it("does not treat an unscoped snapshot as known conversation-specific data", () => {
+ const { surfaces } = fixture();
+ surfaces.handleServerMessage({ type: "surface", spec });
+ expect(surfaces.surfaceSpecOp("s1")).toEqual(spec);
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ });
+
+ it("drops snapshot scope on explicit unsubscribe and catalog removal", () => {
+ const { surfaces } = fixture();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ surfaces.unsubscribeSurfaceOp("s1");
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ surfaces.subscribeSurfaceOp("s1");
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toEqual(spec);
+ surfaces.handleServerMessage({ type: "catalog", catalog: [] });
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ });
+});
+
+describe("surface subscription budget", () => {
+ it("bounds a large catalog and releases old slots before subscribing a replacement catalog", () => {
+ const live = new Set<string>();
+ let peak = 0;
+ const surfaces = createSurfaceProtocol({
+ focusedConversationId: () => "chat",
+ send(message) {
+ if (message.type === "subscribe") live.add(message.surfaceId);
+ if (message.type === "unsubscribe") live.delete(message.surfaceId);
+ peak = Math.max(peak, live.size);
+ },
+ });
+ for (const prefix of ["old", "new"]) {
+ surfaces.handleServerMessage({
+ type: "catalog",
+ catalog: Array.from({ length: 339 }, (_, i) => ({
+ id: `${prefix}-${i}`,
+ region: "inline",
+ title: "Surface",
+ scope: "global" as const,
+ })),
+ });
+ expect(live.size).toBe(128);
+ expect([...live].every((id) => id.startsWith(prefix))).toBe(true);
+ }
+ surfaces.subscribeSurfaceOp("overflow");
+ expect(live.has("overflow")).toBe(false);
+ expect(peak).toBe(128);
+ });
+});
diff --git a/src/app/view-ports.ts b/src/app/view-ports.ts
new file mode 100644
index 0000000..577fe11
--- /dev/null
+++ b/src/app/view-ports.ts
@@ -0,0 +1,54 @@
+/**
+ * The port bag the composition root hands to every registered view wrapper.
+ *
+ * This is the ONLY shape of app state the wrappers may touch: the app store
+ * plus the two chrome ports that live outside the tree (the system-prompt
+ * builder modal and the heartbeat run modal). Keeping it here is what makes
+ * `register-views.ts` possible — the wrappers take `{ ports }` and close over
+ * exactly what `viewContent` used to pass in App.svelte.
+ */
+
+import { tick } from "svelte";
+import type { HeartbeatRunView } from "../features/heartbeat";
+import type { AppStore } from "./store.svelte";
+
+/**
+ * The chat transcript's live scroll element, shared with views that must
+ * preserve the reader's viewport across a prepend (today: the settings view's
+ * chat-limit save — the same mechanism as "show earlier"). The chat pane writes
+ * it on mount via an effect; nothing else may.
+ */
+export interface ChatViewportBridge {
+ el: HTMLElement | null;
+}
+
+export interface AppViewPorts {
+ readonly store: AppStore;
+ readonly chatViewport: ChatViewportBridge;
+ /** Chrome: open the system-prompt builder modal (app shell state, not a view). */
+ readonly openSystemPromptBuilder: () => void;
+ /** Chrome: open the heartbeat run modal for a run (app shell state, not a view). */
+ readonly openHeartbeatRun: (run: HeartbeatRunView) => void;
+}
+
+/**
+ * Run an async action that PREPENDS transcript content while preserving the
+ * reader's viewport position: prepended content grows scrollHeight, so shift
+ * scrollTop by the growth (the manual analogue of CSS scroll anchoring, which
+ * not every engine applies here). Used by "show earlier" AND the chat-limit
+ * save (which refills older history on a raise).
+ */
+export async function preserveViewportAcross<T>(
+ el: HTMLElement | null | undefined,
+ run: () => Promise<T>,
+): Promise<T> {
+ const prevHeight = el?.scrollHeight ?? 0;
+ const prevTop = el?.scrollTop ?? 0;
+ const result = await run();
+ await tick();
+ if (el) {
+ const delta = el.scrollHeight - prevHeight;
+ if (delta > 0) el.scrollTop = prevTop + delta;
+ }
+ return result;
+}
diff --git a/src/app/views/AccountUsagePane.svelte b/src/app/views/AccountUsagePane.svelte
new file mode 100644
index 0000000..09afb5f
--- /dev/null
+++ b/src/app/views/AccountUsagePane.svelte
@@ -0,0 +1,22 @@
+<script lang="ts">
+ /**
+ * The `account-usage` view — Claude + Codex + Hyper usage bars with
+ * on-track pace markers. GLOBAL (not workspace/conversation scoped), so it
+ * stays mounted across tab switches (no {#key}).
+ */
+ import {
+ AccountUsageView,
+ type LoadAccountUsage,
+ type SaveAccountUsageSettings,
+ } from "../../features/account-usage";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ const loadAccountUsage: LoadAccountUsage = (opts) => store.accountUsage(opts);
+ const saveAccountUsageSettings: SaveAccountUsageSettings = (provider, settings) =>
+ store.setAccountUsageSettings(provider, settings);
+</script>
+
+<AccountUsageView load={loadAccountUsage} saveSettings={saveAccountUsageSettings} />
diff --git a/src/app/views/CacheWarmingPane.svelte b/src/app/views/CacheWarmingPane.svelte
new file mode 100644
index 0000000..d1d9b43
--- /dev/null
+++ b/src/app/views/CacheWarmingPane.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ /**
+ * The `cache-warming` view — the backend's conversation-scoped cache-warming
+ * surface with the WarmNow adapter. Re-mounts per conversation (like
+ * ChatView) so the view's local warming history / manual-warm feedback
+ * can't bleed across tabs (the {#key} moved inside the wrapper).
+ */
+ import { CacheWarmingView, type WarmFeedback } from "../../features/cache-warming";
+ import type { AppViewPorts } from "../view-ports";
+
+ const CACHE_WARMING_ID = "cache-warming";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Adapt the store's WarmResult to the cache-warming feature's WarmNow port.
+ async function warmNow(): Promise<WarmFeedback | null> {
+ const result = await store.warmNow();
+ if (result === null) return null;
+ return result.ok
+ ? {
+ ok: true,
+ cachePct: result.response.cachePct,
+ expectedCacheRate: result.response.expectedCacheRate,
+ }
+ : { ok: false, error: result.error };
+ }
+</script>
+
+{#key store.activeConversationId}
+ <CacheWarmingView
+ spec={store.surface(CACHE_WARMING_ID)}
+ canWarm={store.activeConversationId !== null}
+ onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)}
+ {warmNow}
+ />
+{/key}
diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte
new file mode 100644
index 0000000..b5e05bb
--- /dev/null
+++ b/src/app/views/ChatPane.svelte
@@ -0,0 +1,117 @@
+<script lang="ts">
+ /**
+ * The `chat` view — the transcript region that moved out of App.svelte with
+ * ALL of its coupling: the smart-scroll controller (created here, reset on
+ * conversation switch like today's reset effect), the unload-gate attach
+ * effect, the follow-bottom effect, "show earlier" with viewport
+ * preservation, the ScrollToBottom affordance, and the empty-state
+ * watermark. Keyed on the active conversation exactly like today's ChatView.
+ */
+ import { ChatView, PendingInputs, isClaudeSubscriptionModel } from "../../features/chat";
+ import { createSmartScrollController, ScrollToBottom } from "../../features/smart-scroll";
+ import { preserveViewportAcross, type AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+ const textFiles = $derived.by(() => {
+ void store.activeWorkspaceId;
+ void store.textFileContextRevision;
+ const id = store.activeConversationId;
+ return id ? store.createTextFileClient(id) : undefined;
+ });
+ $effect(() => {
+ const client = textFiles;
+ return () => client?.dispose();
+ });
+
+ // Cache timer: Claude Code subscription models only (5-minute prompt-cache expiry).
+ const cacheTimer = $derived(
+ isClaudeSubscriptionModel(store.activeModel) ? store.activeChat.cacheTimer : null,
+ );
+
+ // One controller owns the chat scroll region; the effects below feed it the
+ // edges (identical to the App.svelte wiring this moved out of).
+ const smartScroll = createSmartScrollController();
+ let transcriptEl = $state<HTMLElement | undefined>();
+ let transcriptContentEl = $state<HTMLElement | undefined>();
+
+ // The settings view's chat-limit save preserves the reader's viewport over
+ // the history refill — share the live scroll element through the bridge.
+ $effect(() => {
+ ports.chatViewport.el = transcriptEl ?? null;
+ });
+
+ // Chat-limit unload gate: old chunks may be unloaded only while the reader is
+ // stuck to the bottom. While stuck, a trim removes content far ABOVE the
+ // viewport and the controller re-pins to the bottom — no visible jump; while
+ // reading history, trimming is deferred instead of yanking the page (the old
+ // Dispatch bug). In an $effect so a swapped store prop would be re-wired.
+ $effect(() => {
+ store.attachUnloadGate(() => smartScroll.isAtBottom());
+ });
+
+ // "Show earlier messages": page older history back in, preserving the reader's
+ // viewport position.
+ async function handleShowEarlier(): Promise<void> {
+ await preserveViewportAcross(transcriptEl, () => store.activeChat.showEarlier());
+ }
+
+ // Attach/detach the controller to the live scroll element + content (disposed
+ // on unmount). The content element is observed (ResizeObserver) so the view
+ // follows height changes that aren't a transcript append.
+ $effect(() => {
+ if (!transcriptEl) return;
+ return smartScroll.attach(transcriptEl, transcriptContentEl);
+ });
+
+ // New transcript content streamed in (or messages loaded) → follow the bottom
+ // while stuck. Reads `chunks.length` so the effect re-runs on every append.
+ $effect(() => {
+ void store.activeChat.chunks.length;
+ smartScroll.contentChanged();
+ });
+
+ // Conversation/tab switch → snap to the bottom of the new transcript.
+ $effect(() => {
+ void store.activeConversationId;
+ smartScroll.reset();
+ });
+</script>
+
+<div class="relative h-full">
+ <!-- The right gutter lives INSIDE the scroller (on the content), not outside
+ it: overlay scrollbars (iOS/iPadOS WebKit — Safari, Vivaldi, …; macOS)
+ take no layout space and paint over the scroller's right edge, so with
+ the gutter outside they drew on top of the tool/thinking cards. Now the
+ scroller spans to the pane edge and its scrollbar sits in the empty
+ gutter, clear of the content (classic scrollbars likewise). -->
+ <div bind:this={transcriptEl} class="h-full overflow-y-auto">
+ <div bind:this={transcriptContentEl} class="pr-4">
+ {#key store.activeConversationId}
+ <ChatView
+ chunks={store.activeChat.chunks}
+ recovering={store.activeChat.recovering}
+ turnMetrics={store.activeChat.turnMetrics}
+ hasEarlier={store.activeChat.hasEarlier}
+ onShowEarlier={handleShowEarlier}
+ thinkingKeyBase={store.activeChat.thinkingKeyBase}
+ providerRetry={store.activeChat.providerRetry}
+ apiBaseUrl={store.httpBase}
+ cwd={store.cwd}
+ {textFiles}
+ {cacheTimer}
+ />
+ {/key}
+ <PendingInputs inputs={store.activeChat.pendingInputs} onDiscard={store.activeChat.discardPendingInput} />
+ </div>
+ </div>
+ {#if store.activeChat.chunks.length === 0 && store.activeChat.pendingInputs.length === 0}
+ <div
+ class="pointer-events-none absolute inset-0 flex items-center justify-center"
+ aria-hidden="true"
+ >
+ <span class="select-none text-4xl font-bold opacity-10">Dispatch</span>
+ </div>
+ {/if}
+ <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} />
+</div>
diff --git a/src/app/views/CompactionPane.svelte b/src/app/views/CompactionPane.svelte
new file mode 100644
index 0000000..ff31f0b
--- /dev/null
+++ b/src/app/views/CompactionPane.svelte
@@ -0,0 +1,48 @@
+<script lang="ts">
+ /**
+ * The `compaction` view — message compaction with the store adapters.
+ * Per-conversation (keyed so the percent + feedback can't bleed across tabs;
+ * the {#key} moved inside the wrapper).
+ */
+ import {
+ CompactionView,
+ type CompactNowResult,
+ type SaveCompactPercentResult,
+ } from "../../features/chat";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Adapt the store's compact result to the compaction view's port.
+ async function compactNow(): Promise<CompactNowResult | null> {
+ const result = await store.compactNow();
+ if (result === null) return null;
+ return result.ok
+ ? {
+ ok: true,
+ messagesSummarized: result.response.messagesSummarized,
+ messagesKept: result.response.messagesKept,
+ }
+ : { ok: false, error: result.error };
+ }
+
+ async function saveCompactPercent(
+ percent: number,
+ ): Promise<SaveCompactPercentResult | null> {
+ const result = await store.setCompactPercent(percent);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, percent: result.percent }
+ : { ok: false, error: result.error };
+ }
+</script>
+
+{#key store.currentConversationId}
+ <CompactionView
+ percent={store.compactPercent}
+ canCompact={store.activeConversationId !== null}
+ {compactNow}
+ savePercent={saveCompactPercent}
+ />
+{/key}
diff --git a/src/app/views/ComposerPane.svelte b/src/app/views/ComposerPane.svelte
new file mode 100644
index 0000000..00dbc9f
--- /dev/null
+++ b/src/app/views/ComposerPane.svelte
@@ -0,0 +1,43 @@
+<script lang="ts">
+ /**
+ * The `composer` view — the send box with the status derivation that used to
+ * live in App.svelte (error > queued > running > idle) and the
+ * send/queue/stop ports. Not conversation-scoped (no {#key}) — the composer
+ * is a persistent column fixture, exactly like today.
+ */
+ import { Composer, type ComposerStatus } from "../../features/chat";
+ import { MESSAGE_QUEUE_RENDERER_ID, messageQueueCount } from "../../features/surface-host";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ const queuedMessageCount = $derived(
+ store.activeConversationId === null
+ ? undefined
+ : messageQueueCount(store.surface(MESSAGE_QUEUE_RENDERER_ID, store.activeConversationId)),
+ );
+
+ // Priority: error > queued > running > idle. `queued` (the turn is in flight
+ // but waiting for a concurrency slot — CR-13) wins over `running` so the
+ // corner shows a ring, not dots, during the wait. `turn-start` fires before
+ // the slot is granted, so `generating` is already true while
+ // `conversationStatus === "queued"`; the explicit queued check is what
+ // distinguishes the two.
+ const composerStatus = $derived.by<ComposerStatus>(() => {
+ if (store.activeChat.error) return "error";
+ const id = store.activeConversationId;
+ if (id !== null && store.conversationStatus(id) === "queued") return "queued";
+ return store.activeChat.generating ? "running" : "idle";
+ });
+</script>
+
+<Composer
+ onSend={(text, images) => store.send(text, images)}
+ onQueue={(text, images) => store.queueMessage(text, images)}
+ onStop={() => store.stopGeneration()}
+ contextSize={store.activeChat.currentContextSize}
+ contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
+ {queuedMessageCount}
+ status={composerStatus}
+/>
diff --git a/src/app/views/ConcurrencyPane.svelte b/src/app/views/ConcurrencyPane.svelte
new file mode 100644
index 0000000..d04307f
--- /dev/null
+++ b/src/app/views/ConcurrencyPane.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ /**
+ * The `concurrency` view — per-provider concurrency limits + live status.
+ * GLOBAL (not workspace- or conversation-scoped), so the panel stays mounted
+ * across tab switches (no {#key}, like today).
+ */
+ import {
+ ConcurrencyView,
+ type DeleteConcurrencyLimit,
+ type LoadConcurrencyLimits,
+ type LoadConcurrencyStatus,
+ type SaveConcurrencyCooldown,
+ type SaveConcurrencyLimit,
+ } from "../../features/concurrency";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Thin passthroughs (kept for structural consistency — AGENTS.md "contracts
+ // are the cross-unit surface").
+ const loadConcurrencyLimits: LoadConcurrencyLimits = () => store.concurrencyLimits();
+ const saveConcurrencyLimit: SaveConcurrencyLimit = (providerId, limit) =>
+ store.setConcurrencyLimit(providerId, limit);
+ const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) =>
+ store.deleteConcurrencyLimit(providerId);
+ const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus();
+ const saveConcurrencyCooldown: SaveConcurrencyCooldown = (providerId, cooldownMs) =>
+ store.setConcurrencyCooldown(providerId, cooldownMs);
+</script>
+
+<ConcurrencyView
+ models={store.models}
+ loadLimits={loadConcurrencyLimits}
+ saveLimit={saveConcurrencyLimit}
+ deleteLimit={deleteConcurrencyLimit}
+ loadStatus={loadConcurrencyStatus}
+ saveCooldown={saveConcurrencyCooldown}
+/>
diff --git a/src/app/views/ExtensionsPane.svelte b/src/app/views/ExtensionsPane.svelte
new file mode 100644
index 0000000..d5baa39
--- /dev/null
+++ b/src/app/views/ExtensionsPane.svelte
@@ -0,0 +1,82 @@
+<script lang="ts">
+ /**
+ * The `extensions` view — the frontend-modules table (aggregated from each
+ * feature's public `manifest` so it can't drift from what's actually
+ * composed) plus the generic surface list. The backend's "Loaded Extensions"
+ * surface is a SEPARATE, backend-owned list. Unkeyed (like today).
+ */
+ import Table from "../../components/Table.svelte";
+ import { manifest as accountUsageManifest } from "../../features/account-usage";
+ import { manifest as cacheWarmingManifest } from "../../features/cache-warming";
+ import { manifest as chatManifest } from "../../features/chat";
+ import { manifest as concurrencyManifest } from "../../features/concurrency";
+ import { manifest as conversationCacheManifest } from "../../features/conversation-cache";
+ import { manifest as cwdLspManifest } from "../../features/cwd-lsp";
+ import { manifest as computerManifest } from "../../features/computer";
+ import { manifest as heartbeatManifest } from "../../features/heartbeat";
+ import { manifest as layoutManifest } from "../../features/layout";
+ import { manifest as markdownManifest } from "../../features/markdown";
+ import { manifest as mcpManifest } from "../../features/mcp";
+ import { manifest as namedAgentsManifest } from "../../features/named-agents";
+ import { manifest as searchSettingsManifest } from "../../features/search-settings";
+ import { manifest as settingsManifest } from "../../features/settings";
+ import { manifest as smartScrollManifest } from "../../features/smart-scroll";
+ import { SurfaceView } from "../../features/surface-host";
+ import { manifest as surfaceHostManifest } from "../../features/surface-host";
+ import { manifest as systemPromptManifest } from "../../features/system-prompt";
+ import { manifest as tabsManifest } from "../../features/tabs";
+ import { manifest as viewsManifest } from "../../features/views";
+ import { manifest as visionManifest } from "../../features/vision";
+ import type { AppViewPorts } from "../view-ports";
+
+ // Surfaces with dedicated renderers elsewhere stay out of the generic list:
+ // cache-warming (its own view), message-queue (its own view), todo (the
+ // Tasks view). SurfaceView itself stays fully generic (it never switches on
+ // a surface id) — the FILTER is this view's data selection.
+ const CACHE_WARMING_ID = "cache-warming";
+ const MESSAGE_QUEUE_ID = "message-queue";
+ const TODO_ID = "todo";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ const MODULE_COLUMNS = ["Module", "Description"] as const;
+ // FE features are internal units of this single repo, so there is no
+ // per-module version — they all share dispatch-web's version.
+ const loadedModules: readonly (readonly [string, string])[] = [
+ chatManifest,
+ tabsManifest,
+ surfaceHostManifest,
+ viewsManifest,
+ layoutManifest,
+ conversationCacheManifest,
+ markdownManifest,
+ cacheWarmingManifest,
+ cwdLspManifest,
+ mcpManifest,
+ computerManifest,
+ smartScrollManifest,
+ settingsManifest,
+ systemPromptManifest,
+ heartbeatManifest,
+ concurrencyManifest,
+ accountUsageManifest,
+ visionManifest,
+ namedAgentsManifest,
+ searchSettingsManifest,
+ ].map((m) => [m.name, m.description] as const);
+</script>
+
+<section>
+ <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3>
+ <Table columns={MODULE_COLUMNS} rows={loadedModules} />
+</section>
+<section class="mt-4 flex flex-col gap-3">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3>
+ {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)}
+ <SurfaceView
+ {spec}
+ onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)}
+ />
+ {/each}
+</section>
diff --git a/src/app/views/HeartbeatPane.svelte b/src/app/views/HeartbeatPane.svelte
new file mode 100644
index 0000000..1bb2e86
--- /dev/null
+++ b/src/app/views/HeartbeatPane.svelte
@@ -0,0 +1,56 @@
+<script lang="ts">
+ /**
+ * The `heartbeat` view — workspace-scoped autonomous-agent heartbeat (config
+ * + run history) with the store adapters. Opening a RUN raises the chrome
+ * port: the run modal is app shell state (outside the tree — layout-00
+ * decision 2). Unkeyed; the config + runs are per-workspace (like today).
+ */
+ import {
+ HeartbeatView,
+ type HeartbeatConfigResult,
+ type HeartbeatNextRunResult,
+ type HeartbeatRunView,
+ type HeartbeatRunsResult,
+ type HeartbeatStopResult,
+ } from "../../features/heartbeat";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Thin passthroughs (kept for structural consistency with cwd-lsp/mcp —
+ // AGENTS.md "contracts are the cross-unit surface").
+ async function loadHeartbeatConfig(): Promise<HeartbeatConfigResult> {
+ return store.heartbeatConfig();
+ }
+
+ async function saveHeartbeatConfig(
+ patch: Parameters<typeof store.setHeartbeatConfig>[0],
+ ): Promise<HeartbeatConfigResult> {
+ return store.setHeartbeatConfig(patch);
+ }
+
+ async function loadHeartbeatRuns(): Promise<HeartbeatRunsResult> {
+ return store.heartbeatRuns();
+ }
+
+ async function stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
+ return store.stopHeartbeatRun(runId);
+ }
+
+ async function loadHeartbeatNextRun(): Promise<HeartbeatNextRunResult> {
+ return store.heartbeatNextRun();
+ }
+</script>
+
+<HeartbeatView
+ models={store.models}
+ loadConfig={loadHeartbeatConfig}
+ saveConfig={saveHeartbeatConfig}
+ loadRuns={loadHeartbeatRuns}
+ stopRun={stopHeartbeatRun}
+ loadVariables={() => store.loadSystemPromptVariables()}
+ loadDefaultPrompt={() => store.loadSystemPrompt()}
+ loadNextRun={loadHeartbeatNextRun}
+ onOpenRun={(run) => ports.openHeartbeatRun(run)}
+/>
diff --git a/src/app/views/LspPane.svelte b/src/app/views/LspPane.svelte
new file mode 100644
index 0000000..5645ace
--- /dev/null
+++ b/src/app/views/LspPane.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ /**
+ * The `lsp` view — loaded language servers per conversation. Re-mounts per
+ * conversation (incl. draft) so the loaded server list is isolated (the
+ * {#key} moved inside the wrapper).
+ */
+ import { LspStatusView, type LspStatusResult } from "../../features/cwd-lsp";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ async function loadLspStatus(): Promise<LspStatusResult | null> {
+ const result = await store.lspStatus();
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
+ : { ok: false, error: result.error };
+ }
+</script>
+
+{#key store.currentConversationId}
+ <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} />
+{/key}
diff --git a/src/app/views/McpPane.svelte b/src/app/views/McpPane.svelte
new file mode 100644
index 0000000..82e139d
--- /dev/null
+++ b/src/app/views/McpPane.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ /**
+ * The `mcp` view — loaded MCP servers per conversation. Re-mounts per
+ * conversation (incl. draft) so the loaded server list is isolated (the
+ * {#key} moved inside the wrapper).
+ */
+ import { McpStatusView, type McpStatusResult } from "../../features/mcp";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ async function loadMcpStatus(): Promise<McpStatusResult | null> {
+ const result = await store.mcpStatus();
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
+ : { ok: false, error: result.error };
+ }
+</script>
+
+{#key store.currentConversationId}
+ <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} />
+{/key}
diff --git a/src/app/views/MessageQueuePane.svelte b/src/app/views/MessageQueuePane.svelte
new file mode 100644
index 0000000..46b9a22
--- /dev/null
+++ b/src/app/views/MessageQueuePane.svelte
@@ -0,0 +1,40 @@
+<script lang="ts">
+ /**
+ * The `message-queue` view — pending steering messages, rendered via the
+ * generic SurfaceView (dispatches on rendererId, never surface id). The
+ * non-empty conditional moved INSIDE the view (decision 4): the tree's leaf
+ * always exists; an idle queue renders NOTHING.
+ */
+ import { SurfaceView } from "../../features/surface-host";
+ import { parseMessageQueuePayload } from "../../features/surface-host/logic/message-queue";
+ import type { AppViewPorts } from "../view-ports";
+
+ // The message-queue extension's per-conversation surface (steering). Pulled
+ // out of the generic Extensions list — pending steering messages are tied to
+ // the chat, not the sidebar.
+ const MESSAGE_QUEUE_ID = "message-queue";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // The surface spec + whether it currently has pending messages.
+ const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID));
+ const hasQueuedMessages = $derived.by(() => {
+ const spec = messageQueueSpec;
+ if (spec === null) return false;
+ const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID);
+ if (field === undefined || field.kind !== "custom") return false;
+ const data = parseMessageQueuePayload(field.payload);
+ return data !== null && data.messages.length > 0;
+ });
+</script>
+
+{#if hasQueuedMessages && messageQueueSpec !== null}
+ <div class="px-4 pt-2">
+ <SurfaceView
+ spec={messageQueueSpec}
+ onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)}
+ onCancelQueuedMessage={(messageId) => store.cancelQueuedMessage(messageId)}
+ />
+ </div>
+{/if}
diff --git a/src/app/views/ModelPane.svelte b/src/app/views/ModelPane.svelte
new file mode 100644
index 0000000..0344e47
--- /dev/null
+++ b/src/app/views/ModelPane.svelte
@@ -0,0 +1,131 @@
+<script lang="ts">
+ /**
+ * The `model` view — the model selector + the per-conversation
+ * reasoning-effort / cwd / computer controls, with the store adapters that
+ * used to live in App.svelte. The controls re-mount per workspace
+ * conversation (the {#key} moved inside the wrapper) so inputs can't bleed
+ * across tabs — editable for a draft too.
+ */
+ import {
+ ModelSelector,
+ ReasoningEffortSelector,
+ type ThinkingSelection,
+ type ThinkingSelectionSaveResult,
+ } from "../../features/chat";
+ import { CwdField, type CwdSaveResult } from "../../features/cwd-lsp";
+ import {
+ ComputerField,
+ type ComputerSaveResult,
+ type ComputerStatusResult,
+ type LoadComputerStatus,
+ type SaveComputer,
+ type TestComputer,
+ type TestComputerResult,
+ } from "../../features/computer";
+ import type { NamedAgentSelectionSnapshot } from "../../features/named-agents";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ function handleSelectModel(model: string): void {
+ store.selectModel(model);
+ }
+
+ // A Named Agent selected in the model selector: resolve it to its raw model
+ // AND record the snapshot on the active conversation (plan §6/§7). The store
+ // persists the raw model to `/conversations/:id/model` + retains the snapshot
+ // on the tab.
+ function handleSelectNamedAgent(snapshot: NamedAgentSelectionSnapshot): void {
+ store.selectModel(snapshot.model, snapshot);
+ }
+
+ // Adapt the store's reasoning-effort + thinking results to the chat
+ // feature's combined selector port. The selector sends ONE selection ("off"
+ // or a level); the adapter fans it out to the right per-axis PUT(s). "off" is
+ // a SEPARATE signal from the effort level: it persists `thinking: false`
+ // (the umans route maps that to `reasoning_effort: "none"`), leaving the
+ // effort level untouched so an off→on toggle restores it. A level ensures
+ // thinking is ON (the level is meaningless while thinking is off) then sets
+ // the effort level.
+ async function saveThinkingSelection(
+ selection: ThinkingSelection,
+ ): Promise<ThinkingSelectionSaveResult | null> {
+ if (selection === "off") {
+ const result = await store.setThinking(false);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, selection: "off" }
+ : { ok: false, error: result.error };
+ }
+ // A level: enable thinking first if it is currently off, then set the level.
+ if (store.thinking === false) {
+ const on = await store.setThinking(true);
+ if (on !== null && !on.ok) {
+ return { ok: false, error: on.error };
+ }
+ }
+ const result = await store.setReasoningEffort(selection);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, selection: result.reasoningEffort }
+ : { ok: false, error: result.error };
+ }
+
+ async function saveCwd(cwd: string): Promise<CwdSaveResult | null> {
+ const result = await store.setCwd(cwd);
+ if (result === null) return null;
+ return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error };
+ }
+
+ async function saveComputer(computerId: string | null): Promise<ComputerSaveResult | null> {
+ const result = await store.setComputer(computerId);
+ if (result === null) return null;
+ return result.ok ? { ok: true, computerId: result.computerId } : { ok: false, error: result.error };
+ }
+
+ const loadComputerStatus: LoadComputerStatus = async (
+ alias: string,
+ ): Promise<ComputerStatusResult | null> => {
+ const result = await store.computerStatus(alias);
+ if (result === null) return null;
+ return result.ok ? { ok: true, status: result.response } : { ok: false, error: result.error };
+ };
+
+ const testComputer: TestComputer = async (
+ alias: string,
+ ): Promise<TestComputerResult | null> => {
+ const result = await store.testComputer(alias);
+ if (result === null) return null;
+ return result.ok ? { ok: true, response: result.response } : { ok: false, error: result.error };
+ };
+</script>
+
+<div class="flex flex-col gap-3">
+ <ModelSelector
+ models={store.models}
+ selected={store.activeModel}
+ onSelect={handleSelectModel}
+ modelInfo={store.modelInfo}
+ namedAgents={store.namedAgents}
+ namedAgentSnapshot={store.namedAgentSnapshot}
+ onSelectNamedAgent={handleSelectNamedAgent}
+ zeroProviders={store.enabledProviderCount === 0}
+ />
+ {#key store.currentConversationId}
+ <ReasoningEffortSelector
+ persistedEffort={store.reasoningEffort}
+ persistedThinking={store.thinking}
+ save={saveThinkingSelection}
+ />
+ <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} />
+ <ComputerField
+ computerId={store.computerId}
+ canEdit={true}
+ computers={store.computers}
+ save={saveComputer}
+ loadStatus={loadComputerStatus}
+ test={testComputer}
+ />
+ {/key}
+</div>
diff --git a/src/app/views/NamedAgentsPane.svelte b/src/app/views/NamedAgentsPane.svelte
new file mode 100644
index 0000000..413141a
--- /dev/null
+++ b/src/app/views/NamedAgentsPane.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ /**
+ * The `named-agents` view — a global, collapsible sidebar view of curated
+ * presets. The store owns the list (authoritative for the model selector +
+ * agent tooling); the view drives CRUD via injected ports (auto-save).
+ * Unkeyed — the list is global (no {#key}, like today).
+ */
+ import {
+ NamedAgentsView,
+ type NamedAgentInput,
+ } from "../../features/named-agents";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Adapt the store's Named Agent CRUD to the named-agents feature's ports.
+ const namedAgentPorts = {
+ create: (input: NamedAgentInput) => store.createNamedAgent(input),
+ update: (id: string, input: NamedAgentInput) => store.updateNamedAgent(id, input),
+ remove: (id: string) => store.deleteNamedAgent(id),
+ };
+</script>
+
+<NamedAgentsView agents={store.namedAgents} models={store.models} ports={namedAgentPorts} />
diff --git a/src/app/views/SettingsPane.svelte b/src/app/views/SettingsPane.svelte
new file mode 100644
index 0000000..f119103
--- /dev/null
+++ b/src/app/views/SettingsPane.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ /**
+ * The `settings` view — FE-local settings (the chat limit). Not
+ * conversation-scoped (no {#key}: the chat limit is global). A chat-limit
+ * RAISE refills the active chat (prepends older history) — the reader's
+ * viewport is preserved over the prepend via the chat viewport bridge
+ * (exactly like today's saveChatLimit).
+ */
+ import { tick } from "svelte";
+ import { ChatLimitField, type ChatLimitSaveResult } from "../../features/settings";
+ import { SearchBackendField } from "../../features/search-settings";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> {
+ const el = ports.chatViewport.el;
+ const prevHeight = el?.scrollHeight ?? 0;
+ const prevTop = el?.scrollTop ?? 0;
+ const result = await store.setChatLimit(value);
+ await tick();
+ if (el) {
+ const delta = el.scrollHeight - prevHeight;
+ if (delta > 0) el.scrollTop = prevTop + delta;
+ }
+ return result.ok
+ ? { ok: true, chatLimit: result.chatLimit }
+ : { ok: false, error: result.error };
+ }
+</script>
+
+<div class="flex flex-col gap-3">
+ <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} />
+ <SearchBackendField
+ load={() => store.searchBackendConfig()}
+ save={(backend) => store.setSearchBackend(backend)}
+ />
+</div>
diff --git a/src/app/views/SystemPromptPane.svelte b/src/app/views/SystemPromptPane.svelte
new file mode 100644
index 0000000..0fb5a11
--- /dev/null
+++ b/src/app/views/SystemPromptPane.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ /**
+ * The `system-prompt` view — a pointer to the global system-prompt template
+ * that opens the full-page modal editor (half template / half variable
+ * palette). The modal itself is CHROME (App shell state, outside the tree —
+ * layout-00 decision 2); this view only raises the port. Unkeyed (like
+ * today).
+ */
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+</script>
+
+<div class="flex flex-col gap-2">
+ <p class="text-xs opacity-60">
+ Edit the global system prompt template with variable placeholders. Opens a full-page editor.
+ </p>
+ <button type="button" class="btn btn-primary btn-sm" onclick={() => ports.openSystemPromptBuilder()}>
+ Open builder
+ </button>
+</div>
diff --git a/src/app/views/TabsPane.svelte b/src/app/views/TabsPane.svelte
new file mode 100644
index 0000000..4547e06
--- /dev/null
+++ b/src/app/views/TabsPane.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ /**
+ * The `tabs` view — the conversation tab list (moved out of the top bar into
+ * the sidebar). Re-mounts per workspace so the filtered tab set + scroll
+ * reset cleanly on a workspace switch (the {#key} moved inside the wrapper).
+ */
+ import { TabList } from "../../features/tabs";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+</script>
+
+{#key store.activeWorkspaceId}
+ <TabList
+ tabs={store.tabs}
+ activeConversationId={store.activeConversationId}
+ statusFor={(id) => store.conversationStatus(id)}
+ onSelect={(id) => store.selectTab(id)}
+ onClose={(id) => store.closeTab(id)}
+ onNewDraft={() => store.newDraft()}
+ onRename={(id, title) => store.renameTab(id, title)}
+ onSetTabGroup={(id, parentId) => store.setTabGroup(id, parentId)}
+ />
+{/key}
diff --git a/src/app/views/TasksPane.svelte b/src/app/views/TasksPane.svelte
new file mode 100644
index 0000000..6a375b3
--- /dev/null
+++ b/src/app/views/TasksPane.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+ /**
+ * The `tasks` view — the todo extension's per-conversation task list
+ * (model-maintained, read-only). Re-mounts per conversation so the task list
+ * is isolated per conversation (the {#key} moved inside the wrapper).
+ * TodoList always reserves its fixed height (empty or full).
+ */
+ import TodoList from "../../features/surface-host/ui/TodoList.svelte";
+ import { parseTodoPayload } from "../../features/surface-host/logic/todo";
+ import type { AppViewPorts } from "../view-ports";
+
+ // The `todo` extension's per-conversation task list surface.
+ const TODO_ID = "todo";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ const todoData = $derived.by(() => {
+ const spec = store.surface(TODO_ID);
+ if (spec === null) return null;
+ const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === TODO_ID);
+ if (field === undefined || field.kind !== "custom") return null;
+ return parseTodoPayload(field.payload);
+ });
+</script>
+
+{#key store.activeConversationId}
+ <TodoList payload={todoData} />
+{/key}
diff --git a/src/app/views/VisionPane.svelte b/src/app/views/VisionPane.svelte
new file mode 100644
index 0000000..5d53545
--- /dev/null
+++ b/src/app/views/VisionPane.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+ /**
+ * The `vision` view — global vision settings (image-compaction limit +
+ * model) with the store adapters. Not conversation-scoped (no {#key}), so
+ * the settings stay mounted across tab switches (like today).
+ */
+ import {
+ VisionSettingsView,
+ type LoadVisionSettingsResult,
+ type SaveVisionSettingsResult,
+ type VisionSettingsPatch,
+ } from "../../features/vision";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Adapt the store's global vision-settings API to the vision feature's ports.
+ async function loadVisionSettings(): Promise<LoadVisionSettingsResult> {
+ // The store seeds `visionSettings` on boot; a refresh keeps it current.
+ await store.refreshVisionSettings();
+ const settings = store.visionSettings;
+ if (settings === null) {
+ return { ok: false, error: "Vision settings not available." };
+ }
+ return { ok: true, settings };
+ }
+
+ async function saveVisionSettings(
+ patch: VisionSettingsPatch,
+ ): Promise<SaveVisionSettingsResult> {
+ const result = await store.setVisionSettings(patch);
+ if (result === null) return { ok: false, error: "Vision settings not available." };
+ return result.ok
+ ? { ok: true, settings: result.settings }
+ : { ok: false, error: result.error };
+ }
+</script>
+
+<VisionSettingsView
+ models={store.models}
+ modelInfo={store.modelInfo}
+ load={loadVisionSettings}
+ save={saveVisionSettings}
+/>
diff --git a/src/app/ws-handlers.ts b/src/app/ws-handlers.ts
new file mode 100644
index 0000000..e4fd916
--- /dev/null
+++ b/src/app/ws-handlers.ts
@@ -0,0 +1,216 @@
+import type {
+ ChatAcceptedMessage,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ ChatQueuedMessage,
+ ChatResyncRequiredMessage,
+ ChatSubscriptionRejectedMessage,
+ ConversationCompactedMessage,
+ ConversationOpenMessage,
+ ConversationStatusChangedMessage,
+ ConversationTabGroupChangedMessage,
+ ExtensionsChangedMessage,
+ LayoutChangedMessage,
+ TileLayoutChangedMessage,
+ WorkspaceSetMessage,
+} from "@dispatch/transport-contract";
+import type { SubscribeMessage } from "@dispatch/ui-contract";
+import type { SurfaceSocketHandle, SurfaceSocketOptions } from "../adapters/ws";
+import type { ConversationCache } from "../features/conversation-cache";
+import type { LayoutStore } from "../features/layout";
+import type { PluginsStore } from "../features/plugins";
+import type { TabsStore } from "../features/tabs";
+import type { TileLayoutStore } from "../features/workspaces";
+import type { ChatRegistry } from "./chat-registry.svelte";
+import type { OpenConversations } from "./open-conversations.svelte";
+import type { createPluginHost } from "./plugin-host.svelte";
+import type { createSettingsRefreshers } from "./settings-refreshers.svelte";
+import type { createSurfaceProtocol } from "./surface-protocol.svelte";
+
+/** Typed socket callbacks over explicitly injected app-owned instances. */
+export function createWsHandlers(deps: {
+ url: string;
+ send: SurfaceSocketHandle["send"];
+ registry: ChatRegistry;
+ conversations: OpenConversations;
+ surfaces: ReturnType<typeof createSurfaceProtocol>;
+ cache: ConversationCache;
+ tabsStore: TabsStore;
+ activeWorkspaceId: () => string;
+ workspaceSetMsg: (workspaceId: string) => WorkspaceSetMessage;
+ pluginsStore: PluginsStore;
+ pluginHost: ReturnType<typeof createPluginHost>;
+ layoutStore: LayoutStore;
+ tileLayoutStore: TileLayoutStore;
+ refreshers: ReturnType<typeof createSettingsRefreshers>;
+ reportError: (context: string, err: unknown) => void;
+}): SurfaceSocketOptions {
+ const {
+ registry,
+ conversations,
+ surfaces,
+ cache,
+ tabsStore,
+ pluginsStore,
+ pluginHost,
+ layoutStore,
+ tileLayoutStore,
+ } = deps;
+ function handleChatMessage(
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
+ ): void {
+ let targetId: string | undefined;
+ if (msg.type === "chat.delta") {
+ targetId = msg.event.conversationId;
+ } else {
+ targetId = msg.conversationId;
+ }
+
+ if (targetId !== undefined) {
+ const store = registry.chatStores.get(targetId) ?? registry.watchStores.get(targetId);
+ if (store !== undefined) {
+ store.handleDelta(msg);
+ return;
+ }
+ }
+
+ if (
+ msg.type === "chat.accepted" ||
+ msg.type === "chat.subscription-rejected" ||
+ msg.type === "chat.queued" ||
+ msg.type === "chat.resync-required"
+ )
+ return;
+
+ // fallback: try all stores (chat.error without conversationId)
+ for (const store of registry.chatStores.values()) {
+ store.handleDelta(msg);
+ }
+ for (const store of registry.watchStores.values()) {
+ store.handleDelta(msg);
+ }
+ }
+
+ return {
+ url: deps.url,
+ onMessage: surfaces.handleServerMessage,
+ onChat: handleChatMessage,
+ onConversationOpen(msg: ConversationOpenMessage): void {
+ conversations.openConversation(
+ msg.conversationId,
+ msg.workspaceId,
+ msg.model,
+ msg.tabGroupParentId,
+ );
+ },
+ onConversationTabGroupChanged(msg: ConversationTabGroupChangedMessage): void {
+ // Another client (or this one, echoed) regrouped a tab — apply the
+ // authoritative EFFECTIVE value. Unknown tabs are a reducer no-op.
+ tabsStore.setTabGroupParent(msg.conversationId, msg.tabGroupParentId);
+ },
+ onConversationStatusChanged(msg: ConversationStatusChangedMessage): void {
+ const { conversationId, status, workspaceId } = msg;
+ if (status === "closed") {
+ // Closed on another device (or the backend) — remove the tab locally.
+ if (registry.chatStores.has(conversationId)) {
+ conversations.removeTabLocally(conversationId);
+ }
+ return;
+ }
+ // active / queued / idle — update the status map (drives the tab spinner).
+ // `queued` = the turn is in flight but waiting for a concurrency slot
+ // (broadcast-only, never persisted — CR-13); the tab shows a ring.
+ conversations.conversationStatuses = new Map(conversations.conversationStatuses).set(
+ conversationId,
+ status,
+ );
+ // Reconcile the already-open tab's workspace from this authoritative
+ // lifecycle event (the payload workspace is the conversation's actual
+ // persisted workspace). A raced `conversation.open` may have stamped the
+ // wrong workspace ("default") — correct it here.
+ if (
+ registry.chatStores.has(conversationId) &&
+ (tabsStore.tabs.find((t) => t.conversationId === conversationId)?.workspaceId ??
+ workspaceId) !== workspaceId
+ ) {
+ tabsStore.setWorkspaceId(conversationId, workspaceId);
+ }
+ // If this is a new active OR queued conversation we don't have a tab for,
+ // open one — so a cross-device turn (incl. one waiting in the concurrency
+ // queue) is visible. `idle` never opens a tab.
+ if (
+ (status === "active" || status === "queued") &&
+ !registry.chatStores.has(conversationId)
+ ) {
+ conversations.openConversation(conversationId, workspaceId);
+ }
+ },
+ onConversationCompacted(msg: ConversationCompactedMessage): void {
+ const cid = msg.conversationId;
+ const store = registry.chatStores.get(cid) ?? registry.watchStores.get(cid);
+ if (store && registry.loadedConversationIds.has(cid)) {
+ store.resync(msg.historyEpoch);
+ } else {
+ store?.invalidateHistory(msg.historyEpoch);
+ // Unseen conversations stay lazy. Invalidation is queued before their
+ // next load; epoch-guarded commits reject any older in-flight response.
+ void cache
+ .reset(cid, msg.historyEpoch)
+ .catch((err) => deps.reportError("Failed to reset conversation cache", err));
+ }
+ },
+ onReopen() {
+ // The server forgot our subscriptions on reconnect; re-send each with the
+ // conversation it was subscribed under (protocolSubscribe would no-op since
+ // they're still in our local map, so emit the wire messages directly).
+ for (const [surfaceId, sub] of surfaces.protocol.subscriptions) {
+ const msg: SubscribeMessage =
+ sub.conversationId === undefined
+ ? { type: "subscribe", surfaceId }
+ : { type: "subscribe", surfaceId, conversationId: sub.conversationId };
+ deps.send(msg);
+ }
+ // A reconnect is a NEW connection — the server forgot this client's
+ // workspace too. Re-declare it so the re-pushed catalog is filtered
+ // (plan 06 §6.4).
+ deps.send(deps.workspaceSetMsg(deps.activeWorkspaceId()));
+ registry.reconnect();
+ void conversations.fetchOpenConversations();
+ },
+ onExtensionsChanged(_msg: ExtensionsChangedMessage) {
+ // §11 multi-client coherence: another client (or this one) mutated the
+ // plugin set. Refetch the plugin list (+ any open workspace modal view),
+ // the workspace-scoped model catalog, and the provider guardrail. The
+ // surface catalog is NOT refetched here — the server re-pushes it
+ // (workspace-filtered) on the same broadcast.
+ pluginsStore.onExtensionsChanged();
+ // Frontend plugins (plan 03): the load lifecycle's extensions-changed
+ // trigger — newly-loaded/enabled frontend plugins import + activate;
+ // unloaded/disabled ones are swept (their tiles fall back).
+ void pluginHost.sync();
+ // A layout plugin enabled/disabled mid-session starts/stops serving —
+ // the probe picks the new provider state up without a reload (§2.6).
+ void layoutStore.probe(deps.activeWorkspaceId());
+ void deps.refreshers.refreshModels();
+ void deps.refreshers.refreshProviderStatus();
+ },
+ onLayout(msg: LayoutChangedMessage) {
+ // §2.5 multi-client coherence: a layout write on ANY client (or a
+ // plugin's op) re-renders here — the store filters non-active
+ // workspaces (the server fans out to everyone).
+ layoutStore.onServerTree(msg.workspaceId, msg.tree);
+ },
+ onTileLayout(msg: TileLayoutChangedMessage) {
+ // Multi-client coherence for the home grid's synced layouts: a write
+ // on ANY client re-renders here — the store filters the OTHER
+ // orientation (the server fans out to everyone).
+ tileLayoutStore.onServerTileLayout(msg.mode, msg.data);
+ },
+ };
+}