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
39 changes: 38 additions & 1 deletion packages/webui/src/client/ConnectionStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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
Expand All @@ -82,6 +106,19 @@ export function ConnectionStatus({ sessionId, className }: ConnectionStatusProps
>
<span data-testid="webui-connection-status-label">{copy.label}</span>
<span data-testid="webui-connection-status-detail">{reason ?? copy.detail}</span>
{/* 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 ? (
<button
type="button"
data-testid="webui-connection-status-retry"
className="webui-button-secondary"
onClick={onRetry}
>
{retryLabel}
</button>
) : null}
</div>
);
}
60 changes: 60 additions & 0 deletions packages/webui/src/client/components/StartupFallback.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
role="alert"
data-testid="webui-startup-fallback"
className="mx-auto flex min-h-screen max-w-xl flex-col items-center justify-center gap-spacing_8 p-spacing_24 text-center"
>
<h1 data-testid="webui-startup-fallback-title" className="text-2xl font-semibold">页面未能启动</h1>
<p data-testid="webui-startup-fallback-detail" className="text-text_default_secondary">{detail}</p>
{/* 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. */}
<p
data-testid="webui-startup-fallback-reason"
className="max-w-full break-words font-mono text-text_default_tertiary"
>
{reason}
</p>
<button
type="button"
data-testid="webui-startup-fallback-reload"
className="webui-button-secondary"
onClick={onReload}
>
{reloadLabel}
</button>
</section>
);
}
26 changes: 26 additions & 0 deletions packages/webui/src/client/components/WebuiClientFoundationApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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. */}
<ConnectionStatus
sessionId={selectedSessionId}
hideWhenConnected
onRetry={retrySessionStream}
className="w-full max-w-[743px] justify-center rounded-lg border border-border_default bg-bg_default_secondary px-spacing_8 py-spacing_4"
/>

<div
className={
Expand Down
34 changes: 31 additions & 3 deletions packages/webui/src/client/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ import { route } from "./router.js";
import { NotFound } from "./components/NotFound.js";
import { ArchonPage } from "./components/ArchonPage.js";
import { WebuiErrorBoundary } from "./components/WebuiErrorBoundary.js";
import { WebuiStartupFallback } from "./components/StartupFallback.js";

declare const document: {
getElementById(elementId: string): HTMLElement | null;
createElement(tagName: string): HTMLElement;
readonly body: HTMLElement;
};
declare const location: { readonly host: string; readonly pathname: string; readonly hash: string; href: string };
declare const location: { readonly host: string; readonly pathname: string; readonly hash: string; href: string; reload(): void };

const rootElement = document.getElementById("webui-root");
if (!rootElement) throw new Error("WebUI mount node #webui-root is missing");
interface WebuiRuntimeConfig {
websocketUrl: string;
token: string;
Expand All @@ -28,7 +30,32 @@ interface WebuiRuntimeConfig {
const config = (
globalThis as unknown as { __WEBUI_CONFIG__?: WebuiRuntimeConfig }
).__WEBUI_CONFIG__;
if (!config) throw new Error("WebUI runtime configuration is missing");

if (!rootElement || !config) {
// Boot failures before this point used to be bare `throw`s, and the error
// boundary cannot catch them: it lives inside the render that never
// started, so a missing mount node or a missing runtime config left a
// white page. Render the startup surface instead — into #webui-root when
// the node exists (missing config), or a fresh node on body when even the
// mount point is gone (torn-down or tampered document).
const host =
rootElement ?? document.body.appendChild(document.createElement("div"));
createRoot(host).render(
<WebuiStartupFallback
reason={
!rootElement
? "WebUI mount node #webui-root is missing"
: "WebUI runtime configuration is missing (__WEBUI_CONFIG__)"
}
detail={
!rootElement
? "页面结构加载不完整,可能是资源加载被中断或页面版本不匹配。"
: "页面没有拿到启动所需的连接配置,通常是服务端启动异常。"
}
onReload={() => location.reload()}
/>,
);
} else {
const runtimeConfig = config;
const transport = createWebuiTransport(runtimeConfig);
const sessionId = new URLSearchParams(location.hash.replace(/^#/u, "")).get("session") ?? undefined;
Expand All @@ -49,3 +76,4 @@ root.render(
// must not offer a "retry" that reloads the same missing path.
currentRoute === "404" ? <NotFound /> : <WebuiErrorBoundary><ArchonPage>{app}</ArchonPage></WebuiErrorBoundary>,
);
}
59 changes: 59 additions & 0 deletions packages/webui/src/client/session-stream-retry.ts
Original file line number Diff line number Diff line change
@@ -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),
);
};
}
68 changes: 68 additions & 0 deletions packages/webui/test/unit/connection-status.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import {
ConnectionStatus,
Expand Down Expand Up @@ -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(
<ConnectionStatus sessionId="cs-hide-connected" hideWhenConnected />,
);
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(<ConnectionStatus sessionId={reconnecting} hideWhenConnected />),
).toContain('data-connection-state="reconnecting"');
const failed = seed("cs-hide-failed", { phase: "refused", refusal: "连接被重置" });
expect(
renderToStaticMarkup(<ConnectionStatus sessionId={failed} hideWhenConnected />),
).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(<ConnectionStatus sessionId={failedUnwired} onRetry={retry} />),
).toContain('data-testid="webui-connection-status-retry"');
expect(
renderToStaticMarkup(<ConnectionStatus sessionId={failedUnwired} />),
).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(<ConnectionStatus sessionId={reconnecting} onRetry={retry} />),
).not.toContain("webui-connection-status-retry");
// The healthy state never carries it either.
expect(
renderToStaticMarkup(<ConnectionStatus sessionId="cs-retry-ok" onRetry={retry} />),
).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("<ConnectionStatus");
expect(mountAt, "the shell no longer mounts ConnectionStatus").toBeGreaterThanOrEqual(0);
const mount = source.slice(mountAt, mountAt + 300);
expect(mount).toContain("hideWhenConnected");
expect(mount).toContain("onRetry={retrySessionStream}");
expect(mount).toContain('sessionId={selectedSessionId}');
});
});
Loading
Loading