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)
This commit is contained in:
Peter Steinberger
2026-08-26 07:31:30 -07:00
committed by GitHub
parent 4f563f21ac
commit c91edb5cd5
30 changed files with 502 additions and 251 deletions
@@ -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"
}
+6 -1
View File
@@ -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": {
+1
View File
@@ -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 {
@@ -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
);
}
+3 -4
View File
@@ -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. */
+3 -1
View File
@@ -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);
+7 -1
View File
@@ -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<T>(
retryAfterMs: 1_000,
details: {
method,
reason: restartDraining ? "gateway-restarting" : "gateway-suspending",
reason: restartDraining
? GATEWAY_RESTART_UNAVAILABLE_REASON
: GATEWAY_SUSPEND_UNAVAILABLE_REASON,
phase: getGatewaySuspendAdmissionPhase(),
},
},
@@ -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 });
+4
View File
@@ -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 ?? "",
+108
View File
@@ -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);
});
});
+105
View File
@@ -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<unknown> =>
Promise.reject(new Error("unexpected gateway request")),
);
addEventListener() {
return () => {};
}
}
export function createGatewayStoreTestStore(
params: {
settings?: ReturnType<typeof loadSettings>;
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);
}
+7 -94
View File
@@ -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<unknown> =>
Promise.reject(new Error("unexpected gateway request")),
);
addEventListener() {
return () => {};
}
}
function createStore(
params: {
settings?: ReturnType<typeof loadSettings>;
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(() => {
+54 -1
View File
@@ -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<typeof globalThis.setTimeout> | null = null;
let restartDeadlineTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const eventListeners = new Set<GatewayEventListener>();
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<GatewayEventListener>[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,
+1
View File
@@ -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;
+3
View File
@@ -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;
+12 -29
View File
@@ -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) {
<openclaw-viewer-avatar .user=${avatarUser} variant="footer"></openclaw-viewer-avatar>
<span class="sidebar-identity-card__text">
<span class="sidebar-identity-card__name" title=${selfLabel}>${selfLabel}</span>
${host.offline
? html`<span class="sidebar-identity-card__subtitle sr-only" aria-hidden="true"
>${t("connection.reconnecting")}</span
>`
: gateway
? html`<span
class="sidebar-identity-card__subtitle sidebar-identity-card__subtitle--gateway sr-only"
aria-hidden="true"
>
<span
class="sidebar-identity-card__gateway-health"
data-health=${gateway.health}
></span>
<span class="sidebar-identity-card__gateway-name">${gateway.name}</span>
${gatewayPrimaryTag
? html`<span class="sidebar-identity-card__gateway-primary"
${gatewayPrimaryTag}</span
>`
: nothing}
</span>`
: buildSubtitle
? html`<span class="sidebar-identity-card__subtitle sr-only" aria-hidden="true"
>${buildSubtitle}</span
>`
: nothing}
</span>
</button>
<span class="sidebar-identity-card__status" role="status" aria-live="polite"
>${host.offline ? t("connection.reconnecting") : ""}</span
>
${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}
<span class="sidebar-footer-actions">${renderAppSidebarAttention(host)}</span>
</div>
`;
+15 -5
View File
@@ -191,14 +191,24 @@ export function renderSessionRowBadges(params: {
</span>`;
}
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`<span
class="sidebar-footer-bar__status sidebar-footer-bar__status--restarting"
role="status"
aria-live="polite"
><span class="sidebar-footer-bar__status-dot" aria-hidden="true"></span>${t(
"connection.restarting",
)}</span
>`;
}
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`<openclaw-tooltip .content=${props.title ?? ""}>
<button
@@ -210,7 +220,7 @@ export function renderOfflineSidebarStatus(props: {
>
<span class="sidebar-footer-bar__status-dot" aria-hidden="true"></span>${offline}<span
class="sidebar-footer-bar__status-detail"
${props.reconnecting}</span
${t("connection.reconnecting")}</span
>${queued
? html`<span class="sidebar-footer-bar__status-detail">· ${queued}</span>`
: nothing}
+13 -1
View File
@@ -532,12 +532,18 @@ describe("settings sidebar search", () => {
it("shows the offline retry action without an online status", () => {
const onRetryConnect = vi.fn();
const renderSidebar = (offline: boolean, lastError: string | null, queuedOutboxCount = 0) =>
const renderSidebar = (
offline: boolean,
lastError: string | null,
queuedOutboxCount = 0,
restartPending = false,
) =>
render(
renderSettingsSidebar({
basePath: "",
activeRouteId: "appearance",
offline,
restartPending,
queuedOutboxCount,
lastError,
gatewayVersion: "1.0.0",
@@ -571,5 +577,11 @@ describe("settings sidebar search", () => {
expect(button?.getAttribute("aria-label")).toBe("Offline — Retry now — 3 queued");
button?.click();
expect(onRetryConnect).toHaveBeenCalledOnce();
renderSidebar(true, null, 3, true);
expect(container.querySelector(".sidebar-footer-bar__status--restarting")?.textContent).toBe(
"Restarting…",
);
expect(container.querySelector("button.sidebar-footer-bar__status")).toBeNull();
});
});
+5 -4
View File
@@ -25,7 +25,7 @@ import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
import { findSettingsSearchBlocks } from "../pages/config/settings-search.ts";
import { icons } from "./icons.ts";
import { redactLoginFailureError } from "./login-gate.ts";
import { renderOfflineSidebarStatus } from "./session-row-badges.ts";
import { renderSidebarConnectionStatus } from "./session-row-badges.ts";
import type { SettingsSaveIndicatorProps } from "./settings-save-indicator.ts";
import "./settings-save-indicator.ts";
import "./sidebar-build-chip.ts";
@@ -37,6 +37,7 @@ type SettingsSidebarProps = {
activeSearch?: string;
activeHash?: string;
offline: boolean;
restartPending?: boolean;
queuedOutboxCount?: number;
lastError: string | null;
gatewayVersion: string;
@@ -332,10 +333,10 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) {
)}
</nav>
<footer class="settings-sidebar__footer">
${props.offline
? renderOfflineSidebarStatus({
${props.restartPending || props.offline
? renderSidebarConnectionStatus({
kind: props.restartPending ? "restarting" : "offline",
queuedOutboxCount: props.queuedOutboxCount ?? 0,
reconnecting,
title: props.lastError ? redactLoginFailureError(props.lastError) : reconnecting,
onRetry: props.onRetryConnect,
})
+5 -3
View File
@@ -115,9 +115,11 @@ suite.define(() => {
const identityCard = page.locator(".sidebar-identity-card");
await expect
.poll(async () => {
const subtitle =
(await identityCard.locator(".sidebar-identity-card__subtitle").textContent()) ?? "";
const [gitIdentity, relativeAge] = subtitle.trim().split(" · ", 2);
// The compact build identity lives in the identity button's
// aria-label; the visible subtitle span was removed as dead markup.
const ariaLabel = (await identityCard.getAttribute("aria-label")) ?? "";
const detail = ariaLabel.split(": ").slice(1).join(": ");
const [gitIdentity, relativeAge] = detail.trim().split(" · ", 2);
return { gitIdentity, hasRelativeAge: Boolean(relativeAge?.trim()) };
})
.toEqual({
@@ -1,6 +1,7 @@
import type { Locator, Page } from "playwright";
import { expect, it } from "vitest";
import type { ControlUiBuildInfo } from "../build-info.ts";
import { waitForControlUiGatewayReady } from "../test-helpers/control-ui-e2e-readiness.ts";
import {
captureUnionProof,
createSidebarFooterProofSuite,
@@ -158,6 +159,48 @@ const suite = createSidebarFooterProofSuite(
);
suite.define(() => {
it("shows visible offline retry and immediate announced-restart states", async () => {
const opened = await openSidebarFooterProofPage(suite);
try {
const { gateway, page, sidebar } = opened;
const footer = sidebar.locator(".sidebar-footer-bar");
await setSidebarProofTheme(page, "dark");
await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" });
await waitForControlUiGatewayReady(page);
await gateway.setOnline(false);
// The offline pill waits out the store's 2s offline-stability debounce.
const offline = footer.locator("button.sidebar-footer-bar__status");
await offline.waitFor({ state: "visible", timeout: 10_000 });
expect(await offline.textContent()).toContain("Offline");
expect(await offline.textContent()).toContain("Reconnecting…");
await captureUnionProof(page, "sidebar-account-footer", "feature-dark-offline.png", [footer]);
const socketCount = await gateway.getSocketCount();
await offline.click();
await expect
.poll(() => gateway.getSocketCount(), { timeout: 10_000 })
.toBeGreaterThan(socketCount);
await gateway.setOnline(true);
await expect
.poll(() => footer.locator(".sidebar-footer-bar__status").count(), { timeout: 10_000 })
.toBe(0);
await gateway.emitGatewayEvent("shutdown", {
reason: "gateway restart",
restartExpectedMs: 5_000,
});
const restarting = footer.locator(".sidebar-footer-bar__status--restarting");
await restarting.waitFor({ state: "visible" });
expect(await restarting.textContent()).toBe("Restarting…");
await captureUnionProof(page, "sidebar-account-footer", "feature-dark-restarting.png", [
footer,
]);
} finally {
await suite.closeBrowserContext(opened.context);
}
});
it("keeps the feature account target, identity menu, and visual states coherent", async () => {
const opened = await openSidebarFooterProofPage(suite);
try {
@@ -36,11 +36,11 @@ export async function openSidebarFooterProofPage(
viewport: { height: 900, width: 1440 },
});
const page = await context.newPage();
await installMockGateway(page, { presenceUsers: [SIDEBAR_PROOF_USER] });
const gateway = await installMockGateway(page, { presenceUsers: [SIDEBAR_PROOF_USER] });
await page.goto(`${suite.server.baseUrl}chat`);
const sidebar = page.locator("openclaw-app-sidebar");
await sidebar.locator(".sidebar-identity-card").waitFor();
return { context, page, sidebar };
return { context, gateway, page, sidebar };
}
export async function setSidebarProofTheme(page: Page, mode: "dark" | "light") {
+1 -1
View File
@@ -138,7 +138,7 @@ async function proxyReconnect(
await gateway.closeLatest(1001, "proxy idle timeout");
await expect.poll(() => gateway.getSocketCount(), { timeout: 10_000 }).toBe(expectedSocketCount);
await waitForControlUiGatewayReady(page);
expect(await page.locator(".sidebar-identity-card__status").textContent()).toBe("");
expect(await page.locator(".sidebar-footer-bar__status").count()).toBe(0);
}
async function captureProof(page: Page, name: string): Promise<void> {
+1
View File
@@ -4253,6 +4253,7 @@ export const en: TranslationMap = {
connection: {
queuedCount: "{count} queued",
reconnecting: "Reconnecting…",
restarting: "Restarting…",
retryNow: "Retry now",
actionsUnavailable: "Actions are unavailable while the Gateway reconnects.",
sessionOperationCompletedPreviousConnection:
+20 -58
View File
@@ -3334,74 +3334,19 @@ openclaw-sidebar-attention {
min-width: 0;
}
.sidebar-identity-card__name,
.sidebar-identity-card__subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-identity-card__name {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-strong);
font-size: calc(13.5px * var(--control-ui-text-scale));
font-weight: 600;
line-height: 18px;
}
.sidebar-identity-card__subtitle {
color: var(--muted);
font-size: var(--control-ui-text-xs);
line-height: 1.2;
}
.sidebar-identity-card__subtitle--gateway {
display: flex;
align-items: center;
gap: 4px;
}
.sidebar-identity-card__gateway-health {
width: 6px;
height: 6px;
flex: none;
border-radius: 50%;
background: var(--muted);
}
.sidebar-identity-card__gateway-health[data-health="ok"] {
background: var(--ok);
}
.sidebar-identity-card__gateway-health[data-health="error"] {
background: var(--danger);
}
.sidebar-identity-card__gateway-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-identity-card__gateway-primary {
flex: none;
}
.sidebar-identity-card__status {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
openclaw-tooltip.sidebar-hover-tooltip {
--openclaw-tooltip-max-width: min(340px, calc(100vw - 24px));
--openclaw-tooltip-open-animation: openclaw-tooltip-hover-card-in 100ms var(--ease-out);
@@ -3537,6 +3482,13 @@ openclaw-tooltip.sidebar-hover-tooltip {
background: color-mix(in srgb, var(--danger) 18%, transparent);
}
.sidebar-footer-bar__status--restarting,
.sidebar-footer-bar__status--restarting:hover {
background: color-mix(in srgb, var(--warn) 12%, transparent);
color: var(--warn);
cursor: default;
}
.sidebar-footer-bar__status:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
@@ -3555,6 +3507,16 @@ openclaw-tooltip.sidebar-hover-tooltip {
background: var(--danger);
}
.sidebar-footer-bar__status--restarting .sidebar-footer-bar__status-dot {
background: var(--warn);
}
@media (prefers-reduced-motion: no-preference) {
.sidebar-footer-bar__status--restarting .sidebar-footer-bar__status-dot {
animation: pulse-subtle 1.2s ease-in-out infinite;
}
}
.sidebar-session-group-running {
flex: none;
margin-left: 4px;
@@ -222,17 +222,11 @@ describe("AppSidebar agent chip", () => {
expect(card?.querySelector(".sidebar-identity-card__name")?.textContent?.trim()).toBe(
"Account",
);
expect(card?.querySelector(".sidebar-identity-card__subtitle")?.textContent).toBe(
"Reconnecting…",
);
expect(
card?.querySelector(".sidebar-identity-card__subtitle")?.getAttribute("aria-hidden"),
).toBe("true");
const connectionStatus = sidebar.querySelector(".sidebar-identity-card__status");
expect(connectionStatus?.getAttribute("role")).toBe("status");
expect(card?.querySelector(".sidebar-identity-card__subtitle")).toBeNull();
const connectionStatus = sidebar.querySelector(".sidebar-footer-bar__status");
expect(connectionStatus?.getAttribute("aria-live")).toBe("polite");
expect(connectionStatus?.textContent).toBe("Reconnecting…");
expect(sidebar.querySelector(".sidebar-footer-bar__status")).toBeNull();
expect(connectionStatus?.textContent).toContain("Offline");
expect(connectionStatus?.textContent).toContain("Reconnecting…");
expect(sidebar.querySelector(".sidebar-agent-card__subtitle")?.textContent).not.toContain(
"Offline",
);
@@ -247,7 +241,7 @@ describe("AppSidebar agent chip", () => {
sidebar.offline = false;
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")).toBeNull();
expect(sidebar.querySelector(".sidebar-identity-card__status")?.textContent).toBe("");
expect(sidebar.querySelector(".sidebar-footer-bar__status")).toBeNull();
});
it("shows a working subtitle while the agent has an active run", async () => {
@@ -70,9 +70,6 @@ describe("AppSidebar gateway footer subtitle", () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")?.textContent).toBe(
"git@e8cbc62 · 4h ago",
);
expect(sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label")).toBe(
"Identity and app menu for Account: git@e8cbc62 · 4h ago",
);
@@ -87,7 +84,6 @@ describe("AppSidebar gateway footer subtitle", () => {
sidebar.requestUpdate();
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")).toBeNull();
expect(sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label")).toBe(
"Identity and app menu for Account",
);
@@ -99,7 +95,9 @@ describe("AppSidebar gateway footer subtitle", () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")).toBeNull();
expect(
sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label"),
).not.toContain("Local Gateway");
});
it("stays hidden with one configured gateway", async () => {
@@ -107,7 +105,9 @@ describe("AppSidebar gateway footer subtitle", () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")).toBeNull();
expect(
sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label"),
).not.toContain("Local Gateway");
});
it("shows the current gateway health, name, and primary suffix", async () => {
@@ -116,18 +116,6 @@ describe("AppSidebar gateway footer subtitle", () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
expect(
sidebar.querySelector(".sidebar-identity-card__gateway-health")?.getAttribute("data-health"),
).toBe("ok");
expect(sidebar.querySelector(".sidebar-identity-card__gateway-name")?.textContent).toBe(
"Local Gateway",
);
expect(sidebar.querySelector(".sidebar-identity-card__gateway-primary")?.textContent).toBe(
"· primary",
);
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")?.textContent).not.toContain(
"git@e8cbc62",
);
expect(sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label")).toBe(
"Identity and app menu for Account: Local Gateway, primary",
);
@@ -136,18 +124,27 @@ describe("AppSidebar gateway footer subtitle", () => {
).not.toContain("git@e8cbc62");
});
it("keeps the reconnecting subtitle while offline", async () => {
it("shows the visible offline retry pill instead of a hidden reconnecting subtitle", async () => {
setControlUiBuildInfo({ commit: CONTROL_UI_TEST_COMMIT, release: false });
setNativeGatewayTestState(twoGateways);
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
const onRetryConnect = vi.fn();
sidebar.offline = true;
sidebar.queuedOutboxCount = 3;
sidebar.lastError = "connection refused?token=footer-secret";
sidebar.onRetryConnect = onRetryConnect;
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")?.textContent).toBe(
"Reconnecting…",
);
expect(sidebar.querySelector(".sidebar-identity-card__gateway-name")).toBeNull();
const status = sidebar.querySelector<HTMLButtonElement>(".sidebar-footer-bar__status");
expect(status?.textContent).toContain("Offline");
expect(status?.textContent).toContain("Reconnecting…");
expect(status?.textContent).toContain("3 queued");
expect(
(status?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)?.content,
).toBe("connection refused?[redacted-credential]");
status?.click();
expect(onRetryConnect).toHaveBeenCalledOnce();
expect(sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label")).toBe(
"Identity and app menu for Account: Reconnecting…",
);
@@ -156,6 +153,19 @@ describe("AppSidebar gateway footer subtitle", () => {
).not.toContain("git@e8cbc62");
});
it("prioritizes an announced restart over the stable offline state", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
sidebar.offline = true;
sidebar.restartPending = true;
await sidebar.updateComplete;
const status = sidebar.querySelector(".sidebar-footer-bar__status--restarting");
expect(status?.textContent).toBe("Restarting…");
expect(status?.getAttribute("aria-live")).toBe("polite");
expect(sidebar.querySelector("button.sidebar-footer-bar__status")).toBeNull();
});
it("updates when the native gateway snapshot changes", async () => {
setNativeGatewayTestState(twoGateways);
const gateway = createGateway({} as GatewayBrowserClient);
@@ -170,12 +180,8 @@ describe("AppSidebar gateway footer subtitle", () => {
window.dispatchEvent(new CustomEvent("openclaw:native-gateways-changed"));
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-identity-card__gateway-name")?.textContent).toBe(
"Remote Gateway",
);
expect(
sidebar.querySelector(".sidebar-identity-card__gateway-health")?.getAttribute("data-health"),
).toBe("error");
expect(sidebar.querySelector(".sidebar-identity-card__gateway-primary")).toBeNull();
const ariaLabel = sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label");
expect(ariaLabel).toContain("Remote Gateway");
expect(ariaLabel).not.toContain("primary");
});
});
+3
View File
@@ -58,6 +58,9 @@ export type SidebarLifecycleState = HTMLElement & {
enabledRouteIds?: readonly NavigationRouteId[];
connected: boolean;
offline: boolean;
restartPending: boolean;
queuedOutboxCount: number;
lastError: string | null;
outboxAttentionCountForSession: (sessionKey: string) => number;
hasSessionDraft: (sessionKey: string) => boolean;
terminalAvailable: boolean;
@@ -27,7 +27,7 @@ export async function waitForControlUiGatewayReconnecting(page: Page): Promise<v
{ timeout: controlUiE2eWaitTimeoutMs },
),
page
.locator(".sidebar-identity-card__status", { hasText: "Reconnecting…" })
.locator(".sidebar-footer-bar__status", { hasText: "Reconnecting…" })
.waitFor({ state: "visible", timeout: controlUiE2eWaitTimeoutMs }),
]);
}
+10 -1
View File
@@ -2373,7 +2373,16 @@ function installControlUiMockGateway(
(window as unknown as WindowWithGateway).openclawControlUiE2eGateway = exposed;
const RoutedWebSocket = function (url: string | URL, protocols?: string | string[]) {
const resolvedUrl = String(url);
if (scenario.webSocketPassthroughPrefixes.some((prefix) => resolvedUrl.startsWith(prefix))) {
// Vite's dev client must keep its real socket: the mock would fake the
// open handshake, and a later setOnline(false) close would make the client
// believe the dev server restarted and reload the page mid-test.
const isViteHmr = Array.isArray(protocols)
? protocols.includes("vite-hmr")
: protocols === "vite-hmr";
if (
isViteHmr ||
scenario.webSocketPassthroughPrefixes.some((prefix) => resolvedUrl.startsWith(prefix))
) {
return protocols === undefined
? new NativeWebSocket(resolvedUrl)
: new NativeWebSocket(resolvedUrl, protocols);