diff --git a/packages/webui/src/client/components/PluginManagement.tsx b/packages/webui/src/client/components/PluginManagement.tsx index e5ba74c1..0eda4f26 100644 --- a/packages/webui/src/client/components/PluginManagement.tsx +++ b/packages/webui/src/client/components/PluginManagement.tsx @@ -24,6 +24,51 @@ const sameSelection = ( left.view === right.view && left.query === right.query && left.category === right.category; + +/** + * One MCP server's connection state, as the row shows it. + * + * The runtime already classifies every server + * (`LocalMcpPublicServerStatus`: `available` / `configured` / `disabled` / + * `error` / `unavailable`) and attaches the failure's own reason to the two + * trouble states — the D-3 gap was that the row rendered none of it, so a + * server that could not connect was indistinguishable from a healthy one + * unless you already knew to look elsewhere. `configured` is deliberately + * NOT trouble: it means enabled and not currently connected, which is the + * resting state of a server that connects on use. + */ +export interface WebuiMcpServerStatusView { + readonly key: + | "available" + | "configured" + | "disabled" + | "error" + | "unavailable" + | "unknown"; + readonly label: string; + readonly tone: string; + readonly reason?: string; +} + +export function describeWebuiMcpServerStatus(item: Row): WebuiMcpServerStatusView { + const status = read(item, "status"); + const reason = read(item, "error", "errorMessage") || undefined; + switch (status) { + case "available": + return { key: "available", label: "已连接", tone: "text-text_default_secondary" }; + case "configured": + return { key: "configured", label: "未连接", tone: "text-text_default_tertiary" }; + case "disabled": + return { key: "disabled", label: "已停用", tone: "text-text_default_tertiary" }; + case "error": + return { key: "error", label: "连接失败", tone: "text-text_label_danger_secondary_default", reason }; + case "unavailable": + return { key: "unavailable", label: "不可用", tone: "text-text_label_warning_secondary_default", reason }; + default: + return { key: "unknown", label: "未知状态", tone: "text-text_default_tertiary" }; + } +} + const CATEGORIES: readonly { id: Area; label: string }[] = [ { id: "plugins", label: "插件" }, { id: "skills", label: "技能" }, @@ -1083,6 +1128,10 @@ export function PluginManagement({ const name = nameOf(item) || `item-${index}`; const isOn = enabled(item); const description = read(item, "description", "summary"); + // Computed for every row, read only in the mcp branch: the loose + // read is side-effect-free, and narrowing `area` inside the JSX + // below cannot narrow this binding. + const mcpStatus = describeWebuiMcpServerStatus(item); const action = area === "plugins" ? view === "market" @@ -1195,7 +1244,30 @@ export function PluginManagement({ {appStatus(item)} ) : area === "mcp" ? ( -
+ <> + {/* The status the runtime already knows, on the row + * itself: a server that cannot connect is labelled + * 连接失败 with its own reason inline — visible text, not + * a tooltip, because nobody hovers a row they believe + * is healthy. Mirrors the apps area's status slot. */} +
+ + {mcpStatus.label} + + {mcpStatus.reason ? ( + + {mcpStatus.reason} + + ) : null} +
+
+ ) : (
{ + it("labels each runtime state a user can act on", () => { + expect(describeWebuiMcpServerStatus({ status: "available" })).toMatchObject({ + key: "available", + label: "已连接", + }); + expect(describeWebuiMcpServerStatus({ status: "configured" })).toMatchObject({ + key: "configured", + label: "未连接", + }); + expect(describeWebuiMcpServerStatus({ status: "disabled" })).toMatchObject({ + key: "disabled", + label: "已停用", + }); + expect(describeWebuiMcpServerStatus({ status: "error" })).toMatchObject({ + key: "error", + label: "连接失败", + }); + expect(describeWebuiMcpServerStatus({ status: "unavailable" })).toMatchObject({ + key: "unavailable", + label: "不可用", + }); + }); + + it("carries the failure's own reason on the two trouble states", () => { + // The reason is the point of D-3: 连接失败 alone does not tell the user + // whether to fix a URL, a command, or a credential. The runtime's own + // string arrives verbatim. + expect( + describeWebuiMcpServerStatus({ + status: "error", + error: "MCP_HANDSHAKE_FAILED: server sent no initialize response", + }).reason, + ).toBe("MCP_HANDSHAKE_FAILED: server sent no initialize response"); + expect( + describeWebuiMcpServerStatus({ + status: "unavailable", + errorMessage: "MCP_COMMAND_NOT_FOUND: ./missing-bin", + }).reason, + ).toBe("MCP_COMMAND_NOT_FOUND: ./missing-bin"); + }); + + it("keeps the calm states calm and the trouble states coloured", () => { + // The two trouble states must not share the neutral tone, or the row + // reads as healthy at a glance — the exact failure D-3 describes. + const neutral = describeWebuiMcpServerStatus({ status: "available" }).tone; + expect(describeWebuiMcpServerStatus({ status: "configured" }).tone).toBe( + describeWebuiMcpServerStatus({ status: "disabled" }).tone, + ); + expect(describeWebuiMcpServerStatus({ status: "error" }).tone).not.toBe(neutral); + expect(describeWebuiMcpServerStatus({ status: "unavailable" }).tone).not.toBe(neutral); + }); + + it("treats a missing or unrecognized status as unknown, without inventing a reason", () => { + expect(describeWebuiMcpServerStatus({})).toMatchObject({ + key: "unknown", + label: "未知状态", + }); + // An unknown status with an error string still shows no reason: the + // label 未知状态 claims nothing, and a reason under it would claim + // something the reader cannot verify. + expect(describeWebuiMcpServerStatus({ status: 7, error: "x" }).reason).toBeUndefined(); + }); +}); + +describe("the MCP row wiring", () => { + it("renders the status view on the row, reason included", () => { + const statusAt = SOURCE.indexOf('data-webui-mcp-status={mcpStatus.key}'); + expect(statusAt, "the mcp row no longer renders the status chip").toBeGreaterThanOrEqual(0); + const row = SOURCE.slice(statusAt - 600, statusAt + 900); + expect(row).toContain('{mcpStatus.label}'); + expect(row).toContain('{mcpStatus.reason}'); + expect(row).toContain('data-webui-mcp-status-reason={mcpStatus.key}'); + }); + + it("derives the view from the row item the list loads", () => { + const deriveAt = SOURCE.indexOf("describeWebuiMcpServerStatus(item)"); + expect(deriveAt).toBeGreaterThanOrEqual(0); + // …and inside the row map, not somewhere orphaned from rendering. + const mapAt = SOURCE.indexOf(".map((item, index) => {"); + expect(mapAt).toBeGreaterThan(0); + expect(deriveAt).toBeGreaterThan(mapAt); + expect(deriveAt - mapAt).toBeLessThan(2_000); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 8d41f734..8e55639b 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3696,6 +3696,7 @@ "packages/webui/test/unit/output-error.test.tsx", "packages/webui/test/unit/outside-close.test.ts", "packages/webui/test/unit/plugin-market-catalogue.test.tsx", + "packages/webui/test/unit/plugin-mcp-status.test.tsx", "packages/webui/test/unit/projections.test.ts", "packages/webui/test/unit/questionnaire-goal-autoreply.test.tsx", "packages/webui/test/unit/queue-panel.test.ts", @@ -3829,6 +3830,7 @@ "test/webui-browser/connection-status.spec.mjs", "test/webui-browser/fixture.mjs", "test/webui-browser/harness.mjs", + "test/webui-browser/plugin-mcp-status.spec.mjs", "test/webui-browser/questionnaire.spec.mjs", "test/webui-browser/rail-context-menu.spec.mjs", "test/webui-browser/rail-pin.spec.mjs", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 35503db4..63ee3217 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -286,6 +286,7 @@ "packages/webui/test/unit/session-transfer-route.test.ts", "packages/webui/test/unit/outside-close.test.ts", "packages/webui/test/unit/plugin-market-catalogue.test.tsx", + "packages/webui/test/unit/plugin-mcp-status.test.tsx", "packages/webui/test/unit/webui-plan-mode.test.ts", "packages/webui/test/unit/questionnaire-goal-autoreply.test.tsx", "packages/webui/test/unit/transcript-scroll.test.ts", diff --git a/test/webui-browser/fixture.mjs b/test/webui-browser/fixture.mjs index ecc8b735..daef2263 100644 --- a/test/webui-browser/fixture.mjs +++ b/test/webui-browser/fixture.mjs @@ -67,6 +67,11 @@ export function installFixtureTransport() { // questionnaire has no other way to say what the server answers on the NEXT // poll. `undefined` keeps the old "no pending questionnaire" answer. let questionnaire; + // Per-action results for the plugin-management facade. The panel asks for + // one action at a time (`{action, input}`), so a test stages exactly the + // listing it wants (an MCP server list with connection states, say) and + // every other action keeps the empty default. + const pluginManagementResults = {}; let activeTurn; const requests = []; const sockets = new Set(); @@ -86,6 +91,10 @@ export function installFixtureTransport() { if (operation === "listPendingPermissions") return { requests: [] }; if (operation === "getActiveTurn") return activeTurn; if (operation === "getPendingQuestionnaire") return questionnaire ? { request: questionnaire } : {}; + if (operation === "pluginManagement") { + const action = typeof body?.action === "string" ? body.action : ""; + return pluginManagementResults[action] ?? {}; + } if (operation === "dismissQuestionnaire") return { ok: true }; if (operation === "replyQuestionnaire") return { ok: true }; if (operation === "listQueueMessages") return { items: [] }; @@ -228,6 +237,9 @@ export function installFixtureTransport() { // is inert on purpose, so a staged questionnaire stays pending until the // test answers, dismisses, or replaces it. setQuestionnaire(request) { questionnaire = request ? clone(request) : undefined; }, + setPluginManagementResult(action, result) { + pluginManagementResults[action] = result === undefined ? undefined : clone(result); + }, // Kill every live socket (the "server went away" moment) and/or decide // whether new connections may form. Between a dropAll() and reopening // the gate, the client is fully disconnected — with no turn running, diff --git a/test/webui-browser/plugin-mcp-status.spec.mjs b/test/webui-browser/plugin-mcp-status.spec.mjs new file mode 100644 index 00000000..b13cc42d --- /dev/null +++ b/test/webui-browser/plugin-mcp-status.spec.mjs @@ -0,0 +1,95 @@ +// The MCP servers' connection state on the plugin panel, end to end against +// the built client. +// +// Roadmap D-3 (as transcribed): 「MCP 插件连不上时,界面上要看得见」. The +// runtime classifies every server and attaches the failure's own reason to +// the trouble states; the panel's MCP rows used to render none of it, so a +// dead server was visually identical to a healthy one. This spec walks the +// real path a user walks — rail 插件 → 管理 → MCP — and reads the placed, +// loaded rows: every state labelled, the two failure states carrying their +// server's own reason as visible text. + +import { expect } from "@playwright/test"; + +import { openApp, test } from "./harness.mjs"; + +test.beforeEach(async ({ page }) => { + page.on("pageerror", (error) => console.error("BROWSER_PAGE_ERROR", error.stack ?? error.message)); + page.on("console", (message) => { if (message.type() === "error") console.error("BROWSER_CONSOLE_ERROR", message.text()); }); +}); + +/** Stages the MCP listing before boot; the panel's reload picks it up. */ +async function stageMcpServers(page, servers) { + await page.addInitScript((value) => { + window.__WEBUI_FIXTURE_SETUP__ ??= []; + window.__WEBUI_FIXTURE_SETUP__.push(() => + window.__fixture.setPluginManagementResult("listMcpServers", value), + ); + }, { servers }); +} + +async function openMcpArea(page) { + await page.getByRole("button", { name: "插件" }).first().click(); + await page.locator(".webui-plugin-manage-trigger").click(); + await page.locator('.webui-plugin-category-filter button', { hasText: "MCP" }).click(); +} + +function rowOf(page, name) { + return page.locator(".webui-plugin-row", { hasText: name }); +} + +test("a server that cannot connect is labelled 连接失败 with its own reason", async ({ page }) => { + await stageMcpServers(page, [ + { + name: "broken-http", + transport: "http", + enabled: true, + status: "error", + available: false, + error: "MCP_CONNECTION_TIMEOUT: 握手超时(10s)", + }, + { + name: "gone-binary", + transport: "stdio", + enabled: true, + status: "unavailable", + available: false, + error: "MCP_COMMAND_NOT_FOUND: ./missing-bin", + }, + ]); + await openApp(page, "#session=A"); + await openMcpArea(page); + + // The failure states come with their server's own reason as visible text — + // not a tooltip, because nobody hovers a row they believe is healthy. + const broken = rowOf(page, "broken-http").locator("[data-webui-mcp-status]"); + await expect(broken).toHaveText("连接失败"); + await expect(broken).toHaveAttribute("data-webui-mcp-status", "error"); + await expect(rowOf(page, "broken-http").locator("[data-webui-mcp-status-reason]")) + .toHaveText("MCP_CONNECTION_TIMEOUT: 握手超时(10s)"); + + const gone = rowOf(page, "gone-binary").locator("[data-webui-mcp-status]"); + await expect(gone).toHaveText("不可用"); + await expect(gone).toHaveAttribute("data-webui-mcp-status", "unavailable"); + await expect(rowOf(page, "gone-binary").locator("[data-webui-mcp-status-reason]")) + .toHaveText("MCP_COMMAND_NOT_FOUND: ./missing-bin"); +}); + +test("the calm states are labelled too, so trouble has honest neighbours", async ({ page }) => { + // Without the quiet half, every label would read as an alarm: a panel that + // says 未连接 on a resting server and 连接失败 on a dead one is legible; + // one that says nothing except on failure is a trap. + await stageMcpServers(page, [ + { name: "ok-stdio", transport: "stdio", enabled: true, status: "available", available: true }, + { name: "idle", transport: "stdio", enabled: true, status: "configured", available: false }, + { name: "off", transport: "http", enabled: false, status: "disabled", available: false }, + ]); + await openApp(page, "#session=A"); + await openMcpArea(page); + + await expect(rowOf(page, "ok-stdio").locator("[data-webui-mcp-status]")).toHaveText("已连接"); + await expect(rowOf(page, "idle").locator("[data-webui-mcp-status]")).toHaveText("未连接"); + await expect(rowOf(page, "off").locator("[data-webui-mcp-status]")).toHaveText("已停用"); + // And no row invents a reason where the runtime gave none. + await expect(page.locator("[data-webui-mcp-status-reason]")).toHaveCount(0); +});