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}