diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 10:55:51 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 10:55:51 +0900 |
| commit | 38db3827870960f466be89afbc49f91238d46144 (patch) | |
| tree | 24cb1b896dfadc31e72552dbe67f00530881242e /src/features/mcp | |
| parent | 17ce47987e673b6618454d033885b17b2a01912e (diff) | |
| download | dispatch-web-38db3827870960f466be89afbc49f91238d46144.tar.gz dispatch-web-38db3827870960f466be89afbc49f91238d46144.zip | |
feat: workspaces shell + cwd-lsp rename + mcp/settings/system-prompt features + app wiring
- workspaces: URL-driven conversation grouping (home listing at /, routing,
store, http adapter, WorkspaceCard) wired into the App.svelte shell
- rename features/workspace -> features/cwd-lsp (the cwd/lsp status feature)
- new features: mcp (status view), settings (chat-limit field), system-prompt
(prompt builder), all rendered via the generic surface host
- chat: store + ChatView updates
- tabs: tabs-store updates
- app wiring: ErrorModal (full-screen error surface), app/App.svelte + store.svelte
This commit makes HEAD typecheck clean for the first time: the prior HEAD
(c95cc77) imported features/settings from app/App.svelte but never committed
the feature, so only the full working tree was green.
Diffstat (limited to 'src/features/mcp')
| -rw-r--r-- | src/features/mcp/index.ts | 8 | ||||
| -rw-r--r-- | src/features/mcp/logic/view-model.test.ts | 88 | ||||
| -rw-r--r-- | src/features/mcp/logic/view-model.ts | 110 | ||||
| -rw-r--r-- | src/features/mcp/ui/McpStatusView.svelte | 131 |
4 files changed, 337 insertions, 0 deletions
diff --git a/src/features/mcp/index.ts b/src/features/mcp/index.ts new file mode 100644 index 0000000..a161992 --- /dev/null +++ b/src/features/mcp/index.ts @@ -0,0 +1,8 @@ +export type { LoadMcpStatus, McpStatusResult } from "./logic/view-model"; +export { default as McpStatusView } from "./ui/McpStatusView.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "mcp", + description: "Per-conversation MCP (Model Context Protocol) server status", +} as const; diff --git a/src/features/mcp/logic/view-model.test.ts b/src/features/mcp/logic/view-model.test.ts new file mode 100644 index 0000000..23bf20f --- /dev/null +++ b/src/features/mcp/logic/view-model.test.ts @@ -0,0 +1,88 @@ +import type { McpServerInfo } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { summarizeMcpServers, viewMcpServer, viewMcpServers } from "./view-model"; + +const server = (over: Partial<McpServerInfo> = {}): McpServerInfo => ({ + id: "freecad", + state: "connected", + toolCount: 12, + ...over, +}); + +describe("viewMcpServer", () => { + it("connected → success badge, not busy, no error, passes toolCount", () => { + const v = viewMcpServer(server({ toolCount: 5 })); + expect(v.badge).toBe("success"); + expect(v.statusLabel).toBe("Connected"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + expect(v.toolCount).toBe(5); + expect(v.configSource).toBeNull(); + }); + + it("connecting → warning badge + busy (spinner)", () => { + const v = viewMcpServer(server({ state: "connecting" })); + expect(v.badge).toBe("warning"); + expect(v.statusLabel).toBe("Connecting…"); + expect(v.busy).toBe(true); + expect(v.error).toBeNull(); + }); + + it("disconnected → neutral badge, not busy", () => { + const v = viewMcpServer(server({ state: "disconnected" })); + expect(v.badge).toBe("neutral"); + expect(v.statusLabel).toBe("Disconnected"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + }); + + it("error → error badge + surfaces the reason (with a fallback)", () => { + const withReason = viewMcpServer(server({ state: "error", error: "ENOENT: npx" })); + expect(withReason.badge).toBe("error"); + expect(withReason.busy).toBe(false); + expect(withReason.error).toBe("ENOENT: npx"); + + const noReason = viewMcpServer(server({ state: "error" })); + expect(noReason.error).toBe("Failed to connect"); + }); + + it("passes through configSource when present", () => { + const v = viewMcpServer(server({ configSource: ".dispatch/mcp.json" })); + expect(v.configSource).toBe(".dispatch/mcp.json"); + }); + + it("viewMcpServers maps a list preserving order", () => { + const views = viewMcpServers([server({ id: "a" }), server({ id: "b" })]); + expect(views.map((v) => v.id)).toEqual(["a", "b"]); + }); +}); + +describe("summarizeMcpServers", () => { + it("empty list", () => { + expect(summarizeMcpServers([])).toBe("No MCP servers"); + }); + + it("counts connected / connecting / disconnected / errors", () => { + expect(summarizeMcpServers([server({ state: "connected" })])).toBe("1 connected"); + expect( + summarizeMcpServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "error" }), + ]), + ).toBe("1 connected, 1 error"); + expect( + summarizeMcpServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "connecting" }), + server({ id: "c", state: "disconnected" }), + server({ id: "d", state: "error" }), + server({ id: "e", state: "error" }), + ]), + ).toBe("1 connected, 1 connecting, 1 disconnected, 2 errors"); + }); + + it("lists only non-zero buckets", () => { + expect(summarizeMcpServers([server({ state: "disconnected" })])).toBe("1 disconnected"); + expect(summarizeMcpServers([server({ id: "a", state: "connecting" })])).toBe("1 connecting"); + }); +}); diff --git a/src/features/mcp/logic/view-model.ts b/src/features/mcp/logic/view-model.ts new file mode 100644 index 0000000..247f804 --- /dev/null +++ b/src/features/mcp/logic/view-model.ts @@ -0,0 +1,110 @@ +import type { McpServerInfo, McpServerState } from "@dispatch/transport-contract"; + +/** + * Pure core for the mcp feature — zero DOM, zero effects, zero Svelte. + * + * The mcp feature exposes the live status of the MCP (Model Context Protocol) + * servers configured for a conversation's working directory, fetched from + * `GET /conversations/:id/mcp`. This module holds the pure logic: the mapping + * of a backend `McpServerState` to a display badge + label, and a one-line + * server summary. The effect (the HTTP get MCP status) is INJECTED via the + * `LoadMcpStatus` port below; the composition root implements it. + */ + +// ── Injected port (consumer-defines-port; the composition root adapts the +// store's HTTP call to this shape). ────────────────────────────────────────── + +/** Outcome of `GET /conversations/:id/mcp`; `null` when no real conversation is focused. */ +export type McpStatusResult = + | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly McpServerInfo[] } + | { readonly ok: false; readonly error: string }; + +export type LoadMcpStatus = () => Promise<McpStatusResult | null>; + +// ── MCP server status → display view ─────────────────────────────────────────── + +export type Badge = "success" | "warning" | "error" | "neutral"; + +export interface McpServerView { + readonly id: string; + readonly state: McpServerState; + readonly statusLabel: string; + readonly badge: Badge; + /** True while the state is transient (show a spinner). */ + readonly busy: boolean; + /** The error reason when `state === "error"`, else null. */ + readonly error: string | null; + /** Number of tools discovered from this server. */ + readonly toolCount: number; + /** Which config source the server was resolved from, else null. */ + readonly configSource: string | null; +} + +/** + * Map a server's state to a display label + badge severity + busy flag. Mirrors + * the LSP status visual treatment: `connected` → success, `connecting` (the + * transient state, analogous to LSP's `starting`) → warning + spinner, `error` + * → error, and `disconnected` (a stable idle state) → neutral. + */ +export function viewMcpServer(server: McpServerInfo): McpServerView { + let statusLabel: string; + let badge: Badge; + let busy = false; + switch (server.state) { + case "connected": + statusLabel = "Connected"; + badge = "success"; + break; + case "connecting": + statusLabel = "Connecting…"; + badge = "warning"; + busy = true; + break; + case "disconnected": + statusLabel = "Disconnected"; + badge = "neutral"; + break; + case "error": + statusLabel = "Error"; + badge = "error"; + break; + } + return { + id: server.id, + state: server.state, + statusLabel, + badge, + busy, + error: server.state === "error" ? (server.error ?? "Failed to connect") : null, + toolCount: server.toolCount, + configSource: server.configSource ?? null, + }; +} + +export function viewMcpServers(servers: readonly McpServerInfo[]): readonly McpServerView[] { + return servers.map(viewMcpServer); +} + +/** + * A short one-line summary, e.g. "2 connected" / "1 connected, 1 connecting, + * 1 error". Only non-zero buckets are listed. + */ +export function summarizeMcpServers(servers: readonly McpServerInfo[]): string { + if (servers.length === 0) return "No MCP servers"; + let connected = 0; + let connecting = 0; + let disconnected = 0; + let errored = 0; + for (const s of servers) { + if (s.state === "connected") connected++; + else if (s.state === "error") errored++; + else if (s.state === "connecting") connecting++; + else disconnected++; + } + const parts: string[] = []; + if (connected > 0) parts.push(`${connected} connected`); + if (connecting > 0) parts.push(`${connecting} connecting`); + if (disconnected > 0) parts.push(`${disconnected} disconnected`); + if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`); + return parts.join(", "); +} diff --git a/src/features/mcp/ui/McpStatusView.svelte b/src/features/mcp/ui/McpStatusView.svelte new file mode 100644 index 0000000..9ac40ba --- /dev/null +++ b/src/features/mcp/ui/McpStatusView.svelte @@ -0,0 +1,131 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { + type Badge, + type LoadMcpStatus, + type McpServerView, + summarizeMcpServers, + viewMcpServers, + } from "../logic/view-model"; + + let { + cwd, + canView, + load, + }: { + /** The active conversation's cwd — the trigger to (re)load when it changes. */ + cwd: string | null; + /** Whether a real conversation is focused. */ + canView: boolean; + load: LoadMcpStatus; + } = $props(); + + const badgeClass: Record<Badge, string> = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + neutral: "badge-ghost", + }; + + let servers = $state<readonly McpServerView[]>([]); + let loading = $state(false); + let error = $state<string | null>(null); + let loadedCwd = $state<string | null>(null); + let hasLoaded = $state(false); + let summary = $state(""); + + async function refresh() { + if (!canView) return; + loading = true; + error = null; + const result = await load(); + loading = false; + if (result === null) return; + hasLoaded = true; + if (result.ok) { + servers = viewMcpServers(result.servers); + summary = summarizeMcpServers(result.servers); + loadedCwd = result.cwd; + } else { + error = result.error; + } + } + + // (Re)load on mount and whenever the conversation's cwd changes. The MCP GET + // lazily spawns/connects servers, so we avoid a redundant fetch when `cwd` + // resolves to the value we already loaded for. + $effect(() => { + const target = cwd; + const can = canView; + untrack(() => { + if (!can) return; + if (!hasLoaded || target !== loadedCwd) void refresh(); + }); + }); +</script> + +<div class="flex flex-col gap-2"> + <div class="flex items-center justify-between gap-2"> + <span class="text-xs opacity-70"> + {#if loading} + Resolving… + {:else if hasLoaded && loadedCwd !== null} + {summary} + {:else} + MCP servers + {/if} + </span> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={!canView || loading} + onclick={() => refresh()} + aria-label="Refresh MCP server status" + > + {#if loading} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Refresh + {/if} + </button> + </div> + + {#if !canView} + <p class="text-xs opacity-60">Open or start a conversation to see its MCP servers.</p> + {:else if error} + <p class="text-xs text-error">{error}</p> + {:else if hasLoaded && loadedCwd === null} + <p class="text-xs opacity-60"> + Set a working directory in the Model panel to enable MCP servers. + </p> + {:else if hasLoaded && servers.length === 0 && !loading} + <p class="text-xs opacity-60">No MCP servers configured for this directory.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each servers as server (server.id)} + <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <div class="flex items-center justify-between gap-2"> + <span class="font-medium font-mono">{server.id}</span> + <span class="badge badge-sm {badgeClass[server.badge]} gap-1"> + {#if server.busy} + <span class="loading loading-spinner loading-xs"></span> + {/if} + {server.statusLabel} + </span> + </div> + <div class="flex items-center justify-between gap-2 text-xs opacity-60"> + {#if server.configSource} + <span class="font-mono" title="Config source">{server.configSource}</span> + {:else} + <span></span> + {/if} + <span title="Discovered tools">{server.toolCount} tool{server.toolCount === 1 ? "" : "s"}</span> + </div> + {#if server.error} + <span class="font-mono text-xs text-error">{server.error}</span> + {/if} + </li> + {/each} + </ul> + {/if} +</div> |
