Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions packages/webui/src/client/ConnectionStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,25 @@ import { useWebuiEventChannelDegraded } from "./connection-health.js";
import type { WebuiStreamState } from "./stream.js";

/**
* The three states a user can act on. Deliberately not a transport-level
* liveness probe: nothing in the client exposes one, so the honest signal is
* the turn stream's own phase, which the reconnect paths in `stream.ts` and
* `SessionComposer.tsx` already drive.
* The three states a user can act on.
*
* Two signals feed this, and they are deliberately kept apart:
*
* - `projectWebuiConnectionState` still projects **only** the turn stream's own
* phase, which the reconnect paths in `stream.ts` and `SessionComposer.tsx`
* drive. It says nothing about a link that is not carrying a turn.
* - `useWebuiEventChannelDegraded` is the **transport-level** signal, and it
* exists because the projection above cannot answer that question. The
* long-lived `watchEvents` socket marks itself healthy on an ack and degraded
* on `close`, and the component merges the two: failed beats reconnecting
* beats connected.
*
* What the channel signal does **not** cover, so nothing downstream over-trusts
* it: the client sends no heartbeat on that socket, so detection is
* close-edge only and a half-open link is invisible; and a watcher that was
* never accepted stays "unknown" rather than degraded, so a host that is
* already unreachable at load time reads as connected until something else
* moves.
*/
export type WebuiConnectionState = "connected" | "reconnecting" | "failed";

Expand Down
9 changes: 7 additions & 2 deletions packages/webui/src/client/components/InteractionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,14 @@ export function WebuiInteractionPanel({
// recommended option. An ordinary questionnaire that happens to carry
// `expiresAt` gets no runtime reply, so showing it a countdown would be a
// promise nothing keeps — the TUI gates the same way
// (`goalAutoReplyDeadline` in `questionnaire-picker.ts`).
// (`goalAutoReplyDeadline` in `questionnaire-picker.ts`). The `typeof` and
// `isFinite` pair is that function's guard: without it a null or NaN
// `expiresAt` lands on `Math.max(0, ...)` and the countdown renders
// "time is up" for a value that never was a deadline.
const goalAutoReplyWindow =
questionnaire?.purpose === 1 && questionnaire.expiresAt !== undefined;
questionnaire?.purpose === 1 &&
typeof questionnaire.expiresAt === "number" &&
Number.isFinite(questionnaire.expiresAt);
const [remainingSeconds, setRemainingSeconds] = useState<number | undefined>(() =>
goalAutoReplyWindow
? Math.max(0, Math.ceil((questionnaire!.expiresAt! - Date.now()) / 1000))
Expand Down
51 changes: 40 additions & 11 deletions packages/webui/test/unit/plugin-mcp-status.test.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
// Unit tests for the MCP server status the plugin panel puts on each row.
//
// Roadmap D-3 (as transcribed): 「MCP 插件连不上时,界面上要看得见」. 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 gap was that the
// panel's MCP rows rendered none of it, so a server that could not connect
// was visually identical to a healthy one.
// Roadmap D-3 (as transcribed): 「MCP 插件连不上时,界面上要看得见」. The row
// markup, the six-state mapping and the browser spec all landed with #27.
//
// The mapping is extracted as a pure function so this suite can pin each
// state's label and tone without a DOM; the row wiring is pinned as a
// source-level assertion (the suite's `renderToStaticMarkup` convention —
// rows load through an async effect no static render runs), and the browser
// spec proves the placed, loaded, visible thing end to end.
// Read the premise carefully, because it is not what it looks like. The
// runtime's `LocalMcpPublicServerStatus` does classify every server
// (available / configured / disabled / error / unavailable) — but that is the
// *MCP tool surface* (`LocalMcpPublicFacade`). The plugin page calls
// `listMcpServers` → `listConfiguredServers` → `configuredSummary`, which
// returns only `name / enabled / transport / description / endpoint /
// configJson`, with `configJson` hardcoded to `"{}"`. No `status`, no `error`,
// not even `available`. So on the real wire **every row reads 未知状态** and the
// badge will stay inert until that path carries the field. The mapping below is
// correct and the UI is in place; what is missing is upstream. The contract test
// at the end pins today's honest answer so the day the field arrives is a
// visible change rather than a silent one.
//
// The row wiring is pinned as a source-level assertion: the rows load through
// an async effect, so no static render exercises them. That assertion only
// proves the markup is still there — it cannot prove the badge works, which is
// why the browser spec exists, and why the contract test below matters more
// than either.

import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
Expand Down Expand Up @@ -89,6 +98,26 @@ describe("describeWebuiMcpServerStatus", () => {
});

describe("the MCP row wiring", () => {
it("claims nothing when handed the shape the server actually returns", () => {
// `listConfiguredServers` → `configuredSummary` produces exactly this and
// nothing more. A row fed it must say 未知状态: any other label would be
// claiming a state the server never sent. When `configuredSummary` starts
// carrying the real status this test goes red, which is the point — the
// badge is inert until then, and this is the line that says so.
const rowAsTheServerSendsIt = {
name: "real-shaped-stdio",
enabled: true,
transport: "stdio",
description: "",
endpoint: "",
configJson: "{}",
};
expect(describeWebuiMcpServerStatus(rowAsTheServerSendsIt)).toMatchObject({
key: "unknown",
label: "未知状态",
});
});

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);
Expand Down
Loading