From e7b59210aed30257a5f5e236ce65252b80effa8b Mon Sep 17 00:00:00 2001
From: stevenjj33 <75509501+stevenjj33@users.noreply.github.com>
Date: Mon, 5 Oct 2026 15:43:15 +0800
Subject: [PATCH] fix(webui): make every connection and boot failure visible,
with a way back
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three gaps in error visibility, closed together because they form one
story: something fails, the page says so, and the user has an action.
1. ConnectionStatus existed with zero consumers — no phase ever reached
the page. The shell now mounts it in the main column under the usage
banner slot, with hideWhenConnected so a healthy connection earns no
pixels and 正在重连 / 连接失败 appear on their own. The component
gains the two host calls its docblock reserved: hideWhenConnected
(layout) and onRetry (recovery affordance, failed state only).
2. main.tsx threw before root.render for a missing #webui-root or a
missing __WEBUI_CONFIG__; the error boundary mounts inside that
render, so both were white pages. Both now render
WebuiStartupFallback — a human sentence, the technical cause shown
verbatim, and one action: 刷新重试. Rendered into #webui-root when
the node exists, or a fresh body node when even the mount point is
gone. The happy path stays gated on both preconditions.
3. The stream loop's automatic recovery (drop → reconnecting → one
resumeSession attempt) ended at refused with nothing able to re-enter
it. createSessionStreamRetry is the manual arm the banner's
重试连接 button runs: it clears the standing refusal synchronously
and re-runs the same attach loop used for server-started turns,
anchored on the last applied cursor (or persisted history when no
cursor was recorded). One click is one attempt; a still-dead server
refuses again through the identical path.
The browser fixture's delayEvery now also holds sendMessage /
resumeSession (the auto-answer used to win the race), which is what
makes the transient reconnecting window observable at all.
Tests: connection-status.test.tsx gains the host-call cases plus a
shell-mount source assertion; webui-startup-fallback.test.tsx covers
the surface and the main.tsx wiring; session-stream-retry.test.ts drives
the retry against the real runtime store (cursor anchor, history
anchor, one-attempt refusal). connection-status.spec.mjs proves both
chains in a real browser: drop → 正在重连 on the page → cleared when
the resume lands, and unresumable drop → 连接失败 with the transport's
reason → retry reopens the stream and delivers frames again.
---
.../webui/src/client/ConnectionStatus.tsx | 39 ++++-
.../src/client/components/StartupFallback.tsx | 60 ++++++++
.../components/WebuiClientFoundationApp.tsx | 26 ++++
packages/webui/src/client/main.tsx | 34 ++++-
.../webui/src/client/session-stream-retry.ts | 59 ++++++++
.../test/unit/connection-status.test.tsx | 68 +++++++++
.../test/unit/session-stream-retry.test.ts | 141 ++++++++++++++++++
.../test/unit/webui-startup-fallback.test.tsx | 111 ++++++++++++++
release/public-source.json | 5 +
test/vitest-suites.json | 12 +-
test/webui-browser/connection-status.spec.mjs | 102 +++++++++++++
test/webui-browser/fixture.mjs | 14 ++
12 files changed, 662 insertions(+), 9 deletions(-)
create mode 100644 packages/webui/src/client/components/StartupFallback.tsx
create mode 100644 packages/webui/src/client/session-stream-retry.ts
create mode 100644 packages/webui/test/unit/session-stream-retry.test.ts
create mode 100644 packages/webui/test/unit/webui-startup-fallback.test.tsx
create mode 100644 test/webui-browser/connection-status.spec.mjs
diff --git a/packages/webui/src/client/ConnectionStatus.tsx b/packages/webui/src/client/ConnectionStatus.tsx
index 7b688890..f59b1728 100644
--- a/packages/webui/src/client/ConnectionStatus.tsx
+++ b/packages/webui/src/client/ConnectionStatus.tsx
@@ -51,6 +51,23 @@ export interface ConnectionStatusProps {
/** Omitted on the home screen, which the store keys as its home runtime. */
readonly sessionId?: string;
readonly className?: string;
+ /**
+ * The host's layout call on whether a healthy connection earns a standing
+ * line. The shell passes true: 已连接 is the state worth zero pixels, and
+ * the region earns its place only when something needs the reader's
+ * attention. Omitted, all three states render — the component itself stays
+ * state-complete for any host that wants a permanent indicator.
+ */
+ readonly hideWhenConnected?: boolean;
+ /**
+ * Offers a manual retry from the failed state. The shell wires this to a
+ * fresh stream-loop run anchored on the last cursor this client applied.
+ * Omitted, no button renders — a host with no recovery path should not
+ * offer one.
+ */
+ readonly onRetry?: () => void;
+ /** The retry affordance's label, overridable the way the boundary's is. */
+ readonly retryLabel?: string;
}
/**
@@ -63,9 +80,16 @@ export interface ConnectionStatusProps {
* `data-connection-state`; whether a healthy connection should be visible or
* collapsed is the host's layout call, not this component's.
*/
-export function ConnectionStatus({ sessionId, className }: ConnectionStatusProps): ReactElement {
+export function ConnectionStatus({
+ sessionId,
+ className,
+ hideWhenConnected,
+ onRetry,
+ retryLabel = "重试连接",
+}: ConnectionStatusProps): ReactElement | null {
const { state } = useSessionRuntimeState(sessionId);
const connection = projectWebuiConnectionState(state.stream.phase);
+ if (hideWhenConnected && connection === "connected") return null;
const copy = COPY[connection];
// A failure usually carries the server's own reason (`refusal`), and `status`
// is the looser status string. Both are preferred over the generic detail so
@@ -82,6 +106,19 @@ export function ConnectionStatus({ sessionId, className }: ConnectionStatusProps
>
{copy.label}{reason ?? copy.detail}
+ {/* Only the terminal state gets an affordance: `reconnecting` is the
+ * automatic loop already mid-attempt, and a button there would race the
+ * recovery it duplicates. */}
+ {connection === "failed" && onRetry ? (
+
+ ) : null}
);
}
diff --git a/packages/webui/src/client/components/StartupFallback.tsx b/packages/webui/src/client/components/StartupFallback.tsx
new file mode 100644
index 00000000..ec26cd8e
--- /dev/null
+++ b/packages/webui/src/client/components/StartupFallback.tsx
@@ -0,0 +1,60 @@
+import type { ReactElement } from "react";
+
+/**
+ * The boot-failure surface for `main.tsx`.
+ *
+ * The error boundary cannot catch a boot failure: it lives inside the render
+ * that never started. Before this component existed, a missing `#webui-root`
+ * or a missing `__WEBUI_CONFIG__` threw from module top level and the user got
+ * a white page — the exact failure the boundary was added to prevent, one
+ * render too early for it to see. `main.tsx` renders this surface directly
+ * instead of throwing, so the failure says what it is and offers the one
+ * action that can fix both causes: load the page again.
+ *
+ * Deliberately dependency-free and presentational: `main.tsx` cannot assume
+ * the transport, the config, or even the mount node exists, so this component
+ * touches nothing beyond its props.
+ */
+export function WebuiStartupFallback({
+ reason,
+ detail,
+ reloadLabel = "刷新重试",
+ onReload,
+}: {
+ /** The technical cause, shown verbatim — the boot log is the diagnosis. */
+ readonly reason: string;
+ /** One sentence of plain language saying what the reader should do. */
+ readonly detail: string;
+ /** The single action's label, overridable the way the boundary's is. */
+ readonly reloadLabel?: string;
+ readonly onReload: () => void;
+}): ReactElement {
+ return (
+
+
页面未能启动
+
{detail}
+ {/* Same rule as the boundary's fallback: the message is shown, not just
+ * logged, because this surface is the only place the cause survives —
+ * there is no console guarantee on a machine where the page never
+ * booted. */}
+
+ {reason}
+
+
+
+ );
+}
diff --git a/packages/webui/src/client/components/WebuiClientFoundationApp.tsx b/packages/webui/src/client/components/WebuiClientFoundationApp.tsx
index 445881ef..e9dea59d 100644
--- a/packages/webui/src/client/components/WebuiClientFoundationApp.tsx
+++ b/packages/webui/src/client/components/WebuiClientFoundationApp.tsx
@@ -130,6 +130,8 @@ import {
migrateSessionRuntimeState,
useSessionRuntimeState,
} from "../session-runtime-store.js";
+import { createSessionStreamRetry } from "../session-stream-retry.js";
+import { ConnectionStatus } from "../ConnectionStatus.js";
import { deriveConversationUsageNotice } from "../projection/message-projection.js";
import { deriveRecentWorkspaceDirs } from "../projection/composer-state.js";
@@ -668,6 +670,18 @@ export function WebuiClientFoundationApp(
})
.catch((reason: unknown) => setPageError(reason instanceof Error ? reason.message : String(reason)));
};
+ // The manual arm of the stream loop's recovery — see
+ // `session-stream-retry.ts` for why it clears the refusal and re-runs the
+ // attach loop. Undefined on the home screen (no session to resume) and on
+ // hosts without a `resumeSession` transport: no recovery path, no button.
+ const retrySessionStream =
+ selectedSessionId && transport?.resumeSession
+ ? createSessionStreamRetry({
+ sessionId: selectedSessionId,
+ resumeSession: transport.resumeSession,
+ loadMessages: transport.loadMessages,
+ })
+ : undefined;
const homeMode = !selectedSessionId;
const usageNotice = useMemo(
() => deriveConversationUsageNotice(usageQuota),
@@ -1323,6 +1337,18 @@ export function WebuiClientFoundationApp(
onDismiss={() => setDismissedUsageNoticeKey(usageNoticeKey)}
/>
) : null}
+ {/* The connection region earns its pixels only when
+ * something needs the reader: `hideWhenConnected` keeps
+ * 已连接 off the page, so the banner appears for
+ * 正在重连 and 连接失败 only. Same slot as the usage
+ * banner so the two never stack surprises in different
+ * places. */}
+
location.reload()}
+ />,
+ );
+} else {
const runtimeConfig = config;
const transport = createWebuiTransport(runtimeConfig);
const sessionId = new URLSearchParams(location.hash.replace(/^#/u, "")).get("session") ?? undefined;
@@ -49,3 +76,4 @@ root.render(
// must not offer a "retry" that reloads the same missing path.
currentRoute === "404" ? : {app},
);
+}
diff --git a/packages/webui/src/client/session-stream-retry.ts b/packages/webui/src/client/session-stream-retry.ts
new file mode 100644
index 00000000..9de1f878
--- /dev/null
+++ b/packages/webui/src/client/session-stream-retry.ts
@@ -0,0 +1,59 @@
+// The manual arm of the stream loop's recovery.
+//
+// The automatic arm lives in `stream-loop.ts`: a socket drop mid-turn sets
+// `reconnecting` and runs ONE `resumeSession` attempt; when that attempt
+// itself dies the loop commits `refused` and stops. Nothing else in the
+// client re-enters the loop after that — which was the gap: a failed
+// connection had no user-facing way back.
+//
+// This helper is what the connection banner's 重试连接 button runs. It
+// clears the standing refusal and re-runs the same attach loop the shell
+// uses for a turn the server started (`attachToTurn` in the composer),
+// anchored on the last cursor this client applied. One call is one attempt:
+// a still-dead server refuses again through the identical path and the
+// banner returns with the new reason, so the loop cannot spin on its own.
+
+import type {
+ WebuiClientMessageLoader,
+ WebuiClientSessionResumer,
+} from "./contracts.js";
+import { buildWebuiStreamLoopSink, runWebuiStreamLoop } from "./stream-loop.js";
+import {
+ createSessionRuntimeWriter,
+ readSessionRuntimeState,
+} from "./session-runtime-store.js";
+
+export function createSessionStreamRetry({
+ sessionId,
+ resumeSession,
+ loadMessages,
+}: {
+ readonly sessionId: string;
+ readonly resumeSession: WebuiClientSessionResumer;
+ readonly loadMessages?: WebuiClientMessageLoader;
+}): () => void {
+ return () => {
+ const writer = createSessionRuntimeWriter({
+ kind: "session",
+ sessionId,
+ });
+ const current = readSessionRuntimeState(sessionId).stream;
+ // Clear the refusal first, synchronously: the banner reads the phase, and
+ // leaving `refused` standing while the new attempt opens would show the
+ // failure for a loop that is already streaming again.
+ writer.setStream((stream) => ({
+ ...stream,
+ phase: "idle",
+ refusal: undefined,
+ transcriptIncomplete: false,
+ }));
+ void runWebuiStreamLoop(
+ { resumeSession, loadMessages },
+ {
+ sessionId,
+ ...(current.cursor ? { afterCursor: current.cursor } : {}),
+ },
+ buildWebuiStreamLoopSink(writer.setStream),
+ );
+ };
+}
diff --git a/packages/webui/test/unit/connection-status.test.tsx b/packages/webui/test/unit/connection-status.test.tsx
index d0236055..e0bebab5 100644
--- a/packages/webui/test/unit/connection-status.test.tsx
+++ b/packages/webui/test/unit/connection-status.test.tsx
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import {
ConnectionStatus,
@@ -107,3 +108,70 @@ describe("ConnectionStatus", () => {
expect(markup).toContain("webui-connection-status");
});
});
+
+describe("ConnectionStatus host layout calls", () => {
+ it("collapses the connected state when the host asks it to", () => {
+ // The shell's usage: 已连接 earns zero pixels. The null return (not an
+ // empty div) is the contract — a zero-height leftover would still claim
+ // the banner slot's gap and nudge the transcript on every state change.
+ const markup = renderToStaticMarkup(
+ ,
+ );
+ expect(markup).toBe("");
+ });
+
+ it("still renders the attention states under hideWhenConnected", () => {
+ // The prop is a layout call on the healthy state only: both states that
+ // need the reader collapse for nobody.
+ const reconnecting = seed("cs-hide-reconnect", { phase: "reconnecting" });
+ expect(
+ renderToStaticMarkup(),
+ ).toContain('data-connection-state="reconnecting"');
+ const failed = seed("cs-hide-failed", { phase: "refused", refusal: "连接被重置" });
+ expect(
+ renderToStaticMarkup(),
+ ).toContain('data-connection-state="failed"');
+ });
+
+ it("offers the retry button only in the failed state, and only when wired", () => {
+ const retry = () => {};
+ // No callback, no button — a host without a recovery path must not offer
+ // one (the same rule the rail's pin/star buttons follow).
+ const failedUnwired = seed("cs-retry-unwired", { phase: "refused" });
+ expect(
+ renderToStaticMarkup(),
+ ).toContain('data-testid="webui-connection-status-retry"');
+ expect(
+ renderToStaticMarkup(),
+ ).not.toContain("webui-connection-status-retry");
+ // Reconnecting is the automatic loop mid-attempt; a button there would
+ // race the recovery it duplicates.
+ const reconnecting = seed("cs-retry-reconnect", { phase: "reconnecting" });
+ expect(
+ renderToStaticMarkup(),
+ ).not.toContain("webui-connection-status-retry");
+ // The healthy state never carries it either.
+ expect(
+ renderToStaticMarkup(),
+ ).not.toContain("webui-connection-status-retry");
+ });
+
+ it("is mounted by the shell with the collapsed-when-healthy layout call", () => {
+ // Source-level, the same convention the child-row meta and rail wiring
+ // tests use: `renderToStaticMarkup` cannot fire the store subscription a
+ // real mount needs, and the browser spec proves the placed region for
+ // real. What this pins is that the shell actually renders the component —
+ // before Q-1 the component existed with zero consumers, which is exactly
+ // the gap that made every connection state invisible.
+ const source = readFileSync(
+ new URL("../../src/client/components/WebuiClientFoundationApp.tsx", import.meta.url),
+ "utf8",
+ );
+ const mountAt = source.indexOf("[0];
+
+function seed(
+ sessionId: string,
+ patch: Partial<
+ Pick
+ >,
+): string {
+ updateSessionRuntimeState(sessionId, (current) => ({
+ ...current,
+ stream: { ...current.stream, ...patch },
+ sending: false,
+ }));
+ return sessionId;
+}
+
+/** A `resumeSession` that never settles, so the reopened loop stays parked in `streaming`. */
+function pendingResume() {
+ return vi.fn(
+ (_request: ResumeRequest, _onFrame: Parameters[1]) =>
+ new Promise(() => {}),
+ );
+}
+
+describe("createSessionStreamRetry", () => {
+ it("clears the standing refusal and re-attaches from the recorded cursor", () => {
+ const sessionId = seed("srt-cursor", {
+ phase: "refused",
+ refusal: "WebUI connection closed before [DONE]",
+ cursor: "c9",
+ });
+ const resumeSession = pendingResume();
+ const retry = createSessionStreamRetry({ sessionId, resumeSession });
+
+ retry();
+
+ const stream = readSessionRuntimeState(sessionId).stream;
+ // The attach loop's synchronous prefix: the lease is claimed and the
+ // phase is `streaming` by the time the click handler returns, and the
+ // old refusal is gone rather than lingering under the new attempt.
+ expect(stream.phase).toBe("streaming");
+ expect(stream.refusal).toBeUndefined();
+ expect(stream.transcriptIncomplete).toBe(false);
+ expect(stream.subscription?.owner).toBe("recovered");
+ // Anchored on the cursor the failed loop had applied — resuming from
+ // anywhere else would either replay the transcript or skip frames.
+ expect(resumeSession).toHaveBeenCalledWith(
+ { id: "srt-cursor", afterCursor: "c9" },
+ expect.any(Function),
+ );
+ });
+
+ it("anchors on persisted history when no cursor was recorded", () => {
+ const sessionId = seed("srt-nocursor", {
+ phase: "refused",
+ refusal: "connection failed",
+ });
+ const resumeSession = pendingResume();
+ const retry = createSessionStreamRetry({
+ sessionId,
+ resumeSession,
+ loadMessages: async () => ({
+ messages: [
+ { msgId: "m2", role: "assistant", msgContent: "partial answer", timestamp: 2 },
+ { msgId: "msg-user-3", role: "user", msgContent: "the question", timestamp: 3 },
+ ],
+ hasMore: false,
+ }),
+ });
+
+ retry();
+ // The loadMessages history seeding is awaited inside the loop; flush the
+ // microtask queue before reading what the resume was anchored on.
+ void vi.waitFor(() => {
+ expect(resumeSession).toHaveBeenCalled();
+ });
+
+ return vi.waitFor(() => {
+ expect(resumeSession).toHaveBeenCalledWith(
+ // No `afterCursor` (there was none), and the anchor is the newest
+ // persisted message so the server replays from the recorded edge.
+ expect.objectContaining({ id: "srt-nocursor", afterMsgId: "msg-user-3" }),
+ expect.any(Function),
+ );
+ const request = resumeSession.mock.calls[0]?.[0];
+ expect(request?.afterCursor).toBeUndefined();
+ });
+ });
+
+ it("is one attempt: a still-dead server refuses again through the same path", () => {
+ const sessionId = seed("srt-dead", {
+ phase: "refused",
+ refusal: "old reason",
+ cursor: "c1",
+ });
+ const resumeSession = vi.fn(async () => {
+ throw new Error("connection refused again");
+ });
+ const retry = createSessionStreamRetry({ sessionId, resumeSession });
+
+ retry();
+
+ return vi.waitFor(() => {
+ const stream = readSessionRuntimeState(sessionId).stream;
+ // Back to the terminal state with the NEW reason — the banner returns,
+ // it does not spin: one click ran exactly one resume.
+ expect(stream.phase).toBe("refused");
+ expect(stream.refusal).toBe("connection refused again");
+ // And the failed attempt leaves no lease behind for the next retry to
+ // collide with.
+ expect(stream.subscription).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/webui/test/unit/webui-startup-fallback.test.tsx b/packages/webui/test/unit/webui-startup-fallback.test.tsx
new file mode 100644
index 00000000..6ff7bd1b
--- /dev/null
+++ b/packages/webui/test/unit/webui-startup-fallback.test.tsx
@@ -0,0 +1,111 @@
+// Unit tests for the pre-React-root boot failure surface.
+//
+// `main.tsx` owns two boot checks that run BEFORE `createRoot().render()`:
+// the `#webui-root` mount node and the `__WEBUI_CONFIG__` runtime config.
+// Both used to be bare `throw`s, and no error boundary can catch them — the
+// boundary is mounted BY the render those throws prevented, so either failure
+// was a white page. `WebuiStartupFallback` is the surface `main.tsx` renders
+// instead.
+//
+// This suite pins the two halves the node environment can reach:
+// * the surface itself — human sentence, technical reason, one action —
+// through `renderToStaticMarkup`;
+// * the `main.tsx` wiring — that neither cause throws anymore, that both
+// render the fallback, and that the happy path still only runs when both
+// preconditions hold.
+// The browser-verified half (a served page with a missing config actually
+// painting the surface) is recorded in the PR that added this, with
+// screenshots; the fixture harness always injects a config, so covering it
+// here would mean standing up a deliberately-broken server in the shared
+// suite.
+
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { WebuiStartupFallback } from "../../src/client/components/StartupFallback.js";
+
+const MAIN_SOURCE = readFileSync(
+ new URL("../../src/client/main.tsx", import.meta.url),
+ "utf8",
+);
+
+describe("WebuiStartupFallback", () => {
+ const render = (over: Record = {}): string =>
+ renderToStaticMarkup(
+ createElement(WebuiStartupFallback, {
+ reason: "WebUI runtime configuration is missing (__WEBUI_CONFIG__)",
+ detail: "页面没有拿到启动所需的连接配置,通常是服务端启动异常。",
+ onReload: () => {},
+ ...over,
+ }),
+ );
+
+ it("says something a human can act on, in a sentence", () => {
+ const markup = render();
+ expect(markup).toContain("页面未能启动");
+ expect(markup).toContain("页面没有拿到启动所需的连接配置");
+ // An alert, so assistive tech announces the boot failure rather than
+ // leaving a silent blank document.
+ expect(markup).toContain('role="alert"');
+ });
+
+ it("shows the technical cause verbatim, not just in the console", () => {
+ // Same rule as the error boundary's fallback: on a machine where the page
+ // never booted there is no console guarantee, and this line is the only
+ // place the cause survives.
+ const markup = render({ reason: "WebUI mount node #webui-root is missing" });
+ expect(markup).toContain("WebUI mount node #webui-root is missing");
+ expect(markup).toContain('data-testid="webui-startup-fallback-reason"');
+ });
+
+ it("offers exactly one action: load the page again", () => {
+ const markup = render();
+ expect(markup).toContain("刷新重试");
+ expect(markup).toContain('data-testid="webui-startup-fallback-reload"');
+ // One button, not a menu of speculative fixes — a reload is the only
+ // action that can cure both causes (a torn-down document, a server that
+ // failed to inject the config).
+ expect(markup.match(/