fix(ui): give the Gateway update a visible, truthful outcome (#121686)

* fix(ui): give the Gateway update a visible, truthful outcome

Confirming an update in the Control UI could end in nothing the operator
could see. The sidebar card only knew `updateRunning`, which clears as soon
as `update.run` answers, so a dev-channel handoff — git pull, install, full
build, restart — spent minutes back on the stale "N commits behind" call to
action. A verified install then published a null banner, so success was
never announced at all, and the reload that a verified install triggers
would have destroyed any in-memory notice anyway.

The lifecycle was already recorded; nothing rendered it. Feed the sidebar
the reconciliation fact the settings page already uses, narrate the wait in
the shell callout that survives the restart, and record the verified result
so it outlives the reload it causes.

Failures now name their cause. The updater already stores the failing step
and its captured output in the restart sentinel and `update.status` returns
it whole, so a disk-full install reads "The update failed at install:
ENOSPC: no space left on device" instead of a reason slug plus "see the
gateway logs". Missing handoff-reason guidance is filled in, and the
duplicate post-restart banner resolver is folded into the shared one.

Also fixes a callout clipped by the fixed shell chrome controls, and the
"Available 246 commits behind" double framing in the confirmation.

* fix(ui): report the Gateway update in the dialog that starts it

Follow-up to the first pass: the shell-top callout was the wrong surface for
this. The dialog the operator clicked is now the one that reports the whole
update, and the ambient surfaces are the fallback for anyone who leaves it.

Confirming no longer closes the dialog. It becomes stateful — the danger
button turns into a disabled "Updating…" with a spinner, and the body
narrates the phases in place: installing, then "The Gateway is restarting.
This page disconnects and reconnects on its own." The dialog is mounted on
document.body, outside the shell, so losing the connection cannot unmount
the only surface still reporting. A request the Gateway never accepts stops
spinning after a bounded grace and says so.

A failure lands in the dialog with its recorded cause and next step. Success
still arrives after the reload a verified install triggers, so it stays a
toast — no dialog exists by then.

The shell-top status callout is gone. The same outcome now renders beside
the sidebar update card that started the update, and stays hidden while the
dialog owns the report so the two surfaces never duplicate each other.

Splits the wire-payload readers out of update-overlay-helpers into
update-schedule-dto and moves update.run answer classification next to them,
keeping both owners under the file-size budget.

* fix(ui): keep the update dialog open through the install in E2E and use a defined warn token

* refactor(ui): wire the update progress stream into the floating card and drop unused video capture

* fix(ui): report the current update, not the previous failure

The dialog subscribed to progress before starting the update. Producers
replay their current snapshot synchronously on subscribe, so a retry
still carrying the previous attempt's failure banner painted that old
error as this update's outcome.

Start the request first, and treat a failure present on the subscribe
emit as the previous attempt's: an accepted run clears the banner before
its first await, so a failure that survives means the request was
refused, which the accept timer already reports truthfully.

* fix(ui): keep update helpers internal

* chore(perf): raise startup-JS budget to 350 KiB

Approved by maintainer Vyctor on 2026-08-11.

Trigger: after #121734, main measured 326650 B—6 B below the prior 319 KiB hard ceiling. Current main subsequently moved the ceiling to 320 KiB; this records the authorized final 350 KiB decision for #121686.

* fix(ui): announce update success without session storage

Show the verified install identity directly when the current document does not reload, so denied session storage cannot suppress the operator-visible outcome.

* test(ui): isolate update toast in overlay tests

The Node-only overlay suite now reaches the direct non-reload success toast. Mock the DOM-owned toast boundary so reconciliation assertions can complete without a browser document.
This commit is contained in:
Vyctor H. Brzezowski
2026-08-11 13:49:12 -03:00
committed by GitHub
parent fe4cff7a6b
commit e7ba2e1f6b
34 changed files with 1440 additions and 436 deletions
@@ -1,5 +1,5 @@
{
"startupJsGzipBytes": 326657,
"reason": "chat header project lead-in (#121734); CI-measured merge-ref bytes",
"startupJsGzipBytes": 328285,
"reason": "Gateway update outcome (#121686); Testbox-measured exact-head bytes",
"updatedAt": "2026-08-11"
}
+3 -3
View File
@@ -28,9 +28,9 @@ const CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES = 1024;
const controlUiPerformanceBudgets = {
startupJsRequests: 18,
startupCssRequests: 1,
// 320 KiB maintainer-approved 2026-08 after the chat header project lead-in;
// CI measured 326657 B, one byte above the prior 319 KiB cap.
startupJsGzipBytes: 320 * KIB,
// 350 KiB maintainer-approved by Vyctor 2026-08-11 for #121686;
// #121734 left main 6 B below the prior 319 KiB hard ceiling.
startupJsGzipBytes: 350 * KIB,
// 45 KiB CSS ceilings maintainer-approved 2026-07 alongside the interleaved
// sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline.
startupCssGzipBytes: 45 * KIB,
+2 -2
View File
@@ -397,7 +397,7 @@ describe("OpenClaw shell update affordance", () => {
latestVersion: "2026.7.2",
channel: "stable" as const,
},
updateRunning: false,
updateBusy: false,
canUpdate: true,
onUpdate: vi.fn(),
refreshRequired: false,
@@ -487,7 +487,7 @@ describe("OpenClaw shell update affordance", () => {
const shared = {
onboarding: true,
updateAvailable: null,
updateRunning: false,
updateBusy: false,
onUpdate: vi.fn(),
refreshRequired: true,
onRefresh: vi.fn(),
+34 -10
View File
@@ -38,6 +38,7 @@ import {
loadSettings,
normalizeCatalogOpenTarget,
} from "./settings.ts";
import type { UpdateProgress } from "./update-confirmation.ts";
const EMPTY_OUTBOX_COUNT_FOR_SESSION = () => 0;
const EMPTY_SESSION_HAS_DRAFT = () => false;
@@ -122,6 +123,29 @@ export function renderApplicationShell(host: ShellViewHost) {
: EMPTY_SESSION_HAS_DRAFT;
const navigationSnapshot = context.navigation.snapshot;
const overlaySnapshot = context.overlays.snapshot;
// The install keeps running after `update.run` answers, so the reconciliation
// — not the request — decides how long the update surfaces stay busy.
const updateBusy = overlaySnapshot.updateRunning || overlaySnapshot.updateReconciliationPending;
// The update dialog outlives this render and the connection, so it reads live
// snapshots rather than the values captured here.
const watchUpdateProgress = (listener: (progress: UpdateProgress) => void) => {
const emit = () => {
const update = context.overlays.snapshot;
const banner = update.updateStatusBanner;
listener({
busy: update.updateRunning || update.updateReconciliationPending,
connected: context.gateway.snapshot.phase === "connected",
failure: banner && banner.tone !== "info" ? banner.text : null,
});
};
const stopOverlays = context.overlays.subscribe(emit);
const stopGateway = context.gateway.subscribe(emit);
emit();
return () => {
stopOverlays();
stopGateway();
};
};
const terminalAvailable = isTerminalAvailable(
gatewaySnapshot,
context.config.current.terminalEnabled ?? false,
@@ -232,7 +256,9 @@ export function renderApplicationShell(host: ShellViewHost) {
updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable,
updateSchedule: navigationSurfaceHidden ? null : overlaySnapshot.updateSchedule,
heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId,
updateRunning: overlaySnapshot.updateRunning,
updateBusy,
updateStatusBanner: overlaySnapshot.updateStatusBanner,
watchUpdateProgress,
canUpdate,
canHoldUpdate,
onUpdate: () => void context.overlays.runUpdate(),
@@ -267,7 +293,9 @@ export function renderApplicationShell(host: ShellViewHost) {
updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable,
updateSchedule: navigationSurfaceHidden ? null : overlaySnapshot.updateSchedule,
heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId,
updateRunning: overlaySnapshot.updateRunning,
updateBusy,
updateStatusBanner: overlaySnapshot.updateStatusBanner,
watchUpdateProgress,
canUpdate,
canHoldUpdate,
onUpdate: () => void context.overlays.runUpdate(),
@@ -294,8 +322,7 @@ export function renderApplicationShell(host: ShellViewHost) {
runtimeConfig.configLoading ||
runtimeConfig.configSaving ||
(runtimeConfig.configFormDirty && runtimeConfig.configFormMode === "raw") ||
overlaySnapshot.updateRunning ||
overlaySnapshot.updateReconciliationPending,
updateBusy,
onRetry: () => void context.runtimeConfig.save(),
onReload: () => void context.runtimeConfig.discardDraft(),
onApply: () => void context.runtimeConfig.apply(),
@@ -450,18 +477,15 @@ export function renderApplicationShell(host: ShellViewHost) {
}}
></openclaw-update-banner>`
: nothing}
<openclaw-update-banner
.props=${{
statusBanner: overlaySnapshot.updateStatusBanner,
}}
></openclaw-update-banner>
${renderFloatingUpdateCard({
navigationSurfaceHidden,
onboarding,
updateAvailable: overlaySnapshot.updateAvailable,
updateSchedule: overlaySnapshot.updateSchedule,
heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId,
updateRunning: overlaySnapshot.updateRunning,
updateBusy,
statusBanner: overlaySnapshot.updateStatusBanner,
watchUpdateProgress,
canUpdate,
canHoldUpdate,
onUpdate: () => void context.overlays.runUpdate(),
@@ -52,7 +52,7 @@ describe.skipIf(!hasBrowserLayout)("navigation surface browser layout", () => {
navigationSurfaceHidden: true,
onboarding: false,
updateAvailable: null,
updateRunning: false,
updateBusy: false,
onUpdate: () => undefined,
refreshRequired: true,
onRefresh: () => undefined,
+7 -2
View File
@@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import type { ApplicationContext } from "./context.ts";
import type { UpdateProgress } from "./update-confirmation.ts";
export function navigationSurfaceIsHidden(params: {
onboarding: boolean;
@@ -18,7 +19,9 @@ export function renderFloatingUpdateCard(params: {
updateAvailable: ApplicationContext["overlays"]["snapshot"]["updateAvailable"];
updateSchedule?: ApplicationContext["overlays"]["snapshot"]["updateSchedule"];
heldUpdateCampaignId?: string | null;
updateRunning: boolean;
updateBusy: boolean;
statusBanner?: ApplicationContext["overlays"]["snapshot"]["updateStatusBanner"];
watchUpdateProgress?: (listener: (progress: UpdateProgress) => void) => () => void;
canUpdate?: boolean;
canHoldUpdate?: boolean;
onUpdate: () => void;
@@ -36,7 +39,9 @@ export function renderFloatingUpdateCard(params: {
.updateAvailable=${params.updateAvailable}
.updateSchedule=${params.updateSchedule ?? null}
.heldUpdateCampaignId=${params.heldUpdateCampaignId ?? null}
.updateRunning=${params.updateRunning}
.updateBusy=${params.updateBusy}
.statusBanner=${params.statusBanner ?? null}
.watchUpdateProgress=${params.watchUpdateProgress}
.canUpdate=${params.canUpdate ?? false}
.canHoldUpdate=${params.canHoldUpdate ?? false}
.onUpdate=${params.onUpdate}
+3 -1
View File
@@ -11,10 +11,12 @@ import {
type RequestFn,
} from "./overlays-access.test-support.ts";
import { createApplicationOverlays } from "./overlays.ts";
import { UPDATE_HANDOFF_STARTED_REASON } from "./update-overlay-helpers.ts";
vi.mock("../lib/toast.ts", () => ({ showToast: vi.fn() }));
const UNKNOWN_OUTCOME_TEXT =
"The update request may have been accepted, but the Gateway did not report a final result after reconnect. Run `openclaw update status` before retrying.";
const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started";
function installUpdateTranslations() {
const translations: Record<string, string> = {
+2 -1
View File
@@ -13,13 +13,13 @@ import {
type RequestFn,
} from "./overlays-access.test-support.ts";
import { createApplicationOverlays } from "./overlays.ts";
import { UPDATE_HANDOFF_STARTED_REASON } from "./update-overlay-helpers.ts";
vi.mock("../build-info.ts", () => ({
controlUiVersionDiffersFrom: (gatewayVersion: string | undefined) =>
Boolean(gatewayVersion?.trim() && gatewayVersion.trim() !== "1.0.0"),
reloadControlUiIfStale: vi.fn(),
}));
vi.mock("../lib/toast.ts", () => ({ showToast: vi.fn() }));
const { peekStoredDeviceIdentityIdMock } = vi.hoisted(() => ({
peekStoredDeviceIdentityIdMock: vi.fn((): string | null => "browser-1"),
}));
@@ -29,6 +29,7 @@ vi.mock("../lib/nodes/index.ts", () => ({
const HANDOFF_POLL_MS = 1_000;
const RESTART_VERIFICATION_TIMEOUT_MS = 10_000;
const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started";
function installUpdateTranslations() {
const translations: Record<string, string> = {
+27 -39
View File
@@ -4,7 +4,7 @@ import {
} from "../../../src/gateway/events.js";
import type { GatewayEventFrame } from "../api/gateway.ts";
import type { UpdateAvailable, UpdateHoldResult, UpdateScheduleState } from "../api/types.ts";
import { controlUiVersionDiffersFrom, reloadControlUiIfStale } from "../build-info.ts";
import { controlUiVersionDiffersFrom } from "../build-info.ts";
import { t } from "../i18n/index.ts";
import {
closeDevicePairSetup as closeDevicePairSetupState,
@@ -40,24 +40,30 @@ import {
readOverlayOperatorAccessTransition,
} from "./overlays-access.ts";
import {
classifyUpdateRunResponse,
createPendingUpdateReconciliation,
createUpdateCampaignStatusPoller,
createUpdateStatusRefresher,
createUpdateVerificationController,
projectUpdateStatusResponse,
readUpdateAvailable,
readUpdateAvailableValue,
readUpdateSchedule,
readUpdateScheduleValue,
resolveExpectedUpdateSha,
resolveUnknownUpdateOutcomeBanner,
resolveUpdateStatusBanner,
UPDATE_HANDOFF_STARTED_REASON,
type ApplicationStatusBanner,
type PendingUpdateReconciliation,
type UpdateRestartStatusResponse,
type UpdateRunResponse,
} from "./update-overlay-helpers.ts";
import {
readUpdateAvailable,
readUpdateAvailableValue,
readUpdateSchedule,
readUpdateScheduleValue,
} from "./update-schedule-dto.ts";
import {
announceRecordedUpdateSuccess,
announceVerifiedUpdateInstall,
} from "./update-success-notice.ts";
type ApplicationOverlaySnapshot = {
updateAvailable: UpdateAvailable | null;
@@ -236,7 +242,7 @@ export function createApplicationOverlays(
getHello: () => gateway.snapshot.hello,
publish,
publishBanner: publishUpdateBanner,
onVerifiedInstall: reloadControlUiIfStale,
onVerifiedInstall: announceVerifiedUpdateInstall,
});
const applyUpdateStatusResponse = (response: UpdateRestartStatusResponse) => {
snapshot = {
@@ -439,6 +445,8 @@ export function createApplicationOverlays(
}
});
synchronizeGateway(gateway.snapshot);
// A reload started by the previous document's verified install lands here.
announceRecordedUpdateSuccess();
return {
get snapshot() {
@@ -491,42 +499,22 @@ export function createApplicationOverlays(
) {
return;
}
const status = response.result?.status ?? (response.ok === true ? "ok" : "error");
const expectedVersion =
response.result?.after?.version?.trim() || pendingUpdate.expectedVersion;
const expectedSha = response.result?.after?.sha?.trim() || pendingUpdate.expectedSha;
if (
response.ok === true &&
status === "skipped" &&
response.result?.reason === UPDATE_HANDOFF_STARTED_REASON &&
response.handoff?.status === "started"
) {
pendingUpdate = { expectedVersion, expectedSha, kind: "handoff" };
return;
}
if (response.ok === true && status === "ok") {
pendingUpdate = { expectedVersion, expectedSha, kind: "restart" };
if (response.restart?.coalesced === true) {
snapshot = {
...snapshot,
updateStatusBanner: {
tone: "info",
text: t("updates.coalescedRestart"),
},
};
const accepted = classifyUpdateRunResponse(response, pendingUpdate);
if (accepted) {
pendingUpdate = accepted.pending;
if (accepted.banner) {
snapshot = { ...snapshot, updateStatusBanner: accepted.banner };
}
return;
}
pendingUpdate = null;
if (response.ok !== true || status !== "ok") {
snapshot = {
...snapshot,
updateStatusBanner: resolveUpdateStatusBanner({
status,
reason: response.result?.reason,
}),
};
}
snapshot = {
...snapshot,
updateStatusBanner: resolveUpdateStatusBanner({
status: response.result?.status ?? "error",
reason: response.result?.reason,
}),
};
} catch (error) {
if (
disposed ||
@@ -4,6 +4,29 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
import { getRenderedModalDialog, installDialogPolyfill } from "../test-helpers/modal-dialog.ts";
import { confirmAndStartUpdateRuntime } from "./update-confirmation.runtime.ts";
import type { UpdateProgress } from "./update-confirmation.ts";
/** Drives the dialog the way the shell does: one live lifecycle stream. */
function createProgressStream() {
let emit: ((progress: UpdateProgress) => void) | null = null;
let stopped = false;
return {
get stopped() {
return stopped;
},
watchUpdateProgress: (listener: (progress: UpdateProgress) => void) => {
emit = listener;
listener({ busy: false, connected: true, failure: null });
return () => {
stopped = true;
};
},
async push(progress: UpdateProgress) {
emit?.(progress);
await Promise.resolve();
},
};
}
const UPDATE_AVAILABLE: UpdateAvailable = {
channel: "stable",
@@ -39,10 +62,14 @@ function startUpdate(
updateAvailable?: UpdateAvailable | null;
updateSchedule?: UpdateScheduleState | null;
viaNativeApp?: boolean;
watchUpdateProgress?: (listener: (progress: UpdateProgress) => void) => () => void;
} = {},
) {
const startGatewayUpdate = vi.fn();
const settled = confirmAndStartUpdateRuntime({
...(overrides.watchUpdateProgress
? { watchUpdateProgress: overrides.watchUpdateProgress }
: {}),
startGatewayUpdate: overrides.startGatewayUpdate ?? startGatewayUpdate,
updateAvailable:
overrides.updateAvailable === undefined ? UPDATE_AVAILABLE : overrides.updateAvailable,
@@ -107,6 +134,22 @@ it("shows the git target when no package version is available", async () => {
await settled;
});
it("states a git distance once instead of labelling it as an available version", async () => {
const { settled } = startUpdate({
updateAvailable: { channel: "dev", currentVersion: "2026.8.1", latestVersion: "2026.8.1" },
updateSchedule: {
target: { commitsBehind: 246, kind: "git" },
} as unknown as UpdateScheduleState,
});
const { modal } = await getRenderedModalDialog(document.body);
expect(modal.textContent).toContain("Installed v2026.8.1 · 246 commits behind");
expect(modal.textContent).not.toContain("Available 246");
findButton("Cancel").click();
await settled;
});
it("keeps a repeated request from stacking a second confirmation or update", async () => {
const first = startUpdate();
const second = startUpdate();
@@ -120,3 +163,122 @@ it("keeps a repeated request from stacking a second confirmation or update", asy
await first.settled;
expect(first.startGatewayUpdate).toHaveBeenCalledOnce();
});
it("keeps the dialog open and narrates the install, the restart, and the failure", async () => {
const stream = createProgressStream();
const { settled, startGatewayUpdate } = startUpdate({
watchUpdateProgress: stream.watchUpdateProgress,
});
const { modal } = await getRenderedModalDialog(document.body);
findButton("Update and restart").click();
await Promise.resolve();
expect(startGatewayUpdate).toHaveBeenCalledOnce();
const updating = findButton("Updating…");
expect(updating.disabled).toBe(true);
expect(modal.textContent).toContain("Installing the update on the Gateway");
// The Gateway goes away mid-install; the dialog is mounted outside the shell
// precisely so it can keep reporting through the disconnect.
await stream.push({ busy: true, connected: false, failure: null });
expect(modal.textContent).toContain("The Gateway is restarting");
expect(document.body.querySelector("openclaw-modal-dialog")).not.toBeNull();
await stream.push({
busy: false,
connected: true,
failure: "The update failed at install: ENOSPC: no space left on device, write.",
});
expect(modal.textContent).toContain("ENOSPC: no space left on device");
findButton("Close").click();
await settled;
expect(stream.stopped).toBe(true);
});
it("closes itself once a watched update finishes without a failure", async () => {
const stream = createProgressStream();
const { settled } = startUpdate({ watchUpdateProgress: stream.watchUpdateProgress });
await getRenderedModalDialog(document.body);
findButton("Update and restart").click();
await Promise.resolve();
await stream.push({ busy: true, connected: true, failure: null });
await stream.push({ busy: false, connected: true, failure: null });
await settled;
expect(document.body.querySelector("openclaw-modal-dialog")).toBeNull();
});
/**
* Retry after a failure: the shell keeps the previous attempt's banner until an
* accepted run clears it, and producers replay the current snapshot as their
* subscribe-time emit. `accepted: false` models `overlays.runUpdate` refusing
* the request (disconnected, already running, no admin), which leaves the
* banner in place.
*/
function createRetryStream(options: { accepted: boolean }) {
let progress: UpdateProgress = {
busy: false,
connected: true,
failure: "The update failed at install: ENOSPC: no space left on device, write.",
};
let emit: ((next: UpdateProgress) => void) | null = null;
return {
startGatewayUpdate: () => {
if (!options.accepted) {
return;
}
progress = { busy: true, connected: true, failure: null };
emit?.(progress);
},
watchUpdateProgress: (listener: (next: UpdateProgress) => void) => {
emit = listener;
listener(progress);
return () => {};
},
};
}
it("reports a refused retry as unanswered rather than as the old failure", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const stream = createRetryStream({ accepted: false });
const { settled } = startUpdate({
startGatewayUpdate: stream.startGatewayUpdate,
watchUpdateProgress: stream.watchUpdateProgress,
});
const { modal } = await getRenderedModalDialog(document.body);
findButton("Update and restart").click();
await Promise.resolve();
// The refused request must not inherit the previous error as its outcome.
expect(modal.textContent).not.toContain("ENOSPC");
await vi.advanceTimersByTimeAsync(5_000);
expect(modal.textContent).toContain("The update request went unanswered");
findButton("Close").click();
await settled;
} finally {
vi.useRealTimers();
}
});
it("reports a request the Gateway never accepted instead of spinning forever", async () => {
// Auto-advancing keeps the modal's own animation frames running while the
// grace deadline is fast-forwarded.
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const stream = createProgressStream();
const { settled } = startUpdate({ watchUpdateProgress: stream.watchUpdateProgress });
const { modal } = await getRenderedModalDialog(document.body);
findButton("Update and restart").click();
await vi.advanceTimersByTimeAsync(5_000);
expect(modal.textContent).toContain("The update request went unanswered");
findButton("Close").click();
await settled;
} finally {
vi.useRealTimers();
}
});
+183 -20
View File
@@ -1,14 +1,31 @@
// Implementation of the Control UI's disruptive-update confirmation gate. It
// stays behind the `update-confirmation.ts` lazy boundary because nothing here
// runs until an operator clicks an update affordance, and the startup bundle
// has no room for a dialog nobody has opened yet.
// Implementation of the Control UI's disruptive-update dialog. It stays behind
// the `update-confirmation.ts` lazy boundary because nothing here runs until an
// operator clicks an update affordance, and the startup bundle has no room for
// a dialog nobody has opened yet.
//
// The dialog is the operator's primary surface for the whole update: it opens
// as a confirmation, becomes a progress report on confirm, and reports a
// failure in place. It is mounted on `document.body`, outside the shell, so the
// Gateway restart that tears down the connection cannot unmount it.
import { html, nothing, render } from "lit";
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
import { showConfirmDialog } from "../components/confirm-dialog.ts";
import { t } from "../i18n/index.ts";
import "../components/modal-dialog.ts";
import { postNativeUpdate } from "./native-link-routing.ts";
import type { ConfirmAndStartUpdateParams } from "./update-confirmation.ts";
import type { ConfirmAndStartUpdateParams, UpdateProgress } from "./update-confirmation.ts";
import { formatUpdateTargetLabel } from "./update-overlay-helpers.ts";
/** Bounds the wait for the request to be accepted before calling it a no-start. */
const UPDATE_ACCEPT_GRACE_MS = 4_000;
const UPDATE_DIALOG_OPEN_CLASS = "update-dialog-open";
type DialogPhase =
| { kind: "confirm" }
| { kind: "working"; connected: boolean }
| { kind: "failed"; message: string };
let updateDialogActive = false;
function formatInstalledAndAvailable(
updateAvailable: UpdateAvailable | null,
updateSchedule: UpdateScheduleState | null,
@@ -19,14 +36,38 @@ function formatInstalledAndAvailable(
: null;
const available = formatUpdateTargetLabel(updateSchedule, updateAvailable);
if (installed && available) {
return t("updates.confirm.versions", { available, installed });
// A commit count already reads as a distance, so "Available 246 commits
// behind" would double the framing; only a version needs the label.
const behind =
updateSchedule?.target?.kind === "git" || updateAvailable?.commitsBehind !== undefined;
return t(behind ? "updates.confirm.versionsBehind" : "updates.confirm.versions", {
available,
installed,
});
}
return installed ?? available ?? undefined;
}
function workingMessage(connected: boolean): string {
// The restart is the loud part of the wait; name it while it is happening
// instead of leaving the operator to interpret a frozen page.
return connected ? t("updates.dialog.installing") : t("updates.dialog.restarting");
}
export async function confirmAndStartUpdateRuntime(
params: ConfirmAndStartUpdateParams,
): Promise<void> {
// Native confirms block reentrancy; refuse a second request rather than
// stacking a dialog over an update that is already being reported.
if (updateDialogActive) {
return;
}
updateDialogActive = true;
const host = document.createElement("div");
document.body.append(host);
// One surface owns the outcome at a time: the ambient copy stays hidden while
// the dialog that started this update is still reporting it.
document.body.classList.add(UPDATE_DIALOG_OPEN_CLASS);
const route = params.viaNativeApp
? {
confirmLabel: t("updates.confirm.macAction"),
@@ -38,18 +79,140 @@ export async function confirmAndStartUpdateRuntime(
message: t("updates.confirm.message"),
title: t("chat.sidebar.updateGateway"),
};
const confirmed = await showConfirmDialog({
title: route.title,
// The impact sentence is shared so both routes state the same consequence.
message: `${route.message} ${t("updates.confirm.impact")}`,
details: formatInstalledAndAvailable(params.updateAvailable, params.updateSchedule),
confirmLabel: route.confirmLabel,
const details = formatInstalledAndAvailable(params.updateAvailable, params.updateSchedule);
await new Promise<void>((resolve) => {
let phase: DialogPhase = { kind: "confirm" };
let settled = false;
let stopWatching: (() => void) | undefined;
let acceptTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
let sawBusy = false;
const finish = () => {
if (settled) {
return;
}
settled = true;
stopWatching?.();
if (acceptTimer !== undefined) {
globalThis.clearTimeout(acceptTimer);
}
render(nothing, host);
host.remove();
document.body.classList.remove(UPDATE_DIALOG_OPEN_CLASS);
updateDialogActive = false;
resolve();
};
const draw = () => {
if (settled) {
return;
}
const current = phase;
const working = current.kind === "working";
const failed = current.kind === "failed";
const body =
current.kind === "failed"
? current.message
: current.kind === "working"
? workingMessage(current.connected)
: `${route.message} ${t("updates.confirm.impact")}`;
render(
html`
<openclaw-modal-dialog label=${route.title} description=${body} @modal-cancel=${finish}>
<div class="exec-approval-card">
<div class="exec-approval-header">
<div>
<div class="exec-approval-title">${route.title}</div>
<div class="exec-approval-sub" style="white-space: pre-line">${body}</div>
</div>
</div>
${details && !failed
? html`<div class="exec-approval-command mono">${details}</div>`
: nothing}
<div class="exec-approval-actions">
${failed
? html`<button type="button" class="btn" autofocus @click=${finish}>
${t("common.close")}
</button>`
: html`
<button
type="button"
class="btn danger ${working ? "btn--busy" : ""}"
?disabled=${working}
@click=${confirm}
>
${working
? html`<span class="btn__spinner" aria-hidden="true"></span>${t(
"chat.updating",
)}`
: route.confirmLabel}
</button>
<button type="button" class="btn" autofocus @click=${finish}>
${working ? t("common.close") : t("common.cancel")}
</button>
`}
</div>
</div>
</openclaw-modal-dialog>
`,
host,
);
};
function confirm() {
if (phase.kind !== "confirm") {
return;
}
if (params.viaNativeApp && postNativeUpdate()) {
finish();
return;
}
const watch = params.watchUpdateProgress;
if (!watch) {
params.startGatewayUpdate();
finish();
return;
}
phase = { kind: "working", connected: true };
draw();
// Start before subscribing: an accepted run clears the retained banner
// synchronously, before its first await. Producers then emit that fresh
// snapshot as the subscribe-time emit, so a failure still present on it
// belongs to the previous attempt and is not this update's outcome —
// a refused request is reported by the accept timer below instead.
params.startGatewayUpdate();
let retainedEmit = true;
stopWatching = watch((progress: UpdateProgress) => {
const staleFailure = retainedEmit;
retainedEmit = false;
if (settled || phase.kind === "confirm") {
return;
}
if (progress.failure && !staleFailure) {
phase = { kind: "failed", message: progress.failure };
draw();
return;
}
if (progress.busy) {
sawBusy = true;
} else if (sawBusy) {
// Finished without a failure: the outcome is a toast, or a reload
// that replays it. Nothing left for the dialog to say.
finish();
return;
}
phase = { kind: "working", connected: progress.connected };
draw();
});
acceptTimer = globalThis.setTimeout(() => {
if (settled || sawBusy || phase.kind !== "working") {
return;
}
phase = { kind: "failed", message: t("updates.dialog.notStarted") };
draw();
}, UPDATE_ACCEPT_GRACE_MS);
}
draw();
});
if (!confirmed) {
return;
}
if (params.viaNativeApp && postNativeUpdate()) {
return;
}
params.startGatewayUpdate();
}
+15
View File
@@ -5,6 +5,15 @@
// operator has not opened.
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
/** What the dialog needs to narrate an install it cannot observe directly. */
export type UpdateProgress = {
/** The install is accepted and unfinished, across the restart. */
busy: boolean;
connected: boolean;
/** Set once the update produced a definitive failure. */
failure: string | null;
};
export type ConfirmAndStartUpdateParams = {
updateAvailable: UpdateAvailable | null;
updateSchedule: UpdateScheduleState | null;
@@ -15,6 +24,12 @@ export type ConfirmAndStartUpdateParams = {
*/
viaNativeApp: boolean;
startGatewayUpdate: () => void;
/**
* Streams the update lifecycle so the dialog can stay open and report it.
* A surface that cannot supply one closes on confirm instead of holding a
* dialog it can never update; the ambient surfaces narrate from there.
*/
watchUpdateProgress?: (listener: (progress: UpdateProgress) => void) => () => void;
};
export async function confirmAndStartUpdate(params: ConfirmAndStartUpdateParams): Promise<void> {
+40 -7
View File
@@ -10,14 +10,15 @@ import type {
import {
createUpdateVerificationController,
formatUpdateCampaignLabel,
readUpdateAvailable,
readUpdateSchedule,
resolveUpdateStatusBanner,
} from "./update-overlay-helpers.ts";
import { readUpdateAvailable, readUpdateSchedule } from "./update-schedule-dto.ts";
const translations: Record<string, string> = {
"updates.status": "Update {status}: {reason}. {guidance}",
"updates.failureReasons.dirty": "Commit or stash changes, then retry.",
"updates.failureReasons.depsInstallFailed":
"Dependency install failed. Fix the install error and retry.",
"updates.failureReasons.default":
"See the gateway logs for the exact failure and retry once the cause is fixed.",
"updates.verificationFailed":
@@ -28,9 +29,9 @@ const translations: Record<string, string> = {
"Update finished, but the running install does not match the expected revision. Expected {expected}, running {actual}.",
"updates.outcomeUnknown": "The update outcome is unknown.",
"common.unknown": "Unknown",
"updates.postRestart.restartUnhealthy":
"The replacement process never became healthy and the previous process stayed up.",
"updates.postRestart.default": "Check the gateway logs for the replacement failure.",
"updates.failureReasons.restartUnhealthy":
"The replacement process never became healthy. The previous process stayed up so you can recover.",
"updates.failedAtStep": "The update failed at {step}: {cause}.",
"updates.handoffTimeout":
"Update handoff started, but completion was not reported after reconnect. Run `openclaw update status` for the final result.",
"updates.campaign.countdown": "Updating in {time}",
@@ -226,6 +227,38 @@ describe("update status localization", () => {
});
});
it("names the recorded cause instead of the reason slug when a step failed", async () => {
installTranslations();
await expect(
verifyUpdate({
pending: { kind: "handoff", expectedVersion: "2.0.0", expectedSha: null },
response: {
sentinel: {
kind: "update",
status: "error",
stats: {
reason: "deps-install-failed",
steps: [
{ name: "fetch", log: { exitCode: 0, stderrTail: "done" } },
{
name: "install",
log: {
exitCode: 1,
stderrTail: "Progress: resolved 1\nENOSPC: no space left on device, write",
},
},
],
},
},
},
}),
).resolves.toEqual({
tone: "danger",
text: "The update failed at install: ENOSPC: no space left on device, write. Dependency install failed. Fix the install error and retry.",
});
});
it("preserves unknown status details inside localized fallback guidance", () => {
const translate = installTranslations();
@@ -326,7 +359,7 @@ describe("update status localization", () => {
}),
).resolves.toEqual({
tone: "danger",
text: "Update error: restart-unhealthy. The replacement process never became healthy and the previous process stayed up.",
text: "Update error: restart-unhealthy. The replacement process never became healthy. The previous process stayed up so you can recover.",
});
await expect(
verifyUpdate({
@@ -341,7 +374,7 @@ describe("update status localization", () => {
}),
).resolves.toEqual({
tone: "danger",
text: "Update error: supervisor-exited. Check the gateway logs for the replacement failure.",
text: "Update error: supervisor-exited. See the gateway logs for the exact failure and retry once the cause is fixed.",
});
await expect(
verifyUpdate({
+97 -243
View File
@@ -1,14 +1,14 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { GatewayBrowserClient, GatewayHelloOk } from "../api/gateway.ts";
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
import { t } from "../i18n/index.ts";
import { readUpdateAvailableValue, readUpdateScheduleValue } from "./update-schedule-dto.ts";
export type ApplicationStatusBanner = {
tone: "danger" | "warn" | "info";
text: string;
};
export const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started";
const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started";
const UPDATE_RESTART_HEALTH_PENDING_REASON = "restart-health-pending";
const UPDATE_RESTART_VERIFICATION_POLL_MS = 250;
const UPDATE_RESTART_VERIFICATION_TIMEOUT_MS = 10_000;
@@ -37,6 +37,24 @@ const UPDATE_FAILURE_REASON_KEYS: Record<string, string> = {
"managed-service-handoff-already-running":
"updates.failureReasons.managedServiceHandoffAlreadyRunning",
"doctor-failed": "updates.failureReasons.doctorFailed",
// The detached helper owns these; its output never reaches the gateway log,
// so the default "see the gateway logs" guidance would send operators nowhere.
"managed-service-handoff-failed": "updates.failureReasons.managedServiceHandoffFailed",
"managed-service-handoff-spawn-failed": "updates.failureReasons.managedServiceHandoffSpawnFailed",
"managed-service-handoff-helper-failed": "updates.failureReasons.managedServiceHandoffFailed",
"managed-service-handoff-parent-timeout":
"updates.failureReasons.managedServiceHandoffParentTimeout",
};
// One line is enough to name the cause; the full tail belongs in the CLI.
const MAX_UPDATE_FAILURE_CAUSE_CHARS = 180;
type UpdateSentinelStep = {
name?: string | null;
log?: {
stdoutTail?: string | null;
stderrTail?: string | null;
exitCode?: number | null;
} | null;
};
export type UpdateRestartStatusResponse = {
@@ -46,12 +64,42 @@ export type UpdateRestartStatusResponse = {
stats?: {
reason?: string | null;
after?: { sha?: string | null; version?: string | null } | null;
steps?: UpdateSentinelStep[] | null;
} | null;
} | null;
updateAvailable?: UpdateAvailable | null;
schedule?: UpdateScheduleState;
};
type UpdateFailureCause = { step: string; detail: string };
function lastLogLine(tail: string | null | undefined): string | null {
const lines = (tail ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const last = lines.at(-1);
return last ? last.slice(0, MAX_UPDATE_FAILURE_CAUSE_CHARS) : null;
}
/**
* The updater records why it stopped — the failing step plus its captured
* output — in the restart sentinel. Read that recorded fact instead of making
* the operator reconstruct a disk-full or build failure from a reason slug.
*/
function readUpdateFailureCause(
sentinel: UpdateRestartStatusResponse["sentinel"],
): UpdateFailureCause | null {
const steps = sentinel?.stats?.steps;
// The run stops at its first failure, so the last non-zero exit is the cause.
const failed = Array.isArray(steps)
? steps.findLast((step) => typeof step?.log?.exitCode === "number" && step.log.exitCode !== 0)
: undefined;
const detail = lastLogLine(failed?.log?.stderrTail) ?? lastLogLine(failed?.log?.stdoutTail);
const step = failed?.name?.trim();
return step && detail ? { step, detail } : null;
}
export type UpdateRunResponse = {
ok?: boolean;
result?: {
@@ -95,6 +143,38 @@ export function createUpdateStatusRefresher(params: {
};
}
/**
* Reads what an `update.run` answer means for reconciliation. The RPC answers
* long before a managed handoff finishes, so an accepted request yields the
* pending record to verify after the restart, not an outcome.
*/
export function classifyUpdateRunResponse(
response: UpdateRunResponse,
pending: PendingUpdateReconciliation,
): { pending: PendingUpdateReconciliation; banner: ApplicationStatusBanner | null } | null {
const status = response.result?.status ?? (response.ok === true ? "ok" : "error");
const expectedVersion = response.result?.after?.version?.trim() || pending.expectedVersion;
const expectedSha = response.result?.after?.sha?.trim() || pending.expectedSha;
if (
response.ok === true &&
status === "skipped" &&
response.result?.reason === UPDATE_HANDOFF_STARTED_REASON &&
response.handoff?.status === "started"
) {
return { pending: { expectedVersion, expectedSha, kind: "handoff" }, banner: null };
}
if (response.ok === true && status === "ok") {
return {
pending: { expectedVersion, expectedSha, kind: "restart" },
banner:
response.restart?.coalesced === true
? { tone: "info", text: t("updates.coalescedRestart") }
: null,
};
}
return null;
}
export function resolveExpectedUpdateSha(
schedule: UpdateScheduleState | null,
updateAvailable: UpdateAvailable | null,
@@ -204,7 +284,13 @@ export function createUpdateVerificationController(params: {
}
if (sentinel?.kind === "update" && sentinel.status && sentinel.status !== "ok") {
params.clearPending();
params.publishBanner(resolvePostRestartUpdateBanner(sentinel.stats?.reason));
params.publishBanner(
resolveUpdateStatusBanner({
status: "error",
...(sentinel.stats?.reason ? { reason: sentinel.stats.reason } : {}),
cause: readUpdateFailureCause(sentinel),
}),
);
return;
}
const actualVersion = sentinel?.stats?.after?.version?.trim() || null;
@@ -325,227 +411,6 @@ function resolveUpdateVerificationWindow(
};
}
export function readUpdateAvailable(hello: GatewayHelloOk | null): UpdateAvailable | null {
const snapshot = hello?.snapshot;
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
return null;
}
const update = (snapshot as { updateAvailable?: unknown }).updateAvailable;
return readUpdateAvailableValue(update);
}
export function readUpdateAvailableValue(update: unknown): UpdateAvailable | null {
if (!isRecord(update)) {
return null;
}
const rawCommits = update.commits;
const commits =
Array.isArray(rawCommits) &&
rawCommits.length <= 5 &&
rawCommits.every(
(commit): commit is { sha: string; subject: string } =>
isRecord(commit) &&
typeof commit.sha === "string" &&
commit.sha.length > 0 &&
typeof commit.subject === "string" &&
commit.subject.length <= 120,
)
? rawCommits.map((commit) => ({ sha: commit.sha, subject: commit.subject }))
: undefined;
return typeof update.currentVersion === "string" &&
typeof update.latestVersion === "string" &&
typeof update.channel === "string"
? {
currentVersion: update.currentVersion,
latestVersion: update.latestVersion,
channel: update.channel,
...(typeof update.currentSha === "string" ? { currentSha: update.currentSha } : {}),
...(typeof update.upstreamRef === "string" ? { upstreamRef: update.upstreamRef } : {}),
...(typeof update.upstreamSha === "string" ? { upstreamSha: update.upstreamSha } : {}),
...(Number.isInteger(update.commitsBehind) && Number(update.commitsBehind) >= 0
? { commitsBehind: Number(update.commitsBehind) }
: {}),
...(commits ? { commits } : {}),
}
: null;
}
function readScheduleTarget(value: unknown): UpdateScheduleState["target"] | null {
if (!isRecord(value)) {
return null;
}
if (value.kind === "package" && typeof value.version === "string") {
return { kind: "package", version: value.version };
}
if (
value.kind === "git" &&
typeof value.upstreamRef === "string" &&
typeof value.upstreamSha === "string" &&
Number.isInteger(value.commitsBehind) &&
Number(value.commitsBehind) >= 0
) {
return {
kind: "git",
upstreamRef: value.upstreamRef,
upstreamSha: value.upstreamSha,
commitsBehind: Number(value.commitsBehind),
};
}
return null;
}
function readGitInstallMetadata(value: Record<string, unknown>): {
currentSha?: string;
commitAtMs?: number;
installedAtMs?: number;
} | null {
if (
(value.currentSha !== undefined &&
(typeof value.currentSha !== "string" || value.currentSha.length === 0)) ||
(value.commitAtMs !== undefined &&
(!Number.isInteger(value.commitAtMs) || Number(value.commitAtMs) < 0)) ||
(value.installedAtMs !== undefined &&
(!Number.isInteger(value.installedAtMs) || Number(value.installedAtMs) < 0))
) {
return null;
}
return {
...(typeof value.currentSha === "string" ? { currentSha: value.currentSha } : {}),
...(value.commitAtMs === undefined ? {} : { commitAtMs: Number(value.commitAtMs) }),
...(value.installedAtMs === undefined ? {} : { installedAtMs: Number(value.installedAtMs) }),
};
}
function readGitUpdateStatus(
value: unknown,
): NonNullable<NonNullable<UpdateScheduleState["install"]>["git"]> | null {
if (!isRecord(value)) {
return null;
}
const metadata = readGitInstallMetadata(value);
if (!metadata) {
return null;
}
if (value.status === "current") {
return { ...metadata, status: "current" };
}
if (
value.status === "behind" &&
Number.isInteger(value.commitsBehind) &&
Number(value.commitsBehind) > 0
) {
return { ...metadata, status: "behind", commitsBehind: Number(value.commitsBehind) };
}
if (
value.status === "ahead" &&
Number.isInteger(value.commitsAhead) &&
Number(value.commitsAhead) > 0
) {
return { ...metadata, status: "ahead", commitsAhead: Number(value.commitsAhead) };
}
if (
value.status === "diverged" &&
Number.isInteger(value.commitsAhead) &&
Number(value.commitsAhead) > 0 &&
Number.isInteger(value.commitsBehind) &&
Number(value.commitsBehind) > 0
) {
return {
...metadata,
status: "diverged",
commitsAhead: Number(value.commitsAhead),
commitsBehind: Number(value.commitsBehind),
};
}
if (
value.status === "unavailable" &&
(value.reason === "fetch-failed" ||
value.reason === "no-upstream" ||
value.reason === "no-upstream-sha" ||
value.reason === "comparison-failed" ||
value.reason === "git-unavailable")
) {
return { ...metadata, status: "unavailable", reason: value.reason };
}
return null;
}
function readScheduleCampaign(value: unknown): UpdateScheduleState["campaign"] | null {
if (
!isRecord(value) ||
typeof value.id !== "string" ||
(value.state !== "waiting-for-idle" &&
value.state !== "countdown" &&
value.state !== "applying") ||
!Number.isInteger(value.announcedAtMs) ||
Number(value.announcedAtMs) < 0 ||
!Number.isInteger(value.forceAtMs) ||
Number(value.forceAtMs) < 0 ||
!Number.isInteger(value.updatedAtMs) ||
Number(value.updatedAtMs) < 0 ||
(value.applyAtMs !== undefined &&
(!Number.isInteger(value.applyAtMs) || Number(value.applyAtMs) < 0)) ||
(value.holdUntilMs !== undefined &&
(!Number.isInteger(value.holdUntilMs) || Number(value.holdUntilMs) < 0))
) {
return null;
}
return {
id: value.id,
state: value.state,
announcedAtMs: Number(value.announcedAtMs),
...(value.applyAtMs === undefined ? {} : { applyAtMs: Number(value.applyAtMs) }),
...(value.holdUntilMs === undefined ? {} : { holdUntilMs: Number(value.holdUntilMs) }),
forceAtMs: Number(value.forceAtMs),
updatedAtMs: Number(value.updatedAtMs),
};
}
export function readUpdateScheduleValue(value: unknown): UpdateScheduleState | null {
if (
!isRecord(value) ||
typeof value.channel !== "string" ||
typeof value.autoEnabled !== "boolean"
) {
return null;
}
const rawInstall = isRecord(value.install) ? value.install : null;
const rawInstallKind = rawInstall?.kind;
const installKind =
rawInstallKind === "package" || rawInstallKind === "git" || rawInstallKind === "unknown"
? rawInstallKind
: undefined;
if (value.install !== undefined && installKind === undefined) {
return null;
}
const gitStatus = rawInstall?.git === undefined ? undefined : readGitUpdateStatus(rawInstall.git);
if (rawInstall?.git !== undefined && !gitStatus) {
return null;
}
const target = value.target === undefined ? undefined : readScheduleTarget(value.target);
const campaign = value.campaign === undefined ? undefined : readScheduleCampaign(value.campaign);
if ((value.target !== undefined && !target) || (value.campaign !== undefined && !campaign)) {
return null;
}
return {
channel: value.channel,
autoEnabled: value.autoEnabled,
...(installKind
? { install: { kind: installKind, ...(gitStatus ? { git: gitStatus } : {}) } }
: {}),
...(target ? { target } : {}),
...(campaign ? { campaign } : {}),
};
}
export function readUpdateSchedule(hello: GatewayHelloOk | null): UpdateScheduleState | null {
const snapshot = hello?.snapshot;
if (!isRecord(snapshot)) {
return null;
}
return readUpdateScheduleValue(snapshot.updateSchedule);
}
export function projectUpdateStatusResponse(
response: UpdateRestartStatusResponse,
current: {
@@ -570,6 +435,7 @@ export function projectUpdateStatusResponse(
: resolveUpdateStatusBanner({
status: sentinel.status,
reason: sentinel.stats?.reason ?? undefined,
cause: readUpdateFailureCause(sentinel),
})
: current.updateStatusBanner,
...(Object.hasOwn(response, "updateAvailable")
@@ -638,13 +504,19 @@ export function formatUpdateTargetLabel(
export function resolveUpdateStatusBanner(params: {
status?: string;
reason?: string;
cause?: UpdateFailureCause | null;
}): ApplicationStatusBanner {
const status = (params.status ?? "error").trim() || "error";
const reason = (params.reason ?? "unexpected-error").trim() || "unexpected-error";
const guidance = t(UPDATE_FAILURE_REASON_KEYS[reason] ?? "updates.failureReasons.default");
const cause = params.cause;
return {
tone: status === "skipped" ? "warn" : "danger",
text: t("updates.status", { status, reason, guidance }),
// A recorded cause names what actually broke; the reason slug only names
// which step owned it.
text: cause
? `${t("updates.failedAtStep", { step: cause.step, cause: cause.detail })} ${guidance}`
: t("updates.status", { status, reason, guidance }),
};
}
@@ -670,24 +542,6 @@ function resolveUpdateVerificationBanner(params: {
};
}
function resolvePostRestartUpdateBanner(
reason: string | null | undefined,
): ApplicationStatusBanner {
const normalizedReason = reason?.trim() || "restart-unhealthy";
const guidanceKey =
normalizedReason === "restart-unhealthy"
? "updates.postRestart.restartUnhealthy"
: "updates.postRestart.default";
return {
tone: "danger",
text: t("updates.status", {
status: "error",
reason: normalizedReason,
guidance: t(guidanceKey),
}),
};
}
function resolvePendingUpdateHandoffTimeoutBanner(): ApplicationStatusBanner {
return {
tone: "danger",
+227
View File
@@ -0,0 +1,227 @@
// Normalizes the Gateway's update-availability and update-schedule payloads into
// the shapes the Control UI renders. These readers are the trust boundary for
// wire data, so they stay separate from the lifecycle controllers that consume them.
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { GatewayHelloOk } from "../api/gateway.ts";
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
export function readUpdateAvailable(hello: GatewayHelloOk | null): UpdateAvailable | null {
const snapshot = hello?.snapshot;
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
return null;
}
const update = (snapshot as { updateAvailable?: unknown }).updateAvailable;
return readUpdateAvailableValue(update);
}
export function readUpdateAvailableValue(update: unknown): UpdateAvailable | null {
if (!isRecord(update)) {
return null;
}
const rawCommits = update.commits;
const commits =
Array.isArray(rawCommits) &&
rawCommits.length <= 5 &&
rawCommits.every(
(commit): commit is { sha: string; subject: string } =>
isRecord(commit) &&
typeof commit.sha === "string" &&
commit.sha.length > 0 &&
typeof commit.subject === "string" &&
commit.subject.length <= 120,
)
? rawCommits.map((commit) => ({ sha: commit.sha, subject: commit.subject }))
: undefined;
return typeof update.currentVersion === "string" &&
typeof update.latestVersion === "string" &&
typeof update.channel === "string"
? {
currentVersion: update.currentVersion,
latestVersion: update.latestVersion,
channel: update.channel,
...(typeof update.currentSha === "string" ? { currentSha: update.currentSha } : {}),
...(typeof update.upstreamRef === "string" ? { upstreamRef: update.upstreamRef } : {}),
...(typeof update.upstreamSha === "string" ? { upstreamSha: update.upstreamSha } : {}),
...(Number.isInteger(update.commitsBehind) && Number(update.commitsBehind) >= 0
? { commitsBehind: Number(update.commitsBehind) }
: {}),
...(commits ? { commits } : {}),
}
: null;
}
function readScheduleTarget(value: unknown): UpdateScheduleState["target"] | null {
if (!isRecord(value)) {
return null;
}
if (value.kind === "package" && typeof value.version === "string") {
return { kind: "package", version: value.version };
}
if (
value.kind === "git" &&
typeof value.upstreamRef === "string" &&
typeof value.upstreamSha === "string" &&
Number.isInteger(value.commitsBehind) &&
Number(value.commitsBehind) >= 0
) {
return {
kind: "git",
upstreamRef: value.upstreamRef,
upstreamSha: value.upstreamSha,
commitsBehind: Number(value.commitsBehind),
};
}
return null;
}
function readGitInstallMetadata(value: Record<string, unknown>): {
currentSha?: string;
commitAtMs?: number;
installedAtMs?: number;
} | null {
if (
(value.currentSha !== undefined &&
(typeof value.currentSha !== "string" || value.currentSha.length === 0)) ||
(value.commitAtMs !== undefined &&
(!Number.isInteger(value.commitAtMs) || Number(value.commitAtMs) < 0)) ||
(value.installedAtMs !== undefined &&
(!Number.isInteger(value.installedAtMs) || Number(value.installedAtMs) < 0))
) {
return null;
}
return {
...(typeof value.currentSha === "string" ? { currentSha: value.currentSha } : {}),
...(value.commitAtMs === undefined ? {} : { commitAtMs: Number(value.commitAtMs) }),
...(value.installedAtMs === undefined ? {} : { installedAtMs: Number(value.installedAtMs) }),
};
}
function readGitUpdateStatus(
value: unknown,
): NonNullable<NonNullable<UpdateScheduleState["install"]>["git"]> | null {
if (!isRecord(value)) {
return null;
}
const metadata = readGitInstallMetadata(value);
if (!metadata) {
return null;
}
if (value.status === "current") {
return { ...metadata, status: "current" };
}
if (
value.status === "behind" &&
Number.isInteger(value.commitsBehind) &&
Number(value.commitsBehind) > 0
) {
return { ...metadata, status: "behind", commitsBehind: Number(value.commitsBehind) };
}
if (
value.status === "ahead" &&
Number.isInteger(value.commitsAhead) &&
Number(value.commitsAhead) > 0
) {
return { ...metadata, status: "ahead", commitsAhead: Number(value.commitsAhead) };
}
if (
value.status === "diverged" &&
Number.isInteger(value.commitsAhead) &&
Number(value.commitsAhead) > 0 &&
Number.isInteger(value.commitsBehind) &&
Number(value.commitsBehind) > 0
) {
return {
...metadata,
status: "diverged",
commitsAhead: Number(value.commitsAhead),
commitsBehind: Number(value.commitsBehind),
};
}
if (
value.status === "unavailable" &&
(value.reason === "fetch-failed" ||
value.reason === "no-upstream" ||
value.reason === "no-upstream-sha" ||
value.reason === "comparison-failed" ||
value.reason === "git-unavailable")
) {
return { ...metadata, status: "unavailable", reason: value.reason };
}
return null;
}
function readScheduleCampaign(value: unknown): UpdateScheduleState["campaign"] | null {
if (
!isRecord(value) ||
typeof value.id !== "string" ||
(value.state !== "waiting-for-idle" &&
value.state !== "countdown" &&
value.state !== "applying") ||
!Number.isInteger(value.announcedAtMs) ||
Number(value.announcedAtMs) < 0 ||
!Number.isInteger(value.forceAtMs) ||
Number(value.forceAtMs) < 0 ||
!Number.isInteger(value.updatedAtMs) ||
Number(value.updatedAtMs) < 0 ||
(value.applyAtMs !== undefined &&
(!Number.isInteger(value.applyAtMs) || Number(value.applyAtMs) < 0)) ||
(value.holdUntilMs !== undefined &&
(!Number.isInteger(value.holdUntilMs) || Number(value.holdUntilMs) < 0))
) {
return null;
}
return {
id: value.id,
state: value.state,
announcedAtMs: Number(value.announcedAtMs),
...(value.applyAtMs === undefined ? {} : { applyAtMs: Number(value.applyAtMs) }),
...(value.holdUntilMs === undefined ? {} : { holdUntilMs: Number(value.holdUntilMs) }),
forceAtMs: Number(value.forceAtMs),
updatedAtMs: Number(value.updatedAtMs),
};
}
export function readUpdateScheduleValue(value: unknown): UpdateScheduleState | null {
if (
!isRecord(value) ||
typeof value.channel !== "string" ||
typeof value.autoEnabled !== "boolean"
) {
return null;
}
const rawInstall = isRecord(value.install) ? value.install : null;
const rawInstallKind = rawInstall?.kind;
const installKind =
rawInstallKind === "package" || rawInstallKind === "git" || rawInstallKind === "unknown"
? rawInstallKind
: undefined;
if (value.install !== undefined && installKind === undefined) {
return null;
}
const gitStatus = rawInstall?.git === undefined ? undefined : readGitUpdateStatus(rawInstall.git);
if (rawInstall?.git !== undefined && !gitStatus) {
return null;
}
const target = value.target === undefined ? undefined : readScheduleTarget(value.target);
const campaign = value.campaign === undefined ? undefined : readScheduleCampaign(value.campaign);
if ((value.target !== undefined && !target) || (value.campaign !== undefined && !campaign)) {
return null;
}
return {
channel: value.channel,
autoEnabled: value.autoEnabled,
...(installKind
? { install: { kind: installKind, ...(gitStatus ? { git: gitStatus } : {}) } }
: {}),
...(target ? { target } : {}),
...(campaign ? { campaign } : {}),
};
}
export function readUpdateSchedule(hello: GatewayHelloOk | null): UpdateScheduleState | null {
const snapshot = hello?.snapshot;
if (!isRecord(snapshot)) {
return null;
}
return readUpdateScheduleValue(snapshot.updateSchedule);
}
+37
View File
@@ -0,0 +1,37 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getSafeSessionStorageMock, reloadControlUiIfStaleMock, showToastMock } = vi.hoisted(() => ({
getSafeSessionStorageMock: vi.fn(),
reloadControlUiIfStaleMock: vi.fn(),
showToastMock: vi.fn(),
}));
vi.mock("../build-info.ts", () => ({
reloadControlUiIfStale: reloadControlUiIfStaleMock,
}));
vi.mock("../i18n/index.ts", () => ({
t: (_key: string, params?: Record<string, string>) => `Gateway updated · now on ${params?.sha}.`,
}));
vi.mock("../lib/toast.ts", () => ({ showToast: showToastMock }));
vi.mock("../local-storage.ts", () => ({
getSafeSessionStorage: getSafeSessionStorageMock,
}));
import { announceVerifiedUpdateInstall } from "./update-success-notice.ts";
describe("update success notice", () => {
beforeEach(() => {
vi.clearAllMocks();
getSafeSessionStorageMock.mockReturnValue(null);
reloadControlUiIfStaleMock.mockReturnValue(false);
});
it("announces a non-reloading success when session storage is unavailable", () => {
announceVerifiedUpdateInstall({ version: "2026.8.11", sha: "abcdef1234567890" });
expect(showToastMock).toHaveBeenCalledWith({
message: "Gateway updated · now on abcdef1.",
});
});
});
+66
View File
@@ -0,0 +1,66 @@
// A verified install whose bundle differs from this document reloads the page
// (`reloadControlUiIfStale`), which destroys any in-memory outcome. Record the
// result before that navigation so the reloaded document still tells the
// operator the update finished instead of coming back silently.
import { reloadControlUiIfStale } from "../build-info.ts";
import { t } from "../i18n/index.ts";
import { showToast } from "../lib/toast.ts";
import { getSafeSessionStorage } from "../local-storage.ts";
const UPDATE_SUCCESS_NOTICE_KEY = "openclaw:control-ui:update-succeeded:v1";
type UpdateInstallIdentity = { version: string | null; sha: string | null };
function formatUpdateSuccess(identity: UpdateInstallIdentity): string {
// A git install keeps its version across commits, so the commit is the only
// fact that actually changed; package installs report no commit at all.
const sha = identity.sha?.trim();
if (sha) {
return t("updates.succeededCommit", { sha: sha.slice(0, 7) });
}
const version = identity.version?.trim();
return version ? t("updates.succeededVersion", { version }) : t("updates.succeeded");
}
function takeRecordedUpdateSuccess(): string | null {
try {
const storage = getSafeSessionStorage();
const raw = storage?.getItem(UPDATE_SUCCESS_NOTICE_KEY) ?? null;
storage?.removeItem(UPDATE_SUCCESS_NOTICE_KEY);
return raw;
} catch {
return null;
}
}
/** Records the outcome, then presents it here unless a reload will present it. */
export function announceVerifiedUpdateInstall(identity: UpdateInstallIdentity): void {
try {
getSafeSessionStorage()?.setItem(UPDATE_SUCCESS_NOTICE_KEY, JSON.stringify(identity));
} catch {
// Storage is best effort; a document that stays put still announces below.
}
if (!reloadControlUiIfStale(identity)) {
takeRecordedUpdateSuccess();
showToast({ message: formatUpdateSuccess(identity) });
}
}
/** Presents a recorded install outcome once, then forgets it. */
export function announceRecordedUpdateSuccess(): void {
const raw = takeRecordedUpdateSuccess();
if (!raw) {
return;
}
let identity: UpdateInstallIdentity;
try {
const parsed = JSON.parse(raw) as Partial<UpdateInstallIdentity>;
identity = {
version: typeof parsed.version === "string" ? parsed.version : null,
sha: typeof parsed.sha === "string" ? parsed.sha : null,
};
} catch {
return;
}
showToast({ message: formatUpdateSuccess(identity) });
}
+5 -1
View File
@@ -13,16 +13,20 @@ declare global {
export const CONTROL_UI_BUILD_INFO =
globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO ?? normalizeControlUiBuildInfo(undefined);
/** Reports whether the reload was started, so callers can tell an outcome they
* still have to present from one the reloaded document will present instead. */
export function reloadControlUiIfStale(identity: {
version: string | null;
sha: string | null;
}): void {
}): boolean {
if (
typeof window !== "undefined" &&
controlUiVersionDiffersFrom(identity.version ?? undefined, identity.sha ?? undefined)
) {
window.location.reload();
return true;
}
return false;
}
export function controlUiVersionDiffersFrom(
+7 -1
View File
@@ -11,6 +11,8 @@ import {
} from "../app/context.ts";
import type { CatalogOpenTarget } from "../app/settings.ts";
import type { ThemeMode } from "../app/theme.ts";
import type { UpdateProgress } from "../app/update-confirmation.ts";
import type { ApplicationStatusBanner } from "../app/update-overlay-helpers.ts";
import { readSessionMethodAccess, type SessionMethodAccess } from "../lib/session-method-access.ts";
import { prepareSessionNavigationHandoff } from "../lib/sessions/navigation-handoff.ts";
import { SESSION_NAVIGATION_KEY_PARAM } from "../lib/sessions/route-navigation.ts";
@@ -50,7 +52,11 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement {
@property({ attribute: false }) updateAvailable: UpdateAvailable | null = null;
@property({ attribute: false }) updateSchedule: UpdateScheduleState | null = null;
@property({ attribute: false }) heldUpdateCampaignId: string | null = null;
@property({ attribute: false }) updateRunning = false;
@property({ attribute: false }) updateBusy = false;
@property({ attribute: false }) updateStatusBanner: ApplicationStatusBanner | null = null;
@property({ attribute: false }) watchUpdateProgress:
| ((listener: (progress: UpdateProgress) => void) => () => void)
| undefined = undefined;
@property({ attribute: false }) canUpdate = false;
@property({ attribute: false }) canHoldUpdate = false;
@property({ attribute: false }) onUpdate: () => void = () => undefined;
+3 -1
View File
@@ -535,7 +535,9 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
.updateAvailable=${this.updateAvailable}
.updateSchedule=${this.updateSchedule}
.heldUpdateCampaignId=${this.heldUpdateCampaignId}
.updateRunning=${this.updateRunning}
.updateBusy=${this.updateBusy}
.statusBanner=${this.updateStatusBanner}
.watchUpdateProgress=${this.watchUpdateProgress}
.canUpdate=${this.canUpdate}
.canHoldUpdate=${this.canHoldUpdate}
.onUpdate=${this.onUpdate}
+11 -11
View File
@@ -47,7 +47,7 @@ describe("settings sidebar search", () => {
lastError: null,
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
updateBusy: false,
onUpdate: vi.fn(),
...inactiveRefresh,
searchQuery: "",
@@ -79,7 +79,7 @@ describe("settings sidebar search", () => {
lastError: null,
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
updateBusy: false,
onUpdate: vi.fn(),
...inactiveRefresh,
searchQuery: "",
@@ -110,7 +110,7 @@ describe("settings sidebar search", () => {
lastError: null,
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
updateBusy: false,
onUpdate: vi.fn(),
...inactiveRefresh,
searchQuery: "cp",
@@ -149,7 +149,7 @@ describe("settings sidebar search", () => {
lastError: null,
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
updateBusy: false,
onUpdate: vi.fn(),
...inactiveRefresh,
searchQuery: "mcp",
@@ -205,7 +205,7 @@ describe("settings sidebar search", () => {
lastError: null,
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
updateBusy: false,
onUpdate: vi.fn(),
...inactiveRefresh,
searchQuery: "infrastructure",
@@ -254,7 +254,7 @@ describe("settings sidebar search", () => {
lastError: null,
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
updateBusy: false,
onUpdate: vi.fn(),
...inactiveRefresh,
searchQuery: "agent defaults",
@@ -286,7 +286,7 @@ describe("settings sidebar search", () => {
lastError: null,
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
updateBusy: false,
onUpdate: vi.fn(),
...inactiveRefresh,
searchQuery: "backend",
@@ -332,7 +332,7 @@ describe("settings sidebar search", () => {
lastError: null,
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
updateBusy: false,
onUpdate: vi.fn(),
...inactiveRefresh,
searchQuery,
@@ -415,7 +415,7 @@ describe("settings sidebar search", () => {
lastError: null,
gatewayVersion: "",
updateAvailable: null,
updateRunning: false,
updateBusy: false,
onUpdate: vi.fn(),
...inactiveRefresh,
searchQuery: "",
@@ -453,7 +453,7 @@ describe("settings sidebar search", () => {
latestVersion: "2.0.0",
channel: "stable",
},
updateRunning: false,
updateBusy: false,
canUpdate: true,
onUpdate,
refreshRequired: true,
@@ -504,7 +504,7 @@ describe("settings sidebar search", () => {
lastError,
gatewayVersion: "1.0.0",
updateAvailable: null,
updateRunning: false,
updateBusy: false,
onUpdate: vi.fn(),
...inactiveRefresh,
searchQuery: "",
+8 -2
View File
@@ -16,6 +16,8 @@ import {
} from "../app-navigation.ts";
import { pathForRoute, type RouteId } from "../app-route-paths.ts";
import type { ApplicationNavigationOptions } from "../app/context.ts";
import type { UpdateProgress } from "../app/update-confirmation.ts";
import type { ApplicationStatusBanner } from "../app/update-overlay-helpers.ts";
import { t } from "../i18n/index.ts";
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts";
@@ -40,7 +42,9 @@ type SettingsSidebarProps = {
updateAvailable: UpdateAvailable | null;
updateSchedule?: UpdateScheduleState | null;
heldUpdateCampaignId?: string | null;
updateRunning: boolean;
updateBusy: boolean;
updateStatusBanner?: ApplicationStatusBanner | null;
watchUpdateProgress?: (listener: (progress: UpdateProgress) => void) => () => void;
canUpdate?: boolean;
canHoldUpdate?: boolean;
onUpdate: () => void;
@@ -312,7 +316,9 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) {
.updateAvailable=${props.updateAvailable}
.updateSchedule=${props.updateSchedule ?? null}
.heldUpdateCampaignId=${props.heldUpdateCampaignId ?? null}
.updateRunning=${props.updateRunning}
.updateBusy=${props.updateBusy}
.statusBanner=${props.updateStatusBanner ?? null}
.watchUpdateProgress=${props.watchUpdateProgress}
.canUpdate=${props.canUpdate ?? false}
.canHoldUpdate=${props.canHoldUpdate ?? false}
.onUpdate=${props.onUpdate}
+33 -14
View File
@@ -35,7 +35,7 @@ type SidebarUpdateCardElement = HTMLElement & {
updateAvailable: UpdateAvailable | null;
updateSchedule: UpdateScheduleState | null;
heldUpdateCampaignId: string | null;
updateRunning: boolean;
updateBusy: boolean;
canUpdate: boolean;
canHoldUpdate: boolean;
onUpdate: () => void;
@@ -303,11 +303,11 @@ describe("SidebarUpdateCard", () => {
window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT));
expect(onUpdate).toHaveBeenCalledOnce();
element.updateRunning = true;
element.updateBusy = true;
window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT));
expect(onUpdate).toHaveBeenCalledOnce();
element.updateRunning = false;
element.updateBusy = false;
element.updateAvailable = null;
window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT));
expect(onUpdate).toHaveBeenCalledOnce();
@@ -376,18 +376,37 @@ describe("SidebarUpdateCard", () => {
expect(postMessage).not.toHaveBeenCalled();
});
it("disables the action while updating", async () => {
const element = await mount({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
channel: "stable",
});
element.updateRunning = true;
await element.updateComplete;
it("narrates the whole update, including after the Gateway drops its metadata", async () => {
const element = await mount(
{ currentVersion: "1.0.0", latestVersion: "1.0.0", channel: "dev", commitsBehind: 246 },
{
channel: "dev",
autoEnabled: false,
target: {
kind: "git",
upstreamRef: "origin/main",
upstreamSha: "abc1234def",
commitsBehind: 246,
},
},
);
expect(element.textContent).toContain("246 commits behind");
element.updateBusy = true;
await element.updateComplete;
const action = element.querySelector<HTMLButtonElement>(".sidebar-update-card__action");
expect(action?.disabled).toBe(true);
expect(action?.textContent).toContain("Updating…");
expect(action?.textContent).toContain("Updating Gateway…");
// The stale call to action must not survive into the install.
expect(element.textContent).not.toContain("246 commits behind");
expect(element.querySelector(".sidebar-update-card__dismiss")).toBeNull();
// The restarting Gateway takes its update metadata with it; the card is the
// operator's only remaining sign that an install is still running.
element.updateAvailable = null;
element.updateSchedule = null;
await element.updateComplete;
expect(element.textContent).toContain("Updating Gateway…");
});
it("renders a quiet live countdown, hides dismissal, and stops ticking on disconnect", async () => {
@@ -425,10 +444,10 @@ describe("SidebarUpdateCard", () => {
"Hold 1 h",
);
element.updateRunning = true;
element.updateBusy = true;
await element.updateComplete;
expect(element.querySelector(".sidebar-update-card__hold")).toBeNull();
element.updateRunning = false;
element.updateBusy = false;
await element.updateComplete;
expect(element.querySelector(".sidebar-update-card__hold")).not.toBeNull();
+96 -59
View File
@@ -6,10 +6,11 @@ import {
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
NATIVE_UPDATE_DECLINED_EVENT,
} from "../app/native-link-routing.ts";
import { confirmAndStartUpdate } from "../app/update-confirmation.ts";
import { confirmAndStartUpdate, type UpdateProgress } from "../app/update-confirmation.ts";
import {
formatUpdateCampaignLabel,
formatUpdateTargetLabel,
type ApplicationStatusBanner,
} from "../app/update-overlay-helpers.ts";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
@@ -61,7 +62,11 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
@property({ attribute: false }) updateAvailable: UpdateAvailable | null = null;
@property({ attribute: false }) updateSchedule: UpdateScheduleState | null = null;
@property({ attribute: false }) heldUpdateCampaignId: string | null = null;
@property({ attribute: false }) updateRunning = false;
@property({ attribute: false }) updateBusy = false;
@property({ attribute: false }) statusBanner: ApplicationStatusBanner | null = null;
@property({ attribute: false }) watchUpdateProgress:
| ((listener: (progress: UpdateProgress) => void) => () => void)
| undefined = undefined;
@property({ attribute: false }) canUpdate = false;
@property({ attribute: false }) canHoldUpdate = false;
@property({ attribute: false }) onUpdate: () => void = () => undefined;
@@ -91,7 +96,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
this.nativeUpdateAvailable = false;
if (
(this.updateAvailable || this.updateSchedule?.campaign) &&
!this.updateRunning &&
!this.updateBusy &&
this.canUpdate &&
!this.refreshRequired
) {
@@ -130,12 +135,27 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
}
}
private renderStatus() {
const statusBanner = this.statusBanner;
// The Gateway recorded this outcome; unlike the client's own update
// metadata it stays true even when this client is stale.
return statusBanner
? html`<div
class="sidebar-update-card__status sidebar-update-card__status--${statusBanner.tone}"
role="alert"
>
${statusBanner.text}
</div>`
: nothing;
}
override render() {
// A stale client cannot trust its own update metadata, so refresh takes precedence
// over any available update it may still report.
if (this.refreshRequired) {
return html`
<div class="sidebar-update-card" role="status" aria-live="polite">
${this.renderStatus()}
<button class="sidebar-update-card__action" type="button" @click=${this.onRefresh}>
<span class="sidebar-update-card__icon" aria-hidden="true">${icons.refresh}</span>
<span class="sidebar-update-card__text sidebar-update-card__text--stacked">
@@ -152,11 +172,18 @@ 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.
const statusBanner = this.statusBanner;
if (
!campaign &&
!busy &&
!statusBanner &&
(!update ||
(!hasVersionUpdate && !hasGitUpdate) ||
this.dismissedUpdateKey === updateKey(update) ||
@@ -164,11 +191,9 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
) {
return nothing;
}
const title = this.updateRunning
? t("chat.updating")
: this.nativeUpdateAvailable
? t("chat.sidebar.updateMacAndGateway")
: t("chat.sidebar.updateGateway");
const title = this.nativeUpdateAvailable
? t("chat.sidebar.updateMacAndGateway")
: t("chat.sidebar.updateGateway");
const betaChannelSuffix = update?.channel === "beta" ? " (beta)" : "";
const campaignLabel = formatUpdateCampaignLabel(this.updateSchedule);
const targetLabel = formatUpdateTargetLabel(this.updateSchedule, update);
@@ -176,10 +201,11 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
? targetLabel
? t("updates.sidebar.campaignTarget", { status: campaignLabel, target: targetLabel })
: campaignLabel
: targetLabel
? `${title} · ${targetLabel}${betaChannelSuffix}`
: title;
const busy = this.updateRunning || campaign?.state === "applying";
: busy
? t("updates.sidebar.updating")
: targetLabel
? `${title} · ${targetLabel}${betaChannelSuffix}`
: title;
const countdownActive =
campaign?.state === "countdown" || campaign?.state === "waiting-for-idle";
const holdActive = campaign?.holdUntilMs !== undefined && campaign.holdUntilMs > Date.now();
@@ -192,60 +218,71 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
!holdActive &&
this.heldUpdateCampaignId !== campaign.id,
);
// 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 || (update && (hasVersionUpdate || hasGitUpdate)));
return html`
<div
class="sidebar-update-card"
role=${campaign ? nothing : "status"}
aria-live=${campaign ? nothing : "polite"}
>
<div class="sidebar-update-card__actions">
<button
class="sidebar-update-card__action ${campaign
? "sidebar-update-card__action--undismissable"
: ""} ${busy ? "sidebar-update-card__action--busy" : ""}"
type="button"
title=${this.canUpdate ? nothing : t("updates.adminRequired")}
?disabled=${busy || !this.canUpdate}
@click=${() => {
if (busy || !this.canUpdate) {
return;
}
void confirmAndStartUpdate({
startGatewayUpdate: () => this.onUpdate(),
updateAvailable: this.updateAvailable,
updateSchedule: this.updateSchedule,
// Read the bridge at click time: a Mac app that installed it
// after the last availability event still owns this update.
viaNativeApp: !this.nativeUpdateDeclined && hasNativeUpdateBridge(),
});
}}
>
<span class="sidebar-update-card__icon" aria-hidden="true">${icons.download}</span>
<span
class="sidebar-update-card__text"
role=${countdownActive ? "timer" : nothing}
aria-live=${countdownActive ? "off" : nothing}
>${text}</span
>
</button>
${showHold && campaign
? html`
<button
class="sidebar-update-card__hold"
type="button"
?disabled=${this.holdingCampaignId === campaign.id}
@click=${async () => {
this.holdingCampaignId = campaign.id;
await this.onHoldUpdate();
this.holdingCampaignId = null;
}}
${this.renderStatus()}
${actionable
? html`<div class="sidebar-update-card__actions">
<button
class="sidebar-update-card__action ${campaign
? "sidebar-update-card__action--undismissable"
: ""} ${busy ? "sidebar-update-card__action--busy" : ""}"
type="button"
title=${this.canUpdate ? nothing : t("updates.adminRequired")}
?disabled=${busy || !this.canUpdate}
@click=${() => {
if (busy || !this.canUpdate) {
return;
}
void confirmAndStartUpdate({
startGatewayUpdate: () => this.onUpdate(),
...(this.watchUpdateProgress
? { watchUpdateProgress: this.watchUpdateProgress }
: {}),
updateAvailable: this.updateAvailable,
updateSchedule: this.updateSchedule,
// Read the bridge at click time: a Mac app that installed it
// after the last availability event still owns this update.
viaNativeApp: !this.nativeUpdateDeclined && hasNativeUpdateBridge(),
});
}}
>
<span class="sidebar-update-card__icon" aria-hidden="true"
>${busy ? icons.refresh : icons.download}</span
>
${t("updates.holdOneHour")}
</button>
`
: nothing}
</div>
${campaign || !update
<span
class="sidebar-update-card__text"
role=${countdownActive ? "timer" : nothing}
aria-live=${countdownActive ? "off" : nothing}
>${text}</span
>
</button>
${showHold && campaign
? html`
<button
class="sidebar-update-card__hold"
type="button"
?disabled=${this.holdingCampaignId === campaign.id}
@click=${async () => {
this.holdingCampaignId = campaign.id;
await this.onHoldUpdate();
this.holdingCampaignId = null;
}}
>
${t("updates.holdOneHour")}
</button>
`
: nothing}
</div>`
: nothing}
${campaign || busy || !update || statusBanner
? nothing
: html`
<button
+19 -8
View File
@@ -83,7 +83,8 @@ suite.define(() => {
await page.getByRole("button", { name: /Update Gateway/ }).click();
await page.getByRole("button", { name: "Update and restart", exact: true }).click();
await page
const dialog = page.locator("openclaw-modal-dialog");
await dialog
.getByText(
"Update error: global-install-failed. The global package install did not verify on disk. Retry or reinstall from the CLI.",
{ exact: true },
@@ -91,6 +92,7 @@ suite.define(() => {
.waitFor();
expect(await gateway.getRequests("update.run")).toHaveLength(1);
await page.getByRole("button", { name: "Close", exact: true }).click();
expect(await page.getByRole("button", { name: /Update Gateway/ }).isEnabled()).toBe(true);
expect(pageErrors).toEqual([]);
await page.screenshot({ path: path.join(artifactDir, "package-update-failure.png") });
@@ -132,15 +134,21 @@ suite.define(() => {
await page.getByRole("button", { name: /Update Gateway/ }).click();
await page.getByRole("button", { name: "Update and restart", exact: true }).click();
await page.getByRole("button", { name: "Updating…", exact: true }).waitFor();
expect(await gateway.getRequests("update.run")).toHaveLength(1);
// Leaving the dialog hands the report to the ambient surfaces, which
// keep the restart visible instead of re-offering the same update.
await page.getByRole("button", { name: "Close", exact: true }).click();
await page
.getByText(
"Update installed. A gateway restart is already in progress; status will refresh after it reconnects.",
{ exact: true },
)
.waitFor();
expect(await gateway.getRequests("update.run")).toHaveLength(1);
expect(await page.getByRole("button", { name: /Update Gateway/ }).isEnabled()).toBe(true);
const updating = page.getByRole("button", { name: /Updating Gateway/ });
await updating.waitFor();
expect(await updating.isEnabled()).toBe(false);
expect(pageErrors).toEqual([]);
await page.screenshot({ path: path.join(artifactDir, "coalesced-restart-banner.png") });
},
@@ -218,13 +226,16 @@ suite.define(() => {
await gateway.waitForRequest("update.run");
if (responseFirst) {
await gateway.resolveDeferred("update.run", MANAGED_UPDATE_HANDOFF_RESPONSE);
await expect
.poll(() => page.getByRole("button", { name: /Update Gateway/ }).isEnabled())
.toBe(true);
// The handoff keeps installing after its RPC answers; the dialog
// must keep saying so instead of closing onto a silent page.
await page.getByRole("button", { name: "Updating…", exact: true }).waitFor();
}
await gateway.closeLatest(1012, "managed update handoff");
await page.getByText(expectedText, { exact: false }).waitFor({ timeout: 15_000 });
await page
.locator("openclaw-modal-dialog")
.getByText(expectedText, { exact: false })
.waitFor({ timeout: 15_000 });
expect(await gateway.getRequests("update.run")).toHaveLength(1);
expect(await gateway.getRequests("update.status")).toHaveLength(expectedStatusRequests);
expect(pageErrors).toEqual([]);
+3 -1
View File
@@ -174,7 +174,9 @@ suite.define(() => {
await page.getByRole("button", { name: "Update and restart", exact: true }).click();
await gateway.waitForRequest("update.run");
await page.getByRole("dialog").waitFor({ state: "detached" });
// The dialog that started the update reports it; it stays open through
// the install instead of closing onto a page with nothing to say.
await page.getByRole("button", { name: "Updating…", exact: true }).waitFor();
await page.screenshot({
animations: "disabled",
path: path.join(PROOF_DIR, "07-update-running.png"),
+220
View File
@@ -0,0 +1,220 @@
// Proves the operator-visible lifecycle of a dev-channel Gateway update: the
// confirmation, the multi-minute install, the reconnect result, and a failure
// that names the cause the updater recorded.
import path from "node:path";
import { expect, it } from "vitest";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({
name: "Control UI update lifecycle E2E",
startServerBeforeBrowser: true,
unavailableMessage: (executablePath) => `Playwright Chromium is unavailable at ${executablePath}`,
});
const DEV_UPDATE_AVAILABLE = {
channel: "dev",
commitsBehind: 246,
currentSha: "1111111111111111111111111111111111111111",
currentVersion: "2026.8.1",
latestVersion: "2026.8.1",
upstreamRef: "origin/main",
upstreamSha: "9f3c21a0000000000000000000000000000000aa",
} as const;
const DEV_UPDATE_SCHEDULE = {
autoEnabled: false,
channel: "dev",
install: { kind: "git" as const, git: { status: "behind" as const, commitsBehind: 246 } },
target: {
kind: "git" as const,
commitsBehind: 246,
upstreamRef: "origin/main",
upstreamSha: "9f3c21a0000000000000000000000000000000aa",
},
};
const HANDOFF_STARTED_RESPONSE = {
ok: true,
handoff: { status: "started" },
result: { reason: "managed-service-handoff-started", status: "skipped" },
} as const;
const HANDOFF_PENDING_SENTINEL = {
sentinel: {
kind: "update",
status: "skipped",
stats: { reason: "managed-service-handoff-started" },
},
};
suite.define(() => {
it.each(["light", "dark"] as const)(
"narrates a dev-channel update through to its recorded success (%s)",
async (colorScheme) => {
const artifactDir = path.resolve(`.artifacts/control-ui-e2e/update-lifecycle-${colorScheme}`);
await suite.withPage(
{
colorScheme,
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 720, width: 1280 },
},
async ({ page }) => {
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
const gateway = await installMockGateway(page, {
deferredMethods: ["update.run"],
methodResponses: {
"update.run": HANDOFF_STARTED_RESPONSE,
"update.status": {
sequence: [
HANDOFF_PENDING_SENTINEL,
{
sentinel: {
kind: "update",
status: "ok",
// A git install keeps its version and moves its commit;
// the post-restart finalizer stamps both.
stats: {
after: {
sha: "9f3c21a0000000000000000000000000000000aa",
version: "2026.8.1",
},
},
},
},
],
},
},
});
expect((await page.goto(`${suite.server.baseUrl}chat`))?.status()).toBe(200);
await gateway.waitForRequest("chat.startup");
await gateway.emitGatewayEvent("update.available", {
schedule: DEV_UPDATE_SCHEDULE,
updateAvailable: DEV_UPDATE_AVAILABLE,
});
await page.getByRole("button", { name: /246 commits behind/ }).click();
await page.getByRole("button", { name: "Update and restart", exact: true }).waitFor();
// The modal fades in; capture it settled so the proof is readable.
await page.waitForTimeout(500);
await page.screenshot({ path: path.join(artifactDir, "1-confirm-dialog.png") });
await page.getByRole("button", { name: "Update and restart", exact: true }).click();
// The dialog is the primary surface: it stays open and reports the
// install rather than closing onto a page with nothing to say.
const updating = page.getByRole("button", { name: "Updating…", exact: true });
await updating.waitFor();
expect(await updating.isEnabled()).toBe(false);
await page.getByText("Installing the update on the Gateway", { exact: false }).waitFor();
expect(await gateway.getRequests("update.run")).toHaveLength(1);
await page.screenshot({ path: path.join(artifactDir, "2-installing.png") });
await gateway.resolveDeferred("update.run", HANDOFF_STARTED_RESPONSE);
await gateway.closeLatest(1012, "managed update handoff");
// The dialog lives on document.body, outside the shell, so losing the
// Gateway cannot unmount the only surface still reporting.
await page.getByText("The Gateway is restarting", { exact: false }).waitFor();
await page.screenshot({ path: path.join(artifactDir, "3-restarting.png") });
// The replacement Gateway reports the installed revision, so the
// operator gets a result instead of a silently reverted banner. The
// verified install also reloads this stale document, so the outcome
// has to survive that reload to be seen at all.
await page
.getByText("Gateway updated · now on 9f3c21a.", { exact: true })
.waitFor({ timeout: 20_000 });
await page.screenshot({ path: path.join(artifactDir, "4-success-toast.png") });
expect(pageErrors).toEqual([]);
},
);
},
);
it.each(["light", "dark"] as const)(
"names the recorded cause when the install fails (%s)",
async (colorScheme) => {
const artifactDir = path.resolve(
`.artifacts/control-ui-e2e/update-failure-cause-${colorScheme}`,
);
await suite.withPage(
{
colorScheme,
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 720, width: 1280 },
},
async ({ page }) => {
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
const gateway = await installMockGateway(page, {
methodResponses: {
"update.run": HANDOFF_STARTED_RESPONSE,
"update.status": {
sequence: [
HANDOFF_PENDING_SENTINEL,
{
sentinel: {
kind: "update",
status: "error",
stats: {
reason: "deps-install-failed",
steps: [
{ name: "fetch", log: { exitCode: 0, stderrTail: "" } },
{
name: "install",
log: {
exitCode: 1,
stderrTail:
"Progress: resolved 1204\nENOSPC: no space left on device, write",
},
},
],
},
},
},
],
},
},
});
expect((await page.goto(`${suite.server.baseUrl}chat`))?.status()).toBe(200);
await gateway.waitForRequest("chat.startup");
await gateway.emitGatewayEvent("update.available", {
schedule: DEV_UPDATE_SCHEDULE,
updateAvailable: DEV_UPDATE_AVAILABLE,
});
await page.getByRole("button", { name: /246 commits behind/ }).click();
await page.getByRole("button", { name: "Update and restart", exact: true }).click();
await page.getByRole("button", { name: "Updating…", exact: true }).waitFor();
await gateway.closeLatest(1012, "managed update handoff");
// The recorded cause lands in the dialog the operator is still watching.
await page
.locator("openclaw-modal-dialog")
.getByText(
"The update failed at install: ENOSPC: no space left on device, write. Dependency install failed. Fix the install error and retry.",
{ exact: true },
)
.waitFor({ timeout: 20_000 });
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(artifactDir, "5-failure-in-dialog.png") });
// Closing it leaves the same outcome beside the control that started
// the update, for anyone who dismissed the dialog.
await page.getByRole("button", { name: "Close", exact: true }).click();
await page
.locator(".sidebar-update-card__status")
.filter({ hasText: "ENOSPC" })
.waitFor();
await page.screenshot({ path: path.join(artifactDir, "6-failure-in-sidebar.png") });
expect(pageErrors).toEqual([]);
},
);
},
);
});
+18 -5
View File
@@ -379,6 +379,7 @@ export const en: TranslationMap = {
impact:
"Running sessions are interrupted and this Control UI disconnects until the Gateway is back.",
versions: "Installed {installed} · Available {available}",
versionsBehind: "Installed {installed} · {available}",
action: "Update and restart",
macAction: "Update Mac app and restart",
},
@@ -389,6 +390,13 @@ export const en: TranslationMap = {
},
sidebar: {
campaignTarget: "{status} · {target}",
updating: "Updating Gateway…",
},
dialog: {
installing: "Installing the update on the Gateway. It restarts once the install finishes.",
restarting: "The Gateway is restarting. This page disconnects and reconnects on its own.",
notStarted:
"The update request went unanswered. Try again, or run `openclaw update` in the terminal.",
},
channel: {
stable: "Stable",
@@ -478,13 +486,18 @@ export const en: TranslationMap = {
managedServiceHandoffAlreadyRunning:
"Another managed update is already running. Wait for it to complete, then refresh update status.",
doctorFailed: "Doctor repair failed. Run `openclaw doctor --non-interactive` and retry.",
managedServiceHandoffFailed:
"The update helper stopped before finishing. Run `openclaw update` in the terminal to see why.",
managedServiceHandoffSpawnFailed:
"The Gateway could not start the update helper. Run `openclaw update` in the terminal instead.",
managedServiceHandoffParentTimeout:
"The Gateway stayed up too long for the update helper. Start the update again, or run `openclaw update`.",
default: "See the gateway logs for the exact failure and retry once the cause is fixed.",
},
postRestart: {
restartUnhealthy:
"The replacement process never became healthy and the previous process stayed up.",
default: "Check the gateway logs for the replacement failure.",
},
failedAtStep: "The update failed at {step}: {cause}.",
succeededVersion: "Gateway updated to v{version}.",
succeededCommit: "Gateway updated · now on {sha}.",
succeeded: "Gateway updated and restarted.",
},
devices: {
pairing: {
+15
View File
@@ -23,10 +23,24 @@ function activeModalToastLayer() {
);
}
// Outcomes reported during startup (a restored post-update result, for example)
// race the shell that owns the host element. Hold the latest one instead of
// dropping it, so no caller's message disappears because it arrived too early.
let queuedToast: ToastOptions | null = null;
class OpenClawToastHost extends OpenClawLightDomContentsElement {
@state() private toast: ToastOptions | null = null;
private dismissTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
override connectedCallback() {
super.connectedCallback();
const pending = queuedToast;
queuedToast = null;
if (pending) {
this.show(pending);
}
}
override disconnectedCallback() {
const target = activeModalToastLayer() ?? document.querySelector(".shell");
if (!this.isConnected && this.parentElement?.localName === "openclaw-modal-dialog" && target) {
@@ -101,6 +115,7 @@ class OpenClawToastHost extends OpenClawLightDomContentsElement {
export function showToast(options: ToastOptions): boolean {
const host = document.querySelector<OpenClawToastHost>("openclaw-toast-host");
if (!host) {
queuedToast = options;
return false;
}
const modal = activeModalToastLayer();
+3
View File
@@ -603,6 +603,8 @@ describe("ConfigPage Updates integration", () => {
features: { methods: ["update.run"] },
},
},
// The update dialog watches both stores for the life of the install.
subscribe: () => () => undefined,
},
overlays: {
snapshot: {
@@ -612,6 +614,7 @@ describe("ConfigPage Updates integration", () => {
updateReconciliationPending: false,
updateStatusBanner: null,
},
subscribe: () => () => undefined,
runUpdate,
},
} as unknown as ApplicationContext;
+22 -1
View File
@@ -34,7 +34,7 @@ import {
} from "../../app/settings.ts";
import { startThemeTransition } from "../../app/theme-transition.ts";
import { resolveTheme, type ThemeMode, type ThemeName } from "../../app/theme.ts";
import { confirmAndStartUpdate } from "../../app/update-confirmation.ts";
import { confirmAndStartUpdate, type UpdateProgress } from "../../app/update-confirmation.ts";
import { CONTROL_UI_BUILD_INFO } from "../../build-info.ts";
import {
loadStoredHiddenSessionCatalogIds,
@@ -981,6 +981,26 @@ export class ConfigPage extends OpenClawLightDomElement {
return update.updateRunning || update.updateReconciliationPending;
}
// The update dialog outlives this page and the connection, so it reads live
// snapshots rather than the values captured during a render.
private readonly watchUpdateProgress = (listener: (progress: UpdateProgress) => void) => {
const emit = () => {
const banner = this.context.overlays.snapshot.updateStatusBanner;
listener({
busy: this.isUpdateBusy(),
connected: this.context.gateway.snapshot.phase === "connected",
failure: banner && banner.tone !== "info" ? banner.text : null,
});
};
const stopOverlays = this.context.overlays.subscribe(emit);
const stopGateway = this.context.gateway.subscribe(emit);
emit();
return () => {
stopOverlays();
stopGateway();
};
};
private isCuratedConfigMutationDisabled(): boolean {
const runtimeState = this.context.runtimeConfig.state;
return (
@@ -1025,6 +1045,7 @@ export class ConfigPage extends OpenClawLightDomElement {
onUpdateNow: () =>
void confirmAndStartUpdate({
startGatewayUpdate: () => void this.context.overlays.runUpdate(),
watchUpdateProgress: this.watchUpdateProgress,
updateAvailable: overlaySnapshot.updateAvailable,
updateSchedule: overlaySnapshot.updateSchedule,
// This row has no native-decline listener, so a handoff the Mac app
+24
View File
@@ -502,6 +502,30 @@ openclaw-session-owner-chip {
Buttons - Tactile with personality
=========================================== */
/* A disabled button reads as "unavailable"; the spinner says "working" so a
multi-minute update does not look like a dead control. */
.btn__spinner {
width: 12px;
height: 12px;
flex: 0 0 auto;
border: 2px solid color-mix(in srgb, currentColor 35%, transparent);
border-top-color: currentColor;
border-radius: var(--radius-full);
animation: btn-spinner-spin 0.7s linear infinite;
}
@keyframes btn-spinner-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.btn__spinner {
animation: none;
}
}
.btn {
display: inline-flex;
align-items: center;
+44
View File
@@ -1245,6 +1245,50 @@ html.openclaw-native-macos
cursor: wait;
}
/* The install outlives the request that started it, so the card keeps moving
for the whole wait instead of reading as a dead disabled button. */
.sidebar-update-card__action--busy .sidebar-update-card__icon svg {
animation: sidebar-update-card-spin 1s linear infinite;
}
@keyframes sidebar-update-card-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.sidebar-update-card__action--busy .sidebar-update-card__icon svg {
animation: none;
}
}
/* The update outcome belongs beside the control that started it, not over the
top of the shell where it covers unrelated chrome. While the update dialog is
open it owns the report, so the ambient copy would only duplicate it. */
body.update-dialog-open .sidebar-update-card__status {
display: none;
}
.sidebar-update-card__status {
padding: 8px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--secondary);
font-size: 12px;
line-height: 1.45;
}
.sidebar-update-card__status--danger {
border-color: color-mix(in srgb, var(--danger) 40%, var(--border));
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
.sidebar-update-card__status--warn {
border-color: color-mix(in srgb, var(--warn) 40%, var(--border));
background: color-mix(in srgb, var(--warn) 10%, transparent);
}
.sidebar-update-card__action--undismissable {
padding-right: 8px;
}
+1 -1
View File
@@ -72,7 +72,7 @@ export type SidebarLifecycleState = HTMLElement & {
requestUpdate: () => void;
updateComplete: Promise<boolean>;
updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null;
updateRunning: boolean;
updateBusy: boolean;
canUpdate: boolean;
onUpdate: () => void;
refreshRequired: boolean;