diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 12066289fb76..757f43d6d8b7 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -1098,6 +1098,10 @@ class OpenClawShell extends OpenClawLightDomElement { this.runWithCommandPalette((palette) => palette.openPalette()); }; + private readonly refreshControlUi = () => { + globalThis.location.reload(); + }; + private readonly handleShellNavDrawerToggle = (event: Event) => { const trigger = (event as CustomEvent).detail?.trigger; this.toggleNavigationSurface(trigger instanceof HTMLElement ? trigger : undefined); @@ -1580,6 +1584,19 @@ class OpenClawShell extends OpenClawLightDomElement { onRetry: () => context.gateway.connect(), }} >`} + ({ + CONTROL_UI_BUILD_INFO: { + version: "2026.7.19", + commit: null, + commitAt: null, + builtAt: null, + branch: null, + dirty: null, + buildId: "test", + }, +})); + const HELLO: GatewayHelloOk = { type: "hello-ok", protocol: 1, @@ -88,6 +100,7 @@ describe("createApplicationGateway reconnecting snapshot", () => { gateway.start(); expect(current().started).toBe(1); + expect(current().opts.clientVersion).toBe("2026.7.19"); expect(gateway.snapshot.connected).toBe(false); expect(gateway.snapshot.reconnecting).toBe(false); }); diff --git a/ui/src/app/gateway-store.ts b/ui/src/app/gateway-store.ts index 84a4bbe4c8d9..920dcbbfd183 100644 --- a/ui/src/app/gateway-store.ts +++ b/ui/src/app/gateway-store.ts @@ -7,6 +7,7 @@ import { type GatewayEventListener, type GatewayHelloOk, } from "../api/gateway.ts"; +import { CONTROL_UI_BUILD_INFO } from "../build-info.ts"; import { setAvatarGatewayOrigin } from "../lib/identity-avatar.ts"; import { resolveSessionKey } from "../lib/sessions/index.ts"; import { generateUUID } from "../lib/uuid.ts"; @@ -170,7 +171,7 @@ export function createApplicationGateway( : undefined, password: nextConnection.password.trim() ? nextConnection.password : undefined, clientName: "openclaw-control-ui", - clientVersion: "dev", + clientVersion: CONTROL_UI_BUILD_INFO.version ?? "dev", mode: "webchat", instanceId: generateUUID(), onHello: (hello: GatewayHelloOk) => { diff --git a/ui/src/app/overlays.test.ts b/ui/src/app/overlays.test.ts index 5f4d6503541a..a4b60334646a 100644 --- a/ui/src/app/overlays.test.ts +++ b/ui/src/app/overlays.test.ts @@ -4,6 +4,11 @@ import type { GatewayBrowserClient, GatewayEventFrame } from "../api/gateway.ts" import type { ApplicationGateway, ApplicationGatewaySnapshot } from "./gateway.ts"; import { createApplicationOverlays } from "./overlays.ts"; +vi.mock("../build-info.ts", () => ({ + controlUiVersionDiffersFrom: (gatewayVersion: string | undefined) => + Boolean(gatewayVersion?.trim() && gatewayVersion.trim() !== "1.0.0"), +})); + type RequestFn = (method: string, params?: unknown) => Promise; const VERIFICATION_POLL_MS = 250; @@ -116,6 +121,59 @@ async function flushMicrotasks() { await Promise.resolve(); } +describe("Control UI refresh nudge", () => { + it("waits for a reconnect before flagging a version mismatch", () => { + const gatewayClient = client(async () => []); + const harness = createGatewayHarness(null, false); + const overlays = createApplicationOverlays(harness.gateway); + const mismatchedHello = { + server: { version: "2.0.0" }, + } as ApplicationGatewaySnapshot["hello"]; + + harness.update({ client: gatewayClient, connected: true, hello: mismatchedHello }); + expect(overlays.snapshot.controlUiRefreshRequired).toBe(false); + + harness.update({ sessionKey: "agent:main:same-connection" }); + expect(overlays.snapshot.controlUiRefreshRequired).toBe(false); + + harness.update({ connected: false, hello: null }); + harness.update({ connected: true, hello: mismatchedHello }); + expect(overlays.snapshot.controlUiRefreshRequired).toBe(true); + + harness.update({ sessionKey: "agent:main:after-reconnect" }); + expect(overlays.snapshot.controlUiRefreshRequired).toBe(true); + + overlays.dispose(); + }); + + it("does not flag a matching reconnect and resets on a fresh client lifetime", () => { + const gatewayClient = client(async () => []); + const harness = createGatewayHarness(null, false); + const overlays = createApplicationOverlays(harness.gateway); + const matchingHello = { + server: { version: "1.0.0" }, + } as ApplicationGatewaySnapshot["hello"]; + const mismatchedHello = { + server: { version: "2.0.0" }, + } as ApplicationGatewaySnapshot["hello"]; + + harness.update({ client: gatewayClient, connected: true, hello: matchingHello }); + harness.update({ connected: false, hello: null }); + harness.update({ connected: true, hello: matchingHello }); + expect(overlays.snapshot.controlUiRefreshRequired).toBe(false); + + harness.update({ connected: false, hello: null }); + harness.update({ connected: true, hello: mismatchedHello }); + expect(overlays.snapshot.controlUiRefreshRequired).toBe(true); + + harness.update({ client: null, connected: false, hello: null }); + harness.update({ client: gatewayClient, connected: true, hello: mismatchedHello }); + expect(overlays.snapshot.controlUiRefreshRequired).toBe(false); + + overlays.dispose(); + }); +}); + describe("application approval overlays", () => { it("resolves OpenClaw changes through unified human approval", async () => { const request = vi.fn(async (method) => diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index 5d448980e2d0..284604a48985 100644 --- a/ui/src/app/overlays.ts +++ b/ui/src/app/overlays.ts @@ -4,6 +4,7 @@ import { } from "../../../src/gateway/events.js"; import type { GatewayEventFrame, GatewayHelloOk } from "../api/gateway.ts"; import type { UpdateAvailable } from "../api/types.ts"; +import { controlUiVersionDiffersFrom } from "../build-info.ts"; import { closeDevicePairSetup as closeDevicePairSetupState, createDevicePairSetupState, @@ -39,6 +40,7 @@ type ApplicationOverlaySnapshot = { updateRunning: boolean; updateReconciliationPending: boolean; updateStatusBanner: ApplicationStatusBanner | null; + controlUiRefreshRequired: boolean; approvalQueue: readonly ExecApprovalRequest[]; approvalBusy: boolean; approvalErrors: ReadonlyMap; @@ -220,6 +222,7 @@ export function createApplicationOverlays( updateRunning: false, updateReconciliationPending: false, updateStatusBanner: null, + controlUiRefreshRequired: false, approvalQueue: [], approvalBusy: false, approvalErrors: new Map(), @@ -234,9 +237,7 @@ export function createApplicationOverlays( const listeners = new Set<(next: ApplicationOverlaySnapshot) => void>(); let disposed = false; let activeClient = gateway.snapshot.client; - // A Gateway client survives transport retries; the disconnected boundary - // still starts a new source epoch whose pending server state must be replayed. - let connectedSource: NonNullable | null = null; + let connectedSource: NonNullable | null = null; // Retries start a new source epoch. let connectedEpoch = 0; let pendingUpdateExpectedVersion: string | null = null; let pendingUpdateHandoff = false; @@ -264,12 +265,10 @@ export function createApplicationOverlays( const publish = () => { snapshot = { - updateAvailable: snapshot.updateAvailable, - updateRunning: snapshot.updateRunning, + ...snapshot, // The update RPC can finish before its restart handoff. Keep consumers // locked until the replacement Gateway reports the authoritative result. updateReconciliationPending: pendingUpdateHandoff || pendingUpdateExpectedVersion !== null, - updateStatusBanner: snapshot.updateStatusBanner, approvalQueue: promptState.execApprovalQueue, approvalBusy: promptState.execApprovalBusy, approvalErrors: new Map(promptState.execApprovalErrors), @@ -459,9 +458,8 @@ export function createApplicationOverlays( const synchronizeGateway = (next: ApplicationGateway["snapshot"]) => { const previousClient = activeClient; - const previousConnectedSource = connectedSource; const nextConnectedSource = next.connected ? next.client : null; - const connectedSourceChanged = previousConnectedSource !== nextConnectedSource; + const connectedSourceChanged = connectedSource !== nextConnectedSource; activeClient = next.client; connectedSource = nextConnectedSource; promptState.client = next.client; @@ -482,17 +480,26 @@ export function createApplicationOverlays( promptState.execApprovalBusy = false; promptState.execApprovalErrors.clear(); snapshot = { ...snapshot, updateAvailable: null, updateRunning: false }; + if (!next.client) { + connectedEpoch = 0; + snapshot = { ...snapshot, controlUiRefreshRequired: false }; + } clearExecApprovalTimers(promptState); publish(); return; } - snapshot = { ...snapshot, updateAvailable: readUpdateAvailable(next.hello) }; + snapshot = { + ...snapshot, + updateAvailable: readUpdateAvailable(next.hello), + controlUiRefreshRequired: connectedSourceChanged + ? connectedEpoch > 0 && controlUiVersionDiffersFrom(next.hello?.server?.version) + : snapshot.controlUiRefreshRequired, + }; publish(); if (connectedSourceChanged) { connectedEpoch += 1; - const epoch = connectedEpoch; - void refreshApprovals(next.client, epoch); - void verifyPendingUpdateVersion(next.client, epoch); + void refreshApprovals(next.client, connectedEpoch); + void verifyPendingUpdateVersion(next.client, connectedEpoch); } }; const stopGateway = gateway.subscribe(synchronizeGateway); diff --git a/ui/src/app/vite-config.node.test.ts b/ui/src/app/vite-config.node.test.ts index 14bd0ca5ba8b..e4f6a7ba5a55 100644 --- a/ui/src/app/vite-config.node.test.ts +++ b/ui/src/app/vite-config.node.test.ts @@ -271,11 +271,20 @@ describe("Control UI Vite config", () => { }); }); - it("resolves published OpenClaw packages before the broad plugin alias", () => { - const aliases = resolveExternalPackageAliasesForVite(); + it("uses Node package resolution for external packages inherited by worktrees", () => { + const resolvePackage = vi.fn((specifier: string) => + path.join("/parent/node_modules", specifier), + ); + + const aliases = resolveExternalPackageAliasesForVite(resolvePackage); + + expect(resolvePackage.mock.calls).toEqual([ + ["@openclaw/libterminal/package.json"], + ["@openclaw/uirouter/package.json"], + ]); expect(aliases.find((alias) => alias.find === "@openclaw/libterminal/browser")).toEqual({ find: "@openclaw/libterminal/browser", - replacement: path.join(repoRoot, "node_modules/@openclaw/libterminal/dist/browser.js"), + replacement: path.join("/parent/node_modules/@openclaw/libterminal", "dist/browser.js"), }); }); diff --git a/ui/src/build-info.test.ts b/ui/src/build-info.test.ts index 280e48f09405..62a2a2148259 100644 --- a/ui/src/build-info.test.ts +++ b/ui/src/build-info.test.ts @@ -1,9 +1,27 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { normalizeControlUiBuildInfo } from "./build-info-normalizers.ts"; const COMMIT = "0123456789abcdef0123456789abcdef01234567"; describe("Control UI build info", () => { + it("compares the normalized embedded version with the gateway", async () => { + vi.stubGlobal("OPENCLAW_CONTROL_UI_BUILD_INFO", { + version: "2026.7.19", + buildId: "test", + }); + vi.resetModules(); + + try { + const { controlUiVersionDiffersFrom } = await import("./build-info.ts"); + expect(controlUiVersionDiffersFrom(" 2026.7.19 ")).toBe(false); + expect(controlUiVersionDiffersFrom("2026.7.20")).toBe(true); + expect(controlUiVersionDiffersFrom(undefined)).toBe(false); + } finally { + vi.unstubAllGlobals(); + vi.resetModules(); + } + }); + it("keeps only full Git SHAs", () => { expect(normalizeControlUiBuildInfo({ commit: COMMIT.toUpperCase() }).commit).toBe(COMMIT); expect(normalizeControlUiBuildInfo({ commit: COMMIT.slice(0, 12) }).commit).toBeNull(); diff --git a/ui/src/build-info.ts b/ui/src/build-info.ts index c26b954ed82e..5445781e36d7 100644 --- a/ui/src/build-info.ts +++ b/ui/src/build-info.ts @@ -13,3 +13,11 @@ declare global { const injectedBuildInfo = globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO; export const CONTROL_UI_BUILD_INFO = normalizeControlUiBuildInfo(injectedBuildInfo); + +export function controlUiVersionDiffersFrom(gatewayVersion: string | undefined): boolean { + const controlUiVersion = CONTROL_UI_BUILD_INFO.version?.trim(); + const normalizedGatewayVersion = gatewayVersion?.trim(); + return Boolean( + controlUiVersion && normalizedGatewayVersion && controlUiVersion !== normalizedGatewayVersion, + ); +} diff --git a/ui/src/components/update-banner.test.ts b/ui/src/components/update-banner.test.ts new file mode 100644 index 000000000000..a30fce51fa10 --- /dev/null +++ b/ui/src/components/update-banner.test.ts @@ -0,0 +1,59 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import "./update-banner.ts"; + +type UpdateBannerProps = { + statusBanner: { tone: "danger" | "warn" | "info"; text: string } | null; + action?: { label: string; onClick: () => void }; +}; + +type UpdateBannerElement = HTMLElement & { + props?: UpdateBannerProps; + updateComplete: Promise; +}; + +async function renderBanner(props: UpdateBannerProps): Promise { + const element = document.createElement("openclaw-update-banner") as UpdateBannerElement; + element.props = props; + document.body.append(element); + await element.updateComplete; + return element; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("update banner", () => { + it("preserves status-only banners without an action", async () => { + const element = await renderBanner({ + statusBanner: { tone: "danger", text: "Update failed" }, + }); + + expect(element.querySelector(".callout")?.textContent?.trim()).toBe("Update failed"); + expect(element.querySelector(".callout")?.getAttribute("role")).toBe("alert"); + expect(element.querySelector("button")).toBeNull(); + }); + + it("renders the stale Control UI refresh action", async () => { + const onClick = vi.fn(); + const element = await renderBanner({ + statusBanner: { + tone: "info", + text: "Server updated — refresh for full capabilities", + }, + action: { label: "Refresh", onClick }, + }); + + expect(element.querySelector(".callout__content")?.textContent).toBe( + "Server updated — refresh for full capabilities", + ); + expect(element.querySelector(".callout")?.getAttribute("role")).toBe("status"); + const button = element.querySelector("button"); + expect(button?.textContent?.trim()).toBe("Refresh"); + + button?.click(); + expect(onClick).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/src/components/update-banner.ts b/ui/src/components/update-banner.ts index cd61295e283f..7a68c1dce22f 100644 --- a/ui/src/components/update-banner.ts +++ b/ui/src/components/update-banner.ts @@ -5,6 +5,7 @@ import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts"; type UpdateBannerProps = { statusBanner: { tone: "danger" | "warn" | "info"; text: string } | null; + action?: { label: string; onClick: () => void }; }; class UpdateBanner extends OpenClawLightDomContentsElement { @@ -17,8 +18,16 @@ class UpdateBanner extends OpenClawLightDomContentsElement { } return html` ${props.statusBanner - ? html`