summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.svelte71
-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
-rw-r--r--src/features/chat/index.ts9
-rw-r--r--src/features/chat/store.svelte.ts33
-rw-r--r--src/features/chat/ui/ChatView.svelte32
-rw-r--r--src/features/cwd-lsp/index.ts (renamed from src/features/workspace/index.ts)2
-rw-r--r--src/features/cwd-lsp/logic/view-model.test.ts (renamed from src/features/workspace/logic/view-model.test.ts)0
-rw-r--r--src/features/cwd-lsp/logic/view-model.ts (renamed from src/features/workspace/logic/view-model.ts)4
-rw-r--r--src/features/cwd-lsp/ui/CwdField.svelte (renamed from src/features/workspace/ui/CwdField.svelte)0
-rw-r--r--src/features/cwd-lsp/ui/LspStatusView.svelte (renamed from src/features/workspace/ui/LspStatusView.svelte)0
-rw-r--r--src/features/mcp/index.ts8
-rw-r--r--src/features/mcp/logic/view-model.test.ts88
-rw-r--r--src/features/mcp/logic/view-model.ts110
-rw-r--r--src/features/mcp/ui/McpStatusView.svelte131
-rw-r--r--src/features/settings/index.ts9
-rw-r--r--src/features/settings/logic/view-model.test.ts61
-rw-r--r--src/features/settings/logic/view-model.ts60
-rw-r--r--src/features/settings/ui/ChatLimitField.svelte104
-rw-r--r--src/features/settings/ui/ChatLimitField.test.ts89
-rw-r--r--src/features/system-prompt/index.ts17
-rw-r--r--src/features/system-prompt/logic/view-model.test.ts90
-rw-r--r--src/features/system-prompt/logic/view-model.ts106
-rw-r--r--src/features/system-prompt/ui/SystemPromptBuilder.svelte242
-rw-r--r--src/features/tabs/tabs-store.test.ts32
-rw-r--r--src/features/tabs/tabs.test.ts1
-rw-r--r--src/features/tabs/tabs.ts12
-rw-r--r--src/features/tabs/ui.test.ts10
-rw-r--r--src/features/workspaces/adapter/http.test.ts133
-rw-r--r--src/features/workspaces/adapter/http.ts134
-rw-r--r--src/features/workspaces/index.ts21
-rw-r--r--src/features/workspaces/logic/route.test.ts77
-rw-r--r--src/features/workspaces/logic/route.ts63
-rw-r--r--src/features/workspaces/logic/view-model.test.ts67
-rw-r--r--src/features/workspaces/logic/view-model.ts41
-rw-r--r--src/features/workspaces/store.svelte.ts80
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte180
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts121
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte86
42 files changed, 2969 insertions, 75 deletions
diff --git a/src/App.svelte b/src/App.svelte
index ffd5543..9d06b9f 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -1,7 +1,74 @@
<script lang="ts">
import { App, createAppStore } from "./app";
+ import { createHistoryAdapter } from "./adapters/history";
+ import {
+ createWorkspaceHttp,
+ createWorkspaceStore,
+ pageTitle,
+ parsePath,
+ WorkspacesHome,
+ type Route,
+ } from "./features/workspaces";
+ import { resolveHttpUrl } from "./app/resolve-http-url";
- const store = createAppStore();
+ // Parse the route BEFORE creating the store so the boot draft + active
+ // workspace are correct from the first render (no flash of the "default"
+ // workspace when deep-linking to /<id>).
+ const history = createHistoryAdapter();
+ const initialRoute = parsePath(history.path);
+ const store = createAppStore(
+ initialRoute.kind === "workspace" ? { workspaceId: initialRoute.id } : {},
+ );
+
+ // The workspace HTTP edge shares the same httpBase resolution as the store.
+ const httpBase = resolveHttpUrl(
+ {
+ VITE_HTTP_URL: import.meta.env.VITE_HTTP_URL,
+ VITE_HTTP_PORT: import.meta.env.VITE_HTTP_PORT,
+ },
+ typeof location !== "undefined" ? location : undefined,
+ );
+ const workspaceStore = createWorkspaceStore(
+ createWorkspaceHttp(httpBase, globalThis.fetch.bind(globalThis)),
+ );
+
+ let route = $state<Route>(initialRoute);
+
+ // React to back/forward + programmatic navigation.
+ $effect(() => {
+ return history.subscribe((path) => {
+ route = parsePath(path);
+ });
+ });
+
+ // On entering a workspace: scope the store to it (idempotent — skip if already
+ // scoped) + ensure the workspace exists (create-on-miss, so it appears in the
+ // home list immediately). The backend also auto-creates on `chat.send`.
+ $effect(() => {
+ if (route.kind !== "workspace") return;
+ const id = route.id;
+ if (store.activeWorkspaceId !== id) {
+ store.setActiveWorkspace(id);
+ }
+ void workspaceStore.ensure(id);
+ });
+
+ // Keep the browser tab title in sync with the route: "Dispatch" on the home
+ // page, "Dispatch: {title}" on a workspace page (falling back to the URL slug
+ // until the list loads it). Reads `route` + the workspace list, so it re-runs
+ // on navigation and again once `ensure` refreshes the list.
+ $effect(() => {
+ if (typeof document === "undefined") return;
+ document.title = pageTitle(route, workspaceStore.list);
+ });
+
+ function navigate(path: string): void {
+ history.navigate(path);
+ }
</script>
-<App {store} />
+{#if route.kind === "home"}
+ <WorkspacesHome store={workspaceStore} onNavigate={navigate} />
+{:else}
+ <App {store} />
+{/if}
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();
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index 1596c53..c120916 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -1,5 +1,10 @@
-export type { RenderedChunk, RenderGroup, ToolBatchEntry } from "../../core/chunks";
-export { groupRenderedChunks } from "../../core/chunks";
+export type {
+ ProviderRetryView,
+ RenderedChunk,
+ RenderGroup,
+ ToolBatchEntry,
+} from "../../core/chunks";
+export { groupRenderedChunks, viewProviderRetry } from "../../core/chunks";
export type { TurnMetricsEntry } from "../../core/metrics";
export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
export type {
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index 9beabfc..a31fe55 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -4,7 +4,7 @@ import type {
ChatQueueMessage,
ChatSendMessage,
} from "@dispatch/transport-contract";
-import type { ChatMessage, StoredChunk } from "@dispatch/wire";
+import type { ChatMessage, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire";
import type { RenderedChunk, TranscriptState } from "../../core/chunks";
import {
appendUserMessage,
@@ -19,6 +19,7 @@ import {
selectGenerating,
selectHasEarlier,
selectMessages,
+ selectProviderRetry,
trimTranscript,
unloadCount,
windowTranscript,
@@ -42,6 +43,12 @@ export interface ChatStoreDependencies {
readonly metricsSync: MetricsSync;
readonly cache: ConversationCache;
/**
+ * The workspace this conversation belongs to (its URL slug). Sent on
+ * `chat.send`/`chat.queue` so the backend stamps the conversation's workspace
+ * at creation (default "default"). Absent → omitted (legacy behavior).
+ */
+ readonly workspaceId?: string;
+ /**
* The chat limit: max loaded chunks before the oldest quarter is unloaded
* (see `core/chunks/trim.ts`). Normalized via `normalizeChatLimit`; absent →
* `DEFAULT_CHAT_LIMIT`.
@@ -54,6 +61,12 @@ export interface ChatStoreDependencies {
* to the bottom (the next fold retries). Absent → always allowed.
*/
readonly canUnload?: () => boolean;
+ /**
+ * Called when a swallowed error should be surfaced to the user (e.g. a
+ * metrics sync failure). Wired by the composition root to the app store's
+ * `reportError` → full-screen error modal. Absent → errors are logged only.
+ */
+ readonly onError?: (context: string, err: unknown) => void;
}
export interface ChatStore {
@@ -73,6 +86,13 @@ export interface ChatStore {
* turn was replayed. Drives the composer's "generating…" indicator.
*/
readonly generating: boolean;
+ /**
+ * The latest `provider-retry` event for the current turn, or `null` when no
+ * retry is pending. Drives the transient yellow "retrying…" warning banner —
+ * never persisted (never sent to the model or replayed on reload). Coalesces
+ * to the newest attempt + delay; cleared when content resumes or the turn ends.
+ */
+ readonly providerRetry: TurnProviderRetryEvent | null;
readonly pendingSync: boolean;
readonly error: string | null;
readonly model: string | undefined;
@@ -183,9 +203,11 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
try {
const res = await deps.metricsSync(deps.conversationId);
metrics = applyDurableMetrics(metrics, res.turns);
- } catch {
+ } catch (err) {
// Metrics fetch failure must not block history sync or throw;
- // live-folded metrics remain intact.
+ // live-folded metrics remain intact. Surface via onError (modal).
+ console.error("[syncMetrics] failed:", err);
+ deps.onError?.("Failed to sync conversation metrics", err);
}
}
@@ -251,6 +273,9 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
get generating(): boolean {
return selectGenerating(transcript);
},
+ get providerRetry(): TurnProviderRetryEvent | null {
+ return selectProviderRetry(transcript);
+ },
get pendingSync(): boolean {
return _pendingSync;
},
@@ -295,6 +320,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
conversationId: deps.conversationId,
message: text,
...(_model !== undefined ? { model: _model } : {}),
+ ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
};
deps.transport.send(msg);
},
@@ -306,6 +332,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
type: "chat.queue",
conversationId: deps.conversationId,
text: trimmed,
+ ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
};
deps.transport.send(msg);
},
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index 2b55eb3..f2899c7 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -1,5 +1,6 @@
<script lang="ts">
- import { groupRenderedChunks, type RenderedChunk } from "../index";
+ import type { TurnProviderRetryEvent } from "@dispatch/wire";
+ import { groupRenderedChunks, type RenderedChunk, viewProviderRetry } from "../index";
import {
interleaveTurnMetrics,
viewCacheRate,
@@ -22,6 +23,7 @@
hasEarlier = false,
onShowEarlier,
thinkingKeyBase = 0,
+ providerRetry = null,
}: {
chunks: readonly RenderedChunk[];
turnMetrics?: readonly TurnMetricsEntry[];
@@ -35,6 +37,13 @@
* swap collapse state) when a trim removes older thinking blocks.
*/
thinkingKeyBase?: number;
+ /**
+ * The latest `provider-retry` event for the current turn, or `null` when
+ * no retry is pending → renders the transient yellow "retrying…" banner.
+ * Never persisted (never part of the message history); coalesces to the
+ * newest attempt + delay, and is cleared when content resumes / turn ends.
+ */
+ providerRetry?: TurnProviderRetryEvent | null;
} = $props();
// True while a show-earlier page-in is awaited (disables the button).
@@ -261,4 +270,25 @@
</div>
{/if}
{/each}
+ {#if providerRetry}
+ {@const rv = viewProviderRetry(providerRetry)}
+ <!-- Transient yellow warning: a provider error is being retried with backoff.
+ NOT a message chunk (never persisted/replayed) — a live UI notification only,
+ shown where the reply would appear. Coalesces to the newest attempt + delay,
+ and is cleared (foldEvent) when content resumes or the turn ends. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
+ <div class="chat-bubble w-full bg-transparent">
+ <div class="alert alert-warning flex-wrap items-start gap-2 py-2 text-sm" role="status">
+ <div class="flex flex-wrap items-center gap-2 font-medium">
+ <span aria-hidden="true">⚠</span>
+ <span>{rv.attemptLabel} — retrying in {rv.delayLabel}…</span>
+ {#if rv.code}
+ <span class="badge badge-warning badge-sm font-mono">{rv.code}</span>
+ {/if}
+ </div>
+ <div class="w-full font-mono text-xs opacity-70">{rv.message}</div>
+ </div>
+ </div>
+ </div>
+ {/if}
</div>
diff --git a/src/features/workspace/index.ts b/src/features/cwd-lsp/index.ts
index 9acf994..0c61425 100644
--- a/src/features/workspace/index.ts
+++ b/src/features/cwd-lsp/index.ts
@@ -9,6 +9,6 @@ export { default as LspStatusView } from "./ui/LspStatusView.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "workspace",
+ name: "cwd-lsp",
description: "Per-conversation working directory + language-server status",
} as const;
diff --git a/src/features/workspace/logic/view-model.test.ts b/src/features/cwd-lsp/logic/view-model.test.ts
index a06edeb..a06edeb 100644
--- a/src/features/workspace/logic/view-model.test.ts
+++ b/src/features/cwd-lsp/logic/view-model.test.ts
diff --git a/src/features/workspace/logic/view-model.ts b/src/features/cwd-lsp/logic/view-model.ts
index bc9b30b..97a3aca 100644
--- a/src/features/workspace/logic/view-model.ts
+++ b/src/features/cwd-lsp/logic/view-model.ts
@@ -1,9 +1,9 @@
import type { LspServerInfo, LspServerState } from "@dispatch/transport-contract";
/**
- * Pure core for the workspace feature — zero DOM, zero effects, zero Svelte.
+ * Pure core for the cwd-lsp feature — zero DOM, zero effects, zero Svelte.
*
- * The workspace feature exposes a conversation's per-tab working directory (cwd)
+ * The cwd-lsp feature exposes a conversation's per-tab working directory (cwd)
* and the live status of the language servers configured for that cwd. This
* module holds the pure logic: cwd normalization/validation, the mapping of a
* backend `LspServerState` to a display badge, and a one-line server summary.
diff --git a/src/features/workspace/ui/CwdField.svelte b/src/features/cwd-lsp/ui/CwdField.svelte
index bd8b870..bd8b870 100644
--- a/src/features/workspace/ui/CwdField.svelte
+++ b/src/features/cwd-lsp/ui/CwdField.svelte
diff --git a/src/features/workspace/ui/LspStatusView.svelte b/src/features/cwd-lsp/ui/LspStatusView.svelte
index 77603a1..77603a1 100644
--- a/src/features/workspace/ui/LspStatusView.svelte
+++ b/src/features/cwd-lsp/ui/LspStatusView.svelte
diff --git a/src/features/mcp/index.ts b/src/features/mcp/index.ts
new file mode 100644
index 0000000..a161992
--- /dev/null
+++ b/src/features/mcp/index.ts
@@ -0,0 +1,8 @@
+export type { LoadMcpStatus, McpStatusResult } from "./logic/view-model";
+export { default as McpStatusView } from "./ui/McpStatusView.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "mcp",
+ description: "Per-conversation MCP (Model Context Protocol) server status",
+} as const;
diff --git a/src/features/mcp/logic/view-model.test.ts b/src/features/mcp/logic/view-model.test.ts
new file mode 100644
index 0000000..23bf20f
--- /dev/null
+++ b/src/features/mcp/logic/view-model.test.ts
@@ -0,0 +1,88 @@
+import type { McpServerInfo } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import { summarizeMcpServers, viewMcpServer, viewMcpServers } from "./view-model";
+
+const server = (over: Partial<McpServerInfo> = {}): McpServerInfo => ({
+ id: "freecad",
+ state: "connected",
+ toolCount: 12,
+ ...over,
+});
+
+describe("viewMcpServer", () => {
+ it("connected → success badge, not busy, no error, passes toolCount", () => {
+ const v = viewMcpServer(server({ toolCount: 5 }));
+ expect(v.badge).toBe("success");
+ expect(v.statusLabel).toBe("Connected");
+ expect(v.busy).toBe(false);
+ expect(v.error).toBeNull();
+ expect(v.toolCount).toBe(5);
+ expect(v.configSource).toBeNull();
+ });
+
+ it("connecting → warning badge + busy (spinner)", () => {
+ const v = viewMcpServer(server({ state: "connecting" }));
+ expect(v.badge).toBe("warning");
+ expect(v.statusLabel).toBe("Connecting…");
+ expect(v.busy).toBe(true);
+ expect(v.error).toBeNull();
+ });
+
+ it("disconnected → neutral badge, not busy", () => {
+ const v = viewMcpServer(server({ state: "disconnected" }));
+ expect(v.badge).toBe("neutral");
+ expect(v.statusLabel).toBe("Disconnected");
+ expect(v.busy).toBe(false);
+ expect(v.error).toBeNull();
+ });
+
+ it("error → error badge + surfaces the reason (with a fallback)", () => {
+ const withReason = viewMcpServer(server({ state: "error", error: "ENOENT: npx" }));
+ expect(withReason.badge).toBe("error");
+ expect(withReason.busy).toBe(false);
+ expect(withReason.error).toBe("ENOENT: npx");
+
+ const noReason = viewMcpServer(server({ state: "error" }));
+ expect(noReason.error).toBe("Failed to connect");
+ });
+
+ it("passes through configSource when present", () => {
+ const v = viewMcpServer(server({ configSource: ".dispatch/mcp.json" }));
+ expect(v.configSource).toBe(".dispatch/mcp.json");
+ });
+
+ it("viewMcpServers maps a list preserving order", () => {
+ const views = viewMcpServers([server({ id: "a" }), server({ id: "b" })]);
+ expect(views.map((v) => v.id)).toEqual(["a", "b"]);
+ });
+});
+
+describe("summarizeMcpServers", () => {
+ it("empty list", () => {
+ expect(summarizeMcpServers([])).toBe("No MCP servers");
+ });
+
+ it("counts connected / connecting / disconnected / errors", () => {
+ expect(summarizeMcpServers([server({ state: "connected" })])).toBe("1 connected");
+ expect(
+ summarizeMcpServers([
+ server({ id: "a", state: "connected" }),
+ server({ id: "b", state: "error" }),
+ ]),
+ ).toBe("1 connected, 1 error");
+ expect(
+ summarizeMcpServers([
+ server({ id: "a", state: "connected" }),
+ server({ id: "b", state: "connecting" }),
+ server({ id: "c", state: "disconnected" }),
+ server({ id: "d", state: "error" }),
+ server({ id: "e", state: "error" }),
+ ]),
+ ).toBe("1 connected, 1 connecting, 1 disconnected, 2 errors");
+ });
+
+ it("lists only non-zero buckets", () => {
+ expect(summarizeMcpServers([server({ state: "disconnected" })])).toBe("1 disconnected");
+ expect(summarizeMcpServers([server({ id: "a", state: "connecting" })])).toBe("1 connecting");
+ });
+});
diff --git a/src/features/mcp/logic/view-model.ts b/src/features/mcp/logic/view-model.ts
new file mode 100644
index 0000000..247f804
--- /dev/null
+++ b/src/features/mcp/logic/view-model.ts
@@ -0,0 +1,110 @@
+import type { McpServerInfo, McpServerState } from "@dispatch/transport-contract";
+
+/**
+ * Pure core for the mcp feature — zero DOM, zero effects, zero Svelte.
+ *
+ * The mcp feature exposes the live status of the MCP (Model Context Protocol)
+ * servers configured for a conversation's working directory, fetched from
+ * `GET /conversations/:id/mcp`. This module holds the pure logic: the mapping
+ * of a backend `McpServerState` to a display badge + label, and a one-line
+ * server summary. The effect (the HTTP get MCP status) is INJECTED via the
+ * `LoadMcpStatus` port below; the composition root implements it.
+ */
+
+// ── Injected port (consumer-defines-port; the composition root adapts the
+// store's HTTP call to this shape). ──────────────────────────────────────────
+
+/** Outcome of `GET /conversations/:id/mcp`; `null` when no real conversation is focused. */
+export type McpStatusResult =
+ | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly McpServerInfo[] }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadMcpStatus = () => Promise<McpStatusResult | null>;
+
+// ── MCP server status → display view ───────────────────────────────────────────
+
+export type Badge = "success" | "warning" | "error" | "neutral";
+
+export interface McpServerView {
+ readonly id: string;
+ readonly state: McpServerState;
+ readonly statusLabel: string;
+ readonly badge: Badge;
+ /** True while the state is transient (show a spinner). */
+ readonly busy: boolean;
+ /** The error reason when `state === "error"`, else null. */
+ readonly error: string | null;
+ /** Number of tools discovered from this server. */
+ readonly toolCount: number;
+ /** Which config source the server was resolved from, else null. */
+ readonly configSource: string | null;
+}
+
+/**
+ * Map a server's state to a display label + badge severity + busy flag. Mirrors
+ * the LSP status visual treatment: `connected` → success, `connecting` (the
+ * transient state, analogous to LSP's `starting`) → warning + spinner, `error`
+ * → error, and `disconnected` (a stable idle state) → neutral.
+ */
+export function viewMcpServer(server: McpServerInfo): McpServerView {
+ let statusLabel: string;
+ let badge: Badge;
+ let busy = false;
+ switch (server.state) {
+ case "connected":
+ statusLabel = "Connected";
+ badge = "success";
+ break;
+ case "connecting":
+ statusLabel = "Connecting…";
+ badge = "warning";
+ busy = true;
+ break;
+ case "disconnected":
+ statusLabel = "Disconnected";
+ badge = "neutral";
+ break;
+ case "error":
+ statusLabel = "Error";
+ badge = "error";
+ break;
+ }
+ return {
+ id: server.id,
+ state: server.state,
+ statusLabel,
+ badge,
+ busy,
+ error: server.state === "error" ? (server.error ?? "Failed to connect") : null,
+ toolCount: server.toolCount,
+ configSource: server.configSource ?? null,
+ };
+}
+
+export function viewMcpServers(servers: readonly McpServerInfo[]): readonly McpServerView[] {
+ return servers.map(viewMcpServer);
+}
+
+/**
+ * A short one-line summary, e.g. "2 connected" / "1 connected, 1 connecting,
+ * 1 error". Only non-zero buckets are listed.
+ */
+export function summarizeMcpServers(servers: readonly McpServerInfo[]): string {
+ if (servers.length === 0) return "No MCP servers";
+ let connected = 0;
+ let connecting = 0;
+ let disconnected = 0;
+ let errored = 0;
+ for (const s of servers) {
+ if (s.state === "connected") connected++;
+ else if (s.state === "error") errored++;
+ else if (s.state === "connecting") connecting++;
+ else disconnected++;
+ }
+ const parts: string[] = [];
+ if (connected > 0) parts.push(`${connected} connected`);
+ if (connecting > 0) parts.push(`${connecting} connecting`);
+ if (disconnected > 0) parts.push(`${disconnected} disconnected`);
+ if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`);
+ return parts.join(", ");
+}
diff --git a/src/features/mcp/ui/McpStatusView.svelte b/src/features/mcp/ui/McpStatusView.svelte
new file mode 100644
index 0000000..9ac40ba
--- /dev/null
+++ b/src/features/mcp/ui/McpStatusView.svelte
@@ -0,0 +1,131 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import {
+ type Badge,
+ type LoadMcpStatus,
+ type McpServerView,
+ summarizeMcpServers,
+ viewMcpServers,
+ } from "../logic/view-model";
+
+ let {
+ cwd,
+ canView,
+ load,
+ }: {
+ /** The active conversation's cwd — the trigger to (re)load when it changes. */
+ cwd: string | null;
+ /** Whether a real conversation is focused. */
+ canView: boolean;
+ load: LoadMcpStatus;
+ } = $props();
+
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
+
+ let servers = $state<readonly McpServerView[]>([]);
+ let loading = $state(false);
+ let error = $state<string | null>(null);
+ let loadedCwd = $state<string | null>(null);
+ let hasLoaded = $state(false);
+ let summary = $state("");
+
+ async function refresh() {
+ if (!canView) return;
+ loading = true;
+ error = null;
+ const result = await load();
+ loading = false;
+ if (result === null) return;
+ hasLoaded = true;
+ if (result.ok) {
+ servers = viewMcpServers(result.servers);
+ summary = summarizeMcpServers(result.servers);
+ loadedCwd = result.cwd;
+ } else {
+ error = result.error;
+ }
+ }
+
+ // (Re)load on mount and whenever the conversation's cwd changes. The MCP GET
+ // lazily spawns/connects servers, so we avoid a redundant fetch when `cwd`
+ // resolves to the value we already loaded for.
+ $effect(() => {
+ const target = cwd;
+ const can = canView;
+ untrack(() => {
+ if (!can) return;
+ if (!hasLoaded || target !== loadedCwd) void refresh();
+ });
+ });
+</script>
+
+<div class="flex flex-col gap-2">
+ <div class="flex items-center justify-between gap-2">
+ <span class="text-xs opacity-70">
+ {#if loading}
+ Resolving…
+ {:else if hasLoaded && loadedCwd !== null}
+ {summary}
+ {:else}
+ MCP servers
+ {/if}
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={!canView || loading}
+ onclick={() => refresh()}
+ aria-label="Refresh MCP server status"
+ >
+ {#if loading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Refresh
+ {/if}
+ </button>
+ </div>
+
+ {#if !canView}
+ <p class="text-xs opacity-60">Open or start a conversation to see its MCP servers.</p>
+ {:else if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if hasLoaded && loadedCwd === null}
+ <p class="text-xs opacity-60">
+ Set a working directory in the Model panel to enable MCP servers.
+ </p>
+ {:else if hasLoaded && servers.length === 0 && !loading}
+ <p class="text-xs opacity-60">No MCP servers configured for this directory.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each servers as server (server.id)}
+ <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <div class="flex items-center justify-between gap-2">
+ <span class="font-medium font-mono">{server.id}</span>
+ <span class="badge badge-sm {badgeClass[server.badge]} gap-1">
+ {#if server.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {server.statusLabel}
+ </span>
+ </div>
+ <div class="flex items-center justify-between gap-2 text-xs opacity-60">
+ {#if server.configSource}
+ <span class="font-mono" title="Config source">{server.configSource}</span>
+ {:else}
+ <span></span>
+ {/if}
+ <span title="Discovered tools">{server.toolCount} tool{server.toolCount === 1 ? "" : "s"}</span>
+ </div>
+ {#if server.error}
+ <span class="font-mono text-xs text-error">{server.error}</span>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+ {/if}
+</div>
diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts
new file mode 100644
index 0000000..69e7cd0
--- /dev/null
+++ b/src/features/settings/index.ts
@@ -0,0 +1,9 @@
+export type { ChatLimitParse, ChatLimitSaveResult, SaveChatLimit } from "./logic/view-model";
+export { chatLimitChanged, parseChatLimit } from "./logic/view-model";
+export { default as ChatLimitField } from "./ui/ChatLimitField.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "settings",
+ description: "FE-local settings (chat limit)",
+} as const;
diff --git a/src/features/settings/logic/view-model.test.ts b/src/features/settings/logic/view-model.test.ts
new file mode 100644
index 0000000..65fcae9
--- /dev/null
+++ b/src/features/settings/logic/view-model.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks";
+import { chatLimitChanged, parseChatLimit } from "./view-model";
+
+describe("parseChatLimit", () => {
+ it("parses a plain integer", () => {
+ expect(parseChatLimit("256")).toEqual({ ok: true, value: 256 });
+ expect(parseChatLimit("100")).toEqual({ ok: true, value: 100 });
+ });
+
+ it("trims surrounding whitespace", () => {
+ expect(parseChatLimit(" 50 ")).toEqual({ ok: true, value: 50 });
+ });
+
+ it("floors a decimal", () => {
+ expect(parseChatLimit("100.9")).toEqual({ ok: true, value: 100 });
+ });
+
+ it("clamps below the floor to MIN_CHAT_LIMIT", () => {
+ expect(parseChatLimit("0")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
+ expect(parseChatLimit("-5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
+ expect(parseChatLimit("5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
+ });
+
+ it("clamps above the ceiling to MAX_CHAT_LIMIT", () => {
+ expect(parseChatLimit("99999999")).toEqual({ ok: true, value: MAX_CHAT_LIMIT });
+ });
+
+ it("rejects an empty string", () => {
+ expect(parseChatLimit("")).toEqual({ ok: false, error: expect.any(String) });
+ expect(parseChatLimit(" ")).toEqual({ ok: false, error: expect.any(String) });
+ });
+
+ it("rejects non-numeric input", () => {
+ expect(parseChatLimit("abc")).toEqual({ ok: false, error: expect.any(String) });
+ expect(parseChatLimit("twelve")).toEqual({ ok: false, error: expect.any(String) });
+ });
+});
+
+describe("chatLimitChanged", () => {
+ it("is false when the typed value normalizes to the current limit", () => {
+ expect(chatLimitChanged("256", 256)).toBe(false);
+ });
+
+ it("is true when the typed value differs", () => {
+ expect(chatLimitChanged("100", 256)).toBe(true);
+ expect(chatLimitChanged("256", 100)).toBe(true);
+ });
+
+ it("is false for empty / invalid input (nothing submittable)", () => {
+ expect(chatLimitChanged("", 256)).toBe(false);
+ expect(chatLimitChanged("abc", 256)).toBe(false);
+ });
+
+ it("is false when the typed value clamps to the current limit", () => {
+ // 5 clamps to MIN (10); current is already 10 → no change.
+ expect(chatLimitChanged("5", MIN_CHAT_LIMIT)).toBe(false);
+ // A huge value clamps to MAX; current is already MAX → no change.
+ expect(chatLimitChanged("99999999", MAX_CHAT_LIMIT)).toBe(false);
+ });
+});
diff --git a/src/features/settings/logic/view-model.ts b/src/features/settings/logic/view-model.ts
new file mode 100644
index 0000000..caf76ee
--- /dev/null
+++ b/src/features/settings/logic/view-model.ts
@@ -0,0 +1,60 @@
+import { normalizeChatLimit } from "../../../core/chunks";
+
+/**
+ * Pure core for the settings feature — zero DOM, zero effects, zero Svelte.
+ *
+ * The settings feature exposes FE-local, user-tunable settings. Currently the
+ * chat limit (max loaded chunks per conversation; the policy lives in
+ * `core/chunks/trim.ts`): this module is the view-model seam that parses +
+ * validates a typed value into a normalized limit, so the UI can disable submit
+ * and show an error on garbage input. The bound constants + normalization are
+ * OWNED by `core/chunks` (the single source of truth); this module never
+ * redefines them.
+ */
+
+// ── Injected port (consumer-defines-port; the composition root adapts the
+// store's localStorage persistence to this shape). ──────────────────────────
+
+/** Outcome of persisting a chat-limit setting. */
+export type ChatLimitSaveResult =
+ | { readonly ok: true; readonly chatLimit: number }
+ | { readonly ok: false; readonly error: string };
+
+export type SaveChatLimit = (value: number) => Promise<ChatLimitSaveResult>;
+
+// ── chat-limit parse / validate ───────────────────────────────────────────────
+
+/** Result of parsing a typed chat-limit string. */
+export type ChatLimitParse =
+ | { readonly ok: true; readonly value: number }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Parse a typed chat-limit string into a normalized limit (floored + clamped to
+ * [MIN_CHAT_LIMIT, MAX_CHAT_LIMIT] via `normalizeChatLimit`). Empty or
+ * non-numeric input is an ERROR (so the UI can disable submit + message), NOT a
+ * silent default — the default only applies at boot for an unset key, never
+ * from user typing.
+ */
+export function parseChatLimit(raw: string): ChatLimitParse {
+ const trimmed = raw.trim();
+ if (trimmed.length === 0) {
+ return { ok: false, error: "Enter a number." };
+ }
+ const n = Number(trimmed);
+ if (!Number.isFinite(n)) {
+ return { ok: false, error: "Must be a number." };
+ }
+ return { ok: true, value: normalizeChatLimit(n) };
+}
+
+/**
+ * Whether saving `typed` would change the `current` chat limit. A no-op save
+ * (empty/invalid, or equal after normalization) should be disabled. This is the
+ * dirty-check for the input — it must NOT mutate or clamp, only compare.
+ */
+export function chatLimitChanged(typed: string, current: number): boolean {
+ const parsed = parseChatLimit(typed);
+ if (!parsed.ok) return false;
+ return parsed.value !== current;
+}
diff --git a/src/features/settings/ui/ChatLimitField.svelte b/src/features/settings/ui/ChatLimitField.svelte
new file mode 100644
index 0000000..9b9911a
--- /dev/null
+++ b/src/features/settings/ui/ChatLimitField.svelte
@@ -0,0 +1,104 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks";
+ import { chatLimitChanged, parseChatLimit, type SaveChatLimit } from "../logic/view-model";
+
+ let {
+ chatLimit,
+ save,
+ }: {
+ /** The persisted chat limit (max loaded chunks per conversation). */
+ chatLimit: number;
+ save: SaveChatLimit;
+ } = $props();
+
+ // Seed from the prop; the $effect below re-seeds on external changes (a live
+ // apply from elsewhere) but only while the field is untouched, so an in-flight
+ // change can't clobber what the user typed.
+ let value = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
+ let savedValue = $state<number | null>(null);
+
+ $effect(() => {
+ const incoming = String(chatLimit);
+ untrack(() => {
+ if (value === lastSeed) value = incoming;
+ lastSeed = incoming;
+ });
+ });
+
+ const dirty = $derived(chatLimitChanged(value, chatLimit));
+
+ async function handleSave() {
+ if (saving || !dirty) return;
+ const parsed = parseChatLimit(value);
+ if (!parsed.ok) {
+ error = parsed.error;
+ return;
+ }
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await save(parsed.value);
+ saving = false;
+ if (result.ok) {
+ justSaved = true;
+ savedValue = result.chatLimit;
+ // Reflect the clamped / persisted value back into the input immediately
+ // (the prop will also re-assert it via the effect above).
+ value = String(result.chatLimit);
+ lastSeed = value;
+ } else {
+ error = result.error;
+ }
+ }
+
+ function onInput() {
+ justSaved = false;
+ error = null;
+ }
+</script>
+
+<div class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Chat limit</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-sm w-full font-mono text-xs"
+ placeholder={String(chatLimit)}
+ bind:value
+ disabled={saving}
+ oninput={onInput}
+ onkeydown={(e) => {
+ if (e.key === "Enter") handleSave();
+ }}
+ aria-label="Chat limit"
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={saving || !dirty}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ </div>
+ {#if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if justSaved && !dirty}
+ <p class="text-xs text-success">Saved{savedValue !== null ? `: ${savedValue}.` : "."}</p>
+ {:else}
+ <p class="text-xs opacity-50">
+ Max loaded chunks per conversation ({MIN_CHAT_LIMIT}–{MAX_CHAT_LIMIT}). Lowering it unloads
+ older history; raise it and use "Show earlier" to page back in.
+ </p>
+ {/if}
+</div>
diff --git a/src/features/settings/ui/ChatLimitField.test.ts b/src/features/settings/ui/ChatLimitField.test.ts
new file mode 100644
index 0000000..73bc449
--- /dev/null
+++ b/src/features/settings/ui/ChatLimitField.test.ts
@@ -0,0 +1,89 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import type { ChatLimitSaveResult } from "../logic/view-model";
+import ChatLimitField from "./ChatLimitField.svelte";
+
+// A fake save that resolves ok with the value it was given.
+function fakeSave(): {
+ saves: number[];
+ last: number | null;
+ impl: (value: number) => Promise<ChatLimitSaveResult>;
+} {
+ const saves: number[] = [];
+ return {
+ saves,
+ last: null,
+ impl: async (value: number) => {
+ saves.push(value);
+ return { ok: true, chatLimit: value };
+ },
+ };
+}
+
+describe("ChatLimitField", () => {
+ it("seeds the input from the persisted limit and disables Set while unchanged", () => {
+ render(ChatLimitField, { props: { chatLimit: 256, save: fakeSave().impl } });
+ expect(screen.getByLabelText("Chat limit")).toHaveValue("256");
+ expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
+ });
+
+ it("enables Set when the typed value differs (regression: number-binding coercion)", async () => {
+ const user = userEvent.setup();
+ const save = fakeSave();
+ render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
+
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "10");
+
+ expect(screen.getByRole("button", { name: "Set" })).toBeEnabled();
+ });
+
+ it("keeps Set disabled for non-numeric input", async () => {
+ const user = userEvent.setup();
+ const save = fakeSave();
+ render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
+
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "abc");
+
+ expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
+ });
+
+ it("clicking Set calls save with the parsed value and shows the confirmation", async () => {
+ const user = userEvent.setup();
+ const save = fakeSave();
+ const { rerender } = render(ChatLimitField, {
+ props: { chatLimit: 256, save: save.impl },
+ });
+
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "50");
+ await user.click(screen.getByRole("button", { name: "Set" }));
+
+ expect(save.saves).toEqual([50]);
+ // In the real app the reactive `chatLimit` prop updates to the saved value
+ // (the store sets it synchronously), which clears `dirty` and reveals the
+ // "Saved" badge. Rerender simulates that propagation.
+ rerender({ chatLimit: 50, save: save.impl });
+ expect(screen.getByText(/Saved/i)).toBeInTheDocument();
+ });
+
+ it("clamps a below-floor value when saving", async () => {
+ const user = userEvent.setup();
+ const save = fakeSave();
+ render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
+
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "5"); // clamps to MIN (10)
+ await user.click(screen.getByRole("button", { name: "Set" }));
+
+ // The save port receives the clamped value (the view-model clamps before save).
+ expect(save.saves).toEqual([10]);
+ expect(input).toHaveValue("10");
+ });
+});
diff --git a/src/features/system-prompt/index.ts b/src/features/system-prompt/index.ts
new file mode 100644
index 0000000..7c77675
--- /dev/null
+++ b/src/features/system-prompt/index.ts
@@ -0,0 +1,17 @@
+export type {
+ LoadSystemPrompt,
+ LoadSystemPromptVariables,
+ SaveSystemPrompt,
+ SystemPromptLoadResult,
+ SystemPromptSaveResult,
+ SystemPromptVariablesResult,
+ VariableGroup,
+} from "./logic/view-model";
+export { buildTag, groupVariables, insertTag } from "./logic/view-model";
+export { default as SystemPromptBuilder } from "./ui/SystemPromptBuilder.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "system-prompt",
+ description: "Global system prompt template builder with variable placeholders",
+} as const;
diff --git a/src/features/system-prompt/logic/view-model.test.ts b/src/features/system-prompt/logic/view-model.test.ts
new file mode 100644
index 0000000..a0736cc
--- /dev/null
+++ b/src/features/system-prompt/logic/view-model.test.ts
@@ -0,0 +1,90 @@
+import type { SystemPromptVariable } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ buildIfNotTag,
+ buildIfTag,
+ buildTag,
+ groupVariables,
+ insertTag,
+ isDynamicVariable,
+} from "./view-model";
+
+describe("system-prompt view-model", () => {
+ describe("buildTag", () => {
+ it("builds a variable placeholder", () => {
+ expect(buildTag("system", "time")).toBe("[system:time]");
+ });
+ });
+
+ describe("buildIfTag", () => {
+ it("builds an opening conditional tag", () => {
+ expect(buildIfTag("file", "AGENTS.md")).toBe("[if file:AGENTS.md]");
+ });
+ });
+
+ describe("buildIfNotTag", () => {
+ it("builds a negated opening conditional tag", () => {
+ expect(buildIfNotTag("prompt", "cwd")).toBe("[if !prompt:cwd]");
+ });
+ });
+
+ describe("insertTag", () => {
+ it("inserts a tag at the cursor position", () => {
+ expect(insertTag("Hello world", "[system:time]", 5, 5)).toEqual({
+ template: "Hello[system:time] world",
+ cursor: 18,
+ });
+ });
+
+ it("replaces the selected text", () => {
+ const tag = buildTag("file", "README.md");
+ expect(insertTag("Hello world", tag, 6, 11)).toEqual({
+ template: `Hello ${tag}`,
+ cursor: 6 + tag.length,
+ });
+ });
+
+ it("inserts at the end", () => {
+ const tag = buildTag("git", "branch");
+ expect(insertTag("", tag, 0, 0)).toEqual({
+ template: tag,
+ cursor: tag.length,
+ });
+ });
+ });
+
+ describe("groupVariables", () => {
+ it("groups by type in first-appearing order", () => {
+ const variables: SystemPromptVariable[] = [
+ { type: "system", name: "time", description: "" },
+ { type: "prompt", name: "cwd", description: "" },
+ { type: "system", name: "date", description: "" },
+ { type: "git", name: "branch", description: "" },
+ ];
+ const groups = groupVariables(variables);
+ expect(groups.map((g) => g.type)).toEqual(["system", "prompt", "git"]);
+ expect(groups[0]?.variables.map((v) => v.name)).toEqual(["time", "date"]);
+ expect(groups[1]?.variables.map((v) => v.name)).toEqual(["cwd"]);
+ expect(groups[2]?.variables.map((v) => v.name)).toEqual(["branch"]);
+ });
+
+ it("returns an empty array when no variables", () => {
+ expect(groupVariables([])).toEqual([]);
+ });
+ });
+
+ describe("isDynamicVariable", () => {
+ it("returns true when dynamic is true", () => {
+ expect(
+ isDynamicVariable({ type: "file", name: "path", description: "", dynamic: true }),
+ ).toBe(true);
+ });
+
+ it("returns false when dynamic is missing or false", () => {
+ expect(isDynamicVariable({ type: "system", name: "time", description: "" })).toBe(false);
+ expect(
+ isDynamicVariable({ type: "system", name: "time", description: "", dynamic: false }),
+ ).toBe(false);
+ });
+ });
+});
diff --git a/src/features/system-prompt/logic/view-model.ts b/src/features/system-prompt/logic/view-model.ts
new file mode 100644
index 0000000..6924208
--- /dev/null
+++ b/src/features/system-prompt/logic/view-model.ts
@@ -0,0 +1,106 @@
+import type { SystemPromptVariable } from "@dispatch/transport-contract";
+
+/**
+ * Pure core for the system prompt builder — zero DOM, zero effects, zero Svelte.
+ *
+ * The system prompt is a global template, stored on the backend, resolved once
+ * per conversation at first turn (then persisted for prompt-cache safety). The
+ * frontend builder exposes the template text plus a palette of available
+ * variables; clicking a variable inserts `[type:name]` at the cursor. This module
+ * holds the pure logic: tag construction, insertion into a template, and
+ * grouping of variables. The HTTP edge is injected via the ports defined below.
+ */
+
+// ── Injected ports (composition root adapts the store to these shapes) ─────────
+
+export type SystemPromptLoadResult =
+ | { readonly ok: true; readonly template: string }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadSystemPrompt = () => Promise<SystemPromptLoadResult>;
+
+export type SystemPromptSaveResult =
+ | { readonly ok: true; readonly template: string }
+ | { readonly ok: false; readonly error: string };
+
+export type SaveSystemPrompt = (template: string) => Promise<SystemPromptSaveResult>;
+
+export type SystemPromptVariablesResult =
+ | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadSystemPromptVariables = () => Promise<SystemPromptVariablesResult>;
+
+// ── Template helpers ──────────────────────────────────────────────────────────
+
+/** Build the literal placeholder `[type:name]` for a variable. */
+export function buildTag(type: string, name: string): string {
+ return `[${type}:${name}]`;
+}
+
+/** Build the literal placeholder `[if type:name]` for a conditional block. */
+export function buildIfTag(type: string, name: string): string {
+ return `[if ${type}:${name}]`;
+}
+
+/** Build the literal placeholder `[if !type:name]` for a negated conditional block. */
+export function buildIfNotTag(type: string, name: string): string {
+ return `[if !${type}:${name}]`;
+}
+
+export interface Insertion {
+ /** Template text after insertion. */
+ template: string;
+ /** New cursor position (caret index) after insertion. */
+ cursor: number;
+}
+
+/**
+ * Insert `tag` into `template` at the current selection range. Replaces any
+ * selected text. Returns both the new template and the new cursor position so
+ * the caller can restore the caret after the inserted tag.
+ */
+export function insertTag(
+ template: string,
+ tag: string,
+ selectionStart: number,
+ selectionEnd: number,
+): Insertion {
+ const before = template.slice(0, selectionStart);
+ const after = template.slice(selectionEnd);
+ const next = before + tag + after;
+ return { template: next, cursor: selectionStart + tag.length };
+}
+
+// ── Variable grouping ─────────────────────────────────────────────────────────
+
+export interface VariableGroup {
+ /** Variable type (e.g. `"system"`, `"file"`, `"prompt"`, `"git"`). */
+ readonly type: string;
+ /** Variables of this type. */
+ readonly variables: readonly SystemPromptVariable[];
+}
+
+/**
+ * Group variables by type, preserving the order of first appearance within each
+ * group and the order of first-appearing types.
+ */
+export function groupVariables(
+ variables: readonly SystemPromptVariable[],
+): readonly VariableGroup[] {
+ const order: string[] = [];
+ const map = new Map<string, SystemPromptVariable[]>();
+ for (const v of variables) {
+ if (!map.has(v.type)) {
+ map.set(v.type, []);
+ order.push(v.type);
+ }
+ map.get(v.type)?.push(v);
+ }
+ return order.map((type) => ({ type, variables: map.get(type) ?? [] }));
+}
+
+/** Whether a variable is "dynamic" (any name is valid, e.g. `file:<path>`). */
+export function isDynamicVariable(variable: SystemPromptVariable): boolean {
+ return variable.dynamic === true;
+}
diff --git a/src/features/system-prompt/ui/SystemPromptBuilder.svelte b/src/features/system-prompt/ui/SystemPromptBuilder.svelte
new file mode 100644
index 0000000..4e07317
--- /dev/null
+++ b/src/features/system-prompt/ui/SystemPromptBuilder.svelte
@@ -0,0 +1,242 @@
+<script lang="ts">
+ import type { SystemPromptVariable } from "@dispatch/transport-contract";
+ import { tick, untrack } from "svelte";
+ import {
+ buildTag,
+ groupVariables,
+ insertTag,
+ isDynamicVariable,
+ type LoadSystemPrompt,
+ type LoadSystemPromptVariables,
+ type SaveSystemPrompt,
+ } from "../logic/view-model";
+
+ let {
+ loadPrompt,
+ savePrompt,
+ loadVariables,
+ onClose,
+ }: {
+ loadPrompt: LoadSystemPrompt;
+ savePrompt: SaveSystemPrompt;
+ loadVariables: LoadSystemPromptVariables;
+ onClose: () => void;
+ } = $props();
+
+ let value = $state("");
+ let loadedTemplate = $state("");
+ let loading = $state(false);
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
+ let variables = $state<readonly SystemPromptVariable[]>([]);
+ let filePath = $state("");
+ let textarea: HTMLTextAreaElement | null = null;
+
+ const groups = $derived(groupVariables(variables));
+ const hasChanges = $derived(value !== loadedTemplate);
+
+ async function load() {
+ untrack(() => {
+ loading = true;
+ error = null;
+ });
+
+ const [templateResult, variablesResult] = await Promise.all([loadPrompt(), loadVariables()]);
+
+ loading = false;
+
+ if (!templateResult.ok || !variablesResult.ok) {
+ const parts: string[] = [];
+ if (!templateResult.ok) parts.push(templateResult.error);
+ if (!variablesResult.ok) parts.push(variablesResult.error);
+ error = parts.join("; ");
+ return;
+ }
+
+ value = templateResult.template;
+ loadedTemplate = templateResult.template;
+ variables = variablesResult.variables;
+ }
+
+ async function save() {
+ if (saving || loading) return;
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await savePrompt(value);
+ saving = false;
+ if (!result.ok) {
+ error = result.error;
+ return;
+ }
+ loadedTemplate = result.template;
+ value = result.template;
+ justSaved = true;
+ }
+
+ function reset() {
+ value = loadedTemplate;
+ error = null;
+ justSaved = false;
+ }
+
+ async function insertTagAtCursor(tag: string) {
+ if (textarea === null) return;
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ const insertion = insertTag(value, tag, start, end);
+ value = insertion.template;
+ await tick();
+ textarea.focus();
+ textarea.setSelectionRange(insertion.cursor, insertion.cursor);
+ }
+
+ async function insertFileVariable(type: string) {
+ const path = filePath.trim();
+ if (path.length === 0) return;
+ await insertTagAtCursor(buildTag(type, path));
+ filePath = "";
+ }
+
+ function onKeydown(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+
+ // Load on mount once.
+ $effect(() => {
+ void load();
+ });
+</script>
+
+<svelte:window onkeydown={onKeydown} />
+
+<!-- svelte-ignore a11y_no_static_element_interactions -->
+<div
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
+ role="dialog"
+ aria-modal="true"
+ aria-label="System prompt builder"
+ tabindex="-1"
+ onclick={onClose}
+ onkeydown={onKeydown}
+>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div
+ class="flex h-[85vh] w-full max-w-6xl flex-col overflow-hidden rounded-box bg-base-100 shadow-2xl"
+ onclick={(e) => e.stopPropagation()}
+ >
+ <!-- Header -->
+ <div class="flex shrink-0 items-center justify-between border-b border-base-300 px-4 py-3">
+ <div class="flex items-center gap-2">
+ <h2 class="text-sm font-semibold">System Prompt</h2>
+ {#if loading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ </div>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm btn-square"
+ onclick={onClose}
+ aria-label="Close system prompt builder"
+ >
+ ✕
+ </button>
+ </div>
+
+ <!-- Body: half editor / half variables -->
+ <div class="flex min-h-0 flex-1">
+ <!-- Left: template editor -->
+ <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4">
+ <textarea
+ bind:this={textarea}
+ bind:value
+ class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs"
+ placeholder={loading ? "Loading template..." : "Edit the global system prompt template..."}
+ disabled={loading}
+ aria-label="System prompt template"
+ ></textarea>
+
+ <div class="flex shrink-0 items-center gap-2">
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={loading || saving || !hasChanges}
+ onclick={save}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ disabled={loading || !hasChanges}
+ onclick={reset}
+ >
+ Reset
+ </button>
+ {#if justSaved && !hasChanges}
+ <span class="text-xs text-success">Saved.</span>
+ {:else if hasChanges}
+ <span class="text-xs opacity-60">Unsaved changes</span>
+ {/if}
+ </div>
+
+ {#if error}
+ <p class="shrink-0 text-xs text-error">{error}</p>
+ {/if}
+ </div>
+
+ <!-- Right: variable palette -->
+ <div class="flex w-1/2 min-w-0 flex-col overflow-y-auto p-4">
+ <h3 class="mb-2 shrink-0 text-xs font-semibold uppercase opacity-60">Variables</h3>
+ {#if groups.length === 0 && !loading}
+ <p class="text-xs opacity-60">No variables available.</p>
+ {/if}
+ <div class="flex flex-col gap-3">
+ {#each groups as group (group.type)}
+ <div class="rounded-box bg-base-200 p-3">
+ <span class="text-xs font-semibold uppercase opacity-70">{group.type}</span>
+ <div class="mt-2 flex flex-wrap gap-1">
+ {#each group.variables as variable (variable.type + variable.name)}
+ {#if isDynamicVariable(variable)}
+ <div class="flex items-center gap-1">
+ <input
+ type="text"
+ class="input input-bordered input-xs w-32 font-mono"
+ bind:value={filePath}
+ placeholder={variable.name}
+ onkeydown={(e) => {
+ if (e.key === "Enter") void insertFileVariable(variable.type);
+ }}
+ />
+ <button
+ type="button"
+ class="btn btn-xs"
+ onclick={() => void insertFileVariable(variable.type)}
+ >
+ Insert
+ </button>
+ </div>
+ {:else}
+ <button
+ type="button"
+ class="btn btn-xs"
+ title={variable.description}
+ onclick={() => void insertTagAtCursor(buildTag(variable.type, variable.name))}
+ >
+ {variable.name}
+ </button>
+ {/if}
+ {/each}
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/src/features/tabs/tabs-store.test.ts b/src/features/tabs/tabs-store.test.ts
index 81ec8ad..71a38dc 100644
--- a/src/features/tabs/tabs-store.test.ts
+++ b/src/features/tabs/tabs-store.test.ts
@@ -24,7 +24,7 @@ function createMemoryStorage(initial?: TabsState): TabsStorage & { data: TabsSta
describe("createTabsStore", () => {
it("loads persisted state on construct", () => {
const persisted: TabsState = {
- tabs: [{ conversationId: "c1", model: "m1", title: "T1" }],
+ tabs: [{ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }],
activeConversationId: "c1",
};
const storage = createMemoryStorage(persisted);
@@ -48,11 +48,11 @@ describe("createTabsStore", () => {
const storage = createMemoryStorage();
const store = createTabsStore(storage);
- store.createTab({ conversationId: "c1", model: "m1", title: "T1" });
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
expect(storage.data?.tabs).toHaveLength(1);
expect(storage.data?.activeConversationId).toBe("c1");
- store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
expect(storage.data?.tabs).toHaveLength(2);
store.selectTab("c1");
@@ -76,11 +76,11 @@ describe("createTabsStore", () => {
const storage = createMemoryStorage();
const store = createTabsStore(storage);
- store.createTab({ conversationId: "c1", model: "m1", title: "T1" });
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
expect(store.tabs).toHaveLength(1);
expect(store.activeConversationId).toBe("c1");
- store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
expect(store.tabs).toHaveLength(2);
expect(store.activeConversationId).toBe("c2");
});
@@ -89,8 +89,8 @@ describe("createTabsStore", () => {
const storage = createMemoryStorage();
const store = createTabsStore(storage);
- store.createTab({ conversationId: "c1", model: "m1", title: "T1" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
store.selectTab("c1");
expect(store.activeConversationId).toBe("c1");
@@ -100,9 +100,9 @@ describe("createTabsStore", () => {
const storage = createMemoryStorage();
const store = createTabsStore(storage);
- store.createTab({ conversationId: "c1", model: "m1", title: "T1" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
- store.createTab({ conversationId: "c3", model: "m3", title: "T3" });
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
+ store.createTab({ conversationId: "c3", model: "m3", title: "T3", workspaceId: "default" });
store.selectTab("c2");
store.closeTab("c2");
@@ -114,7 +114,7 @@ describe("createTabsStore", () => {
const storage = createMemoryStorage();
const store = createTabsStore(storage);
- store.createTab({ conversationId: "c1", model: "m1", title: "T1" });
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
store.closeTab("c1");
expect(store.tabs).toHaveLength(0);
expect(store.activeConversationId).toBeNull();
@@ -124,8 +124,8 @@ describe("createTabsStore", () => {
const storage = createMemoryStorage();
const store = createTabsStore(storage);
- store.createTab({ conversationId: "c1", model: "old", title: "T1" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
+ store.createTab({ conversationId: "c1", model: "old", title: "T1", workspaceId: "default" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
store.setModel("c1", "new-model");
expect(store.tabs[0]?.model).toBe("new-model");
@@ -136,7 +136,7 @@ describe("createTabsStore", () => {
const storage = createMemoryStorage();
const store = createTabsStore(storage);
- store.createTab({ conversationId: "c1", model: "m1", title: "Old" });
+ store.createTab({ conversationId: "c1", model: "m1", title: "Old", workspaceId: "default" });
store.setTitle("c1", "New Title");
expect(store.tabs[0]?.title).toBe("New Title");
@@ -146,8 +146,8 @@ describe("createTabsStore", () => {
const storage = createMemoryStorage();
const store = createTabsStore(storage);
- store.createTab({ conversationId: "c1", model: "m1", title: "T1" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
store.newDraft();
expect(store.tabs).toHaveLength(2);
diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts
index 3c2a8c2..e3ca4fa 100644
--- a/src/features/tabs/tabs.test.ts
+++ b/src/features/tabs/tabs.test.ts
@@ -19,6 +19,7 @@ const tab = (conversationId: string, model = "default", title = "Chat"): Tab =>
conversationId,
model,
title,
+ workspaceId: "default",
});
describe("initialState", () => {
diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts
index 3360d3f..53eda78 100644
--- a/src/features/tabs/tabs.ts
+++ b/src/features/tabs/tabs.ts
@@ -2,6 +2,8 @@ export interface Tab {
readonly conversationId: string;
readonly model: string;
readonly title: string;
+ /** The workspace this tab belongs to (the workspace's URL slug). */
+ readonly workspaceId: string;
}
export interface TabsState {
@@ -13,7 +15,15 @@ const DEFAULT_TITLE = "New chat";
const DEFAULT_MAX_TITLE_LENGTH = 40;
export function initialState(persisted?: TabsState): TabsState {
- if (persisted !== undefined) return persisted;
+ if (persisted !== undefined) {
+ // Migrate tabs persisted before workspaces: assign them to the "default"
+ // workspace (the fallback for conversations with no workspace).
+ const tabs = persisted.tabs.map((t) => {
+ const wid = (t as { workspaceId?: string }).workspaceId;
+ return { ...t, workspaceId: wid ?? "default" };
+ });
+ return { tabs, activeConversationId: persisted.activeConversationId };
+ }
return { tabs: [], activeConversationId: null };
}
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index 6cd66bd..a46b692 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -5,9 +5,9 @@ import type { Tab } from "./tabs";
import TabBar from "./ui/TabBar.svelte";
const sampleTabs: readonly Tab[] = [
- { conversationId: "c1", model: "openai/gpt-4", title: "First" },
- { conversationId: "c2", model: "anthropic/claude-3", title: "Second" },
- { conversationId: "c3", model: "google/gemini", title: "Third" },
+ { conversationId: "c1", model: "openai/gpt-4", title: "First", workspaceId: "default" },
+ { conversationId: "c2", model: "anthropic/claude-3", title: "Second", workspaceId: "default" },
+ { conversationId: "c3", model: "google/gemini", title: "Third", workspaceId: "default" },
];
describe("TabBar", () => {
@@ -178,8 +178,8 @@ describe("TabBar", () => {
it("renders a short-handle tab ID badge (shortest unique prefix) per tab", () => {
const tabs: readonly Tab[] = [
- { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha" },
- { conversationId: "7c2db4e5-2222", model: "m", title: "Beta" },
+ { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha", workspaceId: "default" },
+ { conversationId: "7c2db4e5-2222", model: "m", title: "Beta", workspaceId: "default" },
];
render(TabBar, {
props: {
diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts
new file mode 100644
index 0000000..adaff00
--- /dev/null
+++ b/src/features/workspaces/adapter/http.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it, vi } from "vitest";
+import { createWorkspaceHttp } from "./http";
+
+/** Build a fake `fetch` returning a canned Response. */
+function fakeFetch(responses: Array<{ status?: number; body?: unknown } | Error>): typeof fetch {
+ let i = 0;
+ return vi.fn(async () => {
+ const next = responses[i++];
+ if (next === undefined) throw new Error("fakeFetch: no more canned responses");
+ if (next instanceof Error) throw next;
+ const status = next.status ?? 200;
+ const body = next.body;
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ async json() {
+ return body;
+ },
+ } as Response;
+ }) as unknown as typeof fetch;
+}
+
+const BASE = "http://x";
+
+describe("createWorkspaceHttp", () => {
+ it("list returns the workspaces", async () => {
+ const fetchImpl = fakeFetch([
+ {
+ body: {
+ workspaces: [
+ {
+ id: "a",
+ title: "A",
+ defaultCwd: null,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 3,
+ },
+ ],
+ },
+ },
+ ]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const list = await http.list();
+ expect(list).toHaveLength(1);
+ expect(list[0]?.id).toBe("a");
+ expect(list[0]?.conversationCount).toBe(3);
+ expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`);
+ });
+
+ it("list returns [] on a failed response (non-fatal)", async () => {
+ const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 500 }]));
+ expect(await http.list()).toEqual([]);
+ });
+
+ it("list returns [] on a network error", async () => {
+ const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")]));
+ expect(await http.list()).toEqual([]);
+ });
+
+ it("ensure PUTs the id + returns the workspace", async () => {
+ const ws = { id: "my-ws", title: "my-ws", defaultCwd: null, createdAt: 10, lastActivityAt: 10 };
+ const fetchImpl = fakeFetch([{ body: ws }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const result = await http.ensure("my-ws");
+ expect(result).toEqual({ ok: true, value: ws });
+ expect(fetchImpl).toHaveBeenCalledWith(
+ `${BASE}/workspaces/my-ws`,
+ expect.objectContaining({ method: "PUT" }),
+ );
+ });
+
+ it("ensure surfaces the backend error on a 400 (invalid slug)", async () => {
+ const http = createWorkspaceHttp(
+ BASE,
+ fakeFetch([{ status: 400, body: { error: "invalid slug" } }]),
+ );
+ const result = await http.ensure("UPPER");
+ expect(result).toEqual({ ok: false, error: "invalid slug" });
+ });
+
+ it("get returns null on 404", async () => {
+ const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }]));
+ expect(await http.get("nope")).toBeNull();
+ });
+
+ it("get returns the workspace on 200", async () => {
+ const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 };
+ const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }]));
+ expect(await http.get("x")).toEqual(ws);
+ });
+
+ it("setTitle PUTs the title", async () => {
+ const ws = { id: "a", title: "Renamed", defaultCwd: null, createdAt: 1, lastActivityAt: 2 };
+ const fetchImpl = fakeFetch([{ body: ws }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const result = await http.setTitle("a", "Renamed");
+ expect(result).toEqual({ ok: true, value: ws });
+ const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
+ expect(call?.[0]).toBe(`${BASE}/workspaces/a/title`);
+ expect(JSON.parse(call?.[1]?.body)).toEqual({ title: "Renamed" });
+ });
+
+ it("setDefaultCwd PUTs null to clear", async () => {
+ const ws = { id: "a", title: "A", defaultCwd: null, createdAt: 1, lastActivityAt: 2 };
+ const fetchImpl = fakeFetch([{ body: ws }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ await http.setDefaultCwd("a", null);
+ const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
+ expect(call?.[0]).toBe(`${BASE}/workspaces/a/default-cwd`);
+ expect(JSON.parse(call?.[1]?.body)).toEqual({ defaultCwd: null });
+ });
+
+ it("delete returns closedCount", async () => {
+ const fetchImpl = fakeFetch([{ body: { workspaceId: "a", closedCount: 4 } }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const result = await http.delete("a");
+ expect(result).toEqual({ ok: true, value: { closedCount: 4 } });
+ expect(fetchImpl).toHaveBeenCalledWith(
+ `${BASE}/workspaces/a`,
+ expect.objectContaining({ method: "DELETE" }),
+ );
+ });
+
+ it("delete surfaces 409 for 'default'", async () => {
+ const http = createWorkspaceHttp(
+ BASE,
+ fakeFetch([{ status: 409, body: { error: "cannot delete default" } }]),
+ );
+ const result = await http.delete("default");
+ expect(result).toEqual({ ok: false, error: "cannot delete default" });
+ });
+});
diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts
new file mode 100644
index 0000000..97fc2e2
--- /dev/null
+++ b/src/features/workspaces/adapter/http.ts
@@ -0,0 +1,134 @@
+import type {
+ DeleteWorkspaceResponse,
+ EnsureWorkspaceRequest,
+ SetWorkspaceDefaultCwdRequest,
+ SetWorkspaceTitleRequest,
+ Workspace,
+ WorkspaceEntry,
+ WorkspaceListResponse,
+ WorkspaceResponse,
+} from "@dispatch/transport-contract";
+
+/**
+ * Workspace HTTP effects — the injected edge that talks to the backend's
+ * workspace endpoints. Mirrors the store's fetch pattern: `httpBase` + an
+ * injected `fetchImpl` (so it is testable without the network). Returns typed
+ * `WorkspaceResult<T>` (`{ok,value}` | `{ok:false,error}`) for mutating ops so a
+ * caller can surface the backend's `{ error }` reason; reads return data or a
+ * safe empty/null on failure (non-fatal — the UI falls back gracefully).
+ *
+ * Endpoints ([email protected]):
+ * - `GET /workspaces` → list
+ * - `PUT /workspaces/:id` (create-on-miss, idempotent) → ensure
+ * - `GET /workspaces/:id` (404 → null) → get
+ * - `PUT /workspaces/:id/title` → rename
+ * - `PUT /workspaces/:id/default-cwd` → set/clear default cwd
+ * - `DELETE /workspaces/:id` (409 for "default") → delete
+ */
+export type WorkspaceResult<T> =
+ | { readonly ok: true; readonly value: T }
+ | { readonly ok: false; readonly error: string };
+
+export interface WorkspaceHttp {
+ list(): Promise<readonly WorkspaceEntry[]>;
+ ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
+ get(id: string): Promise<Workspace | null>;
+ setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
+ setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
+ delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
+}
+
+async function errText(res: Response): Promise<string> {
+ try {
+ const body = (await res.json()) as { error?: string };
+ return body.error ?? `HTTP ${res.status}`;
+ } catch {
+ return `HTTP ${res.status}`;
+ }
+}
+
+export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): WorkspaceHttp {
+ return {
+ async list(): Promise<readonly WorkspaceEntry[]> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces`);
+ if (!res.ok) return [];
+ const data = (await res.json()) as WorkspaceListResponse;
+ return data.workspaces;
+ } catch {
+ return [];
+ }
+ },
+
+ async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body ?? {}),
+ });
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ return { ok: true, value: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Workspace request failed",
+ };
+ }
+ },
+
+ async get(id): Promise<Workspace | null> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`);
+ if (res.status === 404 || !res.ok) return null;
+ return (await res.json()) as WorkspaceResponse;
+ } catch {
+ return null;
+ }
+ },
+
+ async setTitle(id, title): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/title`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title } satisfies SetWorkspaceTitleRequest),
+ });
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ return { ok: true, value: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Rename failed" };
+ }
+ },
+
+ async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(id)}/default-cwd`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ defaultCwd } satisfies SetWorkspaceDefaultCwdRequest),
+ },
+ );
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ return { ok: true, value: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Set default cwd failed" };
+ }
+ },
+
+ async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ const data = (await res.json()) as DeleteWorkspaceResponse;
+ return { ok: true, value: { closedCount: data.closedCount } };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Delete failed" };
+ }
+ },
+ };
+}
diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts
new file mode 100644
index 0000000..6cc86a3
--- /dev/null
+++ b/src/features/workspaces/index.ts
@@ -0,0 +1,21 @@
+export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http";
+export { createWorkspaceHttp } from "./adapter/http";
+export type { Route } from "./logic/route";
+export {
+ DEFAULT_WORKSPACE_ID,
+ isValidSlug,
+ parsePath,
+ WORKSPACE_SLUG_RE,
+ workspacePath,
+} from "./logic/route";
+export { pageTitle, relativeTime } from "./logic/view-model";
+export type { WorkspaceStore } from "./store.svelte";
+export { createWorkspaceStore } from "./store.svelte";
+export { default as WorkspaceCard } from "./ui/WorkspaceCard.svelte";
+export { default as WorkspacesHome } from "./ui/WorkspacesHome.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "workspaces",
+ description: "URL-driven conversation grouping with a backend-owned default cwd",
+} as const;
diff --git a/src/features/workspaces/logic/route.test.ts b/src/features/workspaces/logic/route.test.ts
new file mode 100644
index 0000000..a6da3e1
--- /dev/null
+++ b/src/features/workspaces/logic/route.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest";
+import {
+ DEFAULT_WORKSPACE_ID,
+ isValidSlug,
+ parsePath,
+ WORKSPACE_SLUG_RE,
+ workspacePath,
+} from "./route";
+
+describe("parsePath", () => {
+ it("treats the root path as home", () => {
+ expect(parsePath("/")).toEqual({ kind: "home" });
+ expect(parsePath("")).toEqual({ kind: "home" });
+ });
+
+ it("trims surrounding slashes", () => {
+ expect(parsePath("//")).toEqual({ kind: "home" });
+ expect(parsePath("/my-ws/")).toEqual({ kind: "workspace", id: "my-ws" });
+ });
+
+ it("parses a single segment as a workspace id", () => {
+ expect(parsePath("/default")).toEqual({ kind: "workspace", id: "default" });
+ expect(parsePath("/my-workspace")).toEqual({ kind: "workspace", id: "my-workspace" });
+ expect(parsePath("/ws1")).toEqual({ kind: "workspace", id: "ws1" });
+ });
+
+ it("takes only the first segment of a deeper path", () => {
+ expect(parsePath("/foo/bar")).toEqual({ kind: "workspace", id: "foo" });
+ expect(parsePath("/foo/bar/baz")).toEqual({ kind: "workspace", id: "foo" });
+ });
+
+ it("URL-decodes the segment", () => {
+ expect(parsePath("/my%20ws")).toEqual({ kind: "workspace", id: "my ws" });
+ });
+
+ it("does not validate the slug — an invalid id is still a workspace route", () => {
+ expect(parsePath("/UPPER")).toEqual({ kind: "workspace", id: "UPPER" });
+ expect(parsePath("/has space")).toEqual({ kind: "workspace", id: "has space" });
+ });
+});
+
+describe("isValidSlug", () => {
+ it("accepts lowercase alphanumeric + internal hyphens", () => {
+ expect(isValidSlug("default")).toBe(true);
+ expect(isValidSlug("my-workspace")).toBe(true);
+ expect(isValidSlug("a")).toBe(true);
+ expect(isValidSlug("ws-1")).toBe(true);
+ });
+
+ it("accepts up to 40 chars", () => {
+ expect(isValidSlug("a".repeat(40))).toBe(true);
+ });
+
+ it("rejects empty and too-long", () => {
+ expect(isValidSlug("")).toBe(false);
+ expect(isValidSlug("a".repeat(41))).toBe(false);
+ });
+
+ it("rejects uppercase, spaces, and leading/trailing hyphens", () => {
+ expect(isValidSlug("MyWS")).toBe(false);
+ expect(isValidSlug("has space")).toBe(false);
+ expect(isValidSlug("-leading")).toBe(false);
+ expect(isValidSlug("trailing-")).toBe(false);
+ expect(isValidSlug("double--hyphen")).toBe(true); // internal doubles are allowed by the regex
+ });
+
+ it("WORKSPACE_SLUG_RE matches the default id", () => {
+ expect(WORKSPACE_SLUG_RE.test(DEFAULT_WORKSPACE_ID)).toBe(true);
+ });
+});
+
+describe("workspacePath", () => {
+ it("builds the URL path for a workspace id", () => {
+ expect(workspacePath("default")).toBe("/default");
+ expect(workspacePath("my-ws")).toBe("/my-ws");
+ });
+});
diff --git a/src/features/workspaces/logic/route.ts b/src/features/workspaces/logic/route.ts
new file mode 100644
index 0000000..e30dc16
--- /dev/null
+++ b/src/features/workspaces/logic/route.ts
@@ -0,0 +1,63 @@
+/**
+ * Pure routing logic for the workspaces feature — zero DOM, zero effects, zero Svelte.
+ *
+ * The app is URL-driven: the root path `/` is the workspaces HOME (lists all
+ * workspaces); a single path segment `/<id>` opens the workspace with that id
+ * (the slug). This module holds the pure mapping from a pathname to a `Route`,
+ * plus the slug-validation rules mirrored from the backend's `PUT /workspaces/:id`.
+ */
+
+/**
+ * The workspace slug regex (mirrors the backend's `PUT /workspaces/:id`
+ * validation): 1–40 chars, lowercase alphanumeric with internal hyphens only
+ * (no leading/trailing hyphen). `"default"` matches and is a valid (but
+ * non-deletable) id.
+ */
+export const WORKSPACE_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
+
+/** A route derived from the URL path. */
+export type Route = { readonly kind: "home" } | { readonly kind: "workspace"; readonly id: string };
+
+/** The reserved id of the always-present fallback workspace. */
+export const DEFAULT_WORKSPACE_ID = "default";
+
+/**
+ * Parse a pathname into a `Route`. `/` (or empty) → home; a leading segment →
+ * the workspace with that id (URL-decoded, surrounding slashes trimmed). Deeper
+ * paths take their FIRST segment (nested routes are not used in v1). Pure:
+ * pathname in, route out. Does NOT validate the slug — an invalid id still
+ * produces a `workspace` route; the backend's ensure call rejects it (the FE
+ * surfaces the error).
+ */
+export function parsePath(pathname: string): Route {
+ const trimmed = pathname.replace(/^\/+|\/+$/g, "");
+ if (trimmed === "") return { kind: "home" };
+ const first = trimmed.split("/")[0] ?? "";
+ const id = safeDecode(first);
+ if (id === "") return { kind: "home" };
+ return { kind: "workspace", id };
+}
+
+/**
+ * Whether a slug is valid for a NEW workspace (the form the backend accepts).
+ * Used by the home view's "new workspace" input before navigating.
+ */
+export function isValidSlug(slug: string): boolean {
+ return WORKSPACE_SLUG_RE.test(slug);
+}
+
+/**
+ * Build the URL path for a workspace id. Used when navigating to / linking a
+ * workspace. Pure: id in, path string out.
+ */
+export function workspacePath(id: string): string {
+ return `/${id}`;
+}
+
+function safeDecode(segment: string): string {
+ try {
+ return decodeURIComponent(segment);
+ } catch {
+ return segment;
+ }
+}
diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts
new file mode 100644
index 0000000..52f7025
--- /dev/null
+++ b/src/features/workspaces/logic/view-model.test.ts
@@ -0,0 +1,67 @@
+import type { WorkspaceEntry } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import { pageTitle, relativeTime } from "./view-model";
+
+describe("relativeTime", () => {
+ const now = 1_000_000_000_000; // 2001-09-09
+
+ it("is 'now' within a minute", () => {
+ expect(relativeTime(now, now)).toBe("now");
+ expect(relativeTime(now - 59_000, now)).toBe("now");
+ });
+
+ it("is minutes under an hour", () => {
+ expect(relativeTime(now - 5 * 60_000, now)).toBe("5m");
+ expect(relativeTime(now - 59 * 60_000, now)).toBe("59m");
+ });
+
+ it("is hours under a day", () => {
+ expect(relativeTime(now - 2 * 60 * 60_000, now)).toBe("2h");
+ });
+
+ it("is days under a week", () => {
+ expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d");
+ });
+
+ it("is a short date beyond a week", () => {
+ // 7+ days ago: just check it is a MM/DD string.
+ const s = relativeTime(now - 10 * 24 * 60 * 60_000, now);
+ expect(s).toMatch(/^\d{2}\/\d{2}$/);
+ });
+});
+
+describe("pageTitle", () => {
+ // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed).
+ const ws = (id: string, title: string): WorkspaceEntry => ({
+ id,
+ title,
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ conversationCount: 0,
+ });
+
+ it("is 'Dispatch' for the home route", () => {
+ expect(pageTitle({ kind: "home" }, [])).toBe("Dispatch");
+ expect(pageTitle({ kind: "home" }, [ws("default", "Default")])).toBe("Dispatch");
+ });
+
+ it("is 'Dispatch: {title}' for a workspace with a display title", () => {
+ const list = [ws("default", "Default"), ws("my-ws", "My Workspace")];
+ expect(pageTitle({ kind: "workspace", id: "my-ws" }, list)).toBe("Dispatch: My Workspace");
+ });
+
+ it("falls back to the slug (id) until the list has loaded the workspace", () => {
+ expect(pageTitle({ kind: "workspace", id: "pending" }, [])).toBe("Dispatch: pending");
+ });
+
+ it("uses the id as the title when it was never customized (defaults to id)", () => {
+ const list = [ws("default", "default")];
+ expect(pageTitle({ kind: "workspace", id: "default" }, list)).toBe("Dispatch: default");
+ });
+
+ it("matches by id, not title", () => {
+ const list = [ws("a", "shared-title"), ws("b", "shared-title")];
+ expect(pageTitle({ kind: "workspace", id: "b" }, list)).toBe("Dispatch: shared-title");
+ });
+});
diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts
new file mode 100644
index 0000000..e904514
--- /dev/null
+++ b/src/features/workspaces/logic/view-model.ts
@@ -0,0 +1,41 @@
+/**
+ * Pure view-model helpers for the workspaces feature — zero DOM, zero effects.
+ */
+
+import type { WorkspaceEntry } from "@dispatch/wire";
+import type { Route } from "./route";
+
+/**
+ * The browser tab / page (`document.title`) text for a route. The home route
+ * (`/`) is "Dispatch"; a workspace route (`/<id>`) is "Dispatch: {title}",
+ * using the workspace's display title and falling back to the URL slug (`id`)
+ * until the workspace list has loaded it — the backend defaults a workspace's
+ * title to its id, so the slug is the correct transient value. Pure: route +
+ * workspaces in, string out.
+ */
+export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]): string {
+ if (route.kind === "home") return "Dispatch";
+ const ws = workspaces.find((w) => w.id === route.id);
+ return `Dispatch: ${ws?.title ?? route.id}`;
+}
+
+/**
+ * Format an epoch-ms timestamp as a short relative string ("now", "3m", "2h",
+ * "5d", or a date). Pure: `now` + `then` in, string out. Future timestamps
+ * (a workspace just created) read as "now".
+ */
+export function relativeTime(then: number, now: number): string {
+ const diff = now - then;
+ if (diff < 60_000) return "now";
+ const mins = Math.floor(diff / 60_000);
+ if (mins < 60) return `${mins}m`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h`;
+ const days = Math.floor(hours / 24);
+ if (days < 7) return `${days}d`;
+ // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests.
+ const d = new Date(then);
+ const month = String(d.getUTCMonth() + 1).padStart(2, "0");
+ const day = String(d.getUTCDate()).padStart(2, "0");
+ return `${month}/${day}`;
+}
diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts
new file mode 100644
index 0000000..0f662dc
--- /dev/null
+++ b/src/features/workspaces/store.svelte.ts
@@ -0,0 +1,80 @@
+import type { EnsureWorkspaceRequest } from "@dispatch/transport-contract";
+import type { Workspace, WorkspaceEntry } from "@dispatch/wire";
+import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http";
+
+/**
+ * Workspace store — a thin reactive wrapper over the pure HTTP edge. Owns the
+ * list + loading/error state; mutations call the injected `WorkspaceHttp` and
+ * refresh the list. State is per-instance (no ambient store); subscriptions are
+ * owned by the composition root.
+ */
+export interface WorkspaceStore {
+ /** All workspaces (sorted by lastActivityAt desc by the backend). */
+ readonly list: readonly WorkspaceEntry[];
+ readonly loading: boolean;
+ readonly error: string | null;
+ /** Refresh the list from the backend. */
+ refresh(): Promise<void>;
+ /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */
+ ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
+ /** Rename a workspace (display only; id unchanged). */
+ rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
+ /** Set/clear a workspace's default cwd. */
+ setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
+ /** Delete a workspace (closes its conversations, reassigns to "default"). */
+ remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
+}
+
+export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore {
+ let list = $state<readonly WorkspaceEntry[]>([]);
+ let loading = $state(false);
+ let error = $state<string | null>(null);
+
+ return {
+ get list(): readonly WorkspaceEntry[] {
+ return list;
+ },
+ get loading(): boolean {
+ return loading;
+ },
+ get error(): string | null {
+ return error;
+ },
+
+ async refresh(): Promise<void> {
+ loading = true;
+ error = null;
+ try {
+ list = await http.list();
+ } catch (err) {
+ error = err instanceof Error ? err.message : "Failed to load workspaces";
+ } finally {
+ loading = false;
+ }
+ },
+
+ async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.ensure(id, body);
+ if (result.ok) void this.refresh();
+ return result;
+ },
+
+ async rename(id, title): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.setTitle(id, title);
+ if (result.ok) void this.refresh();
+ return result;
+ },
+
+ async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.setDefaultCwd(id, defaultCwd);
+ if (result.ok) void this.refresh();
+ return result;
+ },
+
+ async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> {
+ const result = await http.delete(id);
+ if (result.ok) void this.refresh();
+ return result;
+ },
+ };
+}
diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
new file mode 100644
index 0000000..6348bb9
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceCard.svelte
@@ -0,0 +1,180 @@
+<script lang="ts">
+ import type { WorkspaceEntry } from "@dispatch/wire";
+ import { untrack } from "svelte";
+ import type { WorkspaceStore } from "../store.svelte";
+ import { relativeTime } from "../logic/view-model";
+ import { workspacePath } from "../logic/route";
+
+ let {
+ ws,
+ store,
+ onNavigate,
+ }: {
+ ws: WorkspaceEntry;
+ store: WorkspaceStore;
+ onNavigate: (path: string) => void;
+ } = $props();
+
+ // ── Title: double-click to rename inline ──────────────────────────────────
+ let editingTitle = $state(false);
+ let titleDraft = $state("");
+ let titleInput = $state<HTMLInputElement | undefined>();
+ let titleError = $state<string | null>(null);
+
+ function startEditTitle(): void {
+ titleDraft = ws.title;
+ titleError = null;
+ editingTitle = true;
+ queueMicrotask(() => titleInput?.focus());
+ }
+
+ async function saveTitle(): Promise<void> {
+ if (!editingTitle) return;
+ const title = titleDraft.trim();
+ editingTitle = false;
+ if (title === "" || title === ws.title) return;
+ const result = await store.rename(ws.id, title);
+ if (!result.ok) titleError = result.error;
+ }
+
+ function cancelTitle(): void {
+ editingTitle = false;
+ titleError = null;
+ }
+
+ // ── Default cwd: inline input ─────────────────────────────────────────────
+ let cwdDraft = $state(untrack(() => ws.defaultCwd ?? ""));
+ // Reseed when the backend value changes (e.g., after a save or an external
+ // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered.
+ $effect(() => {
+ cwdDraft = ws.defaultCwd ?? "";
+ });
+
+ const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? ""));
+ let savingCwd = $state(false);
+ let cwdError = $state<string | null>(null);
+
+ async function saveCwd(): Promise<void> {
+ if (!cwdDirty || savingCwd) return;
+ savingCwd = true;
+ cwdError = null;
+ const cwd = cwdDraft.trim();
+ const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd);
+ savingCwd = false;
+ if (!result.ok) cwdError = result.error;
+ }
+
+ // ── Delete ─────────────────────────────────────────────────────────────────
+ let deleting = $state(false);
+
+ async function handleDelete(): Promise<void> {
+ if (
+ !window.confirm(
+ `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`,
+ )
+ ) {
+ return;
+ }
+ deleting = true;
+ await store.remove(ws.id);
+ deleting = false;
+ }
+
+ function open(): void {
+ onNavigate(workspacePath(ws.id));
+ }
+</script>
+
+<li class="flex flex-col gap-2 rounded-box border border-primary bg-primary/10 p-3">
+ <div class="flex items-center gap-2">
+ {#if editingTitle}
+ <input
+ bind:this={titleInput}
+ bind:value={titleDraft}
+ class="input input-bordered input-sm flex-1"
+ aria-label="Workspace title"
+ onkeydown={(e) => {
+ if (e.key === "Enter") saveTitle();
+ else if (e.key === "Escape") cancelTitle();
+ }}
+ onblur={saveTitle}
+ />
+ {:else}
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
+ <span
+ class="flex-1 cursor-default truncate font-semibold"
+ title="Double-click to rename"
+ ondblclick={startEditTitle}
+ >{ws.title}</span>
+ {/if}
+ <span class="font-mono text-xs opacity-50">/{ws.id}</span>
+ <span class="ml-auto text-xs opacity-50">
+ {ws.conversationCount}
+ {ws.conversationCount === 1 ? "conversation" : "conversations"}
+ · {relativeTime(ws.lastActivityAt, Date.now())}
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={deleting}
+ title="Delete workspace"
+ aria-label="Delete workspace"
+ onclick={handleDelete}
+ >
+ {#if deleting}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ ✕
+ {/if}
+ </button>
+ </div>
+
+ {#if titleError}
+ <p class="text-xs text-error">{titleError}</p>
+ {/if}
+
+ <div class="flex items-center gap-2">
+ <span class="w-8 shrink-0 text-xs opacity-60">cwd</span>
+ <input
+ type="text"
+ class="input input-bordered input-sm flex-1 font-mono text-xs"
+ placeholder="inherits the server default"
+ bind:value={cwdDraft}
+ aria-label="Default working directory"
+ onkeydown={(e) => {
+ if (e.key === "Enter") saveCwd();
+ }}
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-xs"
+ disabled={!cwdDirty || savingCwd}
+ onclick={saveCwd}
+ >
+ {#if savingCwd}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ </div>
+
+ <div class="flex justify-start">
+ <a
+ class="btn"
+ href={workspacePath(ws.id)}
+ onclick={(e) => {
+ e.preventDefault();
+ open();
+ }}
+ >
+ Open
+ </a>
+ </div>
+
+ {#if cwdError}
+ <p class="text-xs text-error">{cwdError}</p>
+ {:else if !cwdDirty && !ws.defaultCwd}
+ <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p>
+ {/if}
+</li>
diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts
new file mode 100644
index 0000000..385856d
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceCard.test.ts
@@ -0,0 +1,121 @@
+import type { WorkspaceEntry } from "@dispatch/wire";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type { WorkspaceResult } from "../adapter/http";
+import type { WorkspaceStore } from "../store.svelte";
+import WorkspaceCard from "./WorkspaceCard.svelte";
+
+function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
+ return {
+ id: "my-ws",
+ title: "My Workspace",
+ defaultCwd: null,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 3,
+ ...overrides,
+ };
+}
+
+/** A fake store that records calls + resolves ok. */
+function fakeStore() {
+ return {
+ rename: vi.fn(
+ async (id: string, _title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, title: _title }),
+ }),
+ ),
+ setDefaultCwd: vi.fn(
+ async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, defaultCwd }),
+ }),
+ ),
+ remove: vi.fn(
+ async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({
+ ok: true,
+ value: { closedCount: 0 },
+ }),
+ ),
+ };
+}
+
+describe("WorkspaceCard", () => {
+ it("renders the title, slug, and an Open link", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const onNavigate = vi.fn();
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry(), store, onNavigate },
+ });
+ expect(screen.getByText("My Workspace")).toBeInTheDocument();
+ expect(screen.getByText("/my-ws")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "/my-ws");
+ });
+
+ it("double-clicking the title reveals an edit input", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate: vi.fn() } });
+
+ await user.dblClick(screen.getByText("My Workspace"));
+ expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace");
+ });
+
+ it("renames via the store on Enter", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate: vi.fn() } });
+
+ await user.dblClick(screen.getByText("My Workspace"));
+ const input = screen.getByLabelText("Workspace title");
+ await user.clear(input);
+ await user.type(input, "Renamed{Enter}");
+
+ expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed");
+ });
+
+ it("enables Set only when the cwd differs, then saves it", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn() },
+ });
+
+ const input = screen.getByLabelText("Default working directory");
+ expect(input).toHaveValue("/old");
+ expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
+
+ await user.clear(input);
+ await user.type(input, "/new/path");
+ expect(screen.getByRole("button", { name: "Set" })).toBeEnabled();
+
+ await user.click(screen.getByRole("button", { name: "Set" }));
+ expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", "/new/path");
+ });
+
+ it("clears the cwd to null when saved empty (inherits the server default)", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn() },
+ });
+
+ const input = screen.getByLabelText("Default working directory");
+ await user.clear(input);
+ await user.click(screen.getByRole("button", { name: "Set" }));
+
+ expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null);
+ });
+
+ it("the Open link calls onNavigate with the workspace path (no full-card nav)", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const onNavigate = vi.fn();
+ render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate } });
+
+ await user.click(screen.getByRole("link", { name: "Open" }));
+ expect(onNavigate).toHaveBeenCalledWith("/my-ws");
+ });
+});
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
new file mode 100644
index 0000000..5894f0a
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -0,0 +1,86 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import type { WorkspaceStore } from "../store.svelte";
+ import { isValidSlug, workspacePath } from "../logic/route";
+ import WorkspaceCard from "./WorkspaceCard.svelte";
+
+ let { store, onNavigate }: { store: WorkspaceStore; onNavigate: (path: string) => void } = $props();
+
+ onMount(() => {
+ void store.refresh();
+ });
+
+ let newSlug = $state("");
+ let slugError = $state<string | null>(null);
+
+ const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
+
+ function createWorkspace(): void {
+ const slug = newSlug.trim();
+ if (!isValidSlug(slug)) {
+ slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
+ return;
+ }
+ slugError = null;
+ newSlug = "";
+ onNavigate(workspacePath(slug));
+ }
+</script>
+
+<div class="mx-auto flex h-screen w-full max-w-3xl flex-col gap-4 p-6">
+ <header class="flex items-center justify-between">
+ <h1 class="text-2xl font-bold">Workspaces</h1>
+ <a
+ href="/default"
+ class="btn btn-ghost btn-sm"
+ onclick={(e) => {
+ e.preventDefault();
+ onNavigate("/default");
+ }}
+ >
+ Default
+ </a>
+ </header>
+
+ <form
+ class="flex items-end gap-2"
+ onsubmit={(e) => {
+ e.preventDefault();
+ createWorkspace();
+ }}
+ >
+ <div class="flex-1">
+ <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60">New workspace</label>
+ <input
+ id="new-ws"
+ class="input input-bordered w-full"
+ placeholder="my-workspace"
+ bind:value={newSlug}
+ autocomplete="off"
+ spellcheck="false"
+ />
+ </div>
+ <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button>
+ </form>
+ {#if slugError}
+ <p class="text-xs text-error">{slugError}</p>
+ {/if}
+
+ <div class="flex-1 overflow-y-auto">
+ {#if store.loading && store.list.length === 0}
+ <div class="flex h-32 items-center justify-center">
+ <span class="loading loading-spinner loading-sm opacity-60"></span>
+ </div>
+ {:else if store.list.length === 0}
+ <p class="py-8 text-center text-sm opacity-60">
+ No workspaces yet. Create one above or visit <code>/your-name</code> in the URL.
+ </p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each store.list as ws (ws.id)}
+ <WorkspaceCard {ws} {store} {onNavigate} />
+ {/each}
+ </ul>
+ {/if}
+ </div>
+</div>