From 38db3827870960f466be89afbc49f91238d46144 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 25 Jun 2026 10:55:51 +0900 Subject: feat: workspaces shell + cwd-lsp rename + mcp/settings/system-prompt features + app wiring - workspaces: URL-driven conversation grouping (home listing at /, routing, store, http adapter, WorkspaceCard) wired into the App.svelte shell - rename features/workspace -> features/cwd-lsp (the cwd/lsp status feature) - new features: mcp (status view), settings (chat-limit field), system-prompt (prompt builder), all rendered via the generic surface host - chat: store + ChatView updates - tabs: tabs-store updates - app wiring: ErrorModal (full-screen error surface), app/App.svelte + store.svelte This commit makes HEAD typecheck clean for the first time: the prior HEAD (c95cc77) imported features/settings from app/App.svelte but never committed the feature, so only the full working tree was green. --- src/App.svelte | 71 ++++- src/app/App.svelte | 76 ++++- src/app/App.test.ts | 108 ++++++- src/app/ErrorModal.svelte | 151 ++++++++++ src/app/store.svelte.ts | 323 ++++++++++++++++++--- src/app/store.test.ts | 62 +++- src/features/chat/index.ts | 9 +- src/features/chat/store.svelte.ts | 33 ++- src/features/chat/ui/ChatView.svelte | 32 +- src/features/cwd-lsp/index.ts | 14 + src/features/cwd-lsp/logic/view-model.test.ts | 101 +++++++ src/features/cwd-lsp/logic/view-model.ts | 130 +++++++++ src/features/cwd-lsp/ui/CwdField.svelte | 96 ++++++ src/features/cwd-lsp/ui/LspStatusView.svelte | 127 ++++++++ src/features/mcp/index.ts | 8 + src/features/mcp/logic/view-model.test.ts | 88 ++++++ src/features/mcp/logic/view-model.ts | 110 +++++++ src/features/mcp/ui/McpStatusView.svelte | 131 +++++++++ src/features/settings/index.ts | 9 + src/features/settings/logic/view-model.test.ts | 61 ++++ src/features/settings/logic/view-model.ts | 60 ++++ src/features/settings/ui/ChatLimitField.svelte | 104 +++++++ src/features/settings/ui/ChatLimitField.test.ts | 89 ++++++ src/features/system-prompt/index.ts | 17 ++ .../system-prompt/logic/view-model.test.ts | 90 ++++++ src/features/system-prompt/logic/view-model.ts | 106 +++++++ .../system-prompt/ui/SystemPromptBuilder.svelte | 242 +++++++++++++++ src/features/tabs/tabs-store.test.ts | 32 +- src/features/tabs/tabs.test.ts | 1 + src/features/tabs/tabs.ts | 12 +- src/features/tabs/ui.test.ts | 10 +- src/features/workspace/index.ts | 14 - src/features/workspace/logic/view-model.test.ts | 101 ------- src/features/workspace/logic/view-model.ts | 130 --------- src/features/workspace/ui/CwdField.svelte | 96 ------ src/features/workspace/ui/LspStatusView.svelte | 127 -------- src/features/workspaces/adapter/http.test.ts | 133 +++++++++ src/features/workspaces/adapter/http.ts | 134 +++++++++ src/features/workspaces/index.ts | 21 ++ src/features/workspaces/logic/route.test.ts | 77 +++++ src/features/workspaces/logic/route.ts | 63 ++++ src/features/workspaces/logic/view-model.test.ts | 67 +++++ src/features/workspaces/logic/view-model.ts | 41 +++ src/features/workspaces/store.svelte.ts | 80 +++++ src/features/workspaces/ui/WorkspaceCard.svelte | 180 ++++++++++++ src/features/workspaces/ui/WorkspaceCard.test.ts | 121 ++++++++ src/features/workspaces/ui/WorkspacesHome.svelte | 86 ++++++ 47 files changed, 3434 insertions(+), 540 deletions(-) create mode 100644 src/app/ErrorModal.svelte create mode 100644 src/features/cwd-lsp/index.ts create mode 100644 src/features/cwd-lsp/logic/view-model.test.ts create mode 100644 src/features/cwd-lsp/logic/view-model.ts create mode 100644 src/features/cwd-lsp/ui/CwdField.svelte create mode 100644 src/features/cwd-lsp/ui/LspStatusView.svelte create mode 100644 src/features/mcp/index.ts create mode 100644 src/features/mcp/logic/view-model.test.ts create mode 100644 src/features/mcp/logic/view-model.ts create mode 100644 src/features/mcp/ui/McpStatusView.svelte create mode 100644 src/features/settings/index.ts create mode 100644 src/features/settings/logic/view-model.test.ts create mode 100644 src/features/settings/logic/view-model.ts create mode 100644 src/features/settings/ui/ChatLimitField.svelte create mode 100644 src/features/settings/ui/ChatLimitField.test.ts create mode 100644 src/features/system-prompt/index.ts create mode 100644 src/features/system-prompt/logic/view-model.test.ts create mode 100644 src/features/system-prompt/logic/view-model.ts create mode 100644 src/features/system-prompt/ui/SystemPromptBuilder.svelte delete mode 100644 src/features/workspace/index.ts delete mode 100644 src/features/workspace/logic/view-model.test.ts delete mode 100644 src/features/workspace/logic/view-model.ts delete mode 100644 src/features/workspace/ui/CwdField.svelte delete mode 100644 src/features/workspace/ui/LspStatusView.svelte create mode 100644 src/features/workspaces/adapter/http.test.ts create mode 100644 src/features/workspaces/adapter/http.ts create mode 100644 src/features/workspaces/index.ts create mode 100644 src/features/workspaces/logic/route.test.ts create mode 100644 src/features/workspaces/logic/route.ts create mode 100644 src/features/workspaces/logic/view-model.test.ts create mode 100644 src/features/workspaces/logic/view-model.ts create mode 100644 src/features/workspaces/store.svelte.ts create mode 100644 src/features/workspaces/ui/WorkspaceCard.svelte create mode 100644 src/features/workspaces/ui/WorkspaceCard.test.ts create mode 100644 src/features/workspaces/ui/WorkspacesHome.svelte 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 @@ - +{#if route.kind === "home"} + +{:else} + +{/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 @@ -21,6 +21,11 @@ } from "../features/chat"; import { manifest as conversationCacheManifest } from "../features/conversation-cache"; import { manifest as markdownManifest } from "../features/markdown"; + import { + McpStatusView, + manifest as mcpManifest, + type McpStatusResult, + } from "../features/mcp"; import { ChatLimitField, manifest as settingsManifest, @@ -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 { 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 { + 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);
@@ -365,6 +399,7 @@ hasEarlier={store.activeChat.hasEarlier} onShowEarlier={handleShowEarlier} thinkingKeyBase={store.activeChat.thinkingKeyBase} + providerRetry={store.activeChat.providerRetry} /> {/key} @@ -435,6 +470,19 @@ {/if}
+{#if store.fatalError} + store.clearFatalError()} /> +{/if} + +{#if systemPromptModalOpen} + (systemPromptModalOpen = false)} + /> +{/if} + {#snippet viewContent(kind: string)} {#if kind === "model"}
@@ -452,6 +500,11 @@ {#key store.currentConversationId} {/key} + {:else if kind === "mcp"} + + {#key store.currentConversationId} + + {/key} {:else if kind === "extensions"}

Frontend modules

@@ -493,6 +546,21 @@ savePercent={saveCompactPercent} /> {/key} + {:else if kind === "system-prompt"} + +
+

+ Edit the global system prompt template with variable placeholders. Opens a full-page editor. +

+ +
{:else if kind === "settings"} 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 => { + 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 => { + 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 @@ + + + + + + + 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; /** 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([]); let modelInfo = $state>>({}); let activeModel = $state(DEFAULT_MODEL); + let fatalError = $state(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(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(); - 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(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 { + 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>(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 { 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 { + 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; + 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 { + 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 { + 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 { + 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; + 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; + 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, @@ -41,6 +42,12 @@ export interface ChatStoreDependencies { readonly historySync: HistorySync; 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 → @@ -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 @@ + +
+ Working directory +
+ { + if (e.key === "Enter") handleSave(); + }} + aria-label="Working directory" + /> + +
+ {#if !canEdit} +

Start or open a conversation to set its working directory.

+ {:else if error} +

{error}

+ {:else if justSaved && !dirty} +

Saved.

+ {:else} +

Defaults each turn's cwd; drives the language servers below.

+ {/if} +
diff --git a/src/features/cwd-lsp/ui/LspStatusView.svelte b/src/features/cwd-lsp/ui/LspStatusView.svelte new file mode 100644 index 0000000..77603a1 --- /dev/null +++ b/src/features/cwd-lsp/ui/LspStatusView.svelte @@ -0,0 +1,127 @@ + + +
+
+ + {#if loading} + Resolving… + {:else if hasLoaded && loadedCwd !== null} + {summary} + {:else} + Language servers + {/if} + + +
+ + {#if !canView} +

Open or start a conversation to see its language servers.

+ {:else if error} +

{error}

+ {:else if hasLoaded && loadedCwd === null} +

+ Set a working directory in the Model panel to enable language servers. +

+ {:else if hasLoaded && servers.length === 0 && !loading} +

No language servers configured for this directory.

+ {:else} +
    + {#each servers as server (server.id)} +
  • +
    + {server.name} + + {#if server.busy} + + {/if} + {server.statusLabel} + +
    + {#if server.extensionsLabel} + {server.extensionsLabel} + {/if} + {server.root} + {#if server.error} + {server.error} + {/if} +
  • + {/each} +
+ {/if} +
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 => ({ + 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; + +// ── 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 @@ + + +
+
+ + {#if loading} + Resolving… + {:else if hasLoaded && loadedCwd !== null} + {summary} + {:else} + MCP servers + {/if} + + +
+ + {#if !canView} +

Open or start a conversation to see its MCP servers.

+ {:else if error} +

{error}

+ {:else if hasLoaded && loadedCwd === null} +

+ Set a working directory in the Model panel to enable MCP servers. +

+ {:else if hasLoaded && servers.length === 0 && !loading} +

No MCP servers configured for this directory.

+ {:else} +
    + {#each servers as server (server.id)} +
  • +
    + {server.id} + + {#if server.busy} + + {/if} + {server.statusLabel} + +
    +
    + {#if server.configSource} + {server.configSource} + {:else} + + {/if} + {server.toolCount} tool{server.toolCount === 1 ? "" : "s"} +
    + {#if server.error} + {server.error} + {/if} +
  • + {/each} +
+ {/if} +
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; + +// ── 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 @@ + + +
+ Chat limit +
+ { + if (e.key === "Enter") handleSave(); + }} + aria-label="Chat limit" + /> + +
+ {#if error} +

{error}

+ {:else if justSaved && !dirty} +

Saved{savedValue !== null ? `: ${savedValue}.` : "."}

+ {:else} +

+ 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. +

+ {/if} +
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; +} { + 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; + +export type SystemPromptSaveResult = + | { readonly ok: true; readonly template: string } + | { readonly ok: false; readonly error: string }; + +export type SaveSystemPrompt = (template: string) => Promise; + +export type SystemPromptVariablesResult = + | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] } + | { readonly ok: false; readonly error: string }; + +export type LoadSystemPromptVariables = () => Promise; + +// ── 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(); + 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:`). */ +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 @@ + + + + + + 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/workspace/index.ts b/src/features/workspace/index.ts deleted file mode 100644 index 9acf994..0000000 --- a/src/features/workspace/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type { - CwdSaveResult, - LoadLspStatus, - LspStatusResult, - SaveCwd, -} from "./logic/view-model"; -export { default as CwdField } from "./ui/CwdField.svelte"; -export { default as LspStatusView } from "./ui/LspStatusView.svelte"; - -/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ -export const manifest = { - name: "workspace", - description: "Per-conversation working directory + language-server status", -} as const; diff --git a/src/features/workspace/logic/view-model.test.ts b/src/features/workspace/logic/view-model.test.ts deleted file mode 100644 index a06edeb..0000000 --- a/src/features/workspace/logic/view-model.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { LspServerInfo } from "@dispatch/transport-contract"; -import { describe, expect, it } from "vitest"; -import { - cwdChanged, - isSubmittableCwd, - normalizeCwd, - summarizeServers, - viewLspServer, - viewLspServers, -} from "./view-model"; - -const server = (over: Partial = {}): LspServerInfo => ({ - id: "typescript", - name: "TypeScript", - root: "/home/me/project", - extensions: [".ts", ".tsx"], - state: "connected", - ...over, -}); - -describe("cwd helpers", () => { - it("normalizeCwd trims surrounding whitespace", () => { - expect(normalizeCwd(" /a/b ")).toBe("/a/b"); - expect(normalizeCwd("\t/x\n")).toBe("/x"); - }); - - it("isSubmittableCwd is false for empty / whitespace-only", () => { - expect(isSubmittableCwd("")).toBe(false); - expect(isSubmittableCwd(" ")).toBe(false); - expect(isSubmittableCwd("/a")).toBe(true); - }); - - it("cwdChanged: true only when a non-empty trimmed value differs from current", () => { - expect(cwdChanged("/a/b", null)).toBe(true); - expect(cwdChanged("/a/b", "/a/b")).toBe(false); - expect(cwdChanged(" /a/b ", "/a/b")).toBe(false); // trim-equal → no change - expect(cwdChanged("/a/c", "/a/b")).toBe(true); - expect(cwdChanged("", "/a/b")).toBe(false); // empty is not a change (can't clear) - expect(cwdChanged(" ", null)).toBe(false); - }); -}); - -describe("viewLspServer", () => { - it("connected → success badge, not busy, no error", () => { - const v = viewLspServer(server({ state: "connected" })); - expect(v.badge).toBe("success"); - expect(v.statusLabel).toBe("Connected"); - expect(v.busy).toBe(false); - expect(v.error).toBeNull(); - expect(v.extensionsLabel).toBe(".ts .tsx"); - }); - - it("starting / not-started → busy (spinner) with warning / neutral badge", () => { - const starting = viewLspServer(server({ state: "starting" })); - expect(starting.badge).toBe("warning"); - expect(starting.busy).toBe(true); - - const notStarted = viewLspServer(server({ state: "not-started" })); - expect(notStarted.badge).toBe("neutral"); - expect(notStarted.busy).toBe(true); - }); - - it("error → error badge + surfaces the reason (with a fallback)", () => { - const withReason = viewLspServer(server({ state: "error", error: "ENOENT" })); - expect(withReason.badge).toBe("error"); - expect(withReason.busy).toBe(false); - expect(withReason.error).toBe("ENOENT"); - - const noReason = viewLspServer(server({ state: "error" })); - expect(noReason.error).toBe("Failed to start"); - }); - - it("viewLspServers maps a list preserving order", () => { - const views = viewLspServers([server({ id: "a" }), server({ id: "b" })]); - expect(views.map((v) => v.id)).toEqual(["a", "b"]); - }); -}); - -describe("summarizeServers", () => { - it("empty list", () => { - expect(summarizeServers([])).toBe("No language servers"); - }); - - it("counts connected / starting / errors", () => { - expect(summarizeServers([server({ state: "connected" })])).toBe("1 connected"); - expect( - summarizeServers([ - server({ id: "a", state: "connected" }), - server({ id: "b", state: "error" }), - ]), - ).toBe("1 connected, 1 error"); - expect( - summarizeServers([ - server({ id: "a", state: "connected" }), - server({ id: "b", state: "starting" }), - server({ id: "c", state: "error" }), - server({ id: "d", state: "error" }), - ]), - ).toBe("1 connected, 1 starting, 2 errors"); - }); -}); diff --git a/src/features/workspace/logic/view-model.ts b/src/features/workspace/logic/view-model.ts deleted file mode 100644 index bc9b30b..0000000 --- a/src/features/workspace/logic/view-model.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { LspServerInfo, LspServerState } from "@dispatch/transport-contract"; - -/** - * Pure core for the workspace feature — zero DOM, zero effects, zero Svelte. - * - * The workspace 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. - * The effects (the HTTP get/set cwd + get LSP status) are INJECTED via the ports - * below; the composition root implements them. - */ - -// ── Injected ports (consumer-defines-port; the composition root adapts the -// store's HTTP calls to these shapes). ────────────────────────────────────── - -/** Outcome of `PUT /conversations/:id/cwd`; `null` when no real conversation is focused. */ -export type CwdSaveResult = - | { readonly ok: true; readonly cwd: string | null } - | { readonly ok: false; readonly error: string }; - -export type SaveCwd = (cwd: string) => Promise; - -/** Outcome of `GET /conversations/:id/lsp`; `null` when no real conversation is focused. */ -export type LspStatusResult = - | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly LspServerInfo[] } - | { readonly ok: false; readonly error: string }; - -export type LoadLspStatus = () => Promise; - -// ── cwd helpers ─────────────────────────────────────────────────────────────── - -/** Trim surrounding whitespace; the backend rejects an empty cwd. */ -export function normalizeCwd(raw: string): string { - return raw.trim(); -} - -/** Whether a typed cwd is submittable (non-empty after trim). */ -export function isSubmittableCwd(raw: string): boolean { - return normalizeCwd(raw).length > 0; -} - -/** - * Whether saving `typed` would change the persisted `current` cwd. A no-op save - * (unchanged, or empty) should be disabled. - */ -export function cwdChanged(typed: string, current: string | null): boolean { - const next = normalizeCwd(typed); - if (next.length === 0) return false; - return next !== (current ?? ""); -} - -// ── LSP server status → display view ────────────────────────────────────────── - -export type Badge = "success" | "warning" | "error" | "neutral"; - -export interface LspServerView { - readonly id: string; - readonly name: string; - readonly root: string; - /** Space-joined extension list, e.g. ".ts .tsx". */ - readonly extensionsLabel: string; - readonly state: LspServerState; - 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; -} - -/** Map a server's state to a display label + badge severity + busy flag. */ -export function viewLspServer(server: LspServerInfo): LspServerView { - let statusLabel: string; - let badge: Badge; - let busy = false; - switch (server.state) { - case "connected": - statusLabel = "Connected"; - badge = "success"; - break; - case "starting": - statusLabel = "Starting…"; - badge = "warning"; - busy = true; - break; - case "not-started": - statusLabel = "Not started"; - badge = "neutral"; - busy = true; - break; - case "error": - statusLabel = "Error"; - badge = "error"; - break; - } - return { - id: server.id, - name: server.name, - root: server.root, - extensionsLabel: server.extensions.join(" "), - state: server.state, - statusLabel, - badge, - busy, - error: server.state === "error" ? (server.error ?? "Failed to start") : null, - }; -} - -export function viewLspServers(servers: readonly LspServerInfo[]): readonly LspServerView[] { - return servers.map(viewLspServer); -} - -/** A short one-line summary, e.g. "2 connected" / "1 connected, 1 error". */ -export function summarizeServers(servers: readonly LspServerInfo[]): string { - if (servers.length === 0) return "No language servers"; - let connected = 0; - let errored = 0; - let pending = 0; - for (const s of servers) { - if (s.state === "connected") connected++; - else if (s.state === "error") errored++; - else pending++; - } - const parts: string[] = []; - if (connected > 0) parts.push(`${connected} connected`); - if (pending > 0) parts.push(`${pending} starting`); - if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`); - return parts.join(", "); -} diff --git a/src/features/workspace/ui/CwdField.svelte b/src/features/workspace/ui/CwdField.svelte deleted file mode 100644 index bd8b870..0000000 --- a/src/features/workspace/ui/CwdField.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - -
- Working directory -
- { - if (e.key === "Enter") handleSave(); - }} - aria-label="Working directory" - /> - -
- {#if !canEdit} -

Start or open a conversation to set its working directory.

- {:else if error} -

{error}

- {:else if justSaved && !dirty} -

Saved.

- {:else} -

Defaults each turn's cwd; drives the language servers below.

- {/if} -
diff --git a/src/features/workspace/ui/LspStatusView.svelte b/src/features/workspace/ui/LspStatusView.svelte deleted file mode 100644 index 77603a1..0000000 --- a/src/features/workspace/ui/LspStatusView.svelte +++ /dev/null @@ -1,127 +0,0 @@ - - -
-
- - {#if loading} - Resolving… - {:else if hasLoaded && loadedCwd !== null} - {summary} - {:else} - Language servers - {/if} - - -
- - {#if !canView} -

Open or start a conversation to see its language servers.

- {:else if error} -

{error}

- {:else if hasLoaded && loadedCwd === null} -

- Set a working directory in the Model panel to enable language servers. -

- {:else if hasLoaded && servers.length === 0 && !loading} -

No language servers configured for this directory.

- {:else} -
    - {#each servers as server (server.id)} -
  • -
    - {server.name} - - {#if server.busy} - - {/if} - {server.statusLabel} - -
    - {#if server.extensionsLabel} - {server.extensionsLabel} - {/if} - {server.root} - {#if server.error} - {server.error} - {/if} -
  • - {/each} -
- {/if} -
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).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).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` (`{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 (transport-contract@0.16.0): + * - `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 = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: string }; + +export interface WorkspaceHttp { + list(): Promise; + ensure(id: string, body?: EnsureWorkspaceRequest): Promise>; + get(id: string): Promise; + setTitle(id: string, title: string): Promise>; + setDefaultCwd(id: string, defaultCwd: string | null): Promise>; + delete(id: string): Promise>; +} + +async function errText(res: Response): Promise { + 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 { + 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> { + 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 { + 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> { + 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> { + 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> { + 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 `/` 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 (`/`) 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; + /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */ + ensure(id: string, body?: EnsureWorkspaceRequest): Promise>; + /** Rename a workspace (display only; id unchanged). */ + rename(id: string, title: string): Promise>; + /** Set/clear a workspace's default cwd. */ + setDefaultCwd(id: string, defaultCwd: string | null): Promise>; + /** Delete a workspace (closes its conversations, reassigns to "default"). */ + remove(id: string): Promise>; +} + +export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { + let list = $state([]); + let loading = $state(false); + let error = $state(null); + + return { + get list(): readonly WorkspaceEntry[] { + return list; + }, + get loading(): boolean { + return loading; + }, + get error(): string | null { + return error; + }, + + async refresh(): Promise { + 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> { + const result = await http.ensure(id, body); + if (result.ok) void this.refresh(); + return result; + }, + + async rename(id, title): Promise> { + const result = await http.setTitle(id, title); + if (result.ok) void this.refresh(); + return result; + }, + + async setDefaultCwd(id, defaultCwd): Promise> { + const result = await http.setDefaultCwd(id, defaultCwd); + if (result.ok) void this.refresh(); + return result; + }, + + async remove(id): Promise> { + 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 @@ + + +
  • +
    + {#if editingTitle} + { + if (e.key === "Enter") saveTitle(); + else if (e.key === "Escape") cancelTitle(); + }} + onblur={saveTitle} + /> + {:else} + + {ws.title} + {/if} + /{ws.id} + + {ws.conversationCount} + {ws.conversationCount === 1 ? "conversation" : "conversations"} + · {relativeTime(ws.lastActivityAt, Date.now())} + + +
    + + {#if titleError} +

    {titleError}

    + {/if} + +
    + cwd + { + if (e.key === "Enter") saveCwd(); + }} + /> + +
    + + + + {#if cwdError} +

    {cwdError}

    + {:else if !cwdDirty && !ws.defaultCwd} +

    No default cwd set — conversations inherit the server default.

    + {/if} +
  • 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 { + 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> => ({ + ok: true, + value: fakeEntry({ id, title: _title }), + }), + ), + setDefaultCwd: vi.fn( + async (id: string, defaultCwd: string | null): Promise> => ({ + ok: true, + value: fakeEntry({ id, defaultCwd }), + }), + ), + remove: vi.fn( + async (): Promise> => ({ + 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 @@ + + +
    +
    +

    Workspaces

    + { + e.preventDefault(); + onNavigate("/default"); + }} + > + Default + +
    + +
    { + e.preventDefault(); + createWorkspace(); + }} + > +
    + + +
    + +
    + {#if slugError} +

    {slugError}

    + {/if} + +
    + {#if store.loading && store.list.length === 0} +
    + +
    + {:else if store.list.length === 0} +

    + No workspaces yet. Create one above or visit /your-name in the URL. +

    + {:else} +
      + {#each store.list as ws (ws.id)} + + {/each} +
    + {/if} +
    +
    -- cgit v1.2.3