From 2cfacdb671f853aecfa55eb9d28243ed2602266f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 03:06:12 -0700 Subject: [PATCH] fix(ui): move stale-client refresh notice into the sidebar update card (#120483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): move stale-client refresh notice into the sidebar update card The 'Server updated — refresh for full capabilities' notice rendered as a full-width callout at the top of
, where the fixed shell-chrome controls (nav toggle / search, layout.css z-index 45) overlap its text. Move the notice into the existing sidebar-update-card slot above the identity/connectivity footer: refreshRequired takes precedence over updateAvailable (a stale client cannot trust its own update metadata), renders non-dismissible with a refresh icon and two-line text, and is wired through the sidebar, settings sidebar, and collapsed-nav floating card. The old top-of-main banner branch, its resolver helper, and the orphaned i18n key are deleted. * fix(ui): keep stale-client refresh visible during onboarding and clear collapsed chrome cluster Address both accepted ClawSweeper P2 findings: - restore the stale-client refresh action during onboarding - keep the floating update card clear of the three-button collapsed chrome cluster --- ui/src/app/app-host-native-shell.test.ts | 81 ++++++++++++++++- ui/src/app/app-shell-view.ts | 22 +++-- ui/src/app/navigation-surface.browser.test.ts | 88 +++++++++++++++++++ ui/src/app/navigation-surface.ts | 13 ++- ui/src/app/update-overlay-helpers.test.ts | 8 +- ui/src/app/update-overlay-helpers.ts | 7 -- ui/src/components/app-sidebar-base.ts | 2 + ui/src/components/app-sidebar.ts | 2 + ui/src/components/settings-sidebar.test.ts | 23 ++++- ui/src/components/settings-sidebar.ts | 4 + ui/src/components/sidebar-update-card.test.ts | 53 +++++++++++ ui/src/components/sidebar-update-card.ts | 23 ++++- ui/src/components/update-banner.test.ts | 12 +-- ui/src/i18n/locales/en.ts | 3 +- ui/src/styles/layout.css | 58 ++++++++++-- .../test-helpers/app-sidebar-cases/basics.ts | 13 +++ ui/src/test-helpers/app-sidebar.ts | 2 + 17 files changed, 367 insertions(+), 47 deletions(-) create mode 100644 ui/src/app/navigation-surface.browser.test.ts diff --git a/ui/src/app/app-host-native-shell.test.ts b/ui/src/app/app-host-native-shell.test.ts index 3b170be90bb4..0a4e997c5718 100644 --- a/ui/src/app/app-host-native-shell.test.ts +++ b/ui/src/app/app-host-native-shell.test.ts @@ -393,27 +393,59 @@ describe("OpenClaw shell update affordance", () => { }, updateRunning: false, onUpdate: vi.fn(), + refreshRequired: false, + onRefresh: vi.fn(), }; const collapsed = navigationSurfaceIsHidden({ + onboarding: false, navCollapsed: true, navDrawerOpen: false, mobileNavLayout: false, }); render(renderFloatingUpdateCard({ ...shared, navigationSurfaceHidden: collapsed }), container); - expect(container.querySelector("openclaw-sidebar-update-card")).not.toBeNull(); + const card = container.querySelector< + HTMLElement & { + refreshRequired: boolean; + onRefresh: () => void; + } + >("openclaw-sidebar-update-card"); + expect(card).not.toBeNull(); + + render( + renderFloatingUpdateCard({ + ...shared, + navigationSurfaceHidden: collapsed, + updateAvailable: null, + refreshRequired: true, + }), + container, + ); + expect(card?.refreshRequired).toBe(true); + card?.onRefresh(); + expect(shared.onRefresh).toHaveBeenCalledOnce(); const visible = navigationSurfaceIsHidden({ + onboarding: false, navCollapsed: false, navDrawerOpen: false, mobileNavLayout: false, }); - render(renderFloatingUpdateCard({ ...shared, navigationSurfaceHidden: visible }), container); + render( + renderFloatingUpdateCard({ + ...shared, + navigationSurfaceHidden: visible, + updateAvailable: null, + refreshRequired: true, + }), + container, + ); expect(container.querySelector("openclaw-sidebar-update-card")).toBeNull(); }); it("treats a closed mobile drawer as hidden navigation", () => { expect( navigationSurfaceIsHidden({ + onboarding: false, navCollapsed: false, navDrawerOpen: false, mobileNavLayout: true, @@ -421,10 +453,55 @@ describe("OpenClaw shell update affordance", () => { ).toBe(true); expect( navigationSurfaceIsHidden({ + onboarding: false, navCollapsed: false, navDrawerOpen: true, mobileNavLayout: true, }), ).toBe(false); }); + + it("keeps the stale-client refresh visible during onboarding", () => { + const container = document.createElement("div"); + const shared = { + onboarding: true, + updateAvailable: null, + updateRunning: false, + onUpdate: vi.fn(), + refreshRequired: true, + onRefresh: vi.fn(), + }; + expect( + navigationSurfaceIsHidden({ + onboarding: true, + navCollapsed: false, + navDrawerOpen: false, + mobileNavLayout: false, + }), + ).toBe(true); + + for (const navigationSurfaceHidden of [false, true]) { + render(renderFloatingUpdateCard({ ...shared, navigationSurfaceHidden }), container); + const cards = container.querySelectorAll( + "openclaw-sidebar-update-card", + ); + expect(cards).toHaveLength(1); + expect(cards[0]?.refreshRequired).toBe(true); + } + + render( + renderFloatingUpdateCard({ + ...shared, + navigationSurfaceHidden: true, + updateAvailable: { + currentVersion: "2026.7.1", + latestVersion: "2026.7.2", + channel: "stable", + }, + refreshRequired: false, + }), + container, + ); + expect(container.querySelector("openclaw-sidebar-update-card")).toBeNull(); + }); }); diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 3578989dec4b..bc2f77824934 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -38,7 +38,6 @@ import { loadSettings, normalizeCatalogOpenTarget, } from "./settings.ts"; -import { resolveControlUiRefreshRequiredBanner } from "./update-overlay-helpers.ts"; const EMPTY_OUTBOX_COUNT_FOR_SESSION = () => 0; const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "") @@ -149,6 +148,7 @@ export function renderApplicationShell(host: ShellViewHost) { !navDrawerOpen && !settingsTakeover; const navigationSurfaceHidden = navigationSurfaceIsHidden({ + onboarding, navCollapsed, navDrawerOpen, mobileNavLayout, @@ -213,6 +213,8 @@ export function renderApplicationShell(host: ShellViewHost) { updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable, updateRunning: overlaySnapshot.updateRunning, onUpdate: () => void context.overlays.runUpdate(), + refreshRequired: navigationSurfaceHidden ? false : overlaySnapshot.controlUiRefreshRequired, + onRefresh: () => host.refreshControlUi(), onOpenApprovals: () => host.openApprovals(), onRetryConnect: () => context.gateway.connect(), onOpenNewSession: openNewSession, @@ -241,6 +243,8 @@ export function renderApplicationShell(host: ShellViewHost) { updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable, updateRunning: overlaySnapshot.updateRunning, onUpdate: () => void context.overlays.runUpdate(), + refreshRequired: navigationSurfaceHidden ? false : overlaySnapshot.controlUiRefreshRequired, + onRefresh: () => host.refreshControlUi(), searchQuery: host.settingsSearchQuery, searchBlockMatches: settingsSearchBlocks, onExit: () => host.exitSettings(), @@ -398,7 +402,8 @@ export function renderApplicationShell(host: ShellViewHost) { .tabIndex=${-1} > ${gatewaySnapshot.hello?.deviceAuthMigration?.pending === true - ? customElements.get("openclaw-device-auth-migration-banner") + ? // The migration banner is registered by a rare-flow dynamic import after first render. + customElements.get("openclaw-device-auth-migration-banner") ? html`` - : html` host.refreshControlUi() } - : undefined, - }} - >`} + : nothing} void context.overlays.runUpdate(), + refreshRequired: overlaySnapshot.controlUiRefreshRequired, + onRefresh: () => host.refreshControlUi(), })} { + document.body.replaceChildren(); +}); + +async function useDesktopViewport() { + const { page } = await import("vitest/browser"); + await page.viewport(1280, 800); +} + +function overlaps(left: DOMRect, right: DOMRect): boolean { + return !( + left.right <= right.left || + left.left >= right.right || + left.bottom <= right.top || + left.top >= right.bottom + ); +} + +describe.skipIf(!hasBrowserLayout)("navigation surface browser layout", () => { + it("keeps the floating refresh card clear of the collapsed chrome cluster", async () => { + await useDesktopViewport(); + render( + html` +
+
+ + + +
+
+ ${renderFloatingUpdateCard({ + navigationSurfaceHidden: true, + onboarding: false, + updateAvailable: null, + updateRunning: false, + onUpdate: () => undefined, + refreshRequired: true, + onRefresh: () => undefined, + })} +
+
+ `, + document.body, + ); + + const refreshCardHost = document.querySelector< + HTMLElement & { updateComplete: Promise } + >("openclaw-sidebar-update-card"); + await refreshCardHost?.updateComplete; + const refreshCard = refreshCardHost?.querySelector(".sidebar-update-card"); + const buttons = Array.from( + document.querySelectorAll(".shell-chrome-controls__button"), + ); + expect(refreshCard).not.toBeNull(); + expect(buttons).toHaveLength(3); + + const cardBounds = refreshCard!.getBoundingClientRect(); + const buttonBounds = buttons.map((button) => button.getBoundingClientRect()); + expect(cardBounds.width).toBeGreaterThan(0); + for (const bounds of buttonBounds) { + expect(bounds.width).toBeGreaterThan(0); + expect(overlaps(cardBounds, bounds)).toBe(false); + } + expect( + cardBounds.left - Math.max(...buttonBounds.map((bounds) => bounds.right)), + ).toBeGreaterThanOrEqual(8); + }); +}); diff --git a/ui/src/app/navigation-surface.ts b/ui/src/app/navigation-surface.ts index a2a0fdd6dc68..9c3503920b0c 100644 --- a/ui/src/app/navigation-surface.ts +++ b/ui/src/app/navigation-surface.ts @@ -2,11 +2,14 @@ import { html, nothing } from "lit"; import type { ApplicationContext } from "./context.ts"; export function navigationSurfaceIsHidden(params: { + onboarding: boolean; navCollapsed: boolean; navDrawerOpen: boolean; mobileNavLayout: boolean; }): boolean { - return params.mobileNavLayout ? !params.navDrawerOpen : params.navCollapsed; + return ( + params.onboarding || (params.mobileNavLayout ? !params.navDrawerOpen : params.navCollapsed) + ); } export function renderFloatingUpdateCard(params: { @@ -15,8 +18,12 @@ export function renderFloatingUpdateCard(params: { updateAvailable: ApplicationContext["overlays"]["snapshot"]["updateAvailable"]; updateRunning: boolean; onUpdate: () => void; + refreshRequired: boolean; + onRefresh: () => void; }) { - if (!params.navigationSurfaceHidden || params.onboarding) { + // A stale client must always have a visible refresh action, including during + // onboarding, even though update-available actions stay hidden there. + if (params.onboarding ? !params.refreshRequired : !params.navigationSurfaceHidden) { return nothing; } return html``; } diff --git a/ui/src/app/update-overlay-helpers.test.ts b/ui/src/app/update-overlay-helpers.test.ts index aa30595ea2a7..657feffd0755 100644 --- a/ui/src/app/update-overlay-helpers.test.ts +++ b/ui/src/app/update-overlay-helpers.test.ts @@ -3,7 +3,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { i18n } from "../i18n/index.ts"; import { - resolveControlUiRefreshRequiredBanner, resolvePendingUpdateHandoffTimeoutBanner, resolvePostRestartUpdateBanner, resolveUpdateStatusBanner, @@ -11,7 +10,6 @@ import { } from "./update-overlay-helpers.ts"; const translations: Record = { - "updates.refreshRequired": "Server updated — refresh for full capabilities", "updates.status": "Update {status}: {reason}. {guidance}", "updates.failureReasons.dirty": "Commit or stash changes, then retry.", "updates.failureReasons.default": @@ -39,13 +37,9 @@ afterEach(() => { }); describe("update status localization", () => { - it("localizes refresh and known update failure guidance", () => { + it("localizes known update failure guidance", () => { const translate = installTranslations(); - expect(resolveControlUiRefreshRequiredBanner()).toEqual({ - tone: "info", - text: "Server updated — refresh for full capabilities", - }); expect(resolveUpdateStatusBanner({ status: "skipped", reason: "dirty" })).toEqual({ tone: "warn", text: "Update skipped: dirty. Commit or stash changes, then retry.", diff --git a/ui/src/app/update-overlay-helpers.ts b/ui/src/app/update-overlay-helpers.ts index 5885122c20e5..5e480b18aff6 100644 --- a/ui/src/app/update-overlay-helpers.ts +++ b/ui/src/app/update-overlay-helpers.ts @@ -101,13 +101,6 @@ export function readUpdateAvailable(hello: GatewayHelloOk | null): UpdateAvailab : null; } -export function resolveControlUiRefreshRequiredBanner(): ApplicationStatusBanner { - return { - tone: "info", - text: t("updates.refreshRequired"), - }; -} - export function resolveUpdateStatusBanner(params: { status?: string; reason?: string; diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts index 921c2f43b9f0..e8e83436808b 100644 --- a/ui/src/components/app-sidebar-base.ts +++ b/ui/src/components/app-sidebar-base.ts @@ -49,6 +49,8 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement { @property({ attribute: false }) updateAvailable: UpdateAvailable | null = null; @property({ attribute: false }) updateRunning = false; @property({ attribute: false }) onUpdate: () => void = () => undefined; + @property({ attribute: false }) refreshRequired = false; + @property({ attribute: false }) onRefresh: () => void = () => undefined; @property({ attribute: false }) onOpenApprovals?: () => void; @property({ attribute: false }) onRetryConnect?: () => void; @property({ attribute: false }) onOpenNewSession?: ( diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 190fb9f3653b..05fab1e1878b 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -477,6 +477,8 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi .updateAvailable=${this.updateAvailable} .updateRunning=${this.updateRunning} .onUpdate=${this.onUpdate} + .refreshRequired=${this.refreshRequired} + .onRefresh=${this.onRefresh} > ({ onApply: vi.fn(), }); +const inactiveRefresh = { + refreshRequired: false, + onRefresh: () => undefined, +}; + beforeEach(async () => { await i18n.setLocale("en"); container = document.createElement("div"); @@ -44,6 +49,7 @@ describe("settings sidebar search", () => { updateAvailable: null, updateRunning: false, onUpdate: vi.fn(), + ...inactiveRefresh, searchQuery: "", onExit: vi.fn(), onRetryConnect: vi.fn(), @@ -75,6 +81,7 @@ describe("settings sidebar search", () => { updateAvailable: null, updateRunning: false, onUpdate: vi.fn(), + ...inactiveRefresh, searchQuery: "", onExit: vi.fn(), onRetryConnect: vi.fn(), @@ -105,6 +112,7 @@ describe("settings sidebar search", () => { updateAvailable: null, updateRunning: false, onUpdate: vi.fn(), + ...inactiveRefresh, searchQuery: "cp", searchBlockMatches: [ { @@ -143,6 +151,7 @@ describe("settings sidebar search", () => { updateAvailable: null, updateRunning: false, onUpdate: vi.fn(), + ...inactiveRefresh, searchQuery: "mcp", searchBlockMatches: [ { @@ -198,6 +207,7 @@ describe("settings sidebar search", () => { updateAvailable: null, updateRunning: false, onUpdate: vi.fn(), + ...inactiveRefresh, searchQuery: "infrastructure", searchBlockMatches: [ { @@ -246,6 +256,7 @@ describe("settings sidebar search", () => { updateAvailable: null, updateRunning: false, onUpdate: vi.fn(), + ...inactiveRefresh, searchQuery: "agent defaults", onExit: vi.fn(), onRetryConnect: vi.fn(), @@ -277,6 +288,7 @@ describe("settings sidebar search", () => { updateAvailable: null, updateRunning: false, onUpdate: vi.fn(), + ...inactiveRefresh, searchQuery: "backend", searchBlockMatches: [ { @@ -322,6 +334,7 @@ describe("settings sidebar search", () => { updateAvailable: null, updateRunning: false, onUpdate: vi.fn(), + ...inactiveRefresh, searchQuery, onExit: vi.fn(), onRetryConnect: vi.fn(), @@ -403,6 +416,7 @@ describe("settings sidebar search", () => { updateAvailable: null, updateRunning: false, onUpdate: vi.fn(), + ...inactiveRefresh, searchQuery: "", onExit: vi.fn(), onRetryConnect: vi.fn(), @@ -422,8 +436,9 @@ describe("settings sidebar search", () => { expect(labels).toContain("Avancado"); }); - it("keeps the update card above the settings footer", async () => { + it("keeps the refresh card above the settings footer and forwards its action", async () => { const onUpdate = vi.fn(); + const onRefresh = vi.fn(); const onNavigate = vi.fn(); render( renderSettingsSidebar({ @@ -439,6 +454,8 @@ describe("settings sidebar search", () => { }, updateRunning: false, onUpdate, + refreshRequired: true, + onRefresh, searchQuery: "", onExit: vi.fn(), onRetryConnect: vi.fn(), @@ -456,7 +473,8 @@ describe("settings sidebar search", () => { await card?.updateComplete; expect(card?.nextElementSibling?.classList.contains("settings-sidebar__footer")).toBe(true); card?.querySelector(".sidebar-update-card__action")?.click(); - expect(onUpdate).toHaveBeenCalledOnce(); + expect(onRefresh).toHaveBeenCalledOnce(); + expect(onUpdate).not.toHaveBeenCalled(); const buildChip = container.querySelector< HTMLElement & { @@ -486,6 +504,7 @@ describe("settings sidebar search", () => { updateAvailable: null, updateRunning: false, onUpdate: vi.fn(), + ...inactiveRefresh, searchQuery: "", onExit: vi.fn(), onRetryConnect, diff --git a/ui/src/components/settings-sidebar.ts b/ui/src/components/settings-sidebar.ts index 1ad3ca6bfece..8c070e03a6d9 100644 --- a/ui/src/components/settings-sidebar.ts +++ b/ui/src/components/settings-sidebar.ts @@ -40,6 +40,8 @@ type SettingsSidebarProps = { updateAvailable: UpdateAvailable | null; updateRunning: boolean; onUpdate: () => void; + refreshRequired: boolean; + onRefresh: () => void; searchQuery: string; searchBlockMatches?: readonly SettingsSearchBlock[]; onExit: () => void; @@ -305,6 +307,8 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) { .updateAvailable=${props.updateAvailable} .updateRunning=${props.updateRunning} .onUpdate=${props.onUpdate} + .refreshRequired=${props.refreshRequired} + .onRefresh=${props.onRefresh} >