feat(ui): let admins dismiss the sidebar update indicator (#128365)

* feat(ui): let admins dismiss the sidebar update indicator

Refs #128232

* refactor(ui): clean up update dismissal flow

* fix(ui): align update action state

* fix(ui): keep update reconciliation non-actionable

* fix(ui): preserve update reconciliation visibility

* fix: validate update dismissal facts

* fix(ui): resurface active updates

* fix(ui): show applying update campaigns

* fix(gateway): preserve boot identity

* perf(ui): split sidebar update styles

* fix(test): provide captured plugin runtime
This commit is contained in:
Vyctor H. Brzezowski
2026-08-23 19:21:10 -03:00
committed by GitHub
parent bb6e4b1dee
commit 6669872a95
40 changed files with 935 additions and 93 deletions
+1 -2
View File
@@ -3548,7 +3548,7 @@ src/plugins/bundled-channel-config-metadata.ts 4
src/plugins/bundled-plugin-scan.ts 1
src/plugins/candidate-install-owner.ts 2
src/plugins/capability-provider-runtime.ts 16
src/plugins/captured-registration.ts 3
src/plugins/captured-registration.ts 2
src/plugins/channel-presence-policy.ts 2
src/plugins/channel-validation.ts 1
src/plugins/clawhub.ts 10
@@ -4063,7 +4063,6 @@ ui/src/components/session-menu.ts 1
ui/src/components/session-organizer-controller.ts 5
ui/src/components/settings-sidebar.ts 3
ui/src/components/settings-ui.ts 7
ui/src/components/sidebar-attention-dismissals.ts 1
ui/src/components/sidebar-attention.ts 4
ui/src/components/terminal/terminal-connection.ts 2
ui/src/components/terminal/terminal-controller-lifecycle.ts 1
@@ -90,6 +90,7 @@ export const HelloOkSchema = closedObject({
server: closedObject({
version: NonEmptyString,
buildId: Type.Optional(Type.String({ minLength: 1, maxLength: 96 })),
bootId: Type.Optional(Type.String({ minLength: 1, maxLength: 96 })),
controlUiBuildSource: Type.Optional(
Type.Union([Type.Literal("bundled"), Type.Literal("configured")]),
),
+1 -1
View File
@@ -90,7 +90,6 @@ const OBSERVER_DEMO_RUN_ID = "mock-session-observer-run";
const PLAN_DEMO_RUN_ID = "mock-plan-run";
const CUSTODIAN_CHAT_REPLY_DELAY_MS = 600;
const CHAT_SEND_REPLY_DELAY_MS = 200;
type UpdateFixture = {
available: UpdateAvailable;
runResponse: unknown;
@@ -1947,6 +1946,7 @@ async function createChatPickerScenario(
assistantAgentId: "main",
assistantName: "Molty",
defaultAgentId: "main",
gatewayBootId: "mock-gateway-boot-1",
serverBuildId: "mock",
updateSchedule,
updateAvailable: updateFixture?.available ?? null,
+1
View File
@@ -1194,6 +1194,7 @@ async function runGatewayCommandOnce(opts: GatewayRunOpts, hooks: GatewayRunRunt
startupConfigSnapshotReadForNextStart = undefined;
return await startGatewayServer(port, {
bind,
...(activeBootId ? { bootId: activeBootId } : {}),
auth: authOverride,
tailscale: tailscaleOverride,
startupStartedAt,
+2
View File
@@ -22,6 +22,8 @@ export type GatewayServer = {
};
export type GatewayServerOptions = {
/** Exact lifecycle generation projected to connected clients. */
bootId?: string;
/**
* Bind address policy for the Gateway WebSocket/HTTP server.
* - loopback: 127.0.0.1
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { startGatewayServerCore } from "./server-start.js";
describe("Gateway boot ID", () => {
it.each(["", " ", " boot-a", "boot-a ", "x".repeat(97)])(
"rejects an invalid public boot ID",
async (bootId) => {
await expect(startGatewayServerCore(0, { bootId })).rejects.toThrow(
"Gateway boot ID must contain 1 to 96 characters",
);
},
);
});
+12
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { formatErrorMessage } from "../infra/errors.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import {
@@ -24,6 +25,16 @@ export async function startGatewayServerCore(
port = 18789,
opts: GatewayServerOptions = {},
): Promise<GatewayServer> {
// Direct embedders have no CLI lifecycle row, so the server start boundary
// still owns an exact generation instead of making clients infer one.
const suppliedBootId = opts.bootId;
if (
suppliedBootId !== undefined &&
(suppliedBootId.trim() !== suppliedBootId || !suppliedBootId || suppliedBootId.length > 96)
) {
throw new Error("Gateway boot ID must contain 1 to 96 characters");
}
const bootId = suppliedBootId ?? randomUUID();
let releasePostReadyWork: () => void = () => {};
const postReadyWorkBarrier = new Promise<void>((resolve) => {
releasePostReadyWork = resolve;
@@ -68,6 +79,7 @@ export async function startGatewayServerCore(
kernelRuntime: { ...gatewayKernel, ...transport },
port,
opts,
bootId,
log,
logHealth,
logWsControl,
+3
View File
@@ -26,6 +26,7 @@ type GatewayStartedRuntime = GatewayKernelRuntime & GatewayHttpTransport;
export async function finishGatewayStartup(params: {
kernelRuntime: GatewayStartedRuntime;
port: number;
bootId: string;
opts: GatewayStartedRuntime["opts"];
log: GatewayLogger;
logHealth: GatewayLogger;
@@ -42,6 +43,7 @@ export async function finishGatewayStartup(params: {
const {
kernelRuntime: runtime,
port,
bootId,
opts,
log,
logHealth,
@@ -148,6 +150,7 @@ export async function finishGatewayStartup(params: {
attachGatewayWsHandlers({
wss,
clients,
bootId,
preauthConnectionBudget,
port,
gatewayHost: bindHost ?? undefined,
+1
View File
@@ -21,6 +21,7 @@ export function attachGatewayWsHandlers(params: GatewayWsRuntimeParams) {
attachGatewayWsConnectionHandler({
wss: params.wss,
clients: params.clients,
bootId: params.bootId,
preauthConnectionBudget: params.preauthConnectionBudget,
port: params.port,
gatewayHost: params.gatewayHost,
@@ -283,6 +283,7 @@ describe("gateway pre-auth hardening", () => {
attachGatewayWsConnectionHandler({
wss,
clients,
bootId: "preauth-hardening-test-boot",
preauthConnectionBudget,
port: 0,
getResolvedAuth: () => resolvedAuth,
@@ -221,6 +221,7 @@ class PublicWorkerHarness {
attachGatewayWsConnectionHandler({
wss: this.wss,
clients: this.clients,
bootId: "worker-ingress-test-boot",
preauthConnectionBudget: this.preauthBudget,
port: 0,
getResolvedAuth: () => RESOLVED_AUTH,
@@ -117,6 +117,7 @@ export function attachGatewayWsForTest(params: {
params.attach({
wss,
clients: clients as never,
bootId: "ws-test-boot",
preauthConnectionBudget: { release: vi.fn() } as never,
port: 19001,
getResolvedAuth: () => createResolvedGatewayTokenAuth("token"),
+3 -1
View File
@@ -635,11 +635,13 @@ describe("attachGatewayWsConnectionHandler", () => {
it.each([1001, 1006])(
"demotes local app startup abort code %i before the first frame",
async (closeCode) => {
let startupPending = true;
const { socket, logWsControl } = await connectTestWs({
headers: { "user-agent": "OpenClaw/2607000290 CFNetwork/3860 Darwin/25" },
options: { isStartupPending: () => true },
options: { isStartupPending: () => startupPending },
});
startupPending = false;
socket.emit("close", closeCode, Buffer.alloc(0));
expect(logWsControl.debug).toHaveBeenCalledWith(
+12 -30
View File
@@ -70,10 +70,9 @@ import {
} from "./ws-types.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
const MAX_QUEUED_MESSAGE_HANDLER_FRAMES = 16;
const unauthorizedCloseBeforeConnectLogLimiter = new HandshakeAuthLogLimiter();
type GatewayWsSharedHandlerParams = {
bootId: string;
wss: WebSocketServer;
clients: Set<GatewayWsClient>;
preauthConnectionBudget: PreauthConnectionBudget;
@@ -81,16 +80,10 @@ type GatewayWsSharedHandlerParams = {
gatewayHost?: string;
pluginSurfaceScheme?: "http" | "https";
getPluginNodeCapabilities?: () => PluginNodeCapabilitySurface[];
/**
* Auth is read per connection, not per process: a reload can rotate it while
* this handler stays attached. One getter keeps that the only source, so no
* caller can hand over a snapshot that silently outlives the config it came from.
*/
// Read per connection so reloads cannot leave a stale auth snapshot.
getResolvedAuth: () => ResolvedGatewayAuth;
getRequiredSharedGatewaySessionGeneration?: () => string | undefined;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
/** Browser-origin fallback limiter (loopback is never exempt). */
browserRateLimiter?: AuthRateLimiter;
nodeReapprovalCoordinator?: NodeReapprovalCoordinator;
preauthHandshakeTimeoutMs?: number;
@@ -124,7 +117,7 @@ function attachGatewayWsMessageHandlerOnDemand(
): void {
const queued: RawData[] = [];
const queueMessage = (data: RawData) => {
if (queued.length >= MAX_QUEUED_MESSAGE_HANDLER_FRAMES) {
if (queued.length >= 16) {
params.setCloseCause("message-handler-loading-overflow", {
queuedFrames: queued.length,
});
@@ -203,12 +196,10 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
workerConnectionService,
} = params;
const originCheckMetrics: WsOriginCheckMetrics = { hostHeaderFallbackAccepted: 0 };
wss.on("connection", (socket, upgradeReq) => {
let client: GatewayWsClient | null = null;
let closed = false;
const openedAt = Date.now();
const connId = randomUUID();
let client: GatewayWsClient | null = null,
closed = false;
const [openedAt, connId] = [Date.now(), randomUUID()];
const ingressSocket = socket as GatewayIngressWebSocket;
const connectionKind = ingressSocket[GATEWAY_WS_CONNECTION_KIND_PROPERTY] ?? "gateway";
const publicWorkerIngress =
@@ -235,7 +226,6 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]);
const realIp = headerValue(upgradeReq.headers["x-real-ip"]);
const openedDuringStartup = isStartupPending?.() === true;
const pluginNodeCapabilities =
connectionKind === "gateway" ? (getPluginNodeCapabilities?.() ?? []) : [];
const pluginSurfaceBaseUrl =
@@ -351,7 +341,6 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
return { kind: "unavailable" } as const;
}
if (socket.readyState !== WEBSOCKET_OPEN_READY_STATE) {
// Keep pending node results revocable until their close handler drains admitted work.
if (client?.connect.role === "node" && nodeLifecycleDispatch.hasActive()) {
retainClientUntilNodeDrain = true;
}
@@ -473,8 +462,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
? logWsControl.debug
: logWsControl.warn;
const authReason = stringMetaValue(closeMeta, "authReason");
// This pre-connect close path has no client object yet; treat only
// missing shared credentials as suppressible startup retry noise.
// Only missing shared credentials are suppressible startup retry noise.
const shouldLimitMissingAuthClose =
closeCause === "unauthorized" &&
shouldLimitMissingCredentialAuthLog({
@@ -519,9 +507,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
const context = buildRequestContext();
cleanupTalkConnection(connId, logGateway);
context.unsubscribeAllSessionEvents(connId);
// Detach (or, with a zero grace period, kill) any PTY shells this
// connection owned; detached sessions stay reattachable via
// terminal.attach until their reaper fires.
// Detach or kill owned PTY shells; detached sessions remain reattachable until reaped.
context.terminalSessions?.handleDisconnect(connId);
let currentDisconnectedNodeId: string | null = null;
let disconnectedNodeHistory:
@@ -543,8 +529,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
pairingGeneration: nodeSession.pairingGeneration,
};
}
// Retire I/O immediately, but keep the client revocable until admitted
// lifecycle work drains; pairing/token removal must still fence it.
// Retire I/O now, but retain revocation until admitted lifecycle work drains.
retainClientUntilNodeDrain = true;
retireTransport();
try {
@@ -620,15 +605,13 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
});
const setClient = (next: GatewayWsClient) => {
// Concurrent connect frames can finish authentication out of order. Keep
// one socket owner so a raced finalizer cannot leak a client or ping loop.
// Keep one socket owner when concurrent connect frames finish out of order.
if (closed || client) {
return false;
}
if (next.worker) {
for (const existing of clients) {
if (existing.worker?.environmentId === next.worker.environmentId) {
// Fence queued frames before transport teardown releases the old handler and timers.
existing.invalidated = true;
clients.delete(existing);
try {
@@ -657,9 +640,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
awaitingPong = true;
try {
socket.ping();
} catch {
// close() clears the timer; ping can race with a socket already entering CLOSING.
}
} catch {}
}, 25_000);
return true;
};
@@ -702,6 +683,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
upgradeReq,
ingressAttribution,
connId,
bootId: params.bootId,
remoteAddr,
remotePort,
localAddr,
@@ -48,6 +48,7 @@ export async function sendGatewayHello(
): Promise<void> {
const {
connId,
bootId,
nodeReapprovalCoordinator,
gatewayMethods,
events,
@@ -126,6 +127,7 @@ export async function sendGatewayHello(
server: {
version: resolveRuntimeServiceVersion(process.env),
...(serverBuildId ? { buildId: serverBuildId } : {}),
bootId,
controlUiBuildSource,
connId,
},
@@ -82,6 +82,7 @@ function makeContext(role: "operator" | "node", scopes: string[]) {
return {
handler: {
connId: `conn-${role}`,
bootId: "gateway-boot-a",
gatewayMethods: [],
events: [],
buildRequestContext: () => ({ nodeRegistry: { get: () => undefined } }),
@@ -187,6 +188,7 @@ describe("sendGatewayHello update detail scope", () => {
}),
);
expect(helloPayload(context)?.server.buildId).toBe("build-a");
expect(helloPayload(context)?.server.bootId).toBe("gateway-boot-a");
expect(helloPayload(context)?.server.controlUiBuildSource).toBe("bundled");
});
@@ -37,6 +37,7 @@ export type GatewayWsMessageHandlerParams = {
upgradeReq: IncomingMessage;
ingressAttribution: GatewayAttributedIngress;
connId: string;
bootId: string;
remoteAddr?: string;
remotePort?: number;
localAddr?: string;
@@ -196,6 +196,7 @@ describe("Control UI build admission over WebSocket", () => {
},
},
connId: "legacy-build-connection",
bootId: "control-ui-build-admission-test-boot",
remoteAddr: "127.0.0.1",
localAddr: "127.0.0.1",
requestHost: request.headers.host,
@@ -326,6 +326,7 @@ function attachGatewayHarness(options: {
});
attachGatewayWsMessageHandler({
socket,
bootId: "post-connect-health-test-boot",
upgradeReq: {
headers: {
host: requestHost,
@@ -88,6 +88,7 @@ function attachHarness(params: { deferSocketSend?: boolean; startupPending?: boo
attachGatewayWsMessageHandler({
socket,
bootId: "suspension-admission-test-boot",
upgradeReq: {
headers: { host: "127.0.0.1:19001" },
socket: { localAddress: "127.0.0.1", remoteAddress: "127.0.0.1" },
+2 -2
View File
@@ -23,7 +23,7 @@ import type {
PluginTrustedToolPolicyRegistration,
} from "./host-hooks.js";
import type { PluginAgentToolResultMiddlewareRegistration } from "./registry-types.js";
import type { PluginRuntime } from "./runtime/types.js";
import { createPluginRuntime } from "./runtime/index.js";
import type { SessionCatalogProvider } from "./session-catalog.js";
import { normalizePluginToolMatcher } from "./tool-hook-matcher.js";
import type {
@@ -179,7 +179,7 @@ export function createCapturedPluginRegistration(params?: {
source: pluginSource,
registrationMode: params?.registrationMode ?? "full",
config: params?.config ?? ({} as OpenClawConfig),
runtime: {} as PluginRuntime,
runtime: createPluginRuntime(),
logger: noopLogger,
resolvePath: (input) => input,
handlers: {
+15
View File
@@ -622,6 +622,21 @@ export function formatUpdateTargetLabel(
return version ? t("updates.target.version", { version }) : null;
}
export function isUpdateActionable(
updateAvailable: UpdateAvailable | null | undefined,
updateSchedule: UpdateScheduleState | null | undefined,
updateBusy: boolean,
): boolean {
const target = updateSchedule?.target;
return Boolean(
updateBusy ||
updateSchedule?.campaign ||
(updateAvailable && updateAvailable.latestVersion !== updateAvailable.currentVersion) ||
(updateAvailable?.commitsBehind !== undefined && updateAvailable.commitsBehind > 0) ||
(target?.kind === "git" && target.commitsBehind > 0),
);
}
export function resolveUpdateStatusBanner(params: {
status?: string;
reason?: string;
@@ -194,6 +194,7 @@ type SidebarIdentityMenuParams = {
canPairDevice: boolean;
basePath: string;
gatewayVersion: string | null;
updateAttentionDismissed: boolean;
profileViewer?: PresenceViewer;
offline: boolean;
themeMode: ThemeMode;
@@ -572,6 +573,7 @@ export function renderSidebarIdentityMenu(params: SidebarIdentityMenuParams) {
.variant=${"identity"}
.basePath=${params.basePath}
.gatewayVersion=${params.gatewayVersion}
.updateAttentionDismissed=${params.updateAttentionDismissed}
.onNavigate=${(routeId: "about") => {
params.onClose();
params.onNavigate(routeId);
@@ -2,17 +2,17 @@
// Deliberately client-side chrome (like nav width / dock layout), not gateway
// state: dismissing a nag on one device should not acknowledge it everywhere.
import { gatewayOriginScope } from "@openclaw/gateway-client/browser";
import { asNullableRecord, readStringField } from "@openclaw/normalization-core/record-coerce";
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
const SIDEBAR_ATTENTION_KINDS = [
"updateAvailable",
"cronFailed",
"cronOverdue",
"modelAuthExpired",
] as const;
export type SidebarAttentionKind = (typeof SIDEBAR_ATTENTION_KINDS)[number];
const SIDEBAR_ATTENTION_ITEM_KINDS = ["cronFailed", "cronOverdue", "modelAuthExpired"] as const;
export type SidebarAttentionKind = (typeof SIDEBAR_ATTENTION_ITEM_KINDS)[number];
export type SidebarAttentionDismissals = Partial<Record<SidebarAttentionKind, string[]>>;
export type UpdateAttentionDismissal = { version: string; gatewayBootId: string };
export type SidebarAttentionDismissals = Partial<Record<SidebarAttentionKind, string[]>> & {
updateAvailable?: UpdateAttentionDismissal;
};
// Minimal chip shape the snooze logic needs; keeps this module free of the
// component's item type so the two files cannot form an import cycle.
@@ -31,12 +31,13 @@ export function loadDismissals(gatewayUrl: string): SidebarAttentionDismissals {
}
try {
const parsed: unknown = JSON.parse(storage.getItem(dismissalStoreKey(gatewayUrl)) ?? "null");
if (!parsed || typeof parsed !== "object") {
const record = asNullableRecord(parsed);
if (!record) {
return {};
}
const result: SidebarAttentionDismissals = {};
for (const kind of SIDEBAR_ATTENTION_KINDS) {
const value = (parsed as Record<string, unknown>)[kind];
for (const kind of SIDEBAR_ATTENTION_ITEM_KINDS) {
const value = record[kind];
const signatures = Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: typeof value === "string"
@@ -46,6 +47,12 @@ export function loadDismissals(gatewayUrl: string): SidebarAttentionDismissals {
result[kind] = [...new Set(signatures)];
}
}
const updateAvailable = asNullableRecord(record.updateAvailable);
const version = readStringField(updateAvailable, "version");
const gatewayBootId = readStringField(updateAvailable, "gatewayBootId");
if (version && gatewayBootId) {
result.updateAvailable = { version, gatewayBootId };
}
return result;
} catch {
return {};
@@ -84,6 +91,47 @@ export function addDismissal(
return next;
}
export function resolveUpdateAttentionDismissal(params: {
gatewayBootId?: string | null;
updateAvailable?: UpdateAvailable | null;
updateSchedule?: UpdateScheduleState | null;
}): UpdateAttentionDismissal | null {
const target = params.updateSchedule?.target;
const version =
(target?.kind === "package" ? target.version : target?.upstreamSha) ??
params.updateAvailable?.upstreamSha ??
params.updateAvailable?.latestVersion;
const gatewayBootId = params.gatewayBootId?.trim();
const normalizedVersion = version?.trim();
return gatewayBootId && normalizedVersion ? { version: normalizedVersion, gatewayBootId } : null;
}
export function isUpdateAttentionDismissed(
dismissals: SidebarAttentionDismissals,
current: UpdateAttentionDismissal | null,
): boolean {
const stored = dismissals.updateAvailable;
return Boolean(
stored &&
current &&
stored.version === current.version &&
stored.gatewayBootId === current.gatewayBootId,
);
}
export function isUpdateAttentionForced(tone: "danger" | "info" | "warn" | null | undefined) {
return tone === "warn" || tone === "danger";
}
export function dismissUpdateAttention(
gatewayUrl: string,
dismissal: UpdateAttentionDismissal,
): SidebarAttentionDismissals {
const next = { ...loadDismissals(gatewayUrl), updateAvailable: dismissal };
saveDismissals(gatewayUrl, next);
return next;
}
/**
* Drop dismissals whose chip is gone or whose entity set changed, so a state
* that clears and later recurs surfaces again instead of staying hidden by a
@@ -92,10 +140,11 @@ export function addDismissal(
export function pruneDismissals(
dismissals: SidebarAttentionDismissals,
items: readonly DismissableChip[],
updateAvailable: UpdateAttentionDismissal | null = null,
): SidebarAttentionDismissals {
const next: SidebarAttentionDismissals = {};
let changed = false;
for (const kind of SIDEBAR_ATTENTION_KINDS) {
for (const kind of SIDEBAR_ATTENTION_ITEM_KINDS) {
const stored = dismissals[kind];
if (!stored) {
continue;
@@ -110,5 +159,10 @@ export function pruneDismissals(
changed = true;
}
}
if (isUpdateAttentionDismissed(dismissals, updateAvailable)) {
next.updateAvailable = dismissals.updateAvailable;
} else if (dismissals.updateAvailable) {
changed = true;
}
return changed ? next : dismissals;
}
+1 -1
View File
@@ -18,7 +18,7 @@ type SidebarAttentionAction =
| { kind: "askCustodian"; alert: CustodianAlert };
export type SidebarAttentionItem = {
kind: Exclude<SidebarAttentionKind, "updateAvailable">;
kind: SidebarAttentionKind;
severity: "error" | "warning";
icon: IconName;
label: string;
@@ -24,7 +24,7 @@ type SidebarAttentionPanelParams = {
onApprovalDecision: (event: Event, approvalId: string, decision: ExecApprovalDecision) => void;
onClose: (restoreFocus: boolean) => void;
onDismiss: (item: SidebarAttentionItem) => void;
onDismissUpdate: () => void;
onDismissUpdate?: () => void;
onKeydown: (event: KeyboardEvent) => void;
onNavigate: (routeId: NavigationRouteId) => void;
onOpen: (item: SidebarAttentionItem) => void;
+228 -2
View File
@@ -2,16 +2,24 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { CronJob, CronJobsListResult, ModelAuthStatusResult } from "../api/types.ts";
import type {
CronJob,
CronJobsListResult,
ModelAuthStatusResult,
UpdateScheduleState,
} from "../api/types.ts";
import type { ApplicationContext, ApplicationGateway } from "../app/context.ts";
import { createApplicationContextProvider } from "../test-helpers/application-context.ts";
import { createStorageMock as createTestStorageMock } from "../test-helpers/storage.ts";
import { waitForFast } from "../test-helpers/wait-for.ts";
import {
addDismissal,
dismissUpdateAttention,
dismissalStoreKey,
isUpdateAttentionDismissed,
loadDismissals,
pruneDismissals,
resolveUpdateAttentionDismissal,
type SidebarAttentionKind,
} from "./sidebar-attention-dismissals.ts";
import { buildSidebarAttentionItems } from "./sidebar-attention-items.ts";
@@ -57,6 +65,9 @@ type SidebarAttentionElement = HTMLElement & {
updateComplete: Promise<boolean>;
cronJobs: CronJob[];
hasUpdateSurface(): boolean;
updateSurfaceVisible(): boolean;
dismissUpdateSurface(): void;
startUpdate(): void;
modelAuthStatus: ModelAuthStatusResult | null;
loadedAtMs: number;
};
@@ -526,10 +537,173 @@ describe("update attention", () => {
overlaySnapshot.updateCampaignStatusHydrated = true;
expect(element.hasUpdateSurface()).toBe(true);
});
it("keeps restart reconciliation visible after update metadata clears", () => {
const element = document.createElement("openclaw-sidebar-attention") as SidebarAttentionElement;
element.context = {
gateway: { snapshot: { phase: "connected" } },
overlays: {
snapshot: {
updateAvailable: null,
updateSchedule: null,
updateRunning: false,
updateReconciliationPending: true,
updateStatusBanner: null,
},
},
} as unknown as ApplicationContext;
expect(element.hasUpdateSurface()).toBe(true);
});
it("dismisses one target for one Gateway boot and resurfaces on either change", () => {
vi.stubGlobal("localStorage", createTestStorageMock());
const overlaySnapshot = {
updateAvailable: {
currentVersion: "2026.8.1",
latestVersion: "2026.8.2",
channel: "latest",
},
updateSchedule: {
channel: "stable",
autoEnabled: false,
target: { kind: "package" as const, version: "2026.8.2" },
},
updateCampaignStatusHydrated: true,
updateReconciliationPending: false,
updateRunning: false,
updateStatusBanner: null,
};
const gatewaySnapshot = {
client: {} as GatewayBrowserClient,
phase: "connected" as const,
hello: {
server: { bootId: "boot-a" },
auth: { role: "operator", scopes: ["operator.admin", "operator.read"] },
features: { methods: ["update.run"] },
},
};
const element = document.createElement("openclaw-sidebar-attention") as SidebarAttentionElement;
element.context = {
gateway: {
connection: { gatewayUrl: "ws://gateway.test" },
snapshot: gatewaySnapshot,
},
overlays: { snapshot: overlaySnapshot },
} as unknown as ApplicationContext;
(element as unknown as { dismissedScope: string }).dismissedScope = "ws://gateway.test";
expect(element.updateSurfaceVisible()).toBe(true);
element.dismissUpdateSurface();
expect(element.updateSurfaceVisible()).toBe(false);
expect(loadDismissals("ws://gateway.test").updateAvailable).toEqual({
version: "2026.8.2",
gatewayBootId: "boot-a",
});
overlaySnapshot.updateSchedule.target.version = "2026.8.3";
expect(element.updateSurfaceVisible()).toBe(true);
overlaySnapshot.updateSchedule.target.version = "2026.8.2";
gatewaySnapshot.hello.server.bootId = "boot-b";
expect(element.updateSurfaceVisible()).toBe(true);
});
it("forces a dismissed update back for warning and failure outcomes", () => {
vi.stubGlobal("localStorage", createTestStorageMock());
const element = document.createElement("openclaw-sidebar-attention") as SidebarAttentionElement;
const overlaySnapshot = {
updateAvailable: {
currentVersion: "2026.8.1",
latestVersion: "2026.8.2",
channel: "latest",
},
updateSchedule: null as UpdateScheduleState | null,
updateCampaignStatusHydrated: true,
updateReconciliationPending: false,
updateRunning: false,
updateStatusBanner: null as null | { tone: "warn" | "danger"; text: string },
};
element.context = {
gateway: {
connection: { gatewayUrl: "ws://gateway.test" },
snapshot: {
client: {} as GatewayBrowserClient,
phase: "connected",
hello: {
server: { bootId: "boot-a" },
auth: { role: "operator", scopes: ["operator.admin", "operator.read"] },
features: { methods: ["update.run"] },
},
},
},
overlays: { snapshot: overlaySnapshot },
} as unknown as ApplicationContext;
(element as unknown as { dismissedScope: string }).dismissedScope = "ws://gateway.test";
element.dismissUpdateSurface();
expect(element.updateSurfaceVisible()).toBe(false);
overlaySnapshot.updateStatusBanner = { tone: "warn", text: "Update blocked" };
expect(element.updateSurfaceVisible()).toBe(true);
overlaySnapshot.updateStatusBanner = { tone: "danger", text: "Update failed" };
expect(element.updateSurfaceVisible()).toBe(true);
overlaySnapshot.updateStatusBanner = null;
overlaySnapshot.updateRunning = true;
expect(element.updateSurfaceVisible()).toBe(true);
overlaySnapshot.updateRunning = false;
overlaySnapshot.updateReconciliationPending = true;
expect(element.updateSurfaceVisible()).toBe(true);
overlaySnapshot.updateReconciliationPending = false;
overlaySnapshot.updateSchedule = {
channel: "stable",
autoEnabled: true,
target: { kind: "package", version: "2026.8.2" },
campaign: {
id: "campaign-applying",
state: "applying",
announcedAtMs: 1,
forceAtMs: 2,
updatedAtMs: 2,
},
};
expect(element.updateSurfaceVisible()).toBe(true);
});
it("does not start an update when a failure has no actionable target", () => {
const runUpdate = vi.fn();
const element = document.createElement("openclaw-sidebar-attention") as SidebarAttentionElement;
element.context = {
gateway: {
snapshot: {
phase: "connected",
hello: {
auth: { role: "operator", scopes: ["operator.admin"] },
features: { methods: ["update.run"] },
},
},
},
overlays: {
runUpdate,
snapshot: {
updateAvailable: null,
updateSchedule: null,
updateRunning: false,
updateStatusBanner: { tone: "danger", text: "Update failed" },
},
},
} as unknown as ApplicationContext;
element.startUpdate();
expect(runUpdate).not.toHaveBeenCalled();
});
});
describe("pruneDismissals", () => {
const chip = (kind: SidebarAttentionKind, signature: string) => ({ kind, signature });
const chip = (kind: SidebarAttentionKind, signature: string) => ({
kind,
signature,
});
it("keeps a dismissal while the same entity set is still affected", () => {
const dismissals = { cronFailed: ["alpha", "beta"] };
@@ -591,3 +765,55 @@ describe("addDismissal", () => {
expect(loadDismissals(gatewayUrl)).toEqual({ cronFailed: ["legacy-signature"] });
});
});
describe("update dismissal fact", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("uses the canonical package target and persists the literal boot binding", () => {
vi.stubGlobal("localStorage", createTestStorageMock());
const dismissal = resolveUpdateAttentionDismissal({
gatewayBootId: "boot-a",
updateAvailable: {
currentVersion: "2026.8.1",
latestVersion: "2026.8.2",
channel: "latest",
},
updateSchedule: {
channel: "stable",
autoEnabled: false,
target: { kind: "package", version: "2026.8.3" },
},
});
expect(dismissal).toEqual({ version: "2026.8.3", gatewayBootId: "boot-a" });
const stored = dismissUpdateAttention("ws://gateway.test", dismissal!);
expect(isUpdateAttentionDismissed(stored, dismissal)).toBe(true);
expect(
JSON.parse(localStorage.getItem(dismissalStoreKey("ws://gateway.test")) ?? "null"),
).toEqual({ updateAvailable: { version: "2026.8.3", gatewayBootId: "boot-a" } });
});
it("uses the git target SHA instead of an unchanged package version", () => {
expect(
resolveUpdateAttentionDismissal({
gatewayBootId: "boot-a",
updateAvailable: {
currentVersion: "2026.8.1",
latestVersion: "2026.8.1",
channel: "dev",
},
updateSchedule: {
channel: "dev",
autoEnabled: true,
target: {
kind: "git",
upstreamRef: "origin/main",
upstreamSha: "abcdef1234567890",
commitsBehind: 2,
},
},
}),
).toEqual({ version: "abcdef1234567890", gatewayBootId: "boot-a" });
});
});
+126 -27
View File
@@ -9,25 +9,33 @@ import type { NavigationRouteId } from "../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../app/context.ts";
import type { ExecApprovalDecision, ExecApprovalRequest } from "../app/exec-approval.ts";
import {
hasNativeUpdateBridge,
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
NATIVE_UPDATE_DECLINED_EVENT,
} from "../app/native-link-routing.ts";
import type { UpdateProgress } from "../app/update-confirmation.ts";
import { confirmAndStartUpdate, type UpdateProgress } from "../app/update-confirmation.ts";
import { isUpdateActionable } from "../app/update-overlay-helpers.ts";
import { t } from "../i18n/index.ts";
import { createInitialCronState, loadCronJobsPage } from "../lib/cron/index.ts";
import { canCallGatewayMethod } from "../lib/gateway-methods.ts";
import { loadModelAuthStatus } from "../lib/model-auth.ts";
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import "../styles/sidebar-footer-update.css";
import { icons } from "./icons.ts";
import { CUSTODIAN_PANEL_TOGGLE_EVENT } from "./panel-toggle-contract.ts";
import {
addDismissal,
dismissUpdateAttention,
dismissalStoreKey,
isUpdateAttentionDismissed,
isUpdateAttentionForced,
loadDismissals,
pruneDismissals,
resolveUpdateAttentionDismissal,
saveDismissals,
type SidebarAttentionDismissals,
type UpdateAttentionDismissal,
} from "./sidebar-attention-dismissals.ts";
import {
buildSidebarAttentionItems,
@@ -249,7 +257,10 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
this.nativeUpdateDeclined = true;
const snapshot = this.context?.overlays.snapshot;
const campaign = snapshot?.updateSchedule?.campaign;
const busy = snapshot?.updateRunning || campaign?.state === "applying";
const busy =
snapshot?.updateRunning ||
snapshot?.updateReconciliationPending ||
campaign?.state === "applying";
if (
snapshot &&
(snapshot.updateAvailable || campaign) &&
@@ -320,12 +331,8 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
return;
}
const items = this.buildItems();
const updateSurfaceSignature = this.updateSurfaceSignature();
const dismissableItems = updateSurfaceSignature
? [...items, { kind: "updateAvailable" as const, signature: updateSurfaceSignature }]
: items;
const stored = loadDismissals(this.dismissedScope);
const pruned = pruneDismissals(stored, dismissableItems);
const pruned = pruneDismissals(stored, items, this.updateAttentionDismissal());
if (pruned !== stored) {
saveDismissals(this.dismissedScope, pruned);
}
@@ -364,6 +371,9 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
return false;
}
const campaign = snapshot.updateSchedule?.campaign;
if (snapshot.updateReconciliationPending) {
return true;
}
const canHydrateCampaign = canCallGatewayMethod(
this.context?.gateway.snapshot,
"update.status",
@@ -377,36 +387,72 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
);
}
private updateSurfaceSignature(): string | null {
if (!this.hasUpdateSurface()) {
return null;
}
private updateAttentionDismissal(): UpdateAttentionDismissal | null {
const snapshot = this.context?.overlays.snapshot;
const campaign = snapshot?.updateSchedule?.campaign;
return [
snapshot?.updateRunning ? "running" : "",
campaign?.id ?? "",
campaign?.state ?? "",
campaign?.updatedAtMs ?? "",
snapshot?.updateAvailable?.upstreamSha ?? snapshot?.updateAvailable?.latestVersion ?? "",
snapshot?.updateStatusBanner?.tone ?? "",
snapshot?.updateStatusBanner?.text ?? "",
].join("\n");
return resolveUpdateAttentionDismissal({
gatewayBootId: this.context?.gateway.snapshot.hello?.server?.bootId,
updateAvailable: snapshot?.updateAvailable,
updateSchedule: snapshot?.updateSchedule,
});
}
private updateSurfaceForced(): boolean {
const snapshot = this.context?.overlays.snapshot;
return (
snapshot?.updateRunning ||
snapshot?.updateReconciliationPending ||
snapshot?.updateSchedule?.campaign?.state === "applying" ||
isUpdateAttentionForced(snapshot?.updateStatusBanner?.tone)
);
}
private updateSurfaceVisible(): boolean {
const signature = this.updateSurfaceSignature();
return Boolean(signature && !this.dismissed.updateAvailable?.includes(signature));
return (
this.hasUpdateSurface() &&
(this.updateSurfaceForced() ||
!isUpdateAttentionDismissed(this.dismissed, this.updateAttentionDismissal()))
);
}
private dismissUpdateSurface() {
const signature = this.updateSurfaceSignature();
if (!this.dismissedScope || !signature) {
const dismissal = this.updateAttentionDismissal();
if (
!this.dismissedScope ||
!dismissal ||
this.updateSurfaceForced() ||
!canCallGatewayMethod(this.context?.gateway.snapshot, "update.run", "operator.admin")
) {
return;
}
this.dismissed = addDismissal(this.dismissedScope, "updateAvailable", signature);
this.dismissed = dismissUpdateAttention(this.dismissedScope, dismissal);
}
private readonly startUpdate = () => {
const context = this.context;
const snapshot = context?.overlays.snapshot;
const campaign = snapshot?.updateSchedule?.campaign;
const busy =
snapshot?.updateRunning ||
snapshot?.updateReconciliationPending ||
campaign?.state === "applying";
if (
!context ||
!snapshot ||
busy ||
!isUpdateActionable(snapshot.updateAvailable, snapshot.updateSchedule, busy) ||
!canCallGatewayMethod(context.gateway.snapshot, "update.run", "operator.admin")
) {
return;
}
void confirmAndStartUpdate({
startGatewayUpdate: () => void context.overlays.runUpdate(),
...(this.watchUpdateProgress ? { watchUpdateProgress: this.watchUpdateProgress } : {}),
updateAvailable: snapshot.updateAvailable,
updateSchedule: snapshot.updateSchedule,
viaNativeApp: !this.nativeUpdateDeclined && hasNativeUpdateBridge(),
});
};
private readonly closeOnOutsidePointer = (event: PointerEvent) => {
if (!this.panelOpen || event.composedPath().includes(this)) {
return;
@@ -563,6 +609,23 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
return nothing;
}
const updateSurface = this.updateSurfaceVisible();
const updateDismissal = this.updateAttentionDismissal();
const updateForced = this.updateSurfaceForced();
const overlaySnapshot = this.context.overlays.snapshot;
const updateBusy =
overlaySnapshot.updateRunning ||
overlaySnapshot.updateReconciliationPending ||
overlaySnapshot.updateSchedule?.campaign?.state === "applying";
const updateActionable = isUpdateActionable(
overlaySnapshot.updateAvailable,
overlaySnapshot.updateSchedule,
updateBusy,
);
const canUpdate = canCallGatewayMethod(
this.context.gateway.snapshot,
"update.run",
"operator.admin",
);
const approvalQueue = this.approvalQueue();
const items = this.currentItems().toSorted(
(left, right) => ITEM_PRIORITY[left.kind] - ITEM_PRIORITY[right.kind],
@@ -599,6 +662,39 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
>`
: nothing}
</button>
${updateSurface
? html`<span class="sidebar-footer-update-slot">
<button
type="button"
class="sidebar-footer-update"
aria-label=${t("updates.sidebar.availableTitle")}
?disabled=${updateBusy || !updateActionable || !canUpdate}
@click=${this.startUpdate}
>
<span class="sidebar-footer-update__icon" aria-hidden="true"
>${updateBusy ? icons.refresh : icons.download}</span
>
<span class="sidebar-footer-update__label">${t("updates.sidebar.action")}</span>
</button>
${canUpdate && updateDismissal && !updateForced
? html`<openclaw-tooltip
class="sidebar-hover-tooltip"
.content=${t("updates.sidebar.dismissUntilRestartOrVersion")}
.delay=${600}
.closeDelay=${300}
>
<button
type="button"
class="sidebar-footer-update__dismiss"
aria-label=${t("updates.sidebar.dismissUntilRestartOrVersion")}
@click=${() => this.dismissUpdateSurface()}
>
${icons.x}
</button>
</openclaw-tooltip>`
: nothing}
</span>`
: nothing}
${this.panelOpen && this.panelRenderer
? this.panelRenderer({
approvalQueue,
@@ -608,7 +704,10 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
void this.decideApproval(event, approvalId, decision),
onClose: (restoreFocus) => this.closePanel(restoreFocus),
onDismiss: (item) => this.dismiss(item),
onDismissUpdate: () => this.dismissUpdateSurface(),
onDismissUpdate:
canUpdate && updateDismissal && !updateForced
? () => this.dismissUpdateSurface()
: undefined,
onKeydown: this.handlePanelKeydown,
onNavigate: (routeId) => {
this.closePanel(false);
+15 -4
View File
@@ -5,6 +5,7 @@ import { CONTROL_UI_BUILD_INFO } from "../build-info.ts";
import { t } from "../i18n/index.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import "../styles/sidebar-footer-update.css";
import {
formatBuildChipText,
formatSettingsBuildLabel,
@@ -16,6 +17,7 @@ import "./tooltip.ts";
class SidebarBuildChip extends OpenClawLightDomContentsElement {
@property({ attribute: false }) basePath = "";
@property({ attribute: false }) gatewayVersion: string | null = null;
@property({ attribute: false }) updateAttentionDismissed = false;
@property({ attribute: false }) onNavigate?: (routeId: "about") => void;
@property({ attribute: false }) variant: "compact" | "identity" | "settings" = "compact";
@@ -24,9 +26,11 @@ class SidebarBuildChip extends OpenClawLightDomContentsElement {
this.variant === "settings"
? formatSettingsBuildLabel(CONTROL_UI_BUILD_INFO, this.gatewayVersion)
: this.variant === "identity"
? formatSidebarBuildSubtitle(CONTROL_UI_BUILD_INFO)
? this.updateAttentionDismissed
? formatSettingsBuildLabel(CONTROL_UI_BUILD_INFO, this.gatewayVersion)
: formatSidebarBuildSubtitle(CONTROL_UI_BUILD_INFO)
: formatBuildChipText(CONTROL_UI_BUILD_INFO);
if (!text) {
if (!text && !this.updateAttentionDismissed) {
return nothing;
}
return html`
@@ -34,7 +38,9 @@ class SidebarBuildChip extends OpenClawLightDomContentsElement {
<a
class="sidebar-footer-build"
href=${pathForRoute("about", this.basePath)}
aria-label=${t("aboutPage.artifactDetails")}
aria-label=${this.updateAttentionDismissed
? `${t("aboutPage.artifactDetails")}. ${t("updates.sidebar.availableTitle")}`
: t("aboutPage.artifactDetails")}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
@@ -42,7 +48,12 @@ class SidebarBuildChip extends OpenClawLightDomContentsElement {
event.preventDefault();
this.onNavigate?.("about");
}}
>${text}</a
>${text ? html`<span class="sidebar-footer-build__text">${text}</span>` : nothing}
${this.updateAttentionDismissed
? html`<span class="agent-select__badge sidebar-footer-build__update"
>${t("updates.sidebar.availableTitle")}</span
>`
: nothing}</a
>
<div slot="content" class="sidebar-hover-card sidebar-build-hover-card">
${renderSidebarServerDetails(CONTROL_UI_BUILD_INFO, this.gatewayVersion)}
+2 -2
View File
@@ -99,7 +99,7 @@ export function renderSidebarApprovalItem(params: {
export function renderSidebarUpdateSurface(params: {
context: ApplicationContext | undefined;
onDismiss: () => void;
onDismiss?: () => void;
onNavigate: () => void;
visible: boolean;
watchUpdateProgress: ((listener: (progress: UpdateProgress) => void) => () => void) | undefined;
@@ -117,7 +117,7 @@ export function renderSidebarUpdateSurface(params: {
.updateAvailable=${snapshot.updateAvailable}
.updateSchedule=${snapshot.updateSchedule}
.heldUpdateCampaignId=${snapshot.heldUpdateCampaignId}
.updateBusy=${snapshot.updateRunning}
.updateBusy=${snapshot.updateRunning || snapshot.updateReconciliationPending}
.statusBanner=${snapshot.updateStatusBanner}
.watchUpdateProgress=${params.watchUpdateProgress}
.canUpdate=${canCallGatewayMethod(gateway, "update.run", "operator.admin")}
+32
View File
@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
import { keyed } from "lit/directives/keyed.js";
import { DEFAULT_SIDEBAR_ENTRIES, serializeSidebarEntry } from "../app-navigation.ts";
import { isMobileNavLayout } from "../app/mobile-nav-layout.ts";
import { isUpdateActionable } from "../app/update-overlay-helpers.ts";
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
import { t } from "../i18n/index.ts";
import { normalizeAgentLabel } from "../lib/agents/display.ts";
@@ -28,6 +29,12 @@ import {
import { sessionMenuReasons } from "./session-menu-access.ts";
import type { SessionMenuAction } from "./session-menu.ts";
import { listAssignableSessionOwners } from "./session-owner-chip.ts";
import {
isUpdateAttentionDismissed,
isUpdateAttentionForced,
loadDismissals,
resolveUpdateAttentionDismissal,
} from "./sidebar-attention-dismissals.ts";
import type { SidebarMenusController } from "./sidebar-menus-controller.ts";
export function renderSidebarCustomizeMenuForController(controller: SidebarMenusController) {
@@ -117,11 +124,36 @@ export function renderSidebarIdentityMenuForController(controller: SidebarMenusC
presenceEntries: readPresenceEntries(host.sessionData.presencePayload),
presenceInstanceId: host.sessionData.presenceInstanceId,
});
const context = host.sessionDataContext;
const overlaySnapshot = context?.overlays.snapshot;
const updateAttentionDismissal = resolveUpdateAttentionDismissal({
gatewayBootId: context?.gateway.snapshot.hello?.server?.bootId,
updateAvailable: overlaySnapshot?.updateAvailable,
updateSchedule: overlaySnapshot?.updateSchedule,
});
const updateAttentionDismissed = Boolean(
context &&
updateAttentionDismissal &&
isUpdateActionable(
overlaySnapshot?.updateAvailable,
overlaySnapshot?.updateSchedule,
Boolean(overlaySnapshot?.updateRunning || overlaySnapshot?.updateReconciliationPending),
) &&
!overlaySnapshot?.updateRunning &&
!overlaySnapshot?.updateReconciliationPending &&
overlaySnapshot?.updateSchedule?.campaign?.state !== "applying" &&
!isUpdateAttentionForced(overlaySnapshot?.updateStatusBanner?.tone) &&
isUpdateAttentionDismissed(
loadDismissals(context.gateway.connection.gatewayUrl),
updateAttentionDismissal,
),
);
return renderSidebarIdentityMenu({
position,
canPairDevice: host.canPairDevice,
basePath: host.basePath,
gatewayVersion: host.gatewayVersion,
updateAttentionDismissed,
profileViewer: selfUser ? { ...selfUser, watchedSessions: [] } : undefined,
offline: host.offline,
themeMode: host.themeMode,
+2 -4
View File
@@ -10,6 +10,7 @@ import { confirmAndStartUpdate, type UpdateProgress } from "../app/update-confir
import {
formatUpdateCampaignLabel,
formatUpdateTargetLabel,
isUpdateActionable,
type ApplicationStatusBanner,
} from "../app/update-overlay-helpers.ts";
import { t } from "../i18n/index.ts";
@@ -301,9 +302,6 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
const update = this.updateAvailable;
const campaign = this.updateSchedule?.campaign;
const busy = this.updateBusy || campaign?.state === "applying";
const hasGitUpdate =
this.updateSchedule?.target?.kind === "git" && this.updateSchedule.target.commitsBehind > 0;
const hasVersionUpdate = Boolean(update && update.latestVersion !== update.currentVersion);
// A running update outranks availability: the gateway drops its update
// metadata while it restarts, and the card must not vanish or fall back to
// the stale "update available" call to action mid-install.
@@ -340,7 +338,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
);
// An outcome with nothing left to act on is the whole card: re-offering an
// update the operator just ran would bury the reason it failed.
const actionable = Boolean(campaign || busy || hasVersionUpdate || hasGitUpdate);
const actionable = isUpdateActionable(update, this.updateSchedule, this.updateBusy);
return html`
<div
class="sidebar-update-card"
+1 -1
View File
@@ -848,7 +848,7 @@ suite.define(() => {
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
);
const sidebarAutomation = sidebar.locator('[data-attention-kind="cronFailed"]');
await expect.poll(() => sidebar.locator(".sidebar-footer-update").count()).toBe(0);
await expect.poll(() => sidebar.locator(".sidebar-footer-update").count()).toBe(1);
await sidebar.locator(".sidebar-issues-button").click();
await expect.poll(() => sidebarUpdate.count()).toBe(1);
await expect.poll(() => sidebarAutomation.count()).toBe(1);
+2 -2
View File
@@ -79,7 +79,7 @@ suite.define(() => {
);
await updateIssue.locator("summary").click();
await updateIssue.locator(".sidebar-update-card__compact-reason").waitFor();
expect(await page.locator(".sidebar-footer-update").count()).toBe(0);
expect(await page.locator(".sidebar-footer-update").count()).toBe(1);
expect(pageErrors).toEqual([]);
await page.screenshot({ path: path.join(artifactDir, "package-update-failure.png") });
},
@@ -137,7 +137,7 @@ suite.define(() => {
{ exact: true },
)
.waitFor();
expect(await page.locator(".sidebar-footer-update").count()).toBe(0);
expect(await page.locator(".sidebar-footer-update").count()).toBe(1);
expect(pageErrors).toEqual([]);
await page.screenshot({ path: path.join(artifactDir, "coalesced-restart-banner.png") });
},
+128
View File
@@ -62,6 +62,125 @@ async function openConfirmation(page: Page, updateButton: Locator, compact = fal
}
suite.define(() => {
it("shares one dismissal across the footer and Inbox until the Gateway boot changes", async () => {
await suite.withPage(
{ locale: "en-US", serviceWorkers: "block", viewport: { height: 720, width: 1280 } },
async ({ page }) => {
const gateway = await installMockGateway(page, {
gatewayBootId: "gateway-boot-a",
operatorScopes: ["operator.admin", "operator.read"],
updateAvailable: UPDATE_AVAILABLE,
updateSchedule: {
channel: "stable",
autoEnabled: false,
target: { kind: "package", version: "2.0.0" },
},
});
expect((await page.goto(`${suite.server.baseUrl}chat`))?.status()).toBe(200);
await gateway.waitForRequest("chat.startup");
const footerUpdate = page.locator(".sidebar-footer-update");
await footerUpdate.waitFor();
expect(await footerUpdate.isEnabled()).toBe(true);
expect(
await page
.locator("openclaw-sidebar-attention")
.evaluate((attention) =>
[...attention.children]
.map((child) => child.className)
.filter((className) => typeof className === "string" && className.length > 0),
),
).toEqual(["sr-only", "sidebar-issues-button", "sidebar-footer-update-slot"]);
expect((await footerUpdate.boundingBox())?.width).toBe(28);
const inboxBox = await page.locator(".sidebar-issues-button").boundingBox();
const updateSlotBox = await page.locator(".sidebar-footer-update-slot").boundingBox();
expect(inboxBox).not.toBeNull();
expect(updateSlotBox).not.toBeNull();
expect(updateSlotBox!.x - (inboxBox!.x + inboxBox!.width)).toBe(8);
await page.locator(".sidebar-issues-button").click();
await page
.locator('openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]')
.waitFor();
await page.locator(".sidebar-issues-button").click();
await footerUpdate.hover();
await expect.poll(async () => (await footerUpdate.boundingBox())?.width).toBe(76);
const expandedInboxBox = await page.locator(".sidebar-issues-button").boundingBox();
expect(expandedInboxBox).not.toBeNull();
const dismissButton = page.locator(".sidebar-footer-update__dismiss");
expect(await dismissButton.getAttribute("aria-label")).toBe(
"Hide until next restart or update",
);
await expect
.poll(() => dismissButton.evaluate((button) => getComputedStyle(button).opacity))
.toBe("1");
await dismissButton.hover();
await expect.poll(async () => (await footerUpdate.boundingBox())?.width).toBe(76);
await expect
.poll(async () => {
const inboxBoxOnDismiss = await page.locator(".sidebar-issues-button").boundingBox();
return Math.abs(inboxBoxOnDismiss!.x - expandedInboxBox!.x);
})
.toBeLessThanOrEqual(0.5);
await dismissButton.click();
await footerUpdate.waitFor({ state: "detached" });
await page.locator(".sidebar-issues-button").click();
expect(
await page
.locator('openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]')
.count(),
).toBe(0);
await page.locator(".sidebar-issues-button").click();
await page.locator(".sidebar-identity-card").click();
await page.getByText("Update available", { exact: true }).waitFor();
await page.keyboard.press("Escape");
await page.reload();
await gateway.waitForRequest("chat.startup");
expect(await page.locator(".sidebar-footer-update").count()).toBe(0);
await gateway.setGatewayBootId("gateway-boot-b");
await gateway.setOnline(false);
await gateway.setOnline(true);
await page.locator(".sidebar-footer-update").waitFor();
},
);
});
it("keeps the update visible but non-dismissible for read-only operators", async () => {
await suite.withPage(
{ locale: "en-US", serviceWorkers: "block", viewport: { height: 720, width: 1280 } },
async ({ page }) => {
const gateway = await installMockGateway(page, {
gatewayBootId: "gateway-boot-read-only",
operatorScopes: ["operator.read"],
updateAvailable: UPDATE_AVAILABLE,
updateSchedule: {
channel: "stable",
autoEnabled: false,
target: { kind: "package", version: "2.0.0" },
},
});
expect((await page.goto(`${suite.server.baseUrl}chat`))?.status()).toBe(200);
await gateway.waitForRequest("chat.startup");
const footerUpdate = page.locator(".sidebar-footer-update");
await footerUpdate.waitFor();
expect(await footerUpdate.isDisabled()).toBe(true);
expect(await page.locator(".sidebar-footer-update__dismiss").count()).toBe(0);
await page.locator(".sidebar-issues-button").click();
const updateIssue = page.locator(
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
);
await updateIssue.waitFor();
expect(await updateIssue.locator(".sidebar-issues-panel__dismiss").count()).toBe(0);
expect(await gateway.getRequests("update.run")).toHaveLength(0);
},
);
});
it("opens a confirmation that states the action, target, versions, and restart impact", async () => {
await suite.withPage(
{ locale: "en-US", serviceWorkers: "block", viewport: { height: 720, width: 1280 } },
@@ -216,6 +335,15 @@ suite.define(() => {
});
expect(await gateway.getRequests("update.run")).toHaveLength(1);
await page.getByRole("button", { name: "Close", exact: true }).click();
const footerUpdate = page.locator(".sidebar-footer-update");
await expect.poll(() => footerUpdate.isDisabled()).toBe(true);
await page.locator(".sidebar-issues-button").click();
const updateIssue = page.locator(
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
);
await updateIssue.locator("summary").click();
expect(await updateIssue.locator(".sidebar-update-card__action").isDisabled()).toBe(true);
},
);
});
+1
View File
@@ -459,6 +459,7 @@ export const en: TranslationMap = {
campaignTarget: "{status} · {target}",
updating: "Updating Gateway…",
availableTitle: "Update available",
dismissUntilRestartOrVersion: "Hide until next restart or update",
availableSummary: "A newer revision is ready.",
blockedTitle: "Update blocked",
blockedSummary: "The selected revision could not be applied to this checkout.",
+4 -1
View File
@@ -3295,6 +3295,7 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
display: flex;
height: 32px;
align-items: center;
gap: 8px;
transform: translateY(-50%);
}
@@ -3973,7 +3974,9 @@ wa-dropdown.sidebar-identity-menu::part(menu) {
/* Host elements are display:contents, so the anchor itself is the flex item;
min-width 0 lets it yield to the footer icons and ellipsize. */
.sidebar-footer-build {
display: block;
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
color: var(--muted);
+153
View File
@@ -0,0 +1,153 @@
.sidebar-footer-bar:has(.sidebar-footer-update-slot) {
padding-inline-end: 88px;
}
.sidebar-footer-update-slot {
position: relative;
width: 32px;
height: 32px;
flex: 0 0 32px;
transition:
width var(--duration-fast) ease,
flex-basis var(--duration-fast) ease;
}
.sidebar-footer-update-slot:is(:hover:has(.sidebar-footer-update:not(:disabled)), :focus-within) {
width: 76px;
flex-basis: 76px;
}
.sidebar-footer-update-slot::before {
content: "";
position: absolute;
z-index: 1;
inset-block: 0;
inset-inline-end: 20px;
width: 64px;
pointer-events: none;
background: linear-gradient(to right, transparent, var(--sidebar-bg));
opacity: 0;
transition: opacity var(--duration-fast) ease;
}
.sidebar-footer-update-slot:is(
:hover:has(.sidebar-footer-update:not(:disabled)),
:focus-within
)::before {
opacity: 1;
}
.sidebar-footer-update {
position: absolute;
z-index: 2;
inset-block-start: 2px;
inset-inline-end: 2px;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: flex-start;
gap: 5px;
overflow: hidden;
padding: 0 7px;
border: 0;
border-radius: var(--radius-full);
background: var(--inbox-attention);
color: var(--inbox-attention-foreground);
cursor: var(--cursor-action);
transition:
width var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.sidebar-footer-update-slot:is(:hover, :focus-within) .sidebar-footer-update:not(:disabled) {
width: 76px;
background: var(--inbox-attention-hover);
}
.sidebar-footer-update-slot:hover:has(.sidebar-footer-update:not(:disabled)),
.sidebar-footer-update-slot:hover:has(.sidebar-footer-update:not(:disabled))::before,
.sidebar-footer-update-slot:hover .sidebar-footer-update:not(:disabled),
.sidebar-footer-update-slot:hover:has(.sidebar-footer-update:not(:disabled))
.sidebar-footer-update__label,
.sidebar-footer-update-slot:hover .sidebar-footer-update__dismiss {
transition-delay: var(--duration-slow);
}
.sidebar-footer-update:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.sidebar-footer-update:disabled {
cursor: not-allowed;
opacity: 0.56;
}
.sidebar-footer-update__icon {
display: inline-flex;
flex: 0 0 14px;
}
.sidebar-footer-update__label {
opacity: 0;
font-size: var(--control-ui-text-xs);
font-weight: 600;
white-space: nowrap;
transition: opacity var(--duration-fast) ease;
}
.sidebar-footer-update-slot:is(:hover:has(.sidebar-footer-update:not(:disabled)), :focus-within)
.sidebar-footer-update__label {
opacity: 1;
}
.sidebar-footer-update__dismiss {
position: absolute;
z-index: 3;
inset-block-start: -3px;
inset-inline-end: -3px;
display: inline-grid;
width: 16px;
height: 16px;
place-items: center;
padding: 0;
border: 2px solid var(--sidebar-bg);
border-radius: var(--radius-full);
background: var(--text-strong);
color: var(--bg-elevated);
opacity: 0;
pointer-events: none;
transform: scale(0.25);
transition:
opacity var(--duration-fast) ease,
transform var(--duration-fast) ease;
}
.sidebar-footer-update-slot:is(:hover, :focus-within) .sidebar-footer-update__dismiss {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
.sidebar-footer-update__dismiss svg {
width: 9px;
}
@media (hover: none) and (pointer: coarse) {
.sidebar-footer-update__dismiss {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
}
.sidebar-footer-build__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-footer-build__update {
color: var(--inbox-attention);
}
@@ -1,9 +1,75 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationOverlays } from "../../app/overlays-types.ts";
import { dismissUpdateAttention } from "../../components/sidebar-attention-dismissals.ts";
import { createGatewayHarness, createSessions, mountSidebar } from "../app-sidebar.ts";
import "../../components/app-sidebar.ts";
describe("AppSidebar footer identity menu", () => {
it("keeps a dismissed update as a discreet account-menu chip", async () => {
const gatewayHarness = createGatewayHarness({
instanceId: "self-instance",
} as GatewayBrowserClient);
gatewayHarness.publish({
hello: {
...gatewayHarness.gateway.snapshot.hello!,
server: {
...gatewayHarness.gateway.snapshot.hello!.server,
bootId: "boot-a",
},
},
});
const { sidebar, context } = await mountSidebar(
gatewayHarness.gateway,
createSessions("main", ["agent:main:main"]),
);
(context.overlays as unknown as { snapshot: ApplicationOverlays["snapshot"] }).snapshot = {
...context.overlays.snapshot,
updateAvailable: {
currentVersion: "2026.8.1",
latestVersion: "2026.8.2",
channel: "latest",
},
updateSchedule: null,
updateStatusBanner: null,
};
dismissUpdateAttention("ws://gateway.test", {
version: "2026.8.2",
gatewayBootId: "boot-a",
});
sidebar.requestUpdate();
await sidebar.updateComplete;
sidebar.querySelector<HTMLButtonElement>(".sidebar-identity-card")?.click();
await sidebar.updateComplete;
const buildChip = sidebar.querySelector<HTMLElement>("openclaw-sidebar-build-chip");
await (buildChip as (HTMLElement & { updateComplete?: Promise<unknown> }) | null)
?.updateComplete;
expect(
(buildChip as (HTMLElement & { updateAttentionDismissed?: boolean }) | null)
?.updateAttentionDismissed,
).toBe(true);
expect(buildChip?.querySelector(".sidebar-footer-build__update")?.textContent?.trim()).toBe(
"Update available",
);
(context.overlays as unknown as { snapshot: ApplicationOverlays["snapshot"] }).snapshot = {
...context.overlays.snapshot,
updateAvailable: {
currentVersion: "2026.8.2",
latestVersion: "2026.8.2",
channel: "latest",
},
};
sidebar.requestUpdate();
await sidebar.updateComplete;
expect(
(buildChip as HTMLElement & { updateAttentionDismissed?: boolean }).updateAttentionDismissed,
).toBe(false);
expect(buildChip?.querySelector(".sidebar-footer-build__update")).toBeNull();
});
it("owns account utilities, restores focus, and routes Profile", async () => {
const fullName = "Ada Lovelace With A Deliberately Long Display Name";
const gatewayHarness = createGatewayHarness({
+27
View File
@@ -278,6 +278,8 @@ export type ControlUiMockGatewayScenario = {
devGitBranch?: string;
/** Exact immutable Control UI artifact served by the mocked Gateway. */
serverBuildId?: string;
/** Exact Gateway lifecycle generation served in hello. */
gatewayBootId?: string;
/** Optional startup update snapshot for rich local mock fixtures. */
updateAvailable?: UpdateAvailable | null;
/** Optional automatic-update campaign snapshot for rich local mock fixtures. */
@@ -510,6 +512,7 @@ export type MockGatewayControls = {
) => Promise<void>;
resolveDeferred: (method: string, payload?: unknown) => Promise<void>;
setOnline: (online: boolean) => Promise<void>;
setGatewayBootId: (bootId: string) => Promise<void>;
setServerBuildId: (buildId: string) => Promise<void>;
setOperatorScopes: (scopes: string[]) => Promise<void>;
setHistoryMessages: (messages: unknown[]) => Promise<void>;
@@ -880,6 +883,7 @@ function normalizeScenario(
deferredMethods: scenario.deferredMethods ?? [],
devGitBranch: scenario.devGitBranch?.trim() || "",
serverBuildId: scenario.serverBuildId?.trim() || "e2e",
gatewayBootId: scenario.gatewayBootId?.trim() || "e2e-gateway-boot",
updateAvailable: scenario.updateAvailable ?? null,
updateSchedule: scenario.updateSchedule ?? null,
controlUiBuildSource: scenario.controlUiBuildSource ?? "bundled",
@@ -1007,6 +1011,7 @@ function installControlUiMockGateway(
requests: BrowserRequest[];
resolveDeferred: (method: string, payload?: unknown) => void;
setOnline: (online: boolean) => void;
setGatewayBootId: (bootId: string) => void;
setServerBuildId: (buildId: string) => void;
setOperatorScopes: (scopes: string[]) => void;
setHistoryMessages: (messages: unknown[]) => void;
@@ -1027,6 +1032,9 @@ function installControlUiMockGateway(
const scenario: BrowserScenario = input.scenario;
const serverBuildIdStateKey = "openclaw.control-ui-e2e.serverBuildId";
let serverBuildId = scenario.serverBuildId;
let gatewayBootId =
new URL(window.location.href).searchParams.get("mockGatewayBootId")?.trim() ||
scenario.gatewayBootId;
try {
serverBuildId = window.sessionStorage.getItem(serverBuildIdStateKey)?.trim() || serverBuildId;
} catch {
@@ -1693,6 +1701,7 @@ function installControlUiMockGateway(
protocol: protocolVersion,
server: {
buildId: serverBuildId,
bootId: gatewayBootId,
controlUiBuildSource: scenario.controlUiBuildSource,
connId: "control-ui-e2e",
version: scenario.serverVersion,
@@ -2303,6 +2312,9 @@ function installControlUiMockGateway(
socket.openConnection();
}
},
setGatewayBootId(nextBootId) {
gatewayBootId = nextBootId;
},
setServerBuildId(nextBuildId) {
serverBuildId = nextBuildId;
try {
@@ -2589,6 +2601,21 @@ function createMockGatewayControls(
gateway.setOnline(nextOnline);
}, online);
},
async setGatewayBootId(bootId) {
await page.evaluate((nextBootId) => {
const gateway = (
window as Window & {
openclawControlUiE2eGateway?: {
setGatewayBootId: (bootId: string) => void;
};
}
).openclawControlUiE2eGateway;
if (!gateway) {
throw new Error("Mock Gateway is not installed");
}
gateway.setGatewayBootId(nextBootId);
}, bootId);
},
async setServerBuildId(buildId) {
await page.evaluate((nextBuildId) => {
const gateway = (