fix(ui): nudge stale tabs after gateway updates (#111619)

* fix(ui): nudge stale tabs after gateway updates

* fix(ui): resolve external packages in worktrees
This commit is contained in:
Peter Steinberger
2026-07-20 01:51:22 -07:00
committed by GitHub
parent 72f77364b3
commit 5d80b62d21
12 changed files with 227 additions and 30 deletions
+17
View File
@@ -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<ShellNavDrawerToggleDetail>).detail?.trigger;
this.toggleNavigationSurface(trigger instanceof HTMLElement ? trigger : undefined);
@@ -1580,6 +1584,19 @@ class OpenClawShell extends OpenClawLightDomElement {
onRetry: () => context.gateway.connect(),
}}
></openclaw-connection-banner>`}
<openclaw-update-banner
.props=${{
statusBanner: overlaySnapshot.controlUiRefreshRequired
? {
tone: "info",
text: "Server updated — refresh for full capabilities",
}
: null,
action: overlaySnapshot.controlUiRefreshRequired
? { label: t("common.refresh"), onClick: this.refreshControlUi }
: undefined,
}}
></openclaw-update-banner>
<openclaw-update-banner
.props=${{
statusBanner: overlaySnapshot.updateStatusBanner,
+13
View File
@@ -9,6 +9,18 @@ import { createStorageMock } from "../test-helpers/storage.ts";
import { createApplicationGateway } from "./gateway-store.ts";
import { loadSettings } from "./settings.ts";
vi.mock("../build-info.ts", () => ({
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);
});
+2 -1
View File
@@ -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) => {
+58
View File
@@ -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<unknown>;
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<RequestFn>(async (method) =>
+19 -12
View File
@@ -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<string, string>;
@@ -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<typeof activeClient> | null = null;
let connectedSource: NonNullable<typeof activeClient> | 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);
+12 -3
View File
@@ -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"),
});
});
+19 -1
View File
@@ -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();
+8
View File
@@ -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,
);
}
+59
View File
@@ -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<boolean>;
};
async function renderBanner(props: UpdateBannerProps): Promise<UpdateBannerElement> {
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<HTMLButtonElement>("button");
expect(button?.textContent?.trim()).toBe("Refresh");
button?.click();
expect(onClick).toHaveBeenCalledOnce();
});
});
+11 -2
View File
@@ -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`<div class="callout ${props.statusBanner.tone}" role="alert">
${props.statusBanner.text}
? html`<div
class="callout ${props.statusBanner.tone} ${props.action ? "callout--action" : ""}"
role=${props.action ? "status" : "alert"}
>
<span class="callout__content">${props.statusBanner.text}</span>
${props.action
? html`<button class="btn btn--sm" type="button" @click=${props.action.onClick}>
${props.action.label}
</button>`
: nothing}
</div>`
: nothing}
`;
+2 -1
View File
@@ -1400,7 +1400,8 @@
position: relative;
}
.callout--dismissible {
.callout--dismissible,
.callout--action {
display: flex;
align-items: flex-start;
gap: 10px;
+7 -10
View File
@@ -310,22 +310,19 @@ export function resolveSourcePackageAliasesForVite(): ControlUiViteAlias[] {
];
}
export function resolveExternalPackageAliasesForVite(): ControlUiViteAlias[] {
export function resolveExternalPackageAliasesForVite(
resolvePackage: (specifier: string) => string = require.resolve,
): ControlUiViteAlias[] {
const packageRoot = (specifier: string) =>
path.dirname(resolvePackage(`${specifier}/package.json`));
return [
{
find: "@openclaw/libterminal/browser",
replacement: path.join(
repoRoot,
"node_modules",
"@openclaw",
"libterminal",
"dist",
"browser.js",
),
replacement: path.join(packageRoot("@openclaw/libterminal"), "dist/browser.js"),
},
{
find: "@openclaw/uirouter",
replacement: path.join(repoRoot, "node_modules", "@openclaw", "uirouter", "dist", "index.js"),
replacement: path.join(packageRoot("@openclaw/uirouter"), "dist/index.js"),
},
];
}