mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
`;
|
||||
|
||||
@@ -1400,7 +1400,8 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.callout--dismissible {
|
||||
.callout--dismissible,
|
||||
.callout--action {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
|
||||
+7
-10
@@ -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"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user