fix(ui): move stale-client refresh notice into the sidebar update card (#120483)

* 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 <main>, 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
This commit is contained in:
Peter Steinberger
2026-08-08 03:06:12 -07:00
committed by GitHub
parent c933145088
commit 2cfacdb671
17 changed files with 367 additions and 47 deletions
+79 -2
View File
@@ -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<HTMLElement & { refreshRequired: boolean }>(
"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();
});
});
+10 -12
View File
@@ -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`<openclaw-device-auth-migration-banner
.props=${{
state: overlaySnapshot.deviceAuthMigration,
@@ -415,16 +420,7 @@ export function renderApplicationShell(host: ShellViewHost) {
},
}}
></openclaw-update-banner>`
: html`<openclaw-update-banner
.props=${{
statusBanner: overlaySnapshot.controlUiRefreshRequired
? resolveControlUiRefreshRequiredBanner()
: null,
action: overlaySnapshot.controlUiRefreshRequired
? { label: t("common.refresh"), onClick: () => host.refreshControlUi() }
: undefined,
}}
></openclaw-update-banner>`}
: nothing}
<openclaw-update-banner
.props=${{
statusBanner: overlaySnapshot.updateStatusBanner,
@@ -436,6 +432,8 @@ export function renderApplicationShell(host: ShellViewHost) {
updateAvailable: overlaySnapshot.updateAvailable,
updateRunning: overlaySnapshot.updateRunning,
onUpdate: () => void context.overlays.runUpdate(),
refreshRequired: overlaySnapshot.controlUiRefreshRequired,
onRefresh: () => host.refreshControlUi(),
})}
<openclaw-router-outlet
.router=${runtime.router}
@@ -0,0 +1,88 @@
import { html, render } from "lit";
import { afterEach, describe, expect, it } from "vitest";
import "../components/sidebar-update-card.ts";
import "../styles.css";
import { renderFloatingUpdateCard } from "./navigation-surface.ts";
const hasBrowserLayout = !navigator.userAgent.toLowerCase().includes("jsdom");
afterEach(() => {
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`
<div class="shell shell--nav-collapsed" style="animation: none">
<div class="shell-chrome-controls">
<button
class="shell-chrome-controls__button shell-chrome-controls__nav-toggle"
type="button"
aria-label="Expand navigation"
></button>
<button
class="shell-chrome-controls__button shell-chrome-controls__new-thread"
type="button"
aria-label="New thread"
></button>
<button
class="shell-chrome-controls__button shell-chrome-controls__search"
type="button"
aria-label="Search"
></button>
</div>
<main class="content">
${renderFloatingUpdateCard({
navigationSurfaceHidden: true,
onboarding: false,
updateAvailable: null,
updateRunning: false,
onUpdate: () => undefined,
refreshRequired: true,
onRefresh: () => undefined,
})}
</main>
</div>
`,
document.body,
);
const refreshCardHost = document.querySelector<
HTMLElement & { updateComplete: Promise<boolean> }
>("openclaw-sidebar-update-card");
await refreshCardHost?.updateComplete;
const refreshCard = refreshCardHost?.querySelector<HTMLElement>(".sidebar-update-card");
const buttons = Array.from(
document.querySelectorAll<HTMLElement>(".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);
});
});
+11 -2
View File
@@ -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`<openclaw-sidebar-update-card
@@ -24,5 +31,7 @@ export function renderFloatingUpdateCard(params: {
.updateAvailable=${params.updateAvailable}
.updateRunning=${params.updateRunning}
.onUpdate=${params.onUpdate}
.refreshRequired=${params.refreshRequired}
.onRefresh=${params.onRefresh}
></openclaw-sidebar-update-card>`;
}
+1 -7
View File
@@ -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<string, string> = {
"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.",
-7
View File
@@ -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;
+2
View File
@@ -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?: (
+2
View File
@@ -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}
></openclaw-sidebar-update-card>
<openclaw-lobster-pet
.seed=${lobsterPetSeed(this.sessionKey)}
+21 -2
View File
@@ -20,6 +20,11 @@ const saveIndicator = () => ({
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<HTMLButtonElement>(".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,
+4
View File
@@ -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}
></openclaw-sidebar-update-card>
<footer class="settings-sidebar__footer">
${props.offline
@@ -15,6 +15,8 @@ type SidebarUpdateCardElement = HTMLElement & {
updateAvailable: UpdateAvailable | null;
updateRunning: boolean;
onUpdate: () => void;
refreshRequired: boolean;
onRefresh: () => void;
updateComplete: Promise<boolean>;
};
@@ -55,6 +57,57 @@ afterEach(() => {
});
describe("SidebarUpdateCard", () => {
it("renders the refresh state and invokes its action", async () => {
const element = await mount(null);
const onRefresh = vi.fn();
element.refreshRequired = true;
element.onRefresh = onRefresh;
await element.updateComplete;
const card = element.querySelector(".sidebar-update-card");
expect(card?.getAttribute("role")).toBe("status");
expect(card?.getAttribute("aria-live")).toBe("polite");
expect(element.querySelector(".sidebar-update-card__title")?.textContent).toBe(
"Server updated",
);
expect(element.querySelector(".sidebar-update-card__subtitle")?.textContent).toBe(
"Refresh for full capabilities",
);
element.querySelector<HTMLButtonElement>(".sidebar-update-card__action")?.click();
expect(onRefresh).toHaveBeenCalledOnce();
});
it("gives the refresh state precedence over an available update", async () => {
const element = await mount({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
channel: "stable",
});
const onRefresh = vi.fn();
const onUpdate = vi.fn();
element.refreshRequired = true;
element.onRefresh = onRefresh;
element.onUpdate = onUpdate;
await element.updateComplete;
expect(element.textContent).toContain("Server updated");
expect(element.textContent).not.toContain("Update Gateway");
expect(element.textContent).not.toContain("v2.0.0");
element.querySelector<HTMLButtonElement>(".sidebar-update-card__action")?.click();
expect(onRefresh).toHaveBeenCalledOnce();
expect(onUpdate).not.toHaveBeenCalled();
});
it("does not render a dismiss button for the refresh state", async () => {
const element = await mount(null);
element.refreshRequired = true;
await element.updateComplete;
expect(element.querySelector(".sidebar-update-card__dismiss")).toBeNull();
});
it("labels a direct Gateway update and invokes its action", async () => {
const element = await mount({
currentVersion: "1.0.0",
+22 -1
View File
@@ -56,6 +56,8 @@ class SidebarUpdateCard 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;
@state() private dismissedUpdateKey: string | null = null;
@state() private nativeUpdateAvailable = hasNativeUpdateBridge();
private nativeUpdateDeclined = false;
@@ -68,7 +70,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
private readonly handleNativeUpdateDeclined = () => {
this.nativeUpdateDeclined = true;
this.nativeUpdateAvailable = false;
if (this.updateAvailable && !this.updateRunning) {
if (this.updateAvailable && !this.updateRunning && !this.refreshRequired) {
this.onUpdate();
}
};
@@ -93,6 +95,25 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
}
override render() {
// A stale client cannot trust its own update metadata, so refresh takes precedence
// over any available update it may still report.
if (this.refreshRequired) {
return html`
<div class="sidebar-update-card" role="status" aria-live="polite">
<button class="sidebar-update-card__action" type="button" @click=${this.onRefresh}>
<span class="sidebar-update-card__icon" aria-hidden="true">${icons.refresh}</span>
<span class="sidebar-update-card__text sidebar-update-card__text--stacked">
<span class="sidebar-update-card__title"
>${t("chat.sidebar.serverUpdatedTitle")}</span
>
<span class="sidebar-update-card__subtitle"
>${t("chat.sidebar.serverUpdatedRefresh")}</span
>
</span>
</button>
</div>
`;
}
const update = this.updateAvailable;
if (
!update ||
+6 -6
View File
@@ -36,22 +36,22 @@ describe("update banner", () => {
expect(element.querySelector("button")).toBeNull();
});
it("renders the stale Control UI refresh action", async () => {
it("renders a status action", async () => {
const onClick = vi.fn();
const element = await renderBanner({
statusBanner: {
tone: "info",
text: "Server updated — refresh for full capabilities",
tone: "warn",
text: "Secure this browser to finish migration",
},
action: { label: "Refresh", onClick },
action: { label: "Secure", onClick },
});
expect(element.querySelector(".callout__content")?.textContent).toBe(
"Server updated — refresh for full capabilities",
"Secure this browser to finish migration",
);
expect(element.querySelector(".callout")?.getAttribute("role")).toBe("status");
const button = element.querySelector<HTMLButtonElement>("button");
expect(button?.textContent?.trim()).toBe("Refresh");
expect(button?.textContent?.trim()).toBe("Secure");
button?.click();
expect(onClick).toHaveBeenCalledOnce();
+2 -1
View File
@@ -363,7 +363,6 @@ export const en: TranslationMap = {
stylesFailed: "Styles failed to load, so the page may look broken.",
},
updates: {
refreshRequired: "Server updated — refresh for full capabilities",
coalescedRestart:
"Update installed. A gateway restart is already in progress; status will refresh after it reconnects.",
error: "Update error: {error}",
@@ -4657,6 +4656,8 @@ export const en: TranslationMap = {
sidebar: {
updateMacAndGateway: "Update Mac app + Gateway",
updateGateway: "Update Gateway",
serverUpdatedTitle: "Server updated",
serverUpdatedRefresh: "Refresh for full capabilities",
threads: "Threads",
groups: "Groups",
coding: "Coding",
+51 -7
View File
@@ -7,6 +7,17 @@
--shell-gap: 16px;
--shell-nav-expanded-width: 258px;
--shell-nav-width: var(--shell-nav-expanded-width);
--shell-chrome-controls-inset: 10px;
--shell-chrome-control-size: 32px;
--shell-chrome-controls-gap: 4px;
--shell-chrome-controls-collapsed-width: calc(
var(--shell-chrome-control-size) + var(--shell-chrome-control-size) +
var(--shell-chrome-control-size) + var(--shell-chrome-controls-gap) +
var(--shell-chrome-controls-gap)
);
--shell-floating-update-card-left: calc(
var(--shell-chrome-controls-inset) + var(--shell-chrome-controls-collapsed-width) + 8px
);
/* Desktop has no topbar row — the sidebar owns navigation. Narrow viewports
(layout.mobile.css) and the chat split toolbar restore the row height. */
--shell-topbar-height: 0px;
@@ -74,19 +85,19 @@
.shell-chrome-controls {
position: fixed;
top: 10px;
left: calc(var(--shell-nav-width) + 10px);
left: calc(var(--shell-nav-width) + var(--shell-chrome-controls-inset));
z-index: 45;
display: flex;
align-items: center;
gap: 4px;
gap: var(--shell-chrome-controls-gap);
}
.shell-chrome-controls__button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
width: var(--shell-chrome-control-size);
height: var(--shell-chrome-control-size);
padding: 0;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
border-radius: var(--radius-md);
@@ -1132,10 +1143,24 @@ openclaw-settings-save-indicator {
margin: 12px 16px 0;
}
/* Keep the card beside the collapsed-nav control so their hit targets stay distinct. */
/* Clear the collapsed three-button chrome cluster plus an 8px breathing gap. */
.shell:not(.shell--mobile-nav) .sidebar-update-card--floating .sidebar-update-card {
width: min(360px, calc(100% - 48px));
margin-left: 32px;
width: min(360px, calc(100% - var(--shell-floating-update-card-left) - 16px));
margin-left: var(--shell-floating-update-card-left);
}
/* Native hosts hide the web cluster; onboarding never mounts it. */
html.openclaw-native-nav
.shell:not(.shell--mobile-nav)
.sidebar-update-card--floating
.sidebar-update-card,
html.openclaw-native-web-chrome
.shell:not(.shell--mobile-nav)
.sidebar-update-card--floating
.sidebar-update-card,
.shell--onboarding:not(.shell--mobile-nav) .sidebar-update-card--floating .sidebar-update-card {
width: min(360px, calc(100% - 32px));
margin-left: 16px;
}
/* Native dashboard actions must also clear AppKit's drag strip. */
@@ -1209,6 +1234,25 @@ html.openclaw-native-macos
white-space: nowrap;
}
.sidebar-update-card__text--stacked {
display: flex;
flex-direction: column;
}
.sidebar-update-card__title {
color: var(--text-strong);
font-weight: 600;
}
.sidebar-update-card__subtitle {
overflow: hidden;
color: var(--muted);
font-size: 11px;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-update-card__dismiss {
position: absolute;
top: 50%;
@@ -34,12 +34,14 @@ describe("AppSidebar update card wiring", () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
const onUpdate = vi.fn();
const onRefresh = vi.fn();
sidebar.updateAvailable = {
currentVersion: "1.0.0",
latestVersion: "2.0.0",
channel: "stable",
};
sidebar.onUpdate = onUpdate;
sidebar.onRefresh = onRefresh;
await sidebar.updateComplete;
const footer = sidebar.querySelector(".sidebar-shell__footer");
@@ -49,6 +51,17 @@ describe("AppSidebar update card wiring", () => {
expect(card).not.toBeNull();
card?.querySelector<HTMLButtonElement>(".sidebar-update-card__action")?.click();
expect(onUpdate).toHaveBeenCalledOnce();
sidebar.refreshRequired = true;
await sidebar.updateComplete;
const refreshCard = footer?.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
"openclaw-sidebar-update-card",
);
await refreshCard?.updateComplete;
expect(refreshCard?.textContent).toContain("Server updated");
refreshCard?.querySelector<HTMLButtonElement>(".sidebar-update-card__action")?.click();
expect(onRefresh).toHaveBeenCalledOnce();
expect(onUpdate).toHaveBeenCalledOnce();
});
});
+2
View File
@@ -71,6 +71,8 @@ export type SidebarLifecycleState = HTMLElement & {
updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null;
updateRunning: boolean;
onUpdate: () => void;
refreshRequired: boolean;
onRefresh: () => void;
onRetryConnect?: () => void;
onOpenNewSession?: (agentId: string, target?: { catalogId: string }) => void;
variant: "panel" | "drawer";