From 5000f32b8db1db9c5fffcff80bcc19e64d4e085f Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Sun, 23 Aug 2026 11:22:51 -0300 Subject: [PATCH] fix(ui): restore approved inbox panel layout after lazy-chunk split (#128201) * fix(ui): restore Inbox panel spacing * fix(ui): stabilize Inbox update state * fix(ui): keep updates in Inbox * fix(ui): keep update status authoritative * test(ui): add gateway-shaped update fixtures * fix(ui): preserve read-only update campaign visibility * fix(ui): preserve update handoff after Inbox closes * fix(ui): keep floating updates Inbox-owned * refactor(ui): isolate update schedule projection --- scripts/control-ui-mock-dev.ts | 252 +++++++++++++----- ui/src/app/app-host-native-shell.test.ts | 22 +- ui/src/app/app-shell-view.ts | 12 - ui/src/app/navigation-surface.ts | 4 +- ui/src/app/overlays-types.ts | 1 + ui/src/app/overlays-update-campaign.test.ts | 48 ++++ ui/src/app/overlays.ts | 44 ++- ui/src/app/update-schedule-projection.ts | 81 ++++++ ui/src/components/app-sidebar-base.ts | 14 - ui/src/components/app-sidebar-render.ts | 45 +--- ui/src/components/app-sidebar.ts | 38 --- .../sidebar-attention-layout.browser.test.ts | 77 ++++++ ui/src/components/sidebar-attention.test.ts | 51 ++++ ui/src/components/sidebar-attention.ts | 56 +++- ui/src/components/sidebar-update-card.test.ts | 24 +- ui/src/components/sidebar-update-card.ts | 16 +- ui/src/e2e/sidebar-customization.e2e.test.ts | 9 +- ui/src/e2e/update-coalesced.e2e.test.ts | 15 +- ui/src/e2e/update-confirmation.e2e.test.ts | 25 +- ui/src/e2e/update-lifecycle.e2e.test.ts | 7 +- ui/src/styles/layout.css | 116 -------- ui/src/styles/sidebar-issues.css | 48 ++-- 22 files changed, 631 insertions(+), 374 deletions(-) create mode 100644 ui/src/app/update-schedule-projection.ts create mode 100644 ui/src/components/sidebar-attention-layout.browser.test.ts diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index bba031968160..ac9c7978a4dd 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -17,7 +17,9 @@ import { applySharedChannelFieldHelp } from "../src/config/schema.channel-field- import { buildBaseHints } from "../src/config/schema.hints.js"; import { applyConfigTierHints, applyResolvedConfigTierHints } from "../src/config/schema.tiers.js"; import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../src/gateway/control-ui-contract.js"; -import type { UpdateScheduleState } from "../ui/src/api/types.ts"; +import { buildUpdateRestartSentinelPayload } from "../src/infra/update-restart-sentinel-payload.js"; +import type { UpdateRunResult } from "../src/infra/update-runner.js"; +import type { UpdateAvailable, UpdateScheduleState } from "../ui/src/api/types.ts"; import { createControlUiMockBootstrapConfig, createControlUiMockGatewayInitScript, @@ -40,7 +42,15 @@ import { buildSkillWorkshopMocks } from "./control-ui-mock-skill-workshop.js"; type CliOptions = { allowedHosts: string[]; - fixture?: "approval" | "board" | "code-fences" | "swarm" | "workboard"; + fixture?: + | "approval" + | "board" + | "code-fences" + | "swarm" + | "update-available" + | "update-blocked" + | "update-failed" + | "workboard"; host: string; operatorScopes?: string[]; port: number; @@ -81,6 +91,171 @@ const PLAN_DEMO_RUN_ID = "mock-plan-run"; const CUSTODIAN_CHAT_REPLY_DELAY_MS = 600; const CHAT_SEND_REPLY_DELAY_MS = 200; +type UpdateFixture = { + available: UpdateAvailable; + runResponse: unknown; + schedule: UpdateScheduleState; + statusResponse: unknown; +}; + +function buildUpdateFixture(fixture: CliOptions["fixture"], nowMs: number): UpdateFixture | null { + if ( + fixture !== "update-available" && + fixture !== "update-blocked" && + fixture !== "update-failed" + ) { + return null; + } + + if (fixture === "update-available") { + const available: UpdateAvailable = { + currentVersion: "2026.8.1", + latestVersion: "2026.8.2", + channel: "latest", + }; + const schedule: UpdateScheduleState = { + channel: "stable", + autoEnabled: false, + install: { kind: "package" }, + target: { kind: "package", version: available.latestVersion }, + }; + return { + available, + schedule, + statusResponse: { + sentinel: null, + updateAvailable: available, + effectiveChannel: "stable", + schedule, + }, + runResponse: { + ok: true, + result: { + status: "ok", + mode: "global", + before: { version: available.currentVersion }, + after: { version: available.latestVersion }, + steps: [], + durationMs: 12_000, + }, + }, + }; + } + + const currentSha = "83b321ba7d31c04cc6f7a38c87932ec0172b5461"; + const upstreamSha = "ea2ab707d3ab6f9351dcdb2f3c05054097fbfa62"; + const available: UpdateAvailable = { + currentVersion: "2026.8.1", + latestVersion: "2026.8.1", + channel: "dev", + currentSha, + upstreamRef: "origin/main", + upstreamSha, + commitsBehind: 2, + commits: [ + { sha: "f6c71c4", subject: "Keep update status authoritative" }, + { sha: "ea2ab70", subject: "Move update actions into Inbox" }, + ], + }; + const baseSchedule: UpdateScheduleState = { + channel: "dev", + autoEnabled: true, + install: { + kind: "git", + git: { + status: "behind", + currentSha, + commitsBehind: 2, + commitAtMs: nowMs - 2 * 86_400_000, + installedAtMs: nowMs - 7 * 86_400_000, + }, + }, + target: { + kind: "git", + upstreamRef: available.upstreamRef ?? "origin/main", + upstreamSha, + commitsBehind: 2, + }, + }; + + if (fixture === "update-blocked") { + const schedule: UpdateScheduleState = { + ...baseSchedule, + campaign: { + id: "mock-update-waiting-for-idle", + state: "waiting-for-idle", + announcedAtMs: nowMs - 2 * 60_000, + forceAtMs: nowMs + 13 * 60_000, + updatedAtMs: nowMs, + }, + }; + return { + available, + schedule, + statusResponse: { + sentinel: null, + updateAvailable: available, + effectiveChannel: "dev", + schedule, + }, + runResponse: { + ok: true, + result: { + status: "skipped", + mode: "git", + reason: "managed-service-handoff-started", + before: { version: available.currentVersion, sha: currentSha }, + steps: [], + durationMs: 0, + }, + handoff: { status: "started" }, + }, + }; + } + + const schedule: UpdateScheduleState = { + ...baseSchedule, + campaign: { + id: "mock-update-before-failure", + state: "waiting-for-idle", + announcedAtMs: nowMs - 2 * 60_000, + forceAtMs: nowMs + 13 * 60_000, + updatedAtMs: nowMs, + }, + }; + const result: UpdateRunResult = { + status: "error", + mode: "git", + root: "/mock/openclaw", + reason: "build-failed", + before: { version: available.currentVersion, sha: currentSha }, + after: { version: available.latestVersion, sha: upstreamSha }, + steps: [ + { + name: "build", + command: "pnpm build", + cwd: "/mock/openclaw", + durationMs: 8_420, + stdoutTail: "", + stderrTail: "tsc: error TS2345", + exitCode: 1, + }, + ], + durationMs: 11_640, + }; + return { + available, + schedule, + runResponse: { ok: false, result }, + statusResponse: { + sentinel: buildUpdateRestartSentinelPayload({ result, meta: {}, nowMs }), + updateAvailable: available, + effectiveChannel: "dev", + schedule: baseSchedule, + }, + }; +} + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const uiRoot = path.join(repoRoot, "ui"); const boardFixturePath = "/__fixtures/board/"; @@ -175,6 +350,9 @@ function parseFixture(value: string | undefined): CliOptions["fixture"] { value !== "board" && value !== "code-fences" && value !== "swarm" && + value !== "update-available" && + value !== "update-blocked" && + value !== "update-failed" && value !== "workboard" ) { throw new Error(`Unknown Control UI mock fixture: ${value}`); @@ -1645,30 +1823,8 @@ async function createChatPickerScenario( const richAttention = fixture === "approval"; const cronMocks = buildCronMocks(Date.now(), { richAttention }); const updateFixtureNow = Date.now(); - const updateSchedule: UpdateScheduleState | null = richAttention - ? { - channel: "dev", - autoEnabled: true, - install: { - kind: "git", - git: { status: "behind", commitsBehind: 2 }, - }, - target: { - kind: "git", - upstreamRef: "origin/main", - upstreamSha: "a".repeat(40), - commitsBehind: 2, - }, - campaign: { - id: "mock-update-campaign", - state: "waiting-for-idle", - announcedAtMs: updateFixtureNow - 2 * 60_000, - applyAtMs: updateFixtureNow + 15 * 60_000, - forceAtMs: updateFixtureNow + 60 * 60_000, - updatedAtMs: updateFixtureNow, - }, - } - : null; + const updateFixture = buildUpdateFixture(fixture, updateFixtureNow); + const updateSchedule = updateFixture?.schedule ?? null; const heldUpdateSchedule: UpdateScheduleState | null = updateSchedule?.campaign ? { ...updateSchedule, @@ -1793,20 +1949,7 @@ async function createChatPickerScenario( defaultAgentId: "main", serverBuildId: "mock", updateSchedule, - updateAvailable: - fixture === "approval" - ? { - channel: "dev", - currentVersion: "2026.8.1", - latestVersion: "2026.8.1", - upstreamSha: "a".repeat(40), - commitsBehind: 2, - commits: [ - { sha: "abcdef1234567", subject: "Unify sidebar notifications" }, - { sha: "fedcba7654321", subject: "Keep the footer identity compact" }, - ], - } - : null, + updateAvailable: updateFixture?.available ?? null, // Advertised Gateway methods gate session actions (see // ui/src/lib/session-method-access.ts). Omitting the mutation methods left // every session context-menu row disabled, so the harness could not show @@ -1839,7 +1982,7 @@ async function createChatPickerScenario( "sessions.create", "system.info", "terminal.open", - ...(richAttention ? ["update.hold", "update.run", "update.status"] : []), + ...(updateFixture ? ["update.hold", "update.run", "update.status"] : []), ...(fixture === "workboard" ? [ "board.get", @@ -2358,31 +2501,8 @@ async function createChatPickerScenario( "update.hold": heldUpdateSchedule ? { ok: true, schedule: heldUpdateSchedule } : { ok: false }, - "update.run": updateSchedule - ? { ok: false, result: { status: "error", reason: "build-dirty" } } - : {}, - "update.status": updateSchedule - ? { - sentinel: { - kind: "update", - status: "error", - ts: updateFixtureNow, - stats: { - reason: "build-dirty", - steps: [ - { - name: "build", - log: { - exitCode: 1, - stderrTail: - "generated artifacts differ from the selected revision after the build completed; preserve the checkout and retry only after reconciling the generated files and verifying the target revision", - }, - }, - ], - }, - }, - } - : {}, + "update.run": updateFixture?.runResponse ?? {}, + "update.status": updateFixture?.statusResponse ?? {}, "usage.status": modelProviders.usageStatus, "device.pair.list": { paired: [ diff --git a/ui/src/app/app-host-native-shell.test.ts b/ui/src/app/app-host-native-shell.test.ts index 8d37833dc7c4..84ac9e30aa28 100644 --- a/ui/src/app/app-host-native-shell.test.ts +++ b/ui/src/app/app-host-native-shell.test.ts @@ -424,7 +424,7 @@ describe("OpenClaw native shell", () => { }); describe("OpenClaw shell update affordance", () => { - it("renders floating attention and loud update states only while navigation is collapsed", async () => { + it("renders floating attention while keeping update actions in navigation", async () => { const container = document.createElement("div"); document.body.append(container); const shared = { @@ -448,21 +448,10 @@ describe("OpenClaw shell update affordance", () => { mobileNavLayout: false, }); render(renderFloatingUpdateCard({ ...shared, navigationSurfaceHidden: collapsed }), container); - const card = container.querySelector< - HTMLElement & { - canUpdate: boolean; - onRefresh: () => void; - refreshRequired: boolean; - updateComplete: Promise; - } - >("openclaw-sidebar-update-card"); - expect(card).not.toBeNull(); expect( container.querySelector("openclaw-sidebar-attention.sidebar-attention--floating"), ).not.toBeNull(); - await card?.updateComplete; - expect(card?.canUpdate).toBe(true); - expect(card?.querySelector(".sidebar-update-card")).toBeNull(); + expect(container.querySelector("openclaw-sidebar-update-card")).toBeNull(); render( renderFloatingUpdateCard({ @@ -473,8 +462,11 @@ describe("OpenClaw shell update affordance", () => { }), container, ); - expect(card?.refreshRequired).toBe(true); - card?.onRefresh(); + const refreshCard = container.querySelector< + HTMLElement & { onRefresh: () => void; refreshRequired: boolean } + >("openclaw-sidebar-update-card"); + expect(refreshCard?.refreshRequired).toBe(true); + refreshCard?.onRefresh(); expect(shared.onRefresh).toHaveBeenCalledOnce(); expect(shared.onUpdate).not.toHaveBeenCalled(); diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index b299827c477e..b91dedd00187 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -375,19 +375,7 @@ export function renderApplicationShell(host: ShellViewHost) { lobsterPetSounds: uiSettings.lobsterPetSounds === true, gatewayVersion: config.serverVersion ?? gatewaySnapshot.hello?.server?.version ?? null, devGitBranch: config.devGitBranch, - updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable, - updateSchedule: navigationSurfaceHidden ? null : overlaySnapshot.updateSchedule, - heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId, - updateBusy, - updateStatusBanner: overlaySnapshot.updateStatusBanner, watchUpdateProgress, - canUpdate, - canHoldUpdate, - onUpdate: () => void context.overlays.runUpdate(), - refreshRequired: navigationSurfaceHidden ? false : overlaySnapshot.controlUiRefreshRequired, - onRefresh: () => host.refreshControlUi(), - onHoldUpdate: () => context.overlays.holdUpdate(), - onReviewUpdate: () => host.navigate("updates"), onOpenApprovals: () => host.openApprovals(), onRetryConnect: () => context.gateway.connect(), onOpenNewSession: openNewSession, diff --git a/ui/src/app/navigation-surface.ts b/ui/src/app/navigation-surface.ts index 6abda0f5e8ba..2552eb9ea7f3 100644 --- a/ui/src/app/navigation-surface.ts +++ b/ui/src/app/navigation-surface.ts @@ -41,9 +41,7 @@ export function renderFloatingUpdateCard(params: { // it still needs the floating copy while navigation is hidden. const desktopNavigationHidden = params.navigationSurfaceHidden && !params.mobileNavLayout; const showAttention = desktopNavigationHidden && !params.onboarding && !params.compact; - const showUpdateCard = - !params.compact && - (params.refreshRequired || (!params.onboarding && params.navigationSurfaceHidden)); + const showUpdateCard = !params.compact && params.refreshRequired; if (!showAttention && !showUpdateCard) { return nothing; } diff --git a/ui/src/app/overlays-types.ts b/ui/src/app/overlays-types.ts index 378828e7c378..7ac58ae8d322 100644 --- a/ui/src/app/overlays-types.ts +++ b/ui/src/app/overlays-types.ts @@ -9,6 +9,7 @@ export type ApplicationOverlaySnapshot = { heldUpdateCampaignId: string | null; updateRunning: boolean; updateStatusRefreshing: boolean; + updateCampaignStatusHydrated: boolean; updateReconciliationPending: boolean; updateStatusBanner: ApplicationStatusBanner | null; recordedUpdateAttempt: RecordedUpdateAttempt | null; diff --git a/ui/src/app/overlays-update-campaign.test.ts b/ui/src/app/overlays-update-campaign.test.ts index 9701247d3f11..411bcea6b10d 100644 --- a/ui/src/app/overlays-update-campaign.test.ts +++ b/ui/src/app/overlays-update-campaign.test.ts @@ -241,11 +241,59 @@ describe("application update campaign overlays", () => { await vi.advanceTimersByTimeAsync(10_000); await flushMicrotasks(); expect(request.mock.calls.filter(([method]) => method === "update.status")).toHaveLength(1); + expect(overlays.snapshot.updateCampaignStatusHydrated).toBe(false); + expect(overlays.snapshot.updateSchedule?.campaign?.id).toBe("campaign-2"); } finally { overlays.dispose(); } }); + it("holds a campaign surface until its first authoritative status arrives", async () => { + vi.useFakeTimers(); + const updateStatus = deferred(); + const request = vi.fn((method) => + method === "update.status" ? updateStatus.promise : Promise.resolve({}), + ); + const harness = createGatewayHarness(client(request)); + harness.update({ + hello: { + auth: { role: "operator", scopes: ["operator.admin"] }, + snapshot: { + updateSchedule: { + channel: "dev", + autoEnabled: true, + campaign: { + id: "campaign-blocked", + state: "waiting-for-idle", + announcedAtMs: 1_000, + forceAtMs: 901_000, + updatedAtMs: 1_000, + }, + }, + }, + } as ApplicationGatewaySnapshot["hello"], + }); + const overlays = createApplicationOverlays(harness.gateway); + + expect(overlays.snapshot.updateCampaignStatusHydrated).toBe(false); + await vi.advanceTimersByTimeAsync(5_000); + expect(request.mock.calls.filter(([method]) => method === "update.status")).toHaveLength(1); + expect(overlays.snapshot.updateCampaignStatusHydrated).toBe(false); + + updateStatus.resolve({ + sentinel: { + kind: "update", + status: "error", + stats: { reason: "build-dirty" }, + }, + }); + await flushMicrotasks(); + + expect(overlays.snapshot.updateCampaignStatusHydrated).toBe(true); + expect(overlays.snapshot.updateStatusBanner?.text).toContain("build-dirty"); + overlays.dispose(); + }); + it("holds an active campaign and adopts the returned schedule", async () => { const request = vi.fn(async (method) => method === "update.hold" diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index aa56378e1d9c..ec3fc386eecd 100644 --- a/ui/src/app/overlays.ts +++ b/ui/src/app/overlays.ts @@ -3,7 +3,7 @@ import { type GatewayUpdateAvailableEventPayload, } from "../../../src/gateway/events.js"; import type { GatewayEventFrame } from "../api/gateway.ts"; -import type { UpdateHoldResult, UpdateScheduleState } from "../api/types.ts"; +import type { UpdateHoldResult } from "../api/types.ts"; import { controlUiBuildDiffersFrom } from "../build-info.ts"; import { t } from "../i18n/index.ts"; import { @@ -53,12 +53,12 @@ import { type UpdateRestartStatusResponse, type UpdateRunResponse, } from "./update-overlay-helpers.ts"; +import { readUpdateScheduleValue } from "./update-schedule-dto.ts"; import { - readUpdateAvailable, - readUpdateAvailableValue, - readUpdateSchedule, - readUpdateScheduleValue, -} from "./update-schedule-dto.ts"; + projectConnectedUpdateSnapshot, + projectUpdateAvailableEvent, + resolveHeldUpdateCampaignId, +} from "./update-schedule-projection.ts"; import { announceRecordedUpdateSuccess, announceVerifiedUpdateInstall, @@ -82,6 +82,7 @@ export function createApplicationOverlays( heldUpdateCampaignId: null, updateRunning: false, updateStatusRefreshing: false, + updateCampaignStatusHydrated: true, updateReconciliationPending: false, updateStatusBanner: null, recordedUpdateAttempt: null, @@ -184,10 +185,6 @@ export function createApplicationOverlays( snapshot = { ...snapshot, recordedUpdateAttempt }; publish(); }; - const heldCampaignId = (schedule: UpdateScheduleState | null) => - schedule?.campaign?.holdUntilMs !== undefined - ? schedule.campaign.id - : snapshot.heldUpdateCampaignId; const updateVerification = createUpdateVerificationController({ getPending: () => pendingUpdate, clearPending: () => { @@ -214,6 +211,7 @@ export function createApplicationOverlays( recordedUpdateAttempt: snapshot.recordedUpdateAttempt, heldUpdateCampaignId: snapshot.heldUpdateCampaignId, }), + updateCampaignStatusHydrated: true, }; publish(); }; @@ -327,6 +325,7 @@ export function createApplicationOverlays( updateSchedule: null, updateRunning: false, updateStatusRefreshing: false, + updateCampaignStatusHydrated: true, }; updateCampaignPoller.stop(); if (next.phase === "reload-required") { @@ -341,8 +340,6 @@ export function createApplicationOverlays( publish(); return; } - const updateSchedule = - connectedSourceChanged || helloChanged ? readUpdateSchedule(next.hello) : undefined; const serverBuildIdentity = { version: next.hello?.server?.version, buildId: next.hello?.server?.buildId, @@ -352,11 +349,7 @@ export function createApplicationOverlays( snapshot = { ...snapshot, ...(connectedSourceChanged || helloChanged - ? { - updateAvailable: readUpdateAvailable(next.hello), - updateSchedule: updateSchedule ?? null, - heldUpdateCampaignId: heldCampaignId(updateSchedule ?? null), - } + ? projectConnectedUpdateSnapshot(snapshot, next.hello) : {}), controlUiRefreshRequired: connectedSourceChanged ? (exactBuildIdentityAvailable || connectedEpoch > 0) && @@ -408,19 +401,9 @@ export function createApplicationOverlays( } if (event.event === GATEWAY_EVENT_UPDATE_AVAILABLE) { const payload = event.payload as GatewayUpdateAvailableEventPayload | undefined; - const updateSchedule = - payload && Object.hasOwn(payload, "schedule") - ? readUpdateScheduleValue(payload.schedule) - : undefined; snapshot = { ...snapshot, - updateAvailable: readUpdateAvailableValue(payload?.updateAvailable), - ...(updateSchedule !== undefined - ? { - updateSchedule, - heldUpdateCampaignId: heldCampaignId(updateSchedule), - } - : {}), + ...projectUpdateAvailableEvent(snapshot, payload), }; publish(); updateCampaignPoller.sync(); @@ -580,7 +563,10 @@ export function createApplicationOverlays( ...(updateSchedule !== undefined ? { updateSchedule } : {}), heldUpdateCampaignId: response.ok ? campaign.id - : heldCampaignId(updateSchedule ?? null), + : resolveHeldUpdateCampaignId( + updateSchedule ?? snapshot.updateSchedule, + snapshot.heldUpdateCampaignId, + ), }; publish(); } diff --git a/ui/src/app/update-schedule-projection.ts b/ui/src/app/update-schedule-projection.ts new file mode 100644 index 000000000000..1230230461c9 --- /dev/null +++ b/ui/src/app/update-schedule-projection.ts @@ -0,0 +1,81 @@ +import type { GatewayHelloOk } from "../api/gateway.ts"; +import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts"; +import { + readUpdateAvailable, + readUpdateAvailableValue, + readUpdateSchedule, + readUpdateScheduleValue, +} from "./update-schedule-dto.ts"; + +type UpdateScheduleProjection = { + updateAvailable: UpdateAvailable | null; + updateSchedule: UpdateScheduleState | null; + heldUpdateCampaignId: string | null; + updateCampaignStatusHydrated: boolean; +}; + +function retainCampaignStatusHydration( + current: UpdateScheduleState | null, + next: UpdateScheduleState | null | undefined, + hydrated: boolean, +): boolean { + const currentCampaign = current?.campaign; + const nextCampaign = next?.campaign; + return ( + !nextCampaign || + (hydrated && + currentCampaign?.id === nextCampaign.id && + currentCampaign.updatedAtMs === nextCampaign.updatedAtMs) + ); +} + +export function resolveHeldUpdateCampaignId( + schedule: UpdateScheduleState | null, + currentCampaignId: string | null, +): string | null { + return schedule?.campaign?.holdUntilMs !== undefined ? schedule.campaign.id : currentCampaignId; +} + +export function projectConnectedUpdateSnapshot( + current: UpdateScheduleProjection, + hello: GatewayHelloOk | null, +): UpdateScheduleProjection { + const updateSchedule = readUpdateSchedule(hello); + return { + updateAvailable: readUpdateAvailable(hello), + updateSchedule, + heldUpdateCampaignId: resolveHeldUpdateCampaignId(updateSchedule, current.heldUpdateCampaignId), + updateCampaignStatusHydrated: retainCampaignStatusHydration( + current.updateSchedule, + updateSchedule, + current.updateCampaignStatusHydrated, + ), + }; +} + +export function projectUpdateAvailableEvent( + current: UpdateScheduleProjection, + payload: { updateAvailable?: unknown; schedule?: unknown } | undefined, +): Partial { + const updateSchedule = + payload && Object.hasOwn(payload, "schedule") + ? readUpdateScheduleValue(payload.schedule) + : undefined; + return { + updateAvailable: readUpdateAvailableValue(payload?.updateAvailable), + ...(updateSchedule !== undefined + ? { + updateSchedule, + heldUpdateCampaignId: resolveHeldUpdateCampaignId( + updateSchedule, + current.heldUpdateCampaignId, + ), + updateCampaignStatusHydrated: retainCampaignStatusHydration( + current.updateSchedule, + updateSchedule, + current.updateCampaignStatusHydrated, + ), + } + : {}), + }; +} diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts index e6e05569a280..47017103ed2a 100644 --- a/ui/src/components/app-sidebar-base.ts +++ b/ui/src/components/app-sidebar-base.ts @@ -1,6 +1,5 @@ import { consume } from "@lit/context"; import { property } from "lit/decorators.js"; -import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts"; import { DEFAULT_SIDEBAR_ENTRIES, type NavigationRouteId } from "../app-navigation.ts"; import type { RouteId } from "../app-route-paths.ts"; import { selectApplicationSession } from "../app/agent-selection.ts"; @@ -12,7 +11,6 @@ import { 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"; @@ -49,21 +47,9 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement { @property({ attribute: false }) lobsterPetSounds = false; @property({ attribute: false }) gatewayVersion: string | null = null; @property({ attribute: false }) devGitBranch: string | null = null; - @property({ attribute: false }) updateAvailable: UpdateAvailable | null = null; - @property({ attribute: false }) updateSchedule: UpdateScheduleState | null = null; - @property({ attribute: false }) heldUpdateCampaignId: string | null = null; - @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; - @property({ attribute: false }) refreshRequired = false; - @property({ attribute: false }) onRefresh: () => void = () => undefined; - @property({ attribute: false }) onHoldUpdate: () => Promise = async () => false; - @property({ attribute: false }) onReviewUpdate: () => void = () => undefined; @property({ attribute: false }) onOpenApprovals?: () => void; @property({ attribute: false }) onRetryConnect?: () => void; @property({ attribute: false }) onOpenNewSession?: ( diff --git a/ui/src/components/app-sidebar-render.ts b/ui/src/components/app-sidebar-render.ts index e1ea77ccfaf3..2c0b06535928 100644 --- a/ui/src/components/app-sidebar-render.ts +++ b/ui/src/components/app-sidebar-render.ts @@ -7,9 +7,7 @@ import { } from "../app-navigation.ts"; import { isRouteId, isSessionRouteId, pathForRoute } from "../app-route-paths.ts"; import { resolveControlUiAuthToken } from "../app/control-ui-auth.ts"; -import { hasNativeUpdateBridge } from "../app/native-link-routing.ts"; import { isNativeWebChromeHost } from "../app/native-web-chrome.ts"; -import { confirmAndStartUpdate } from "../app/update-confirmation.ts"; import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts"; import { CONTROL_UI_BUILD_INFO } from "../build-info.ts"; import { t } from "../i18n/index.ts"; @@ -51,7 +49,6 @@ import { formatSidebarBuildSubtitle } from "./sidebar-build-chip-format.ts"; type AppSidebarRenderHost = AppSidebarSessionNavigationElement & { activePluginTabId: string; activeWorkboardBoardId: string; - nativeUpdateDeclined: boolean; offline: boolean; getRouteSessionKey(): string; renderPinnedSidebarSession(session: SidebarRecentSession): unknown; @@ -370,16 +367,8 @@ export function renderAppSidebarFooterBar(host: AppSidebarRenderHost) { : gateway ? `${gateway.name}${gatewayPrimaryTag ? `, ${gatewayPrimaryTag}` : ""}` : buildSubtitle; - const availableUpdate = host.updateAvailable; - const showUpdate = availableUpdate !== null; - const updateBusy = host.updateBusy || host.updateSchedule?.campaign?.state === "applying"; - const showInbox = true; return html` -