From 1418cee7ce86d1921f13ee02a8113e71d32d9d3e Mon Sep 17 00:00:00 2001 From: stevenjj33 <75509501+stevenjj33@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:50:56 +0800 Subject: [PATCH 1/4] fix(webui): give the adopted composer store the storage adapter again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e40a63f moved composer-store ownership into the application and made the shell adopt the composition root's application (A4-s2). The root built its application without a `composer` dep, so `create-application` fell back to `createWebuiComposerStore()` — storage-less by design, because the application layer may not import infrastructure. The shell's own storage-backed store (`providedApplication?.composer ?? create(...)`) became dead code on the live path, and with it every draft: typing still worked and switching sessions still restored the in-memory draft, but nothing ever reached localStorage, so a reload lost them all. Verified on webui @ 6a455a2 before this change: the drafts spec failed deterministically (3 runs, same failure — after reload the composer is empty and `webui.composer.state.v1` is null even before the reload), and a probe showed the key was never written. With the injection: 7/7 in composer.spec.mjs, 97/97 in the full browser suite. The composition root now injects the same storage adapter the shell would have built, exactly as the shell already does for `unreadStorage` when it builds its own application. The unread controller has the same gap at the same call site (no failing gate covers it); left for a follow-up rather than widening this fix. --- packages/webui/src/client/main.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/main.tsx b/packages/webui/src/client/main.tsx index 08178c953..4ce0d2bcc 100644 --- a/packages/webui/src/client/main.tsx +++ b/packages/webui/src/client/main.tsx @@ -13,13 +13,14 @@ import { NotFound } from "./components/shell/NotFound.js"; import { ArchonPage } from "./components/shell/ArchonPage.js"; import { WebuiErrorBoundary } from "./components/shell/WebuiErrorBoundary.js"; import { WebuiStartupFallback } from "./components/shell/StartupFallback.js"; -import { WebuiBrowserCapabilitiesProvider } from "./bindings/browser-capabilities.js"; +import { WebuiBrowserCapabilitiesProvider, useWebuiBrowserCapabilities } from "./bindings/browser-capabilities.js"; import { createWebuiBrowserDom } from "./infrastructure/browser-dom.js"; import { createWebuiBrowserStorage, getWebuiBrowserStorage, } from "./infrastructure/storage.js"; import { createWebuiApplication } from "./application/create-application.js"; +import { createWebuiComposerStore } from "./application/composer/composer-store.js"; import { createWebuiEventEffectsRegistry } from "./application/turn/event-effects-registry.js"; import { createWebuiPortSlices } from "./application/port-slices.js"; import { createWebuiOpenEventChannel } from "./bindings/event-channel-adapter.js"; @@ -69,6 +70,17 @@ function WebuiClientRoot({ () => createWebuiEventEffectsRegistry(), [], ); + // The same storage adapter the shell would have built for its own composer + // store. The application layer may not import infrastructure, so the + // composition root injects it — without this the adopted composer store has + // no storage at all and every draft dies with the page (regression from + // e40a63f: the root stopped handing the shell a store to build, and the + // application's own default is storage-less by design). + const { storage } = useWebuiBrowserCapabilities(); + const composerStore = React.useMemo( + () => createWebuiComposerStore({ storage }), + [storage], + ); const slices = React.useMemo( () => createWebuiPortSlices(transport), [transport], @@ -85,6 +97,7 @@ function WebuiClientRoot({ // own view reactions on top and registers them through // `setEventEffects`, because it renders after this instance exists. effects: effectsRegistry.asWebuiEventEffects(), + composer: composerStore, turns: { resumeSession: transport.resumeSession ?? (async () => {}), ...(transport.loadMessages @@ -96,7 +109,7 @@ function WebuiClientRoot({ ? { loadMessages: transport.loadMessages } : {}), }), - [effectsRegistry, transport], + [composerStore, effectsRegistry, transport], ); React.useEffect(() => () => application.dispose(), [application]); From 017c78d4350f9448bf6e8b3072021bcb76ee90cc Mon Sep 17 00:00:00 2001 From: stevenjj33 <75509501+stevenjj33@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:59:17 +0800 Subject: [PATCH 2/4] fix(webui): let an MCP row prove its own connection, with the runtime's reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin panel never called testMcpServer — the one action whose answer is a real connection result — so a server that could not connect had no way to say why from the UI: the listing rows carry no status at all (upstream's configuredSummary sends only name/enabled/transport/ description/endpoint/configJson, which is a separate fix), and the reason sat in the server log. Each MCP row now drives the probe and shows what came back: the runtime's own errorMessage verbatim on failure (falling back to the errorCode when that is all it said, and claiming nothing when it said nothing), and the tool count on success. The projection lives beside the existing status derivation so the row stays a renderer. Falsified: removing the row's test button turns the new source pin red (1 failed / 11 passed) and the new browser case fails at the click; both green again with the wiring restored. The browser case drives the real path (插件 → 管理 → MCP → 测试连接) with the runtime's own result shape. --- .../components/plugins/PluginCatalogue.tsx | 48 +++++++++++- .../client/projection/plugins/mcp-status.ts | 37 ++++++++++ .../test/unit/plugin-mcp-status.test.tsx | 74 ++++++++++++++++++- test/webui-browser/plugin-mcp-status.spec.mjs | 56 ++++++++++++++ 4 files changed, 213 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/components/plugins/PluginCatalogue.tsx b/packages/webui/src/client/components/plugins/PluginCatalogue.tsx index fb07d394a..5cd01db8f 100644 --- a/packages/webui/src/client/components/plugins/PluginCatalogue.tsx +++ b/packages/webui/src/client/components/plugins/PluginCatalogue.tsx @@ -13,7 +13,7 @@ import { ToggleSwitch } from "../shared/ToggleSwitch.js"; import { readWebuiPluginRow as read, type WebuiPluginRow as Row } from "../../contracts/plugins/plugin-rows.js"; import { PluginImportDialog } from "./PluginImportDialog.js"; import { PluginItemDialog } from "./PluginItemDialog.js"; -import { describeWebuiMcpServerStatus } from "../../projection/plugins/mcp-status.js"; +import { describeWebuiMcpServerStatus, describeWebuiMcpTestResult, type WebuiMcpTestResultView } from "../../projection/plugins/mcp-status.js"; type Area = "plugins" | "skills" | "apps" | "mcp" | "agents"; type PluginCatalogueSelection = { @@ -126,6 +126,15 @@ export function PluginCatalogue({ 2, ), ); + /* The on-demand connection probe's outcome, keyed by server name: the one + * action that answers with a real connection result, driven from the row so + * a server that cannot connect can say why without a log file. One slot is + * enough — a user tests one server at a time. */ + const [mcpTest, setMcpTest] = useState<{ + readonly name: string; + readonly busy: boolean; + readonly view?: WebuiMcpTestResultView; + } | null>(null); const [agentName, setAgentName] = useState(""); const [agentDescription, setAgentDescription] = useState(""); const [agentPrompt, setAgentPrompt] = useState(""); @@ -232,6 +241,22 @@ export function PluginCatalogue({ if (window.confirm(`确定${label}?此操作会立即影响本地配置。`)) void mutate(action, input); }; + /* Drive `testMcpServer` from the row and show what came back. Unlike the + * other mutations this one's RESULT is the feature: `{ success, toolCount?, + * errorCode?, errorMessage? }` is the only place the real connection reason + * exists, so the outcome is read here rather than through `mutate`'s + * boolean. A thrown request is reported as a failed probe with an honest + * "never sent" detail — the row must never stay silent after a click. */ + const runMcpTest = async (item: Row) => { + const name = nameOf(item); + setMcpTest({ name, busy: true }); + try { + const result = await runAction("testMcpServer", { name }); + setMcpTest({ name, busy: false, view: describeWebuiMcpTestResult(result) }); + } catch { + setMcpTest({ name, busy: false, view: { key: "failed", label: "连接失败", detail: "测试请求未能发送" } }); + } + }; const nameOf = (item: Row) => read( item, @@ -1050,8 +1075,29 @@ export function PluginCatalogue({ {mcpStatus.reason} ) : null} + {/* The probe's outcome for this row: the runtime's own + * reason as visible text, replacing the need to read a + * server log. Keyed by name so a test on one row does + * not decorate another. */} + {mcpTest?.name === name && mcpTest.view ? ( + + {mcpTest.view.label} + {mcpTest.view.detail ? ` · ${mcpTest.view.detail}` : ""} + + ) : null}
+