diff options
Diffstat (limited to 'src/app')
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); + }, + }; +} |
