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/logic | |
| 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/logic')
| -rw-r--r-- | src/features/mcp/logic/view-model.test.ts | 88 | ||||
| -rw-r--r-- | src/features/mcp/logic/view-model.ts | 110 |
2 files changed, 198 insertions, 0 deletions
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(", "); +} |
