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(/