summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 10:55:51 +0900
committerAdam Malczewski <[email protected]>2026-06-25 10:55:51 +0900
commit38db3827870960f466be89afbc49f91238d46144 (patch)
tree24cb1b896dfadc31e72552dbe67f00530881242e /src/app
parent17ce47987e673b6618454d033885b17b2a01912e (diff)
downloaddispatch-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.svelte76
-rw-r--r--src/app/App.test.ts108
-rw-r--r--src/app/ErrorModal.svelte151
-rw-r--r--src/app/store.svelte.ts323
-rw-r--r--src/app/store.test.ts62
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();