From 6669872a95f87b9a79ebebbaac5718cd877f86bd Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Sun, 23 Aug 2026 19:21:10 -0300 Subject: [PATCH] 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 --- config/assertion-safety-baseline.txt | 3 +- .../gateway-protocol/src/schema/frames.ts | 1 + scripts/control-ui-mock-dev.ts | 2 +- src/cli/gateway-cli/run.ts | 1 + src/gateway/server-public.ts | 2 + src/gateway/server-start.boot-id.test.ts | 13 + src/gateway/server-start.ts | 12 + src/gateway/server-startup-finish.ts | 3 + src/gateway/server-ws-runtime.ts | 1 + src/gateway/server.preauth-hardening.test.ts | 1 + .../server.public-worker-ingress.test.ts | 1 + .../server/ws-connection.test-helpers.ts | 1 + src/gateway/server/ws-connection.test.ts | 4 +- src/gateway/server/ws-connection.ts | 42 +--- .../server/ws-connection/connect-hello.ts | 2 + .../connect-hello.update-scope.test.ts | 2 + .../ws-connection/message-handler-types.ts | 1 + ...handler.control-ui-build-admission.test.ts | 1 + ...essage-handler.post-connect-health.test.ts | 1 + ...ssage-handler.suspension-admission.test.ts | 1 + src/plugins/captured-registration.ts | 4 +- ui/src/app/update-overlay-helpers.ts | 15 ++ ui/src/components/app-sidebar-agent-menu.ts | 2 + .../sidebar-attention-dismissals.ts | 78 +++++- ui/src/components/sidebar-attention-items.ts | 2 +- .../sidebar-attention-panel.runtime.ts | 2 +- ui/src/components/sidebar-attention.test.ts | 230 +++++++++++++++++- ui/src/components/sidebar-attention.ts | 153 ++++++++++-- ui/src/components/sidebar-build-chip.ts | 19 +- ui/src/components/sidebar-issue-item.ts | 4 +- ui/src/components/sidebar-menus-render.ts | 32 +++ ui/src/components/sidebar-update-card.ts | 6 +- ui/src/e2e/sidebar-customization.e2e.test.ts | 2 +- ui/src/e2e/update-coalesced.e2e.test.ts | 4 +- ui/src/e2e/update-confirmation.e2e.test.ts | 128 ++++++++++ ui/src/i18n/locales/en.ts | 1 + ui/src/styles/layout.css | 5 +- ui/src/styles/sidebar-footer-update.css | 153 ++++++++++++ .../app-sidebar-cases/identity-menu.ts | 66 +++++ ui/src/test-helpers/control-ui-e2e.ts | 27 ++ 40 files changed, 935 insertions(+), 93 deletions(-) create mode 100644 src/gateway/server-start.boot-id.test.ts create mode 100644 ui/src/styles/sidebar-footer-update.css diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index b59ba7e1ac1a..52bfbeb8cc6e 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -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 diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index d6e18925ca88..22f1db520f03 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -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")]), ), diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index ac9c7978a4dd..03cfa65b1f7e 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -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, diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 1f135e96a3ae..1291d6cf494f 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -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, diff --git a/src/gateway/server-public.ts b/src/gateway/server-public.ts index 4cd68f4c14be..36d441567e15 100644 --- a/src/gateway/server-public.ts +++ b/src/gateway/server-public.ts @@ -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 diff --git a/src/gateway/server-start.boot-id.test.ts b/src/gateway/server-start.boot-id.test.ts new file mode 100644 index 000000000000..11bb1037da86 --- /dev/null +++ b/src/gateway/server-start.boot-id.test.ts @@ -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", + ); + }, + ); +}); diff --git a/src/gateway/server-start.ts b/src/gateway/server-start.ts index b8cbbb338418..e6566e34e288 100644 --- a/src/gateway/server-start.ts +++ b/src/gateway/server-start.ts @@ -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 { + // 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((resolve) => { releasePostReadyWork = resolve; @@ -68,6 +79,7 @@ export async function startGatewayServerCore( kernelRuntime: { ...gatewayKernel, ...transport }, port, opts, + bootId, log, logHealth, logWsControl, diff --git a/src/gateway/server-startup-finish.ts b/src/gateway/server-startup-finish.ts index 5bff5edcb725..8b2eab605454 100644 --- a/src/gateway/server-startup-finish.ts +++ b/src/gateway/server-startup-finish.ts @@ -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, diff --git a/src/gateway/server-ws-runtime.ts b/src/gateway/server-ws-runtime.ts index 0a6eb572c525..6136aac2a1c3 100644 --- a/src/gateway/server-ws-runtime.ts +++ b/src/gateway/server-ws-runtime.ts @@ -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, diff --git a/src/gateway/server.preauth-hardening.test.ts b/src/gateway/server.preauth-hardening.test.ts index 10b7dfd2cdd6..663a3936804e 100644 --- a/src/gateway/server.preauth-hardening.test.ts +++ b/src/gateway/server.preauth-hardening.test.ts @@ -283,6 +283,7 @@ describe("gateway pre-auth hardening", () => { attachGatewayWsConnectionHandler({ wss, clients, + bootId: "preauth-hardening-test-boot", preauthConnectionBudget, port: 0, getResolvedAuth: () => resolvedAuth, diff --git a/src/gateway/server.public-worker-ingress.test.ts b/src/gateway/server.public-worker-ingress.test.ts index 2c5c7a84d57a..40259f3608d0 100644 --- a/src/gateway/server.public-worker-ingress.test.ts +++ b/src/gateway/server.public-worker-ingress.test.ts @@ -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, diff --git a/src/gateway/server/ws-connection.test-helpers.ts b/src/gateway/server/ws-connection.test-helpers.ts index c3f33bd9fd3b..03ced8ab595a 100644 --- a/src/gateway/server/ws-connection.test-helpers.ts +++ b/src/gateway/server/ws-connection.test-helpers.ts @@ -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"), diff --git a/src/gateway/server/ws-connection.test.ts b/src/gateway/server/ws-connection.test.ts index e24212046de2..505f7e88678d 100644 --- a/src/gateway/server/ws-connection.test.ts +++ b/src/gateway/server/ws-connection.test.ts @@ -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( diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index dc3ee58577f6..3d3e509be0f3 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -70,10 +70,9 @@ import { } from "./ws-types.js"; type SubsystemLogger = ReturnType; - -const MAX_QUEUED_MESSAGE_HANDLER_FRAMES = 16; const unauthorizedCloseBeforeConnectLogLimiter = new HandshakeAuthLogLimiter(); type GatewayWsSharedHandlerParams = { + bootId: string; wss: WebSocketServer; clients: Set; 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, diff --git a/src/gateway/server/ws-connection/connect-hello.ts b/src/gateway/server/ws-connection/connect-hello.ts index ba6534c522db..d73fca56f5a3 100644 --- a/src/gateway/server/ws-connection/connect-hello.ts +++ b/src/gateway/server/ws-connection/connect-hello.ts @@ -48,6 +48,7 @@ export async function sendGatewayHello( ): Promise { 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, }, diff --git a/src/gateway/server/ws-connection/connect-hello.update-scope.test.ts b/src/gateway/server/ws-connection/connect-hello.update-scope.test.ts index 6ceadca0462e..90f4df7fb865 100644 --- a/src/gateway/server/ws-connection/connect-hello.update-scope.test.ts +++ b/src/gateway/server/ws-connection/connect-hello.update-scope.test.ts @@ -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"); }); diff --git a/src/gateway/server/ws-connection/message-handler-types.ts b/src/gateway/server/ws-connection/message-handler-types.ts index 2ed3224dca78..3531c2130c39 100644 --- a/src/gateway/server/ws-connection/message-handler-types.ts +++ b/src/gateway/server/ws-connection/message-handler-types.ts @@ -37,6 +37,7 @@ export type GatewayWsMessageHandlerParams = { upgradeReq: IncomingMessage; ingressAttribution: GatewayAttributedIngress; connId: string; + bootId: string; remoteAddr?: string; remotePort?: number; localAddr?: string; diff --git a/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts b/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts index 52db8ac11512..8694fde66a06 100644 --- a/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts +++ b/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts @@ -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, diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index 901d6fbd741c..2c91555617c8 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -326,6 +326,7 @@ function attachGatewayHarness(options: { }); attachGatewayWsMessageHandler({ socket, + bootId: "post-connect-health-test-boot", upgradeReq: { headers: { host: requestHost, diff --git a/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts b/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts index 7d83caedb182..7ad7e414f108 100644 --- a/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts +++ b/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts @@ -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" }, diff --git a/src/plugins/captured-registration.ts b/src/plugins/captured-registration.ts index 90c9845ac8ab..aee5ddd50b1e 100644 --- a/src/plugins/captured-registration.ts +++ b/src/plugins/captured-registration.ts @@ -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: { diff --git a/ui/src/app/update-overlay-helpers.ts b/ui/src/app/update-overlay-helpers.ts index 198d6ba049d8..b9b8f9d245d4 100644 --- a/ui/src/app/update-overlay-helpers.ts +++ b/ui/src/app/update-overlay-helpers.ts @@ -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; diff --git a/ui/src/components/app-sidebar-agent-menu.ts b/ui/src/components/app-sidebar-agent-menu.ts index 91ac0c8a2291..e157afdf12c2 100644 --- a/ui/src/components/app-sidebar-agent-menu.ts +++ b/ui/src/components/app-sidebar-agent-menu.ts @@ -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); diff --git a/ui/src/components/sidebar-attention-dismissals.ts b/ui/src/components/sidebar-attention-dismissals.ts index 466b0403b441..884b2d0750c7 100644 --- a/ui/src/components/sidebar-attention-dismissals.ts +++ b/ui/src/components/sidebar-attention-dismissals.ts @@ -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>; +export type UpdateAttentionDismissal = { version: string; gatewayBootId: string }; +export type SidebarAttentionDismissals = Partial> & { + 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)[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; } diff --git a/ui/src/components/sidebar-attention-items.ts b/ui/src/components/sidebar-attention-items.ts index 01ade9e735b8..8db9b02c44d8 100644 --- a/ui/src/components/sidebar-attention-items.ts +++ b/ui/src/components/sidebar-attention-items.ts @@ -18,7 +18,7 @@ type SidebarAttentionAction = | { kind: "askCustodian"; alert: CustodianAlert }; export type SidebarAttentionItem = { - kind: Exclude; + kind: SidebarAttentionKind; severity: "error" | "warning"; icon: IconName; label: string; diff --git a/ui/src/components/sidebar-attention-panel.runtime.ts b/ui/src/components/sidebar-attention-panel.runtime.ts index c272bcedeac4..1d2cc50436c4 100644 --- a/ui/src/components/sidebar-attention-panel.runtime.ts +++ b/ui/src/components/sidebar-attention-panel.runtime.ts @@ -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; diff --git a/ui/src/components/sidebar-attention.test.ts b/ui/src/components/sidebar-attention.test.ts index 616337b41018..95fa54d207f2 100644 --- a/ui/src/components/sidebar-attention.test.ts +++ b/ui/src/components/sidebar-attention.test.ts @@ -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; 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" }); + }); +}); diff --git a/ui/src/components/sidebar-attention.ts b/ui/src/components/sidebar-attention.ts index ad17b1a3f9bb..4c917c87afd8 100644 --- a/ui/src/components/sidebar-attention.ts +++ b/ui/src/components/sidebar-attention.ts @@ -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} + ${updateSurface + ? html` + + ${canUpdate && updateDismissal && !updateForced + ? html` + + ` + : nothing} + ` + : 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); diff --git a/ui/src/components/sidebar-build-chip.ts b/ui/src/components/sidebar-build-chip.ts index 20584c359f1a..26111ff72fa7 100644 --- a/ui/src/components/sidebar-build-chip.ts +++ b/ui/src/components/sidebar-build-chip.ts @@ -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 { { if (!shouldHandleNavigationClick(event)) { return; @@ -42,7 +48,12 @@ class SidebarBuildChip extends OpenClawLightDomContentsElement { event.preventDefault(); this.onNavigate?.("about"); }} - >${text}${text ? html`${text}` : nothing} + ${this.updateAttentionDismissed + ? html`${t("updates.sidebar.availableTitle")}` + : nothing}