mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
4f563f21ac
commit
c91edb5cd5
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user