diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json index 70f06055da29..29abbc45fd42 100644 --- a/config/control-ui-startup-budget-baseline.json +++ b/config/control-ui-startup-budget-baseline.json @@ -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" } diff --git a/scripts/check-control-ui-performance.mts b/scripts/check-control-ui-performance.mts index 1b9056c68a57..c62f78e6cc1f 100644 --- a/scripts/check-control-ui-performance.mts +++ b/scripts/check-control-ui-performance.mts @@ -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, diff --git a/ui/src/app/app-host-native-shell.test.ts b/ui/src/app/app-host-native-shell.test.ts index aa13cae3f8af..d5ade20da8a2 100644 --- a/ui/src/app/app-host-native-shell.test.ts +++ b/ui/src/app/app-host-native-shell.test.ts @@ -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(), diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 2230d4d8bc36..010a19c4a794 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -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) { }} >` : nothing} - ${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(), diff --git a/ui/src/app/navigation-surface.browser.test.ts b/ui/src/app/navigation-surface.browser.test.ts index 8ce583812628..1f5637c75727 100644 --- a/ui/src/app/navigation-surface.browser.test.ts +++ b/ui/src/app/navigation-surface.browser.test.ts @@ -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, diff --git a/ui/src/app/navigation-surface.ts b/ui/src/app/navigation-surface.ts index 3609a8353886..925be1bd8980 100644 --- a/ui/src/app/navigation-surface.ts +++ b/ui/src/app/navigation-surface.ts @@ -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} diff --git a/ui/src/app/overlays-update-race.test.ts b/ui/src/app/overlays-update-race.test.ts index 4d716f4e3a24..701d0205b1ef 100644 --- a/ui/src/app/overlays-update-race.test.ts +++ b/ui/src/app/overlays-update-race.test.ts @@ -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 = { diff --git a/ui/src/app/overlays.test.ts b/ui/src/app/overlays.test.ts index 47b89616f621..1285615ae35a 100644 --- a/ui/src/app/overlays.test.ts +++ b/ui/src/app/overlays.test.ts @@ -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 = { diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index fe147feceaa0..2237609ba964 100644 --- a/ui/src/app/overlays.ts +++ b/ui/src/app/overlays.ts @@ -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 || diff --git a/ui/src/app/update-confirmation.runtime.test.ts b/ui/src/app/update-confirmation.runtime.test.ts index 0f544a0b61a8..957cbc96b37e 100644 --- a/ui/src/app/update-confirmation.runtime.test.ts +++ b/ui/src/app/update-confirmation.runtime.test.ts @@ -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(); + } +}); diff --git a/ui/src/app/update-confirmation.runtime.ts b/ui/src/app/update-confirmation.runtime.ts index 007d7a3acc66..ef13d4b31fa7 100644 --- a/ui/src/app/update-confirmation.runtime.ts +++ b/ui/src/app/update-confirmation.runtime.ts @@ -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 { + // 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((resolve) => { + let phase: DialogPhase = { kind: "confirm" }; + let settled = false; + let stopWatching: (() => void) | undefined; + let acceptTimer: ReturnType | 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` + + + + + ${route.title} + ${body} + + + ${details && !failed + ? html`${details}` + : nothing} + + ${failed + ? html` + ${t("common.close")} + ` + : html` + + ${working + ? html`${t( + "chat.updating", + )}` + : route.confirmLabel} + + + ${working ? t("common.close") : t("common.cancel")} + + `} + + + + `, + 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(); } diff --git a/ui/src/app/update-confirmation.ts b/ui/src/app/update-confirmation.ts index 65754e279ae1..13494c702919 100644 --- a/ui/src/app/update-confirmation.ts +++ b/ui/src/app/update-confirmation.ts @@ -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 { diff --git a/ui/src/app/update-overlay-helpers.test.ts b/ui/src/app/update-overlay-helpers.test.ts index c3941157ba6e..5346515a1265 100644 --- a/ui/src/app/update-overlay-helpers.test.ts +++ b/ui/src/app/update-overlay-helpers.test.ts @@ -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 = { "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 = { "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({ diff --git a/ui/src/app/update-overlay-helpers.ts b/ui/src/app/update-overlay-helpers.ts index bd9e1da3c932..7718a8616ddf 100644 --- a/ui/src/app/update-overlay-helpers.ts +++ b/ui/src/app/update-overlay-helpers.ts @@ -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 = { "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): { - 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["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", diff --git a/ui/src/app/update-schedule-dto.ts b/ui/src/app/update-schedule-dto.ts new file mode 100644 index 000000000000..c45404862ee2 --- /dev/null +++ b/ui/src/app/update-schedule-dto.ts @@ -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): { + 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["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); +} diff --git a/ui/src/app/update-success-notice.test.ts b/ui/src/app/update-success-notice.test.ts new file mode 100644 index 000000000000..176be6b2f10b --- /dev/null +++ b/ui/src/app/update-success-notice.test.ts @@ -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) => `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.", + }); + }); +}); diff --git a/ui/src/app/update-success-notice.ts b/ui/src/app/update-success-notice.ts new file mode 100644 index 000000000000..9e87f1b3f41f --- /dev/null +++ b/ui/src/app/update-success-notice.ts @@ -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; + identity = { + version: typeof parsed.version === "string" ? parsed.version : null, + sha: typeof parsed.sha === "string" ? parsed.sha : null, + }; + } catch { + return; + } + showToast({ message: formatUpdateSuccess(identity) }); +} diff --git a/ui/src/build-info.ts b/ui/src/build-info.ts index cce13bbb7288..cd85d59e7c56 100644 --- a/ui/src/build-info.ts +++ b/ui/src/build-info.ts @@ -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( diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts index fd796625b025..edf5e6ad0585 100644 --- a/ui/src/components/app-sidebar-base.ts +++ b/ui/src/components/app-sidebar-base.ts @@ -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; diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index f3395fa349f0..da3f1edf904c 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -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} diff --git a/ui/src/components/settings-sidebar.test.ts b/ui/src/components/settings-sidebar.test.ts index 1bb99cd7de38..4ac6381fb342 100644 --- a/ui/src/components/settings-sidebar.test.ts +++ b/ui/src/components/settings-sidebar.test.ts @@ -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: "", diff --git a/ui/src/components/settings-sidebar.ts b/ui/src/components/settings-sidebar.ts index c510829d7359..515913a44768 100644 --- a/ui/src/components/settings-sidebar.ts +++ b/ui/src/components/settings-sidebar.ts @@ -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} diff --git a/ui/src/components/sidebar-update-card.test.ts b/ui/src/components/sidebar-update-card.test.ts index 4f0594290099..89a18c151329 100644 --- a/ui/src/components/sidebar-update-card.test.ts +++ b/ui/src/components/sidebar-update-card.test.ts @@ -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(".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(); diff --git a/ui/src/components/sidebar-update-card.ts b/ui/src/components/sidebar-update-card.ts index 66054bb8a143..d4e16667517a 100644 --- a/ui/src/components/sidebar-update-card.ts +++ b/ui/src/components/sidebar-update-card.ts @@ -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` + ${statusBanner.text} + ` + : 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` + ${this.renderStatus()} ${icons.refresh} @@ -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` - - { - 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(), - }); - }} - > - ${icons.download} - ${text} - - ${showHold && campaign - ? html` - { - this.holdingCampaignId = campaign.id; - await this.onHoldUpdate(); - this.holdingCampaignId = null; - }} + ${this.renderStatus()} + ${actionable + ? html` + { + 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(), + }); + }} + > + ${busy ? icons.refresh : icons.download} - ${t("updates.holdOneHour")} - - ` - : nothing} - - ${campaign || !update + ${text} + + ${showHold && campaign + ? html` + { + this.holdingCampaignId = campaign.id; + await this.onHoldUpdate(); + this.holdingCampaignId = null; + }} + > + ${t("updates.holdOneHour")} + + ` + : nothing} + ` + : nothing} + ${campaign || busy || !update || statusBanner ? nothing : html` { 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([]); diff --git a/ui/src/e2e/update-confirmation.e2e.test.ts b/ui/src/e2e/update-confirmation.e2e.test.ts index baef39b27b50..5a1102aa8330 100644 --- a/ui/src/e2e/update-confirmation.e2e.test.ts +++ b/ui/src/e2e/update-confirmation.e2e.test.ts @@ -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"), diff --git a/ui/src/e2e/update-lifecycle.e2e.test.ts b/ui/src/e2e/update-lifecycle.e2e.test.ts new file mode 100644 index 000000000000..04fe4192dcc5 --- /dev/null +++ b/ui/src/e2e/update-lifecycle.e2e.test.ts @@ -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([]); + }, + ); + }, + ); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index fbbf60dd5694..eb03cc60b09f 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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: { diff --git a/ui/src/lib/toast.ts b/ui/src/lib/toast.ts index 2fde3f427451..5144c6065338 100644 --- a/ui/src/lib/toast.ts +++ b/ui/src/lib/toast.ts @@ -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 | 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("openclaw-toast-host"); if (!host) { + queuedToast = options; return false; } const modal = activeModalToastLayer(); diff --git a/ui/src/pages/config/config-page.test.ts b/ui/src/pages/config/config-page.test.ts index 0127c6d699c8..467110251504 100644 --- a/ui/src/pages/config/config-page.test.ts +++ b/ui/src/pages/config/config-page.test.ts @@ -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; diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index a06a7d704310..dc4e8eb2f3da 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -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 diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 77a5708a8c1e..5171696c6c5c 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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; diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 75cf198c7605..f195948e0069 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -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; } diff --git a/ui/src/test-helpers/app-sidebar.ts b/ui/src/test-helpers/app-sidebar.ts index ba366e489007..d9c57290fe6e 100644 --- a/ui/src/test-helpers/app-sidebar.ts +++ b/ui/src/test-helpers/app-sidebar.ts @@ -72,7 +72,7 @@ export type SidebarLifecycleState = HTMLElement & { requestUpdate: () => void; updateComplete: Promise; updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null; - updateRunning: boolean; + updateBusy: boolean; canUpdate: boolean; onUpdate: () => void; refreshRequired: boolean;