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`` + : html` + + + `} +
+
+
+ `, + 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`` + : 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` - ${campaign || !update + ${text} + + ${showHold && campaign + ? html` + + ` + : nothing} + ` + : nothing} + ${campaign || busy || !update || statusBanner ? nothing : html`