From c91edb5cd51caf2ede88fe5c2f77b817b5931ff5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 07:31:30 -0700 Subject: [PATCH] fix(ui): restore visible gateway status in the sidebar footer and announce restarts (#130025) * fix(ui): restore visible gateway offline status and announce restarts in the sidebar footer The sidebar footer has been the canonical offline indicator since #112600 (which deleted the top connection banner in its favor), but #125070's footer compaction made it sr-only + aria-hidden - invisible to everyone. Restore the visible red offline pill (click-to-retry, queued count, redacted error tooltip) and add a restart-aware amber state: the gateway already broadcasts shutdown { restartExpectedMs } before restarting and refuses drain-phase work with reason "gateway-restarting", so the Control UI now shows "Restarting..." immediately - before the socket drops - and degrades to the offline pill when a restart overruns max(3x restartExpectedMs, 15s). One shared renderSidebarConnectionStatus serves the main and settings sidebars; the dead sr-only spans and clipped live region are deleted. The drain reason strings become shared gateway-protocol constants. The e2e mock now passes Vite's vite-hmr client socket through to the native WebSocket so going offline in source-served suites no longer triggers a dev-client page reload mid-test. * refactor(ui): delete dead sr-only footer subtitle spans and fit the startup budget The connected-state identity subtitle spans (gateway health dot, name, build subtitle) were the same sr-only + aria-hidden dead-markup class the previous commit fixed for offline: invisible to sighted users and screen readers, with the identity button's aria-label already carrying the same information. Delete them plus their orphaned CSS; tests now assert the aria-label contract. The e2e mock's Vite dev-client socket passthrough keys on the vite-hmr subprotocol. The gateway store drops validation the shared timer clamp already owns and re-arms the restart deadline on every fresh drain rejection (server still says restarting, so amber stays honest). The startup-budget baseline refresh (340901 -> 341446, jitter-safe max of three builds) records the ~530 B this feature legitimately adds over the 512 B per-change tolerance; reason stored in the baseline file. * fix(ui): keep ordinary gateway stops on the offline pill path ClawSweeper finding: every shutdown broadcast armed the amber Restarting state, so a deliberate stop hid the Offline/Retry action for the 15s deadline floor. The amber state now arms only when the broadcast carries a numeric restartExpectedMs; ordinary stops flow through the normal offline pill. The server omits restartExpectedMs on non-restart shutdowns instead of broadcasting null, aligning the wire with ShutdownEventSchema's optional integer. Also rewires build-info-unicode.e2e to the identity aria-label (the visible subtitle span it asserted was deleted as dead markup) and splits the restart-state store tests into gateway-store.restart.test.ts with a shared test-support harness to respect the max-lines cap. * chore(ui): tighten startup gzip baseline after composer-queue rebase The combined tree measures 340754-340770 B across builds, below the 341489 B baseline main recorded for the composer stack; pin 340810 B (max observed + jitter headroom) so the ratchet reflects reality. * fix(ui): unexport the test-support fake gateway client (knip dead export) --- .../control-ui-startup-budget-baseline.json | 4 +- packages/gateway-protocol/package.json | 7 +- packages/gateway-protocol/src/index.ts | 1 + .../src/restart-unavailable.ts | 19 +++ src/gateway/server-close.test.ts | 7 +- src/gateway/server-close.ts | 4 +- src/gateway/server-methods.ts | 8 +- .../server/ws-connection/message-handler.ts | 8 +- ui/src/app/app-shell-view.ts | 4 + ui/src/app/gateway-store.restart.test.ts | 108 ++++++++++++++++++ ui/src/app/gateway-store.test-support.ts | 105 +++++++++++++++++ ui/src/app/gateway-store.test.ts | 101 ++-------------- ui/src/app/gateway-store.ts | 55 ++++++++- ui/src/app/gateway.ts | 1 + ui/src/components/app-sidebar-base.ts | 3 + ui/src/components/app-sidebar-render.ts | 41 ++----- ui/src/components/session-row-badges.ts | 20 +++- ui/src/components/settings-sidebar.test.ts | 14 ++- ui/src/components/settings-sidebar.ts | 9 +- ui/src/e2e/build-info-unicode.e2e.test.ts | 8 +- ui/src/e2e/sidebar-account-footer.e2e.test.ts | 43 +++++++ .../e2e/sidebar-footer-proof.test-support.ts | 4 +- ui/src/e2e/usage-reconnect.e2e.test.ts | 2 +- ui/src/i18n/locales/en.ts | 1 + ui/src/styles/layout.css | 78 ++++--------- .../test-helpers/app-sidebar-cases/basics.ts | 16 +-- .../app-sidebar-cases/footer-status.ts | 66 ++++++----- ui/src/test-helpers/app-sidebar.ts | 3 + .../test-helpers/control-ui-e2e-readiness.ts | 2 +- ui/src/test-helpers/control-ui-e2e.ts | 11 +- 30 files changed, 502 insertions(+), 251 deletions(-) create mode 100644 packages/gateway-protocol/src/restart-unavailable.ts create mode 100644 ui/src/app/gateway-store.restart.test.ts create mode 100644 ui/src/app/gateway-store.test-support.ts diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json index 525f24284c16..e378301b60b1 100644 --- a/config/control-ui-startup-budget-baseline.json +++ b/config/control-ui-startup-budget-baseline.json @@ -1,5 +1,5 @@ { - "startupJsGzipBytes": 341489, - "reason": "composer queue stack rebased onto current main", + "startupJsGzipBytes": 340810, + "reason": "sidebar gateway status pill rebased onto the composer-queue main: combined tree measures 340754-340770 B; pinned with jitter headroom", "updatedAt": "2026-08-26" } diff --git a/packages/gateway-protocol/package.json b/packages/gateway-protocol/package.json index a3e84ce4b56d..b3bebd575198 100644 --- a/packages/gateway-protocol/package.json +++ b/packages/gateway-protocol/package.json @@ -57,6 +57,11 @@ "import": "./dist/gateway-error-details.mjs", "default": "./dist/gateway-error-details.mjs" }, + "./restart-unavailable": { + "types": "./dist/restart-unavailable.d.mts", + "import": "./dist/restart-unavailable.mjs", + "default": "./dist/restart-unavailable.mjs" + }, "./schema": { "types": "./dist/schema.d.mts", "import": "./dist/schema.mjs", @@ -74,7 +79,7 @@ } }, "scripts": { - "build": "tsdown src/index.ts src/client-info.ts src/connect-error-details.ts src/frame-guards.ts src/gateway-error-details.ts src/schema.ts src/startup-unavailable.ts src/version.ts --no-config --platform node --format esm --dts --out-dir dist --clean", + "build": "tsdown src/index.ts src/client-info.ts src/connect-error-details.ts src/frame-guards.ts src/gateway-error-details.ts src/restart-unavailable.ts src/schema.ts src/startup-unavailable.ts src/version.ts --no-config --platform node --format esm --dts --out-dir dist --clean", "prepack": "pnpm run build && node --import tsx ../../scripts/protocol-gen.ts --out ./protocol.schema.json" }, "dependencies": { diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index a551d7a23f10..049ceaa2c4be 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -35,6 +35,7 @@ export { } from "./schema/sessions-create.js"; export * from "./schema/projects.js"; export * from "./migration-api.js"; +export * from "./restart-unavailable.js"; export type * from "./public-session-catalog.js"; export * from "./validator-registry.js"; export type { diff --git a/packages/gateway-protocol/src/restart-unavailable.ts b/packages/gateway-protocol/src/restart-unavailable.ts new file mode 100644 index 000000000000..c41aa6db6095 --- /dev/null +++ b/packages/gateway-protocol/src/restart-unavailable.ts @@ -0,0 +1,19 @@ +/** Structured error reason used while the gateway drains for a restart. */ +export const GATEWAY_RESTART_UNAVAILABLE_REASON = "gateway-restarting"; +/** Structured error reason used while the gateway drains for a suspension. */ +export const GATEWAY_SUSPEND_UNAVAILABLE_REASON = "gateway-suspending"; + +/** Detects the structured retryable error emitted while a restart drain refuses work. */ +export function isGatewayRestartUnavailableError(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + // SAFETY: optional read off an untrusted shape; the reason equality gates the result. + const details = (error as { details?: unknown }).details; + return ( + typeof details === "object" && + details !== null && + // SAFETY: same untrusted-shape read, guarded by the equality check. + (details as { reason?: unknown }).reason === GATEWAY_RESTART_UNAVAILABLE_REASON + ); +} diff --git a/src/gateway/server-close.test.ts b/src/gateway/server-close.test.ts index 5408bc0dceff..383cc059faa5 100644 --- a/src/gateway/server-close.test.ts +++ b/src/gateway/server-close.test.ts @@ -2040,10 +2040,9 @@ describe("createGatewayCloseHandler", () => { await close({ reason: " upgrade ", restartExpectedMs: Number.NaN }); - expect(deps.broadcast).toHaveBeenCalledWith("shutdown", { - reason: "upgrade", - restartExpectedMs: null, - }); + // Non-restart shutdowns omit restartExpectedMs entirely: the schema declares + // an optional integer and clients key the restart presentation on presence. + expect(deps.broadcast).toHaveBeenCalledWith("shutdown", { reason: "upgrade" }); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index 4e232820f328..0918ee56f356 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -969,9 +969,11 @@ export function createGatewayCloseHandler( clearInterval(timer); } params.nodePresenceTimers.clear(); + // Omit rather than null: ShutdownEventSchema declares an optional integer, + // and clients key the restart presentation on the field's presence. params.broadcast("shutdown", { reason, - restartExpectedMs, + ...(restartExpectedMs === null ? {} : { restartExpectedMs }), }); if (params.maintenance) { clearInterval(params.maintenance.tickInterval); diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index d590f88b4bec..0b73977ebc10 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -5,6 +5,10 @@ import { missingScopeErrorShape, type ErrorShape, } from "../../packages/gateway-protocol/src/index.js"; +import { + GATEWAY_RESTART_UNAVAILABLE_REASON, + GATEWAY_SUSPEND_UNAVAILABLE_REASON, +} from "../../packages/gateway-protocol/src/restart-unavailable.js"; import { gatewayStartupUnavailableDetails, GATEWAY_STARTUP_RETRY_AFTER_MS, @@ -596,7 +600,9 @@ export async function runWithGatewayRequestEnvelope( retryAfterMs: 1_000, details: { method, - reason: restartDraining ? "gateway-restarting" : "gateway-suspending", + reason: restartDraining + ? GATEWAY_RESTART_UNAVAILABLE_REASON + : GATEWAY_SUSPEND_UNAVAILABLE_REASON, phase: getGatewaySuspendAdmissionPhase(), }, }, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index b7d0af9f6bab..b20d43d0be77 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -13,6 +13,10 @@ import { validateConnectParams, validateRequestFrame, } from "../../../../packages/gateway-protocol/src/index.js"; +import { + GATEWAY_RESTART_UNAVAILABLE_REASON, + GATEWAY_SUSPEND_UNAVAILABLE_REASON, +} from "../../../../packages/gateway-protocol/src/restart-unavailable.js"; import { getRuntimeConfig } from "../../../config/io.js"; import { releaseNodePairingCleanupClaim, @@ -430,7 +434,9 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar } const restartDraining = isGatewayRestartDraining(); - const reason = restartDraining ? "gateway-restarting" : "gateway-suspending"; + const reason = restartDraining + ? GATEWAY_RESTART_UNAVAILABLE_REASON + : GATEWAY_SUSPEND_UNAVAILABLE_REASON; const operation = restartDraining ? "restart" : "suspension"; const phase = getGatewaySuspendAdmissionPhase(); setLastFrameMeta({ type: "req", method: "connect", id: parsed.id }); diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 43f1fa25c1a7..6ccc4a4ecd98 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -342,6 +342,9 @@ export function renderApplicationShell(host: ShellViewHost) { sessionKey: host.activeSessionKey, connected: gatewayConnected, offline: gatewaySnapshot.offlineStable, + restartPending: gatewaySnapshot.restartPending === true, + queuedOutboxCount: storedOutboxes?.total ?? 0, + lastError: gatewaySnapshot.lastError, outboxAttentionCountForSession, hasSessionDraft, terminalAvailable, @@ -380,6 +383,7 @@ export function renderApplicationShell(host: ShellViewHost) { activeSearch: host.routeState.location?.search ?? "", activeHash: host.routeState.location?.hash ?? "", offline: gatewaySnapshot.offlineStable, + restartPending: gatewaySnapshot.restartPending, queuedOutboxCount: storedOutboxes?.total ?? 0, lastError: gatewaySnapshot.lastError, gatewayVersion: config.serverVersion ?? gatewaySnapshot.hello?.server?.version ?? "", diff --git a/ui/src/app/gateway-store.restart.test.ts b/ui/src/app/gateway-store.restart.test.ts new file mode 100644 index 000000000000..05bdf1308089 --- /dev/null +++ b/ui/src/app/gateway-store.restart.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment node +// Restart-aware connection state: shutdown broadcast, drain rejection, deadline. +// Split from gateway-store.test.ts to respect the max-lines cap. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setAvatarGatewayOrigin } from "../lib/identity-avatar.ts"; +import { + createGatewayEvent, + createGatewayStoreTestStore as createStore, + GATEWAY_STORE_TEST_HELLO as HELLO, + stubGatewayStoreTestGlobals, +} from "./gateway-store.test-support.ts"; + +describe("createApplicationGateway restart state", () => { + beforeEach(() => { + stubGatewayStoreTestGlobals(); + }); + + afterEach(() => { + setAvatarGatewayOrigin(null); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("publishes shutdown immediately while connected and clears it after the next hello", () => { + const { gateway, current } = createStore(); + gateway.start(); + current().opts.onHello?.(HELLO); + current().opts.onEvent?.( + createGatewayEvent("shutdown", { reason: "gateway restart", restartExpectedMs: 8_000 }), + ); + expect(gateway.snapshot.phase).toBe("connected"); + expect(gateway.snapshot.restartPending).toBe(true); + current().opts.onClose?.({ code: 1012, reason: "gateway restarting", willRetry: true }); + current().opts.onHello?.(HELLO); + expect(gateway.snapshot.restartPending).toBe(false); + }); + + it.each([ + { restartExpectedMs: 1_000, deadlineMs: 15_000 }, + { restartExpectedMs: 8_000, deadlineMs: 24_000 }, + ])( + "degrades an overdue restart to stable offline after $deadlineMs ms", + async ({ restartExpectedMs, deadlineMs }) => { + vi.useFakeTimers(); + const { gateway, current } = createStore(); + gateway.start(); + current().opts.onHello?.(HELLO); + current().opts.onEvent?.( + createGatewayEvent("shutdown", { reason: "gateway restart", restartExpectedMs }), + ); + current().opts.onClose?.({ code: 1012, reason: "gateway restarting", willRetry: true }); + + await vi.advanceTimersByTimeAsync(deadlineMs - 1); + expect(gateway.snapshot.restartPending).toBe(true); + expect(gateway.snapshot.offlineStable).toBe(true); + + await vi.advanceTimersByTimeAsync(1); + expect(gateway.snapshot.restartPending).toBe(false); + expect(gateway.snapshot.offlineStable).toBe(true); + }, + ); + + it("keeps an ordinary stop on the offline pill path (no restart state)", () => { + const { gateway, current } = createStore(); + gateway.start(); + current().opts.onHello?.(HELLO); + current().opts.onEvent?.(createGatewayEvent("shutdown", { reason: "gateway stopping" })); + expect(gateway.snapshot.restartPending).toBeFalsy(); + current().opts.onClose?.({ code: 1001, reason: "gateway stopping", willRetry: true }); + expect(gateway.snapshot.restartPending).toBeFalsy(); + expect(gateway.snapshot.phase).toBe("reconnecting"); + }); + + it("recognizes the structured restart rejection before the first successful hello", () => { + const { gateway, current } = createStore(); + gateway.start(); + + current().opts.onClose?.({ + code: 1013, + reason: "gateway restart in progress", + willRetry: true, + error: { + code: "UNAVAILABLE", + message: "connect unavailable during gateway restart", + details: { reason: "gateway-restarting" }, + }, + }); + expect(gateway.snapshot.phase).toBe("connecting"); + expect(gateway.snapshot.restartPending).toBe(true); + }); + + it("clears the pending restart deadline when stopped", async () => { + vi.useFakeTimers(); + const { gateway, current } = createStore(); + gateway.start(); + current().opts.onHello?.(HELLO); + current().opts.onEvent?.( + createGatewayEvent("shutdown", { reason: "gateway restart", restartExpectedMs: 1_000 }), + ); + + gateway.stop(); + await vi.advanceTimersByTimeAsync(15_000); + + expect(gateway.snapshot.phase).toBe("stopped"); + expect(gateway.snapshot.restartPending).toBe(false); + }); +}); diff --git a/ui/src/app/gateway-store.test-support.ts b/ui/src/app/gateway-store.test-support.ts new file mode 100644 index 000000000000..d83b0ac058bb --- /dev/null +++ b/ui/src/app/gateway-store.test-support.ts @@ -0,0 +1,105 @@ +// Shared harness for the gateway-store test suites (base + restart split); +// keeps the fake client and store factory in one place under the max-lines cap. +import { vi } from "vitest"; +import type { + GatewayBrowserClient, + GatewayBrowserClientOptions, + GatewayEventFrame, + GatewayHelloOk, +} from "../api/gateway.ts"; +import { createStorageMock } from "../test-helpers/storage.ts"; +import { createApplicationGateway } from "./gateway-store.ts"; +import { loadSettings } from "./settings.ts"; + +export const GATEWAY_STORE_TEST_HELLO: GatewayHelloOk = { + type: "hello-ok", + protocol: 1, + auth: { role: "operator", scopes: [] }, +}; + +export function createGatewayEvent( + event = "chat", + payload: unknown = {}, + seq = 1, +): GatewayEventFrame { + return { + type: "event", + event, + payload, + seq, + stateVersion: { presence: seq, health: seq }, + }; +} + +class FakeGatewayClient { + started = 0; + stopped = 0; + readonly instanceId: string; + + constructor(readonly opts: GatewayBrowserClientOptions) { + this.instanceId = opts.instanceId ?? ""; + } + + start() { + this.started += 1; + } + + stop() { + this.stopped += 1; + } + + request = vi.fn( + (_method: string, _params: unknown): Promise => + Promise.reject(new Error("unexpected gateway request")), + ); + + addEventListener() { + return () => {}; + } +} + +export function createGatewayStoreTestStore( + params: { + settings?: ReturnType; + persistDefaultConnectionSettings?: boolean; + resourceBasePath?: string; + } = {}, +) { + const clients: FakeGatewayClient[] = []; + const gateway = createApplicationGateway( + params.settings ?? loadSettings(), + "", + "", + (opts) => { + const client = new FakeGatewayClient(opts); + clients.push(client); + return client as unknown as GatewayBrowserClient; + }, + { + persistDefaultConnectionSettings: params.persistDefaultConnectionSettings, + resourceBasePath: params.resourceBasePath, + }, + ); + const current = () => { + const client = clients.at(-1); + if (!client) { + throw new Error("expected a gateway client"); + } + return client; + }; + return { gateway, clients, current }; +} + +export function stubGatewayStoreTestGlobals() { + vi.stubGlobal("localStorage", createStorageMock()); + vi.stubGlobal("sessionStorage", createStorageMock()); + vi.stubGlobal("navigator", { language: "en-US" } as Navigator); + vi.stubGlobal("location", { + protocol: "http:", + host: "127.0.0.1:18789", + hostname: "127.0.0.1", + origin: "http://127.0.0.1:18789", + pathname: "/", + href: "http://127.0.0.1:18789/", + } as Location); +} diff --git a/ui/src/app/gateway-store.test.ts b/ui/src/app/gateway-store.test.ts index ab07518de28c..7d706216a364 100644 --- a/ui/src/app/gateway-store.test.ts +++ b/ui/src/app/gateway-store.test.ts @@ -1,15 +1,13 @@ // @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; -import type { - GatewayBrowserClient, - GatewayBrowserClientOptions, - GatewayEventFrame, - GatewayHelloOk, -} from "../api/gateway.ts"; import { resolveAvatar, setAvatarGatewayOrigin } from "../lib/identity-avatar.ts"; -import { createStorageMock } from "../test-helpers/storage.ts"; -import { createApplicationGateway } from "./gateway-store.ts"; +import { + createGatewayEvent, + createGatewayStoreTestStore as createStore, + GATEWAY_STORE_TEST_HELLO as HELLO, + stubGatewayStoreTestGlobals, +} from "./gateway-store.test-support.ts"; import { loadSettings } from "./settings.ts"; const { scheduleStaleChunkReloadMock } = vi.hoisted(() => ({ @@ -43,95 +41,10 @@ vi.mock("../build-info.ts", () => ({ : Boolean(identity.version && identity.version !== "2026.7.19"), })); -const HELLO: GatewayHelloOk = { - type: "hello-ok", - protocol: 1, - auth: { role: "operator", scopes: [] }, -}; - -function createGatewayEvent(event = "chat", payload: unknown = {}, seq = 1): GatewayEventFrame { - return { - type: "event", - event, - payload, - seq, - stateVersion: { presence: seq, health: seq }, - }; -} - -class FakeGatewayClient { - started = 0; - stopped = 0; - readonly instanceId: string; - - constructor(readonly opts: GatewayBrowserClientOptions) { - this.instanceId = opts.instanceId ?? ""; - } - - start() { - this.started += 1; - } - - stop() { - this.stopped += 1; - } - - request = vi.fn( - (_method: string, _params: unknown): Promise => - Promise.reject(new Error("unexpected gateway request")), - ); - - addEventListener() { - return () => {}; - } -} - -function createStore( - params: { - settings?: ReturnType; - persistDefaultConnectionSettings?: boolean; - resourceBasePath?: string; - } = {}, -) { - const clients: FakeGatewayClient[] = []; - const gateway = createApplicationGateway( - params.settings ?? loadSettings(), - "", - "", - (opts) => { - const client = new FakeGatewayClient(opts); - clients.push(client); - return client as unknown as GatewayBrowserClient; - }, - { - persistDefaultConnectionSettings: params.persistDefaultConnectionSettings, - resourceBasePath: params.resourceBasePath, - }, - ); - const current = () => { - const client = clients.at(-1); - if (!client) { - throw new Error("expected a gateway client"); - } - return client; - }; - return { gateway, clients, current }; -} - describe("createApplicationGateway connection phase", () => { beforeEach(() => { scheduleStaleChunkReloadMock.mockClear(); - vi.stubGlobal("localStorage", createStorageMock()); - vi.stubGlobal("sessionStorage", createStorageMock()); - vi.stubGlobal("navigator", { language: "en-US" } as Navigator); - vi.stubGlobal("location", { - protocol: "http:", - host: "127.0.0.1:18789", - hostname: "127.0.0.1", - origin: "http://127.0.0.1:18789", - pathname: "/", - href: "http://127.0.0.1:18789/", - } as Location); + stubGatewayStoreTestGlobals(); }); afterEach(() => { diff --git a/ui/src/app/gateway-store.ts b/ui/src/app/gateway-store.ts index 3854242c252c..ea6bd320a4a6 100644 --- a/ui/src/app/gateway-store.ts +++ b/ui/src/app/gateway-store.ts @@ -1,7 +1,9 @@ import { isRetryableGatewayStartupUnavailableError, readControlUiBuildMismatchId, + resolveSafeTimeoutDelayMs, } from "@openclaw/gateway-client/browser"; +import { isGatewayRestartUnavailableError } from "../../../packages/gateway-protocol/src/restart-unavailable.js"; import type { ControlUiBootstrapProfileHint } from "../../../src/gateway/control-ui-bootstrap-contract.js"; // Control UI module owns the application gateway store: the reactive // snapshot around GatewayBrowserClient consumed by the app shell. @@ -119,6 +121,7 @@ export function createApplicationGateway( const isCurrentClient = (expected: GatewayBrowserClient | null) => !stopped && client === expected; let offlineIndicatorTimer: ReturnType | null = null; + let restartDeadlineTimer: ReturnType | null = null; const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>(); const eventListeners = new Set(); const eventLogListeners = new Set<(events: readonly EventLogEntry[]) => void>(); @@ -129,6 +132,26 @@ export function createApplicationGateway( offlineIndicatorTimer = null; } }; + const clearRestartDeadlineTimer = () => { + if (restartDeadlineTimer !== null) { + globalThis.clearTimeout(restartDeadlineTimer); + restartDeadlineTimer = null; + } + }; + const scheduleRestartDeadline = (restartExpectedMs?: number) => { + clearRestartDeadlineTimer(); + restartDeadlineTimer = globalThis.setTimeout( + () => { + restartDeadlineTimer = null; + if (!stopped) { + setSnapshot({ ...snapshot, restartPending: false }); + } + }, + // Floor 15s: a failed restart must degrade to the offline pill, never + // wear the amber state forever. + resolveSafeTimeoutDelayMs((restartExpectedMs ?? 0) * 3, { minMs: 15_000 }), + ); + }; const scheduleOfflineIndicator = () => { if ( stopped || @@ -252,7 +275,23 @@ export function createApplicationGateway( }; const recordGatewayEvent = (event: Parameters[0]) => { const eventClient = client; - if (event.event === "presence") { + if (event.event === "shutdown") { + // Only a restart-bearing shutdown arms the amber state; an ordinary stop + // (restartExpectedMs absent) flows through the normal offline pill so the + // retry action stays reachable. Hostile values fall to the timer clamp. + const payload = event.payload; + const expected = + payload && typeof payload === "object" && "restartExpectedMs" in payload + ? payload.restartExpectedMs + : undefined; + if (typeof expected === "number") { + scheduleRestartDeadline(expected); + setSnapshot({ ...snapshot, restartPending: true }); + if (!isCurrentClient(eventClient)) { + return; + } + } + } else if (event.event === "presence") { const entries = readPresenceEntries(event.payload); if (entries) { const selfUser = resolveSelfPresenceUser(entries, client?.instanceId); @@ -309,6 +348,9 @@ export function createApplicationGateway( connectionOverrides.gatewayUrl !== undefined && connectionOverrides.gatewayUrl !== connection.gatewayUrl; // A different Gateway has no established session to keep mounted on failure. + // Accepted tradeoff: a restart pill armed for the previous gateway may + // linger across a mid-restart gateway switch until the next hello or the + // restart deadline clears it; no special-case reset for that rare edge. if (gatewayUrlChanged) { everConnected = false; } @@ -415,10 +457,12 @@ export function createApplicationGateway( hello.pluginSurfaceUrls?.canvas, ); const canvasLeaseGeneration = beginCanvasSurfaceLease(nextClient); + clearRestartDeadlineTimer(); setSnapshot({ ...snapshot, client: nextClient, phase: "connected", + restartPending: false, hello, canvasPluginSurfaceUrl, // Trim guards a whitespace-only defaultId from becoming a truthy selection. @@ -461,6 +505,12 @@ export function createApplicationGateway( return; } const lastErrorCode = resolveGatewayErrorDetailCode(error) ?? error?.code ?? null; + // Fresh drain evidence re-arms the deadline: the server still says + // "restarting", so the amber state stays honest for another window. + const restartPending = isGatewayRestartUnavailableError(error); + if (restartPending) { + scheduleRestartDeadline(); + } setSnapshot({ ...snapshot, client: nextClient, @@ -479,6 +529,7 @@ export function createApplicationGateway( hello: null, canvasPluginSurfaceUrl: null, selfUser: null, + restartPending: restartPending || snapshot.restartPending === true, lastError: startupPending ? null : error?.message @@ -566,6 +617,7 @@ export function createApplicationGateway( stop: () => { stopped = true; clearOfflineIndicatorTimer(); + clearRestartDeadlineTimer(); stopCanvasSurfaceLease(); client?.stop(); client = null; @@ -575,6 +627,7 @@ export function createApplicationGateway( client: null, phase: "stopped", offlineStable: false, + restartPending: false, hello: null, canvasPluginSurfaceUrl: null, assistantAgentId: null, diff --git a/ui/src/app/gateway.ts b/ui/src/app/gateway.ts index 81b930213d59..d281e7f81c53 100644 --- a/ui/src/app/gateway.ts +++ b/ui/src/app/gateway.ts @@ -16,6 +16,7 @@ export type ApplicationGatewaySnapshot = { client: GatewayBrowserClient | null; phase: ApplicationGatewayPhase; offlineStable: boolean; + restartPending?: boolean; hello: GatewayHelloOk | null; canvasPluginSurfaceUrl: string | null; assistantAgentId: string | null; diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts index 47017103ed2a..cd7e760250fd 100644 --- a/ui/src/components/app-sidebar-base.ts +++ b/ui/src/components/app-sidebar-base.ts @@ -28,6 +28,9 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement { @property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[]; @property({ attribute: false }) connected = false; @property({ attribute: false }) offline = false; + @property({ attribute: false }) restartPending = false; + @property({ attribute: false }) queuedOutboxCount = 0; + @property({ attribute: false }) lastError: string | null = null; @property({ attribute: false }) outboxAttentionCountForSession = (_sessionKey: string) => 0; @property({ attribute: false }) hasSessionDraft: (sessionKey: string) => boolean = () => false; @property({ attribute: false }) terminalAvailable = false; diff --git a/ui/src/components/app-sidebar-render.ts b/ui/src/components/app-sidebar-render.ts index f8edef660686..4177f4dfb27b 100644 --- a/ui/src/components/app-sidebar-render.ts +++ b/ui/src/components/app-sidebar-render.ts @@ -37,13 +37,14 @@ import { renderSidebarSessionSectionHeader } from "./app-sidebar-session-section import type { SidebarRecentSession } from "./app-sidebar-session-types.ts"; import type { SidebarWorkboardBoard } from "./app-sidebar-workboard.ts"; import { icons } from "./icons.ts"; +import { redactLoginFailureError } from "./login-gate.ts"; import { renderSessionAttentionIcon, renderSessionRunSpinner, sessionAttentionSubtitle, } from "./session-attention-presentation.ts"; import { renderSessionGlyph, renderSessionUnreadBadge } from "./session-glyph.ts"; -import { renderSessionRowBadges } from "./session-row-badges.ts"; +import { renderSessionRowBadges, renderSidebarConnectionStatus } from "./session-row-badges.ts"; import { formatSidebarBuildSubtitle } from "./sidebar-build-chip-format.ts"; type AppSidebarRenderHost = AppSidebarSessionNavigationElement & { @@ -380,36 +381,18 @@ export function renderAppSidebarFooterBar(host: AppSidebarRenderHost) { ${selfLabel} - ${host.offline - ? html`` - : gateway - ? html`` - : buildSubtitle - ? html`` - : nothing} - ${host.offline ? t("connection.reconnecting") : ""} + ${host.restartPending || host.offline + ? renderSidebarConnectionStatus({ + kind: host.restartPending ? "restarting" : "offline", + queuedOutboxCount: host.queuedOutboxCount, + title: host.lastError + ? redactLoginFailureError(host.lastError) + : t("connection.reconnecting"), + onRetry: () => host.onRetryConnect?.(), + }) + : nothing} ${renderAppSidebarAttention(host)} `; diff --git a/ui/src/components/session-row-badges.ts b/ui/src/components/session-row-badges.ts index 124b2a55984b..43d5710b0b01 100644 --- a/ui/src/components/session-row-badges.ts +++ b/ui/src/components/session-row-badges.ts @@ -191,14 +191,24 @@ export function renderSessionRowBadges(params: { `; } -export function renderOfflineSidebarStatus(props: { - queuedOutboxCount: number; - reconnecting: string; +export function renderSidebarConnectionStatus(props: { + kind: "offline" | "restarting"; + queuedOutboxCount?: number; title?: string; onRetry: () => void; }) { + if (props.kind === "restarting") { + return html`${t( + "connection.restarting", + )}`; + } const offline = t("common.offline"); - const count = props.queuedOutboxCount; + const count = props.queuedOutboxCount ?? 0; const queued = count ? t("connection.queuedCount", { count: String(count) }) : null; return html`