mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 02:45:38 -06:00
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:
committed by
GitHub
parent
c933145088
commit
2cfacdb671
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?: (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user