diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 10:55:51 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 10:55:51 +0900 |
| commit | 38db3827870960f466be89afbc49f91238d46144 (patch) | |
| tree | 24cb1b896dfadc31e72552dbe67f00530881242e /src/app | |
| parent | 17ce47987e673b6618454d033885b17b2a01912e (diff) | |
| download | dispatch-web-38db3827870960f466be89afbc49f91238d46144.tar.gz dispatch-web-38db3827870960f466be89afbc49f91238d46144.zip | |
feat: workspaces shell + cwd-lsp rename + mcp/settings/system-prompt features + app wiring
- workspaces: URL-driven conversation grouping (home listing at /, routing,
store, http adapter, WorkspaceCard) wired into the App.svelte shell
- rename features/workspace -> features/cwd-lsp (the cwd/lsp status feature)
- new features: mcp (status view), settings (chat-limit field), system-prompt
(prompt builder), all rendered via the generic surface host
- chat: store + ChatView updates
- tabs: tabs-store updates
- app wiring: ErrorModal (full-screen error surface), app/App.svelte + store.svelte
This commit makes HEAD typecheck clean for the first time: the prior HEAD
(c95cc77) imported features/settings from app/App.svelte but never committed
the feature, so only the full working tree was green.
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.svelte | 76 | ||||
| -rw-r--r-- | src/app/App.test.ts | 108 | ||||
| -rw-r--r-- | src/app/ErrorModal.svelte | 151 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 323 | ||||
| -rw-r--r-- | src/app/store.test.ts | 62 |
5 files changed, 678 insertions, 42 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index 9225cc7..18be3ea 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -22,6 +22,11 @@ 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, @@ -42,9 +47,17 @@ type CwdSaveResult, LspStatusView, type LspStatusResult, - manifest as workspaceManifest, - } from "../features/workspace"; + manifest as cwdLspManifest, + } from "../features/cwd-lsp"; + import { + SystemPromptBuilder, + type LoadSystemPrompt as LoadSystemPromptAlias, + type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias, + type SaveSystemPrompt as SaveSystemPromptAlias, + manifest as systemPromptManifest, + } from "../features/system-prompt"; import type { AppStore } from "./store.svelte"; + import ErrorModal from "./ErrorModal.svelte"; import { createLocalStore } from "../adapters/local-storage"; import { untrack } from "svelte"; @@ -67,10 +80,12 @@ const viewKinds = [ { 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: "system-prompt", label: "System Prompt" }, { id: "settings", label: "Settings" }, ] as const; @@ -99,9 +114,11 @@ conversationCacheManifest, markdownManifest, cacheWarmingManifest, - workspaceManifest, + cwdLspManifest, + mcpManifest, smartScrollManifest, settingsManifest, + systemPromptManifest, ].map((m) => [m.name, m.description] as const); // Smart-scroll: keep the transcript pinned to the bottom while it streams, @@ -187,6 +204,7 @@ }); const storedSidebarOpen = sidebarOpenStore.load(); let sidebarOpen = $state(storedSidebarOpen ?? (typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true)); + let systemPromptModalOpen = $state(false); $effect(() => { sidebarOpenStore.save(sidebarOpen); @@ -278,7 +296,7 @@ : { ok: false, error: result.error }; } - // Adapt the store's cwd/LSP results to the workspace feature's ports. + // 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; @@ -292,6 +310,22 @@ ? { ok: true, cwd: result.response.cwd, servers: result.response.servers } : { 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); </script> <main class="relative flex h-screen overflow-hidden"> @@ -365,6 +399,7 @@ hasEarlier={store.activeChat.hasEarlier} onShowEarlier={handleShowEarlier} thinkingKeyBase={store.activeChat.thinkingKeyBase} + providerRetry={store.activeChat.providerRetry} /> {/key} </div> @@ -435,6 +470,19 @@ {/if} </main> +{#if store.fatalError} + <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} /> +{/if} + +{#if systemPromptModalOpen} + <SystemPromptBuilder + loadPrompt={loadSystemPromptPrompt} + savePrompt={saveSystemPromptPrompt} + loadVariables={loadSystemPromptVariablesPrompt} + onClose={() => (systemPromptModalOpen = false)} + /> +{/if} + {#snippet viewContent(kind: string)} {#if kind === "model"} <div class="flex flex-col gap-3"> @@ -452,6 +500,11 @@ {#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> @@ -493,6 +546,21 @@ savePercent={saveCompactPercent} /> {/key} + {: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. --> diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 6a39296..1a93948 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -1,4 +1,4 @@ -import type { WsServerMessage } from "@dispatch/transport-contract"; +import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; @@ -62,6 +62,9 @@ function fakeFetchImpl(): typeof fetch { status: 200, }); } + if (url.includes("/conversations?status=")) { + return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); + } if (url.endsWith("/cwd")) { return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 }); } @@ -415,4 +418,107 @@ describe("App component interaction tests", () => { store.dispose(); }); + + it("shows a full-screen error modal when fetchOpenConversations fails", async () => { + // A fetch that throws for the conversations list endpoint (simulating a + // 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"] }), { + status: 200, + }); + } + if (url.includes("/conversations?status=")) { + throw new TypeError("Failed to fetch: network error"); + } + if (url.endsWith("/cwd")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 }); + } + if (url.endsWith("/lsp")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), { + status: 200, + }); + } + return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + }; + + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: failingFetch, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + // The modal should appear with the error text + const dialog = await screen.findByRole("dialog", { name: "Error" }, { timeout: 3000 }); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveTextContent("Failed to load conversations"); + expect(dialog).toHaveTextContent("TypeError"); + expect(dialog).toHaveTextContent("network error"); + + // Dismiss button clears the modal + const dismissBtn = screen.getByRole("button", { name: "Dismiss error" }); + await userEvent.setup().click(dismissBtn); + expect(store.fatalError).toBeNull(); + + store.dispose(); + }); + + it("does not show the error modal when fetchOpenConversations succeeds", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), // returns valid empty conversations list + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + // Wait a tick for boot async to settle + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(store.fatalError).toBeNull(); + + store.dispose(); + }); + + it("sends workspaceId when setting cwd", async () => { + let capturedBody: SetCwdRequest | undefined; + const fetchWithCapture = 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("/cwd") && init?.method === "PUT") { + capturedBody = JSON.parse(init.body as string) as SetCwdRequest; + return new Response(JSON.stringify({ conversationId: "c", cwd: capturedBody.cwd }), { + status: 200, + }); + } + return fakeFetchImpl()(input); + }; + + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fetchWithCapture, + localStorage: createFakeStorage(), + workspaceId: "my-team", + }); + ws.resolveOpen(); + + // Let async boot settle before mutating cwd. + await new Promise((resolve) => setTimeout(resolve, 50)); + + const result = await store.setCwd("arch-rewrite"); + expect(result).toMatchObject({ ok: true, cwd: "arch-rewrite" }); + expect(capturedBody).toEqual({ cwd: "arch-rewrite", workspaceId: "my-team" }); + + store.dispose(); + }); }); diff --git a/src/app/ErrorModal.svelte b/src/app/ErrorModal.svelte new file mode 100644 index 0000000..7fc09a6 --- /dev/null +++ b/src/app/ErrorModal.svelte @@ -0,0 +1,151 @@ +<script lang="ts"> + /** + * Full-screen error modal — surfaces critical errors that would otherwise be + * silently swallowed (e.g. the cross-device tab-restore fetch failing). Shows + * the full error text + stack trace in a scrollable `<pre>`, a Copy button + * (clipboard), and an X to dismiss. Pure presentation: the error string and + * dismiss callback are injected as props. + */ + let { + error, + onDismiss, + }: { + error: string; + onDismiss: () => void; + } = $props(); + + let copied = $state(false); + let copyTimer: ReturnType<typeof setTimeout> | undefined; + + async function handleCopy(): Promise<void> { + try { + await navigator.clipboard.writeText(error); + copied = true; + clearTimeout(copyTimer); + copyTimer = setTimeout(() => { + copied = false; + }, 2000); + } catch { + // Clipboard API may be unavailable (non-secure context). Fallback: + // select the text so the user can Ctrl+C manually. + const pre = document.getElementById("error-modal-text"); + if (pre !== null) { + const range = document.createRange(); + range.selectNodeContents(pre); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + } + } + + function handleKeydown(event: KeyboardEvent): void { + if (event.key === "Escape") { + event.preventDefault(); + onDismiss(); + } + } +</script> + +<svelte:window onkeydown={handleKeydown} /> + +<!-- Full-screen overlay: fixed, high z-index, semi-transparent backdrop. --> +<!-- svelte-ignore a11y_no_static_element_interactions --> +<div + class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" + role="dialog" + aria-modal="true" + aria-label="Error" +> + <div class="flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-base-100 shadow-xl"> + <!-- Header --> + <div class="flex items-center justify-between border-b border-base-300 px-4 py-3"> + <div class="flex items-center gap-2"> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + class="size-5 text-error" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" + /> + </svg> + <h2 class="text-lg font-semibold text-error">Something went wrong</h2> + </div> + <button + type="button" + class="btn btn-ghost btn-sm btn-square" + aria-label="Dismiss error" + onclick={onDismiss} + > + <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" + > + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> + </svg> + </button> + </div> + + <!-- Body: scrollable error text --> + <div class="overflow-auto p-4"> + <pre + id="error-modal-text" + class="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed opacity-80" + >{error}</pre> + </div> + + <!-- Footer: copy button --> + <div class="flex items-center justify-between gap-2 border-t border-base-300 px-4 py-3"> + <span class="text-xs opacity-50">Press Esc to dismiss</span> + <button + type="button" + class="btn btn-sm {copied ? 'btn-success' : 'btn-outline'}" + onclick={handleCopy} + > + {#if copied} + <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="m4.5 12.75 6 6 9-13.5" /> + </svg> + Copied + {:else} + <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="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m18 3.75h-3.75m3.75 0-2.25 2.25m2.25-2.25 2.25 2.25M4.5 6.75 6.75 4.5" + /> + </svg> + Copy + {/if} + </button> + </div> + </div> +</div> diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 6cff5f8..daba83f 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -11,19 +11,27 @@ import type { ConversationStatusChangedMessage, CwdResponse, LspStatusResponse, + McpStatusResponse, ModelMetadata, + ModelResponse, ModelsResponse, ReasoningEffort, ReasoningEffortResponse, SetCompactPercentRequest, SetCwdRequest, + SetModelRequest, SetReasoningEffortRequest, + SetSystemPromptTemplateRequest, SetTitleRequest, + SystemPromptTemplateResponse, + SystemPromptVariable, + SystemPromptVariablesResponse, WarmRequest, WarmResponse, } from "@dispatch/transport-contract"; import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; import type { ConversationStatus } from "@dispatch/wire"; +import { untrack } from "svelte"; import { createIdbChunkStore } from "../adapters/idb"; import { createLocalStore } from "../adapters/local-storage"; import type { WebSocketLike } from "../adapters/ws"; @@ -65,6 +73,11 @@ 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 } @@ -80,6 +93,19 @@ export type CompactPercentResult = | { readonly ok: true; readonly percent: number } | { 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 } @@ -88,6 +114,8 @@ export type ChatLimitResult = 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; readonly activeChat: ChatStore; readonly models: readonly string[]; /** Per-model metadata (contextWindow, etc.) from `GET /models`. */ @@ -113,6 +141,8 @@ export interface AppStore { 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; @@ -174,6 +204,30 @@ export interface AppStore { * 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; /** @@ -199,6 +253,14 @@ export interface AppStore { * bottom). */ attachUnloadGate(gate: () => boolean): void; + /** + * A critical error that blocks normal operation (e.g. the cross-device tab + * restore fetch failed). When non-null, a full-screen modal is shown with the + * error details. Cleared by `clearFatalError` (the modal's dismiss button). + */ + readonly fatalError: string | null; + /** Dismiss the fatal error (called by the error modal's X button). */ + clearFatalError(): void; dispose(): void; } @@ -210,6 +272,8 @@ export interface CreateAppStoreOptions { 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 { @@ -241,6 +305,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let models = $state<readonly string[]>([]); let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({}); let activeModel = $state(DEFAULT_MODEL); + let fatalError = $state<string | null>(null); + + // The workspace currently in view (its URL slug); "default" until routing + // sets it. Tabs are filtered to this workspace; a new conversation is stamped + // with it on `chat.send`. + let activeWorkspaceId = $state<string>(opts?.workspaceId ?? "default"); const wsLocation = typeof location !== "undefined" ? location : undefined; const wsUrl = @@ -297,10 +367,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const chatStores = new Map<string, ChatStore>(); - function createChatFor(conversationId: string, model: string): ChatStore { + function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore { return createChatStore({ conversationId, model, + workspaceId, transport: { send(msg) { socket?.send(msg); @@ -315,11 +386,22 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // `setChatLimit`. chatLimit: normalizeChatLimit(chatLimitStore.load()), canUnload: () => (unloadGate === null ? true : unloadGate()), + onError: (context, err) => { + reportError(`${context} (conversation: ${conversationId})`, err); + }, }); } const initialDraftId = randomId(); - let draftStore: ChatStore = createChatFor(initialDraftId, DEFAULT_MODEL); + // 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); @@ -337,8 +419,31 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const data = (await res.json()) as CwdResponse; // Guard a slow response losing a race with a conversation switch. if (workspaceConversationId() === id) cwd = data.cwd ?? null; - } catch { - // Non-fatal: a cwd fetch failure just leaves the prior value. + } catch (err) { + reportError("Failed to load working directory", err); + } + } + + /** Refetch the workspace conversation's persisted model (works for a draft too). */ + async function refreshModel(): Promise<void> { + const id = workspaceConversationId(); + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/model`); + if (!res.ok) return; + const data = (await res.json()) as ModelResponse; + if (workspaceConversationId() !== id) return; + 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); + } else { + draftStore.setModel(data.model); + } + } + } catch (err) { + reportError("Failed to load model", err); } } @@ -360,8 +465,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const data = (await res.json()) as ReasoningEffortResponse; // Guard a slow response losing a race with a conversation switch. if (workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null; - } catch { - // Non-fatal: an effort fetch failure just leaves the default rendering. + } catch (err) { + reportError("Failed to load reasoning effort", err); } } @@ -380,8 +485,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { if (!res.ok) return; const data = (await res.json()) as CompactPercentResponse; if (workspaceConversationId() === id) compactPercent = data.threshold; - } catch { - // Non-fatal: a percent fetch failure just leaves null. + } catch (err) { + reportError("Failed to load compact percent", err); } } @@ -448,8 +553,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { function closeConversation(conversationId: string): void { void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, { method: "POST", - }).catch(() => { - // Non-fatal — see doc comment. + }).catch((err) => { + reportError("Failed to close conversation", err); }); } @@ -513,14 +618,17 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { /** * Open a conversation tab — used by the `conversation.open` WS broadcast - * (CLI `--open` flag). If the conversation is already open, this is a no-op; - * otherwise create a chat store, load its history, subscribe to its live + * (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). + * 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): void { + function openConversation(conversationId: string, workspaceId: string): void { if (chatStores.has(conversationId)) return; - const store = createChatFor(conversationId, activeModel); + const store = createChatFor(conversationId, activeModel, workspaceId); chatStores.set(conversationId, store); void store.load(); subscribeChat(conversationId); @@ -528,6 +636,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { conversationId, model: activeModel, title: "Conversation", + workspaceId, }); } @@ -552,6 +661,20 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { void refreshCompactPercent(); } + /** + * Surface a swallowed error to the user via the full-screen error modal + * (`fatalError` → `ErrorModal`). Logs to `console.error` too so the stack is + * in devtools. Called from catch blocks that previously swallowed errors silently. + */ + function reportError(context: string, err: unknown): void { + console.error(`[reportError] ${context}`, err); + const detail = + err instanceof Error + ? `${err.name}: ${err.message}\n\n${err.stack ?? "(no stack trace available)"}` + : String(err); + 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()); @@ -580,7 +703,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const existingIds = new Set(chatStores.keys()); for (const conv of data.conversations) { if (!existingIds.has(conv.id)) { - const store = createChatFor(conv.id, activeModel); + const store = createChatFor(conv.id, activeModel, conv.workspaceId); chatStores.set(conv.id, store); void store.load(); subscribeChat(conv.id); @@ -588,6 +711,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { conversationId: conv.id, model: activeModel, title: conv.title, + workspaceId: conv.workspaceId, }); } else { // Already open — update the title from the backend if it differs. @@ -602,8 +726,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { removeTabLocally(tab.conversationId); } } - } catch { - // Non-fatal: fall back to the localStorage-restored tabs. + } catch (err) { + reportError( + `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`, + err, + ); } } @@ -612,10 +739,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { onMessage: handleServerMessage, onChat: handleChatMessage, onConversationOpen(msg: ConversationOpenMessage): void { - openConversation(msg.conversationId); + openConversation(msg.conversationId, msg.workspaceId); }, onConversationStatusChanged(msg: ConversationStatusChangedMessage): void { - const { conversationId, status } = msg; + const { conversationId, status, workspaceId } = msg; if (status === "closed") { // Closed on another device (or the backend) — remove the tab locally. if (chatStores.has(conversationId)) { @@ -627,7 +754,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { conversationStatuses = new Map(conversationStatuses).set(conversationId, status); // If this is a new active conversation we don't have a tab for, open one. if (status === "active" && !chatStores.has(conversationId)) { - openConversation(conversationId); + openConversation(conversationId, workspaceId); } }, onConversationCompacted(msg: ConversationCompactedMessage): void { @@ -641,7 +768,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { store.dispose(); } void cache.delete(cid); - const fresh = createChatFor(cid, activeModel); + const fresh = createChatFor(cid, activeModel, activeWorkspaceId); chatStores.set(cid, fresh); void fresh.load(); if (wasActive) { @@ -692,15 +819,15 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } } }) - .catch(() => { - // Model fetch failure is non-fatal; use defaults. + .catch((err) => { + reportError("Failed to load model list", err); }); // 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); + const store = createChatFor(tab.conversationId, tab.model, tab.workspaceId); chatStores.set(tab.conversationId, store); void store.load(); // Watch each restored conversation's live turns: after a reload mid-turn the @@ -720,6 +847,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); void refreshCwd(); + void refreshModel(); void refreshReasoningEffort(); void refreshCompactPercent(); @@ -730,11 +858,29 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { return { get tabs(): readonly Tab[] { - return tabsStore.tabs; + return tabsStore.tabs.filter((t) => t.workspaceId === activeWorkspaceId); }, get activeConversationId(): string | null { return tabsStore.activeConversationId; }, + get activeWorkspaceId(): string { + return activeWorkspaceId; + }, + setActiveWorkspace(workspaceId: string): void { + activeWorkspaceId = 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; + tabsStore.newDraft(); + refreshActiveChat(); + syncSubscriptions(); + void refreshCwd(); + void refreshModel(); + void refreshReasoningEffort(); + void refreshCompactPercent(); + }, get activeChat(): ChatStore { return activeChat; }, @@ -796,13 +942,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { conversationId, model, title: deriveTitle(text), + workspaceId: activeWorkspaceId, }); chatStores.set(conversationId, draftStore); void draftStore.load(); // Prepare next draft const nextDraftId = randomId(); - draftStore = createChatFor(nextDraftId, activeModel); + draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId); draftConversationId = nextDraftId; refreshActiveChat(); @@ -834,6 +981,13 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { if (activeId !== null) { tabsStore.setModel(activeId, model); chatStores.get(activeId)?.setModel(model); + void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(activeId)}/model`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model } satisfies SetModelRequest), + }).catch((err) => { + reportError("Failed to persist model", err); + }); } else { draftStore.setModel(model); } @@ -842,11 +996,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { newDraft(): void { tabsStore.newDraft(); const nextDraftId = randomId(); - draftStore = createChatFor(nextDraftId, activeModel); + draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId); draftConversationId = nextDraftId; refreshActiveChat(); syncSubscriptions(); void refreshCwd(); + void refreshModel(); void refreshReasoningEffort(); void refreshCompactPercent(); }, @@ -860,6 +1015,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); syncSubscriptions(); void refreshCwd(); + void refreshModel(); void refreshReasoningEffort(); void refreshCompactPercent(); }, @@ -877,8 +1033,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title } satisfies SetTitleRequest), - }).catch(() => { - // Best-effort — the local tab is already renamed. + }).catch((err) => { + reportError("Failed to rename conversation", err); }); }, @@ -918,7 +1074,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { async setCwd(value: string): Promise<CwdResult | null> { const id = workspaceConversationId(); - const body: SetCwdRequest = { cwd: value }; + const body: SetCwdRequest = { + cwd: value, + workspaceId: untrack(() => activeWorkspaceId), + }; try { const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, { method: "PUT", @@ -974,8 +1133,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { if (conversationId === null) return; void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, { method: "POST", - }).catch(() => { - // Non-fatal — the existing event flow handles the turn settle. + }).catch((err) => { + reportError("Failed to stop generation", err); }); }, @@ -1082,10 +1241,108 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { }; } }, + + 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", + }; + } + }, + + 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", + }; + } + }, + attachUnloadGate(gate: () => boolean): void { unloadGate = gate; }, + get fatalError(): string | null { + return fatalError; + }, + clearFatalError(): void { + fatalError = null; + }, + dispose(): void { for (const store of chatStores.values()) { store.dispose(); diff --git a/src/app/store.test.ts b/src/app/store.test.ts index db6fdaa..5711442 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -103,6 +103,7 @@ function reconnectableSocket(): ReconnectableSocket { interface FakeFetchOptions { models?: readonly string[]; history?: Record<string, ConversationHistoryResponse>; + model?: string | null; } function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch { @@ -113,6 +114,15 @@ function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch { if (url.endsWith("/models")) { return new Response(JSON.stringify({ models }), { status: 200 }); } + if (url.endsWith("/model")) { + return new Response( + JSON.stringify({ + conversationId: "ignored", + model: opts?.model ?? null, + }), + { status: 200 }, + ); + } const body = history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse); return new Response(JSON.stringify(body), { status: 200 }); @@ -583,20 +593,64 @@ describe("createAppStore", () => { store.dispose(); }); - it("selecting a model updates the active tab", () => { + it("selecting a model persists it to the backend", () => { const ws = fakeSocket(); + const fetchImpl = fakeFetchImpl(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl, localStorage: createFakeStorage(), }); ws.resolveOpen(); store.send("hello"); - store.selectModel("openai/gpt-4o"); - expect(store.activeModel).toBe("openai/gpt-4o"); + const put = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ conversationId: "ignored", model: "openai/gpt-4o" }), { + status: 200, + }), + ); + const capturingStore = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (init?.method === "PUT" && url.endsWith("/model")) { + put(url, init); + } + return fetchImpl(input, init); + }, + localStorage: createFakeStorage(), + }); + capturingStore.send("hello"); + capturingStore.selectModel("openai/gpt-4o"); + + expect(put).toHaveBeenCalledOnce(); + const [callUrl, callInit] = put.mock.calls[0] as [string, RequestInit]; + expect(callUrl.endsWith("/model")).toBe(true); + expect(JSON.parse(callInit.body as string)).toEqual({ model: "openai/gpt-4o" }); + + store.dispose(); + capturingStore.dispose(); + }); + + it("focuses a conversation with a persisted model", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ model: "openai/gpt-4o" }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + 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"); + + // Wait for the persisted model fetch to resolve. + await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o")); expect(store.tabs[0]?.model).toBe("openai/gpt-4o"); store.dispose(); |
