refactor(ui): harden Lit lifecycle boundaries (#102745)

* refactor(ui): harden Lit lifecycle boundaries

* fix(ui): clear ChatPage subscriptions before teardown

* test(ui): satisfy lifecycle lint gates

* chore: defer Lit lifecycle release note

* fix(ui): sync raw-copy baseline

* test(ui): avoid order-sensitive Sessions mock

---------

Co-authored-by: Peter Steinberger <peter@steipete.me>
This commit is contained in:
Peter Steinberger
2026-07-09 05:28:52 -07:00
committed by GitHub
parent e45e755fb9
commit deb7faf7b0
122 changed files with 12276 additions and 2338 deletions
+3
View File
@@ -1993,6 +1993,9 @@ importers:
'@create-markdown/preview':
specifier: 2.0.3
version: 2.0.3(shiki@4.3.0)
'@lit/context':
specifier: 1.1.6
version: 1.1.6
'@noble/ed25519':
specifier: 3.1.0
version: 3.1.0
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@create-markdown/preview": "2.0.3",
"@lit/context": "1.1.6",
"@noble/ed25519": "3.1.0",
"@openclaw/libterminal": "0.3.1",
"@openclaw/media-core": "workspace:*",
+171
View File
@@ -0,0 +1,171 @@
/* @vitest-environment jsdom */
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type {
ApplicationContext,
ApplicationGateway,
ApplicationGatewaySnapshot,
} from "./context.ts";
import "./app-host.ts";
type AppLifecycleState = {
loginToken: string;
loginPassword: string;
loginShowGatewayToken: boolean;
loginShowGatewayPassword: boolean;
disconnectedCallback: () => void;
synchronizeGateway: (gateway: ApplicationGateway) => void;
};
type ShellInitializationState = {
routeState: { routeId?: string };
ensureAgentsList: (
snapshot: { client: GatewayBrowserClient | null; connected: boolean },
agents: ApplicationContext["agents"],
) => void;
ensureRuntimeConfig: (
snapshot: { client: GatewayBrowserClient | null; connected: boolean },
runtimeConfig: ApplicationContext["runtimeConfig"],
) => void;
};
type ShellEpochState = {
navDrawerOpen: boolean;
navDrawerTrigger: HTMLElement | null;
lastWorkspaceLocation: { routeId: string; search: string } | null;
activeSessionKey: string;
agentLabel: string;
commandPaletteTarget: unknown;
agentsListClient: GatewayBrowserClient | null;
agentsListSource: ApplicationContext["agents"] | null;
sessionKeyClient: GatewayBrowserClient | null;
runtimeConfigClient: GatewayBrowserClient | null;
runtimeConfigSource: ApplicationContext["runtimeConfig"] | null;
terminalClient: GatewayBrowserClient | null;
settingsPreloadTimers: Map<EventTarget, ReturnType<typeof globalThis.setTimeout>>;
disconnectedCallback: () => void;
};
describe("OpenClaw app lifecycle", () => {
it("hides revealed login credentials when the app connection epoch ends", () => {
const app = document.createElement("openclaw-app") as unknown as AppLifecycleState;
app.loginShowGatewayToken = true;
app.loginShowGatewayPassword = true;
app.disconnectedCallback();
expect(app.loginShowGatewayToken).toBe(false);
expect(app.loginShowGatewayPassword).toBe(false);
});
it("hides revealed login credentials when the Gateway source changes", () => {
const app = document.createElement("openclaw-app") as unknown as AppLifecycleState;
const snapshot = {
client: null,
connected: false,
reconnecting: false,
lastError: null,
lastErrorCode: null,
} as ApplicationGatewaySnapshot;
const firstGateway = {
snapshot,
connection: { gatewayUrl: "ws://first.test", token: "first", password: "first-password" },
} as ApplicationGateway;
const secondGateway = {
snapshot,
connection: {
gatewayUrl: "ws://second.test",
token: "second",
password: "second-password",
},
} as ApplicationGateway;
app.synchronizeGateway(firstGateway);
app.loginShowGatewayToken = true;
app.loginShowGatewayPassword = true;
app.synchronizeGateway(secondGateway);
expect(app.loginShowGatewayToken).toBe(false);
expect(app.loginShowGatewayPassword).toBe(false);
expect(app.loginToken).toBe("second");
expect(app.loginPassword).toBe("second-password");
});
});
describe("OpenClaw shell source initialization", () => {
it("clears retained presentation and source ownership when its context epoch ends", () => {
const shell = document.createElement("openclaw-app-shell") as unknown as ShellEpochState;
const client = {} as GatewayBrowserClient;
const agents = {} as ApplicationContext["agents"];
const runtimeConfig = {} as ApplicationContext["runtimeConfig"];
const trigger = document.createElement("button");
shell.navDrawerOpen = true;
shell.navDrawerTrigger = trigger;
shell.lastWorkspaceLocation = { routeId: "overview", search: "?agent=old" };
shell.activeSessionKey = "agent:old:main";
shell.agentLabel = "Old agent";
shell.commandPaletteTarget = {};
shell.agentsListClient = client;
shell.agentsListSource = agents;
shell.sessionKeyClient = client;
shell.runtimeConfigClient = client;
shell.runtimeConfigSource = runtimeConfig;
shell.terminalClient = client;
shell.settingsPreloadTimers.set(
trigger,
globalThis.setTimeout(() => undefined, 60_000),
);
shell.disconnectedCallback();
expect(shell.navDrawerOpen).toBe(false);
expect(shell.navDrawerTrigger).toBeNull();
expect(shell.lastWorkspaceLocation).toBeNull();
expect(shell.activeSessionKey).toBe("");
expect(shell.agentLabel).toBe("");
expect(shell.commandPaletteTarget).toBeUndefined();
expect(shell.agentsListClient).toBeNull();
expect(shell.agentsListSource).toBeNull();
expect(shell.sessionKeyClient).toBeNull();
expect(shell.runtimeConfigClient).toBeNull();
expect(shell.runtimeConfigSource).toBeNull();
expect(shell.terminalClient).toBeNull();
expect(shell.settingsPreloadTimers.size).toBe(0);
});
it("initializes replacement capabilities even when the Gateway client is unchanged", () => {
const shell = document.createElement(
"openclaw-app-shell",
) as unknown as ShellInitializationState;
shell.routeState = { routeId: "overview" };
const client = {} as GatewayBrowserClient;
const snapshot = { client, connected: true };
const firstAgents = {
state: { agentsList: null },
ensureList: vi.fn(() => Promise.resolve(null)),
} as unknown as ApplicationContext["agents"];
const secondAgents = {
state: { agentsList: null },
ensureList: vi.fn(() => Promise.resolve(null)),
} as unknown as ApplicationContext["agents"];
const firstRuntimeConfig = {
ensureLoaded: vi.fn(() => Promise.resolve()),
} as unknown as ApplicationContext["runtimeConfig"];
const secondRuntimeConfig = {
ensureLoaded: vi.fn(() => Promise.resolve()),
} as unknown as ApplicationContext["runtimeConfig"];
shell.ensureAgentsList(snapshot, firstAgents);
shell.ensureAgentsList(snapshot, firstAgents);
shell.ensureAgentsList(snapshot, secondAgents);
shell.ensureRuntimeConfig(snapshot, firstRuntimeConfig);
shell.ensureRuntimeConfig(snapshot, firstRuntimeConfig);
shell.ensureRuntimeConfig(snapshot, secondRuntimeConfig);
expect(firstAgents.ensureList).toHaveBeenCalledOnce();
expect(secondAgents.ensureList).toHaveBeenCalledOnce();
expect(firstRuntimeConfig.ensureLoaded).toHaveBeenCalledOnce();
expect(secondRuntimeConfig.ensureLoaded).toHaveBeenCalledOnce();
});
});
+195 -137
View File
@@ -1,6 +1,6 @@
import { consume, ContextProvider } from "@lit/context";
import type { RouteLocation, RouterState } from "@openclaw/uirouter";
import { html, LitElement, nothing } from "lit";
import { html, nothing } from "lit";
import { property, query, state } from "lit/decorators.js";
import { hasStoredGatewayAuth, type GatewayBrowserClient } from "../api/gateway.ts";
import type { AgentsListResult } from "../api/types.ts";
@@ -31,6 +31,8 @@ import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts"
import { searchForSession } from "../lib/sessions/index.ts";
import { resolveAgentIdFromSessionKey } from "../lib/sessions/session-key.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../lib/string-coerce.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import { renderDevicePairSetup } from "../pages/nodes/view-pairing.ts";
import { pluginTabKey, pluginTabRefFromSearch } from "../pages/plugin/route.ts";
import { bootstrapApplication, type ApplicationRuntime } from "./bootstrap.ts";
@@ -134,7 +136,7 @@ function isMobileNavLayout(): boolean {
return globalThis.matchMedia?.("(max-width: 1100px)").matches ?? false;
}
class OpenClawApp extends LitElement {
class OpenClawApp extends OpenClawLightDomElement {
@state() private gatewayConnected = false;
@state() private gatewayReconnecting = false;
@state() private gatewayLastError: string | null = null;
@@ -162,58 +164,79 @@ class OpenClawApp extends LitElement {
private readonly contextProvider = new ContextProvider(this, {
context: applicationContext,
});
private stopGatewaySubscription: (() => void) | undefined;
private stopConfigSubscription: (() => void) | undefined;
private readonly subscriptions = new SubscriptionsController(this);
private loginGatewaySource: ApplicationContext["gateway"] | null = null;
private loginConnectionClient: GatewayBrowserClient | null = null;
override createRenderRoot() {
return this;
constructor() {
super();
this.subscriptions
.watch(
() => this.context?.gateway,
(gateway, notify) => gateway.subscribe(notify),
(gateway) => this.synchronizeGateway(gateway),
)
.watch(
() => (this.terminalOnly ? this.context?.config : undefined),
(config, notify) => config.subscribe(notify),
() => this.updateTerminalSurface(),
);
}
override connectedCallback() {
super.connectedCallback();
this.resetLoginSensitivePresentation();
this.runtime = bootstrapApplication();
this.context = this.runtime.context;
this.initialAuthPresent = hasStoredGatewayAuth(this.context.gateway.connection);
this.pendingGatewayUrl = this.runtime.pendingGatewayConnection?.gatewayUrl ?? null;
// Context identity changes only across a full app-tree connection epoch;
// descendants reconnect and rebuild their controller-owned state afterward.
this.contextProvider.setValue(this.context);
this.syncLoginConnection();
let gatewayClient = this.context.gateway.snapshot.client;
this.updateGatewayStatus(this.context.gateway.snapshot);
this.stopGatewaySubscription = this.context.gateway.subscribe((snapshot) => {
if (snapshot.client !== gatewayClient) {
gatewayClient = snapshot.client;
this.syncLoginConnection();
}
this.updateGatewayStatus(snapshot);
this.updateTerminalSurface();
});
if (this.terminalOnly) {
// Terminal availability also depends on config.terminalEnabled, which
// can arrive after the gateway snapshot; track it for this document mode.
this.updateTerminalSurface();
this.stopConfigSubscription = this.context.config.subscribe(() => {
this.updateTerminalSurface();
});
}
// The runtime is created after controller hostConnected hooks run. Ensure
// their lazy source getters bind on both the initial mount and reconnect.
this.requestUpdate();
void this.runtime.start().catch((error: unknown) => {
console.error("[openclaw] application start failed", error);
});
}
override disconnectedCallback() {
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.stopConfigSubscription?.();
this.stopConfigSubscription = undefined;
// Stop reactive subscriptions before disposing their application sources.
this.subscriptions.clear();
this.runtime?.stop();
this.runtime = undefined;
this.context = undefined;
this.loginGatewaySource = null;
this.loginConnectionClient = null;
this.pendingGatewayUrl = null;
this.resetLoginSensitivePresentation();
super.disconnectedCallback();
}
private syncLoginConnection() {
const connection = this.context?.gateway.connection;
private synchronizeGateway(gateway: ApplicationContext["gateway"]) {
const sourceChanged = gateway !== this.loginGatewaySource;
if (sourceChanged) {
this.loginGatewaySource = gateway;
this.loginConnectionClient = null;
this.resetLoginSensitivePresentation();
}
const snapshot = gateway.snapshot;
const clientChanged = snapshot.client !== this.loginConnectionClient;
if (clientChanged) {
this.loginConnectionClient = snapshot.client;
this.resetLoginSensitivePresentation();
}
if (sourceChanged || clientChanged) {
this.syncLoginConnection(gateway);
}
this.updateGatewayStatus(snapshot);
this.updateTerminalSurface();
}
private syncLoginConnection(gateway = this.context?.gateway) {
const connection = gateway?.connection;
if (!connection) {
return;
}
@@ -222,6 +245,11 @@ class OpenClawApp extends LitElement {
this.loginPassword = connection.password;
}
private resetLoginSensitivePresentation() {
this.loginShowGatewayToken = false;
this.loginShowGatewayPassword = false;
}
private readonly updateGatewayStatus = (snapshot: {
connected: boolean;
reconnecting: boolean;
@@ -368,10 +396,10 @@ class OpenClawApp extends LitElement {
}
}
class OpenClawShell extends LitElement {
class OpenClawShell extends OpenClawLightDomElement {
@property({ attribute: false }) runtime?: ApplicationRuntime;
@property({ attribute: false }) onboarding = false;
@consume({ context: applicationContext, subscribe: false })
@consume({ context: applicationContext, subscribe: true })
private context?: ApplicationContext<RouteId>;
@state() private navCollapsed = false;
@@ -406,125 +434,128 @@ class OpenClawShell extends LitElement {
// chat (the app default route) when settings was the entry point.
private lastWorkspaceLocation: { routeId: RouteId; search: string } | null = null;
private agentsListClient: GatewayBrowserClient | null = null;
private agentsListSource: ApplicationContext["agents"] | null = null;
private sessionKeyClient: GatewayBrowserClient | null = null;
private stopAgentsSubscription: (() => void) | undefined;
private stopConfigSubscription: (() => void) | undefined;
private stopGatewaySubscription: (() => void) | undefined;
private stopNavigationSubscription: (() => void) | undefined;
private stopRouteSubscription: (() => void) | undefined;
private stopOverlaySubscription: (() => void) | undefined;
private stopRuntimeConfigSubscription: (() => void) | undefined;
private stopThemeSubscription: (() => void) | undefined;
private runtimeConfigClient: GatewayBrowserClient | null = null;
private runtimeConfigSource: ApplicationContext["runtimeConfig"] | null = null;
private readonly settingsPreloadTimers = new Map<
EventTarget,
ReturnType<typeof globalThis.setTimeout>
>();
private readonly subscriptions = new SubscriptionsController(this);
override createRenderRoot() {
return this;
constructor() {
super();
this.subscriptions
.effect(
() => this.context,
() => () => this.resetShellEpochState(),
)
.watch(
() => this.context?.navigation,
(navigation, notify) => navigation.subscribe(notify),
(navigation) => this.updateNavigationPreferences(navigation.snapshot),
)
.watch(
() => this.context?.gateway,
(gateway, notify) => gateway.subscribe(notify),
(gateway) => this.synchronizeGateway(gateway.snapshot),
)
.watch(
() => this.context?.config,
(config, notify) => config.subscribe(notify),
() => {
const snapshot = this.context?.gateway.snapshot;
if (snapshot) {
this.updateTerminalSurface(snapshot);
}
},
)
.watch(
() => this.context?.theme,
(theme, notify) => theme.subscribe(notify),
)
.watch(
() => this.context?.agents,
(agents, notify) => agents.subscribe(notify),
(agents) => {
this.updateAgentLabel();
const snapshot = this.context?.gateway.snapshot;
if (snapshot) {
this.ensureAgentsList(snapshot, agents);
}
},
)
.effect(
() => this.runtime?.router,
(router) => {
this.updateRouteState(selectShellRouteState(router.getState()));
return router.subscribeSelector(
selectShellRouteState,
(routeState) => this.updateRouteState(routeState),
equalShellRouteState,
);
},
)
.watch(
() => this.context?.overlays,
(overlays, notify) => overlays.subscribe(notify),
(overlays) => {
this.overlaySnapshot = overlays.snapshot;
},
)
.watch(
() => this.context?.runtimeConfig,
(runtimeConfig, notify) => runtimeConfig.subscribe(notify),
(runtimeConfig) => {
const snapshot = this.context?.gateway.snapshot;
if (snapshot) {
this.ensureRuntimeConfig(snapshot, runtimeConfig);
}
},
);
}
override connectedCallback() {
super.connectedCallback();
this.startSubscriptions();
this.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget);
document.addEventListener("keydown", this.handleDocumentKeydown);
window.addEventListener("resize", this.handleWindowResize);
}
override updated() {
this.startSubscriptions();
}
private startSubscriptions() {
const runtime = this.runtime;
const context = this.context;
if (
!runtime ||
!context ||
this.stopAgentsSubscription ||
this.stopConfigSubscription ||
this.stopGatewaySubscription ||
this.stopNavigationSubscription ||
this.stopRouteSubscription ||
this.stopOverlaySubscription ||
this.stopRuntimeConfigSubscription ||
this.stopThemeSubscription
) {
return;
}
this.updateNavigationPreferences(context.navigation.snapshot);
this.stopNavigationSubscription = context.navigation.subscribe((snapshot) => {
this.updateNavigationPreferences(snapshot);
});
this.updateGatewaySessionKey(context.gateway.snapshot);
this.updateGatewayStatus(context.gateway.snapshot);
this.updateTerminalSurface(context.gateway.snapshot);
this.updateAgentLabel();
this.ensureRuntimeConfig(context.gateway.snapshot);
this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => {
this.updateGatewaySessionKey(snapshot);
this.updateGatewayStatus(snapshot);
this.updateTerminalSurface(snapshot);
this.updateAgentLabel();
this.ensureAgentsList(snapshot);
this.ensureRuntimeConfig(snapshot);
});
this.stopConfigSubscription = context.config.subscribe(() => {
this.updateTerminalSurface(context.gateway.snapshot);
});
this.stopThemeSubscription = context.theme.subscribe(() => this.requestUpdate());
this.stopAgentsSubscription = context.agents.subscribe(() => {
this.updateAgentLabel();
});
this.updateRouteState(selectShellRouteState(runtime.router.getState()));
this.stopRouteSubscription = runtime.router.subscribeSelector(
selectShellRouteState,
(routeState) => {
this.updateRouteState(routeState);
},
equalShellRouteState,
);
this.overlaySnapshot = context.overlays.snapshot;
this.stopOverlaySubscription = context.overlays.subscribe((snapshot) => {
this.overlaySnapshot = snapshot;
});
this.stopRuntimeConfigSubscription = context.runtimeConfig.subscribe(() => {
// Route enablement (e.g. Workboard) derives from the config snapshot.
this.requestUpdate();
});
}
override disconnectedCallback() {
this.removeEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget);
document.removeEventListener("keydown", this.handleDocumentKeydown);
window.removeEventListener("resize", this.handleWindowResize);
this.stopAgentsSubscription?.();
this.stopAgentsSubscription = undefined;
this.stopConfigSubscription?.();
this.stopConfigSubscription = undefined;
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.stopNavigationSubscription?.();
this.stopNavigationSubscription = undefined;
this.stopRouteSubscription?.();
this.stopRouteSubscription = undefined;
this.stopOverlaySubscription?.();
this.stopOverlaySubscription = undefined;
this.stopRuntimeConfigSubscription?.();
this.stopRuntimeConfigSubscription = undefined;
this.stopThemeSubscription?.();
this.stopThemeSubscription = undefined;
this.agentsListClient = null;
this.sessionKeyClient = null;
this.terminalClient = null;
this.navDrawerTrigger = null;
this.resetShellEpochState();
super.disconnectedCallback();
}
private resetShellEpochState() {
this.navDrawerOpen = false;
this.navDrawerTrigger = null;
this.lastWorkspaceLocation = null;
this.activeSessionKey = "";
this.agentLabel = "";
this.commandPaletteTarget = undefined;
this.agentsListClient = null;
this.agentsListSource = null;
this.sessionKeyClient = null;
this.runtimeConfigClient = null;
this.runtimeConfigSource = null;
this.terminalClient = null;
for (const timer of this.settingsPreloadTimers.values()) {
globalThis.clearTimeout(timer);
}
this.settingsPreloadTimers.clear();
}
private readonly handleThemeChange = (event: CustomEvent<ThemeModeChangeDetail>) => {
const context = this.context;
if (!context) {
return;
}
context.theme.setMode(event.detail.mode, event.detail.element);
this.requestUpdate();
};
private chatNavigationOptions(options?: ApplicationNavigationOptions) {
@@ -709,6 +740,15 @@ class OpenClawShell extends LitElement {
this.requestUpdate();
};
private synchronizeGateway(snapshot: ApplicationContext["gateway"]["snapshot"]) {
this.updateGatewaySessionKey(snapshot);
this.updateGatewayStatus(snapshot);
this.updateTerminalSurface(snapshot);
this.updateAgentLabel();
this.ensureAgentsList(snapshot);
this.ensureRuntimeConfig(snapshot);
}
private readonly updateGatewayStatus = (snapshot: {
connected: boolean;
lastError: string | null;
@@ -731,15 +771,28 @@ class OpenClawShell extends LitElement {
);
}
private ensureRuntimeConfig(snapshot: {
client: GatewayBrowserClient | null;
connected: boolean;
}) {
private ensureRuntimeConfig(
snapshot: {
client: GatewayBrowserClient | null;
connected: boolean;
},
runtimeConfig = this.context?.runtimeConfig,
) {
// The sidebar hides config-gated routes (Workboard), so the snapshot must
// load eagerly instead of waiting for a page that happens to fetch it.
if (snapshot.connected && snapshot.client) {
void this.context?.runtimeConfig.ensureLoaded();
if (!snapshot.connected || !snapshot.client || !runtimeConfig) {
this.runtimeConfigClient = null;
return;
}
if (
this.runtimeConfigClient === snapshot.client &&
this.runtimeConfigSource === runtimeConfig
) {
return;
}
this.runtimeConfigClient = snapshot.client;
this.runtimeConfigSource = runtimeConfig;
void runtimeConfig.ensureLoaded();
}
private enabledRouteIds(): readonly RouteId[] {
@@ -748,20 +801,24 @@ class OpenClawShell extends LitElement {
: ROUTE_IDS_WITHOUT_WORKBOARD;
}
private ensureAgentsList(snapshot: { client: GatewayBrowserClient | null; connected: boolean }) {
private ensureAgentsList(
snapshot: { client: GatewayBrowserClient | null; connected: boolean },
agents = this.context?.agents,
) {
if (!snapshot.connected || !snapshot.client) {
this.agentsListClient = null;
return;
}
const routeId = this.routeState.routeId;
if (!routeId || routeId === "chat" || this.context?.agents.state.agentsList) {
if (!agents || !routeId || routeId === "chat" || agents.state.agentsList) {
return;
}
if (this.agentsListClient === snapshot.client) {
if (this.agentsListClient === snapshot.client && this.agentsListSource === agents) {
return;
}
this.agentsListClient = snapshot.client;
void this.context?.agents.ensureList();
this.agentsListSource = agents;
void agents.ensureList();
}
private updateGatewaySessionKey(snapshot: {
@@ -891,6 +948,7 @@ class OpenClawShell extends LitElement {
onExit: () => this.exitSettings(),
onNavigate: (routeId) => this.navigate(routeId),
onPreload: (routeId) => context.preload(routeId),
preloadTimers: this.settingsPreloadTimers,
})
: html`<openclaw-app-sidebar
.basePath=${context.basePath}
+60
View File
@@ -0,0 +1,60 @@
import { consume, ContextProvider } from "@lit/context";
import { LitElement } from "lit";
import { afterEach, describe, expect, it } from "vitest";
import type { RouteId } from "../app-route-paths.ts";
import { applicationContext, type ApplicationContext } from "./context.ts";
const PROVIDER_ELEMENT_NAME = "test-application-context-provider";
const CONSUMER_ELEMENT_NAME = "test-application-context-consumer";
class TestApplicationContextProvider extends LitElement {
private readonly contextProvider = new ContextProvider(this, {
context: applicationContext,
});
setContext(context: ApplicationContext<RouteId>) {
this.contextProvider.setValue(context);
}
}
class TestApplicationContextConsumer extends LitElement {
@consume({ context: applicationContext, subscribe: true })
context?: ApplicationContext<RouteId>;
}
if (!customElements.get(PROVIDER_ELEMENT_NAME)) {
customElements.define(PROVIDER_ELEMENT_NAME, TestApplicationContextProvider);
}
if (!customElements.get(CONSUMER_ELEMENT_NAME)) {
customElements.define(CONSUMER_ELEMENT_NAME, TestApplicationContextConsumer);
}
afterEach(() => {
document.body.replaceChildren();
});
describe("application context consumption", () => {
it("rebinds a retained consumer after its provider value changes while disconnected", async () => {
const initialContext = { basePath: "/initial" } as ApplicationContext<RouteId>;
const replacementContext = { basePath: "/replacement" } as ApplicationContext<RouteId>;
const provider = document.createElement(
PROVIDER_ELEMENT_NAME,
) as TestApplicationContextProvider;
const consumer = document.createElement(
CONSUMER_ELEMENT_NAME,
) as TestApplicationContextConsumer;
document.body.append(provider);
provider.setContext(initialContext);
provider.append(consumer);
await consumer.updateComplete;
expect(consumer.context).toBe(initialContext);
consumer.remove();
provider.setContext(replacementContext);
provider.append(consumer);
await consumer.updateComplete;
expect(consumer.context).toBe(replacementContext);
});
});
+157 -2
View File
@@ -5,6 +5,7 @@ import type { ApplicationGateway, ApplicationGatewaySnapshot } from "./gateway.t
import { createApplicationOverlays } from "./overlays.ts";
type RequestFn = (method: string, params?: unknown) => Promise<unknown>;
const VERIFICATION_POLL_MS = 250;
function deferred<T = unknown>() {
let resolve!: (value: T | PromiseLike<T>) => void;
@@ -25,11 +26,14 @@ function approval(id: string, createdAtMs: number) {
};
}
function createGatewayHarness(initialClient: GatewayBrowserClient) {
function createGatewayHarness(
initialClient: GatewayBrowserClient | null,
initialConnected = initialClient !== null,
) {
let snapshot: ApplicationGatewaySnapshot = {
assistantAgentId: "main",
client: initialClient,
connected: true,
connected: initialConnected,
reconnecting: false,
hello: null,
lastError: null,
@@ -85,7 +89,59 @@ function client(request: RequestFn): GatewayBrowserClient {
return { request } as unknown as GatewayBrowserClient;
}
async function flushMicrotasks() {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
describe("application approval overlays", () => {
it("reloads pending approvals for each connected epoch", async () => {
const firstList = deferred();
const reconnectedList = deferred();
let execListRequests = 0;
const request = vi.fn<RequestFn>((method) => {
if (method !== "exec.approval.list") {
return Promise.resolve([]);
}
execListRequests += 1;
return execListRequests === 1 ? firstList.promise : reconnectedList.promise;
});
const gatewayClient = client(request);
const harness = createGatewayHarness(null, false);
const overlays = createApplicationOverlays(harness.gateway);
harness.update({ client: gatewayClient, connected: false });
await flushMicrotasks();
expect(request).not.toHaveBeenCalled();
harness.update({ connected: true });
await flushMicrotasks();
expect(execListRequests).toBe(1);
expect(request).toHaveBeenCalledWith("exec.approval.list", {});
expect(request).toHaveBeenCalledWith("plugin.approval.list", {});
harness.update({ connected: false });
expect(overlays.snapshot.approvalQueue).toEqual([]);
harness.update({ connected: true });
await flushMicrotasks();
expect(execListRequests).toBe(2);
reconnectedList.resolve([approval("approval-reconnected", 2_000)]);
await vi.waitFor(() => {
expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual([
"approval-reconnected",
]);
});
firstList.resolve([approval("approval-stale", 1_000)]);
await flushMicrotasks();
expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual([
"approval-reconnected",
]);
overlays.dispose();
});
it("does not attach an older resolve failure to a newer approval", async () => {
const resolveAttempt = deferred();
const request = vi.fn<RequestFn>((method) =>
@@ -142,6 +198,47 @@ describe("application approval overlays", () => {
expect(overlays.snapshot.approvalQueue).toEqual([]);
overlays.dispose();
});
it("does not dismiss a new approval when an old same-client decision settles", async () => {
const oldResolve = deferred();
const request = vi.fn<RequestFn>((method) =>
method.endsWith(".list") ? Promise.resolve([]) : oldResolve.promise,
);
const gatewayClient = client(request);
const harness = createGatewayHarness(gatewayClient);
const overlays = createApplicationOverlays(harness.gateway);
harness.emitApproval("approval-old", 1_000);
const oldDecision = overlays.decideApproval("allow-once");
harness.update({ connected: false });
harness.update({ connected: true });
await flushMicrotasks();
harness.emitApproval("approval-new", 2_000);
oldResolve.resolve({ ok: true });
await oldDecision;
expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual(["approval-new"]);
expect(overlays.snapshot.approvalBusy).toBe(false);
overlays.dispose();
});
it("ignores a decision that settles after disposal", async () => {
const resolveAttempt = deferred();
const request = vi.fn<RequestFn>((method) =>
method.endsWith(".list") ? Promise.resolve([]) : resolveAttempt.promise,
);
const harness = createGatewayHarness(client(request));
const overlays = createApplicationOverlays(harness.gateway);
harness.emitApproval("approval-active", 1_000);
const decision = overlays.decideApproval("allow-once");
overlays.dispose();
resolveAttempt.reject(new Error("disposed"));
await decision;
expect(overlays.snapshot.approvalError).toBeNull();
});
});
describe("application update overlays", () => {
@@ -164,4 +261,62 @@ describe("application update overlays", () => {
expect(overlays.snapshot.updateRunning).toBe(false);
overlays.dispose();
});
it("verifies on reconnect and survives updates within the connected epoch", async () => {
vi.useFakeTimers();
let statusRequests = 0;
const request = vi.fn<RequestFn>((method) => {
if (method.endsWith(".list")) {
return Promise.resolve([]);
}
if (method === "update.run") {
return Promise.resolve({
ok: true,
result: { status: "ok", after: { version: "2.0.0" } },
});
}
if (method === "update.status") {
statusRequests += 1;
return Promise.resolve(
statusRequests === 1
? {
sentinel: {
kind: "update",
status: "skipped",
stats: { reason: "restart-health-pending" },
},
}
: {
sentinel: {
kind: "update",
status: "ok",
stats: { after: { version: "2.0.0" } },
},
},
);
}
return Promise.resolve({});
});
const gatewayClient = client(request);
const harness = createGatewayHarness(gatewayClient);
const overlays = createApplicationOverlays(harness.gateway);
try {
await overlays.runUpdate();
harness.update({ connected: false });
harness.update({ connected: true });
await flushMicrotasks();
expect(statusRequests).toBe(1);
harness.update({ sessionKey: "agent:main:next" });
await vi.advanceTimersByTimeAsync(VERIFICATION_POLL_MS);
await flushMicrotasks();
expect(statusRequests).toBe(2);
expect(overlays.snapshot.updateStatusBanner).toBeNull();
} finally {
overlays.dispose();
vi.useRealTimers();
}
});
});
+74 -29
View File
@@ -194,6 +194,11 @@ type UpdateRunResponse = {
restart?: { coalesced?: boolean } | null;
};
type UpdateVerificationWait = {
timer: ReturnType<typeof globalThis.setTimeout>;
resolve: (active: boolean) => void;
};
export function createApplicationOverlays(gateway: ApplicationGateway): ApplicationOverlays {
let snapshot: ApplicationOverlaySnapshot = {
updateAvailable: null,
@@ -211,13 +216,21 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
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 connectedEpoch = 0;
let pendingUpdateExpectedVersion: string | null = null;
let pendingUpdateHandoff = false;
let updateRunGeneration = 0;
let updateVerificationGeneration = 0;
let updateVerificationTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
let updateVerificationWait: UpdateVerificationWait | null = null;
let devicePairPendingCountGeneration = 0;
let approvalDecision: { client: NonNullable<typeof activeClient>; id: string } | null = null;
let approvalDecision: {
client: NonNullable<typeof activeClient>;
epoch: number;
id: string;
} | null = null;
const devicePairSetupState: DevicePairSetupState & { pendingCount: number } = {
client: gateway.snapshot.client,
connected: gateway.snapshot.connected,
@@ -291,9 +304,13 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
publish();
};
const refreshApprovals = async (client: NonNullable<typeof activeClient>) => {
const refreshApprovals = async (
client: NonNullable<typeof activeClient>,
epoch = connectedEpoch,
) => {
const applied = await refreshPendingApprovalQueue(promptState, {
isCurrentClient: (requestClient) => requestClient === client && isCurrentClient(client),
isCurrentClient: (requestClient) =>
requestClient === client && epoch === connectedEpoch && isCurrentClient(client),
});
if (applied && !disposed) {
publish();
@@ -305,26 +322,40 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
publish();
};
const settleUpdateVerificationWait = (active: boolean) => {
const wait = updateVerificationWait;
if (!wait) {
return;
}
updateVerificationWait = null;
globalThis.clearTimeout(wait.timer);
wait.resolve(active);
};
const cancelUpdateVerification = () => {
updateVerificationGeneration += 1;
if (updateVerificationTimer !== null) {
globalThis.clearTimeout(updateVerificationTimer);
updateVerificationTimer = null;
}
settleUpdateVerificationWait(false);
};
const waitForUpdateVerification = (delayMs: number, generation: number) =>
new Promise<boolean>((resolve) => {
// Verification loops are serialized, but settling a prior wait keeps a
// future refactor from stranding its continuation behind a replaced timer.
settleUpdateVerificationWait(false);
const timer = globalThis.setTimeout(() => {
if (updateVerificationTimer === timer) {
updateVerificationTimer = null;
if (updateVerificationWait?.timer !== timer) {
return;
}
updateVerificationWait = null;
resolve(generation === updateVerificationGeneration && !disposed);
}, delayMs);
updateVerificationTimer = timer;
updateVerificationWait = { timer, resolve };
});
const verifyPendingUpdateVersion = async (client: NonNullable<typeof activeClient>) => {
const verifyPendingUpdateVersion = async (
client: NonNullable<typeof activeClient>,
epoch: number,
) => {
const generation = updateVerificationGeneration;
const expectedVersion = pendingUpdateExpectedVersion?.trim() || null;
const pendingHandoff = pendingUpdateHandoff;
@@ -333,6 +364,7 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
}
const isCurrentVerification = () =>
generation === updateVerificationGeneration &&
epoch === connectedEpoch &&
!disposed &&
activeClient === client &&
gateway.snapshot.client === client &&
@@ -405,14 +437,20 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
);
};
const stopGateway = gateway.subscribe((next) => {
updateRunGeneration += 1;
cancelUpdateVerification();
const synchronizeGateway = (next: ApplicationGateway["snapshot"]) => {
const previousClient = activeClient;
const previousConnectedSource = connectedSource;
const nextConnectedSource = next.connected ? next.client : null;
const connectedSourceChanged = previousConnectedSource !== nextConnectedSource;
activeClient = next.client;
connectedSource = nextConnectedSource;
promptState.client = next.client;
devicePairSetupState.client = next.client;
devicePairSetupState.connected = next.connected;
if (connectedSourceChanged) {
updateRunGeneration += 1;
cancelUpdateVerification();
}
if (previousClient !== next.client || !next.connected) {
approvalDecision = null;
devicePairPendingCountGeneration += 1;
@@ -432,15 +470,15 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
return;
}
snapshot = { ...snapshot, updateAvailable: readUpdateAvailable(next.hello) };
if (previousClient !== next.client) {
void refreshApprovals(next.client);
if (next.client) {
void verifyPendingUpdateVersion(next.client);
}
} else {
publish();
publish();
if (connectedSourceChanged) {
connectedEpoch += 1;
const epoch = connectedEpoch;
void refreshApprovals(next.client, epoch);
void verifyPendingUpdateVersion(next.client, epoch);
}
});
};
const stopGateway = gateway.subscribe(synchronizeGateway);
const stopEvents = gateway.subscribeEvents((event) => {
if (disposed || !isGatewayEvent(event)) {
@@ -480,6 +518,7 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
}
}
});
synchronizeGateway(gateway.snapshot);
return {
get snapshot() {
@@ -584,30 +623,35 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
}
promptState.execApprovalBusy = true;
promptState.execApprovalError = null;
const operation = { client, id: active.id };
const operation = { client, epoch: connectedEpoch, id: active.id };
approvalDecision = operation;
const isCurrentOperation = () =>
approvalDecision === operation &&
operation.epoch === connectedEpoch &&
isCurrentClient(operation.client);
publish();
try {
const method =
active.kind === "plugin" ? "plugin.approval.resolve" : "exec.approval.resolve";
await client.request(method, { id: active.id, decision });
if (!isCurrentClient(client)) {
if (!isCurrentOperation()) {
return;
}
dismissExecApprovalPrompt(promptState, active.id);
} catch (error) {
if (isStaleApprovalResolutionError(error)) {
if (!isCurrentClient(client)) {
if (!isCurrentOperation()) {
return;
}
dismissExecApprovalPrompt(promptState, active.id);
const currentClient = activeClient;
if (currentClient && isCurrentClient(currentClient)) {
await refreshApprovals(currentClient);
const epoch = connectedEpoch;
if (currentClient && isCurrentOperation()) {
await refreshApprovals(currentClient, epoch);
}
return;
}
if (isCurrentClient(client) && promptState.execApprovalQueue[0]?.id === active.id) {
if (isCurrentOperation() && promptState.execApprovalQueue[0]?.id === active.id) {
promptState.execApprovalError = `Approval failed: ${error instanceof Error ? error.message : String(error)}`;
}
} finally {
@@ -653,6 +697,7 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat
},
dispose() {
disposed = true;
approvalDecision = null;
updateRunGeneration += 1;
devicePairPendingCountGeneration += 1;
cancelUpdateVerification();
+239
View File
@@ -0,0 +1,239 @@
import { createRouter, definePage, type RouteLocation } from "@openclaw/uirouter";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RouterOutletController, selectRenderedRouteMatch } from "./router-outlet-controller.ts";
type RouteId = "first" | "second";
type TestContext = { label: string };
type TestModule = { render: (data: TestData | undefined) => unknown };
type TestData = { label: string };
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
};
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
function location(pathname: string): RouteLocation {
return { pathname, search: "", hash: "" };
}
function module(label: string): TestModule {
return { render: () => label };
}
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
afterEach(() => {
vi.useRealTimers();
});
describe("RouterOutletController pending presentation", () => {
it("delays a cold-start fallback until the route has been pending for one second", async () => {
vi.useFakeTimers();
const routeModule = deferred<TestModule>();
const routeData = deferred<TestData>();
const router = createRouter<RouteId, TestContext, TestModule, TestData>({
routes: [
definePage({
id: "first",
path: "/first",
component: () => routeModule.promise,
loader: () => routeData.promise,
}),
],
});
const controller = new RouterOutletController<RouteId, TestContext, TestModule, TestData>(
vi.fn(),
);
controller.setInputs({ router });
controller.connect();
const navigation = router.navigate("first", { label: "test" });
expect(controller.snapshot.pending?.routeId).toBe("first");
expect(controller.snapshot.showPending).toBe(false);
await vi.advanceTimersByTimeAsync(999);
expect(controller.snapshot.showPending).toBe(false);
await vi.advanceTimersByTimeAsync(1);
expect(controller.snapshot.showPending).toBe(true);
routeModule.resolve(module("first"));
routeData.resolve({ label: "loaded" });
await navigation;
expect(controller.snapshot.showPending).toBe(false);
controller.disconnect();
router.stop();
});
it("keeps active content while the next route module is cold", async () => {
vi.useFakeTimers();
const secondModule = deferred<TestModule>();
const secondData = deferred<TestData>();
const router = createRouter<RouteId, TestContext, TestModule, TestData>({
routes: [
definePage({
id: "first",
path: "/first",
component: () => module("first"),
loader: () => ({ label: "first" }),
}),
definePage({
id: "second",
path: "/second",
component: () => secondModule.promise,
loader: () => secondData.promise,
}),
],
});
const controller = new RouterOutletController<RouteId, TestContext, TestModule, TestData>(
vi.fn(),
);
controller.setInputs({ router });
controller.connect();
await router.navigate("first", { label: "test" });
const navigation = router.navigate("second", { label: "test" });
expect(
selectRenderedRouteMatch(controller.snapshot.active, controller.snapshot.pending)?.routeId,
).toBe("first");
await vi.advanceTimersByTimeAsync(2_000);
expect(controller.snapshot.showPending).toBe(false);
secondModule.resolve(module("second"));
await flushPromises();
expect(
selectRenderedRouteMatch(controller.snapshot.active, controller.snapshot.pending)?.routeId,
).toBe("second");
expect(controller.snapshot.active?.data).toBeUndefined();
secondData.resolve({ label: "second" });
await navigation;
controller.disconnect();
router.stop();
});
it("restarts a canceled pending delay after reconnect", async () => {
vi.useFakeTimers();
const routeModule = deferred<TestModule>();
const routeData = deferred<TestData>();
const router = createRouter<RouteId, TestContext, TestModule, TestData>({
routes: [
definePage({
id: "first",
path: "/first",
component: () => routeModule.promise,
loader: () => routeData.promise,
}),
],
});
const controller = new RouterOutletController<RouteId, TestContext, TestModule, TestData>(
vi.fn(),
);
controller.setInputs({ router });
controller.connect();
const navigation = router.navigate("first", { label: "test" });
await vi.advanceTimersByTimeAsync(500);
controller.disconnect();
await vi.advanceTimersByTimeAsync(1_000);
expect(controller.snapshot.showPending).toBe(false);
controller.connect();
await vi.advanceTimersByTimeAsync(999);
expect(controller.snapshot.showPending).toBe(false);
await vi.advanceTimersByTimeAsync(1);
expect(controller.snapshot.showPending).toBe(true);
routeModule.resolve(module("first"));
routeData.resolve({ label: "loaded" });
await navigation;
controller.disconnect();
router.stop();
});
});
describe("RouterOutletController not-found boundary", () => {
function createTestRouter() {
return createRouter<RouteId, TestContext, TestModule, TestData>({
routes: [
definePage({
id: "first",
path: "/first",
component: () => module("first"),
loader: () => ({ label: "first" }),
}),
],
});
}
it("notifies once for an unmatched location", async () => {
const router = createTestRouter();
const onNotFound = vi.fn();
const controller = new RouterOutletController<RouteId, TestContext, TestModule, TestData>(
vi.fn(),
);
controller.setInputs({ router, onNotFound });
controller.connect();
await router.navigateLocation(location("/missing"), { label: "test" });
await flushPromises();
expect(onNotFound).toHaveBeenCalledTimes(1);
controller.setInputs({ router, onNotFound });
await flushPromises();
expect(onNotFound).toHaveBeenCalledTimes(1);
controller.disconnect();
router.stop();
});
it("suppresses a queued fallback after the router recovers", async () => {
const router = createTestRouter();
const onNotFound = vi.fn();
const controller = new RouterOutletController<RouteId, TestContext, TestModule, TestData>(
vi.fn(),
);
controller.setInputs({ router, onNotFound });
controller.connect();
const missing = router.navigateLocation(location("/missing"), { label: "test" });
const recovery = router.navigate("first", { label: "test" });
await Promise.all([missing, recovery]);
await flushPromises();
expect(onNotFound).not.toHaveBeenCalled();
controller.disconnect();
router.stop();
});
it("cancels the queued fallback on disconnect and re-evaluates it on reconnect", async () => {
const router = createTestRouter();
const onNotFound = vi.fn();
const controller = new RouterOutletController<RouteId, TestContext, TestModule, TestData>(
vi.fn(),
);
controller.setInputs({ router, onNotFound });
controller.connect();
const missing = router.navigateLocation(location("/missing"), { label: "test" });
controller.disconnect();
await missing;
await flushPromises();
expect(onNotFound).not.toHaveBeenCalled();
controller.connect();
await flushPromises();
expect(onNotFound).toHaveBeenCalledTimes(1);
controller.disconnect();
router.stop();
});
});
+273
View File
@@ -0,0 +1,273 @@
import type { RouteMatch, Router, RouterState } from "@openclaw/uirouter";
const DEFAULT_PENDING_DELAY_MS = 1_000;
type RouterOutletStateSlice<
TRouteId extends string = string,
TModule = unknown,
TData = unknown,
> = {
status: RouterState<TRouteId, TModule, TData>["status"];
active: RouteMatch<TRouteId, TModule, TData> | undefined;
pending: RouteMatch<TRouteId, TModule, TData> | undefined;
};
export type RouterOutletSnapshot<
TRouteId extends string = string,
TModule = unknown,
TData = unknown,
> = RouterOutletStateSlice<TRouteId, TModule, TData> & {
showPending: boolean;
};
type RouterOutletInputs<TRouteId extends string, TLoadContext, TModule, TData> = {
router?: Router<TRouteId, TLoadContext, TModule, TData>;
onNotFound?: () => void;
};
type RouterOutletControllerOptions = {
pendingDelayMs?: number;
};
export function selectRenderedRouteMatch<TRouteId extends string, TModule, TData>(
active: RouteMatch<TRouteId, TModule, TData> | undefined,
pending: RouteMatch<TRouteId, TModule, TData> | undefined,
): RouteMatch<TRouteId, TModule, TData> | undefined {
const coldPending =
pending?.status === "pending" && pending.module === undefined && pending.error === undefined;
return coldPending && active ? active : (pending ?? active);
}
function selectRouterOutletState<TRouteId extends string, TModule, TData>(
state: RouterState<TRouteId, TModule, TData>,
): RouterOutletStateSlice<TRouteId, TModule, TData> {
return {
status: state.status,
active: state.matches[0],
pending: state.pendingMatches[0],
};
}
function equalRouterOutletState(
previous: RouterOutletStateSlice,
next: RouterOutletStateSlice,
): boolean {
return (
previous.status === next.status &&
previous.active === next.active &&
previous.pending === next.pending
);
}
function idleSnapshot<TRouteId extends string, TModule, TData>(): RouterOutletSnapshot<
TRouteId,
TModule,
TData
> {
return {
status: "idle",
active: undefined,
pending: undefined,
showPending: false,
};
}
/**
* Owns route-presentation timing and effects without depending on a renderer.
* Render adapters provide invalidation and bind the controller to their own
* connection lifecycle.
*/
export class RouterOutletController<
TRouteId extends string = string,
TLoadContext = unknown,
TModule = unknown,
TData = unknown,
> {
private router?: Router<TRouteId, TLoadContext, TModule, TData>;
private onNotFound?: () => void;
private connected = false;
private unsubscribe?: () => void;
private selection: RouterOutletStateSlice<TRouteId, TModule, TData> = idleSnapshot();
private snapshotValue: RouterOutletSnapshot<TRouteId, TModule, TData> = idleSnapshot();
private pendingMatchId?: string;
private pendingTimer?: ReturnType<typeof globalThis.setTimeout>;
private showPending = false;
private notFoundActive = false;
private notFoundQueued = false;
private notFoundGeneration = 0;
private readonly pendingDelayMs: number;
constructor(
private readonly invalidate: () => void,
options: RouterOutletControllerOptions = {},
) {
this.pendingDelayMs = options.pendingDelayMs ?? DEFAULT_PENDING_DELAY_MS;
}
get snapshot(): RouterOutletSnapshot<TRouteId, TModule, TData> {
return this.snapshotValue;
}
setInputs(inputs: RouterOutletInputs<TRouteId, TLoadContext, TModule, TData>): void {
this.onNotFound = inputs.onNotFound;
if (this.router === inputs.router) {
return;
}
this.detachSource();
this.router = inputs.router;
if (this.connected) {
this.attachSource();
return;
}
const selection = inputs.router
? selectRouterOutletState(inputs.router.getState())
: idleSnapshot<TRouteId, TModule, TData>();
this.selection = selection;
this.publish({ ...selection, showPending: false });
}
connect(): void {
if (this.connected) {
return;
}
this.connected = true;
this.attachSource(false);
// A disconnected host may have retained DOM for an older snapshot. Always
// reconcile once on reconnect, even when the router state stayed stable.
this.invalidate();
}
disconnect(): void {
if (!this.connected) {
return;
}
this.connected = false;
this.detachSource();
}
private attachSource(notify = true): void {
const router = this.router;
if (!router || this.unsubscribe) {
return;
}
this.applySelection(selectRouterOutletState(router.getState()), notify);
this.unsubscribe = router.subscribeSelector(
selectRouterOutletState,
(selection) => this.applySelection(selection),
equalRouterOutletState,
);
}
private detachSource(): void {
this.unsubscribe?.();
this.unsubscribe = undefined;
this.clearPendingTimer();
this.pendingMatchId = undefined;
this.showPending = false;
this.cancelNotFoundEffect();
}
private applySelection(
selection: RouterOutletStateSlice<TRouteId, TModule, TData>,
notify = true,
): void {
this.selection = selection;
const pending = selection.pending;
const coldPending =
pending?.status === "pending" && pending.module === undefined && pending.error === undefined;
const needsPendingFallback = coldPending && !selection.active;
if (!needsPendingFallback) {
this.clearPendingTimer();
this.pendingMatchId = undefined;
this.showPending = false;
} else if (this.pendingMatchId !== pending.id) {
this.clearPendingTimer();
this.pendingMatchId = pending.id;
this.showPending = false;
this.schedulePendingFallback(pending.id);
} else if (this.connected && !this.showPending && this.pendingTimer === undefined) {
this.schedulePendingFallback(pending.id);
}
this.publish({ ...selection, showPending: this.showPending }, notify);
this.updateNotFoundEffect(selection.status);
}
private schedulePendingFallback(matchId: string): void {
if (!this.connected) {
return;
}
this.pendingTimer = globalThis.setTimeout(() => {
this.pendingTimer = undefined;
const pending = this.selection.pending;
const stillCold =
pending?.id === matchId &&
pending.status === "pending" &&
pending.module === undefined &&
pending.error === undefined &&
!this.selection.active;
if (!this.connected || this.pendingMatchId !== matchId || !stillCold) {
return;
}
this.showPending = true;
this.publish({ ...this.selection, showPending: true });
}, this.pendingDelayMs);
}
private updateNotFoundEffect(status: RouterOutletStateSlice["status"]): void {
if (status !== "notFound") {
if (this.notFoundActive || this.notFoundQueued) {
this.cancelNotFoundEffect();
}
return;
}
if (!this.connected || this.notFoundActive) {
return;
}
this.notFoundActive = true;
this.notFoundQueued = true;
const generation = ++this.notFoundGeneration;
queueMicrotask(() => {
if (
!this.connected ||
generation !== this.notFoundGeneration ||
this.selection.status !== "notFound"
) {
return;
}
this.notFoundQueued = false;
this.onNotFound?.();
});
}
private cancelNotFoundEffect(): void {
this.notFoundGeneration += 1;
this.notFoundActive = false;
this.notFoundQueued = false;
}
private publish(snapshot: RouterOutletSnapshot<TRouteId, TModule, TData>, notify = true): void {
const previous = this.snapshotValue;
if (
previous.status === snapshot.status &&
previous.active === snapshot.active &&
previous.pending === snapshot.pending &&
previous.showPending === snapshot.showPending
) {
return;
}
this.snapshotValue = snapshot;
if (notify && this.connected) {
this.invalidate();
}
}
private clearPendingTimer(): void {
if (this.pendingTimer !== undefined) {
globalThis.clearTimeout(this.pendingTimer);
this.pendingTimer = undefined;
}
}
}
+120
View File
@@ -0,0 +1,120 @@
import { createRouter, definePage, type Router } from "@openclaw/uirouter";
import { html, type LitElement } from "lit";
import { afterEach, describe, expect, it } from "vitest";
import "./router-outlet.ts";
type RouteId = "page";
type TestContext = { label: string };
type TestData = { label: string };
type TestModule = { render: (data: TestData | undefined) => unknown };
type TestRouter = Router<RouteId, TestContext, TestModule, TestData>;
type RouterOutletElement = LitElement & {
router?: TestRouter;
retryContext?: TestContext;
onNotFound?: () => void;
};
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
};
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function createOutlet(router: TestRouter, context: TestContext): RouterOutletElement {
const outlet = document.createElement("openclaw-router-outlet") as RouterOutletElement;
outlet.router = router;
outlet.retryContext = context;
document.body.append(outlet);
return outlet;
}
async function settleOutlet(outlet: RouterOutletElement): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
await Promise.resolve();
await outlet.updateComplete;
}
}
afterEach(() => {
document.body.replaceChildren();
});
describe("openclaw-router-outlet", () => {
it("renders route data through the public custom-element boundary", async () => {
const context = { label: "loaded" };
const router = createRouter<RouteId, TestContext, TestModule, TestData>({
routes: [
definePage({
id: "page",
path: "/page",
component: () => ({
render: (data: TestData | undefined) =>
html`<div data-testid="route-page">${data?.label}</div>`,
}),
loader: (loadContext) => ({ label: loadContext.label }),
}),
],
});
const outlet = createOutlet(router, context);
await router.navigate("page", context);
await settleOutlet(outlet);
expect(outlet.querySelector('[data-testid="route-page"]')?.textContent).toBe("loaded");
outlet.remove();
router.stop();
});
it("keeps a loaded route visible with an error and retries through the latest context", async () => {
const firstLoad = deferred<TestData>();
let loadCount = 0;
const router = createRouter<RouteId, TestContext, TestModule, TestData>({
routes: [
definePage({
id: "page",
path: "/page",
component: () => ({
render: (data: TestData | undefined) =>
html`<div data-testid="route-page">${data?.label ?? "pending"}</div>`,
}),
loader: (context) => {
loadCount += 1;
return loadCount === 1 ? firstLoad.promise : { label: context.label };
},
}),
],
});
const initialContext = { label: "initial" };
const retryContext = { label: "retried" };
const outlet = createOutlet(router, initialContext);
const navigation = router.navigate("page", initialContext);
await settleOutlet(outlet);
firstLoad.reject(new Error("load failed"));
await expect(navigation).rejects.toThrow("load failed");
await settleOutlet(outlet);
expect(outlet.querySelector('[data-testid="route-page"]')?.textContent).toBe("pending");
expect(outlet.querySelector('[role="alert"]')?.textContent).toContain("load failed");
outlet.retryContext = retryContext;
await outlet.updateComplete;
outlet.querySelector<HTMLButtonElement>("button")?.click();
await settleOutlet(outlet);
expect(loadCount).toBe(2);
expect(outlet.querySelector('[data-testid="route-page"]')?.textContent).toBe("retried");
expect(outlet.querySelector('[role="alert"]')).toBeNull();
outlet.remove();
router.stop();
});
});
+45 -169
View File
@@ -1,11 +1,16 @@
import type { RouteMatch, Router, RouterState } from "@openclaw/uirouter";
import { html, LitElement, nothing } from "lit";
import { AsyncDirective } from "lit/async-directive.js";
import type { Router } from "@openclaw/uirouter";
import { html, nothing } from "lit";
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { property } from "lit/decorators.js";
import { directive } from "lit/directive.js";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import {
RouterOutletController,
selectRenderedRouteMatch,
type RouterOutletSnapshot,
} from "./router-outlet-controller.ts";
const PENDING_UI_DELAY_MS = 1_000;
export { selectRenderedRouteMatch } from "./router-outlet-controller.ts";
type RenderableModule<TData> = {
render: (data: TData | undefined) => unknown;
@@ -15,48 +20,6 @@ type RouterOutletOptions<TLoadContext = unknown> = {
retryContext?: TLoadContext;
};
type RouterOutletBoundaryOptions = {
onNotFound?: () => void;
};
type RouterOutletSelection<TRouteId extends string = string, TModule = unknown, TData = unknown> = {
status: RouterState<TRouteId, TModule, TData>["status"];
active: RouteMatch<TRouteId, TModule, TData> | undefined;
pending: RouteMatch<TRouteId, TModule, TData> | undefined;
showPending: boolean;
};
export function selectRenderedRouteMatch<TRouteId extends string, TModule, TData>(
active: RouteMatch<TRouteId, TModule, TData> | undefined,
pending: RouteMatch<TRouteId, TModule, TData> | undefined,
): RouteMatch<TRouteId, TModule, TData> | undefined {
const coldPending =
pending?.status === "pending" && pending.module === undefined && pending.error === undefined;
return coldPending && active ? active : (pending ?? active);
}
function selectRouterOutletState<TRouteId extends string, TModule, TData>(
state: RouterState<TRouteId, TModule, TData>,
): RouterOutletSelection<TRouteId, TModule, TData> {
return {
status: state.status,
active: state.matches[0],
pending: state.pendingMatches[0],
showPending: false,
};
}
function equalRouterOutletState(
previous: RouterOutletSelection,
next: RouterOutletSelection,
): boolean {
return (
previous.status === next.status &&
previous.active === next.active &&
previous.pending === next.pending
);
}
function isRenderableModule<TData>(module: unknown): module is RenderableModule<TData> {
return (
typeof module === "object" &&
@@ -113,7 +76,7 @@ function renderError<TRouteId extends string, TLoadContext, TModule, TData>(
function renderRouterOutlet<TRouteId extends string, TLoadContext, TModule, TData = unknown>(
router: Router<TRouteId, TLoadContext, TModule, TData>,
selection: RouterOutletSelection<TRouteId, TModule, TData>,
selection: RouterOutletSnapshot<TRouteId, TModule, TData>,
options: RouterOutletOptions<TLoadContext> = {},
): unknown {
const pending = selection.pending;
@@ -165,126 +128,43 @@ function renderRouterOutlet<TRouteId extends string, TLoadContext, TModule, TDat
: renderedPage();
}
class RouterOutletDirective extends AsyncDirective {
private router?: Router<string, unknown, unknown, unknown>;
private retryContext: unknown;
private unsubscribe?: () => void;
private boundaryOptions?: RouterOutletBoundaryOptions;
private notFoundScheduled = false;
private pendingMatchId?: string;
private pendingTimer?: ReturnType<typeof globalThis.setTimeout>;
private pendingSelection?: RouterOutletSelection;
private showPending = false;
type RouterOutletInputs<TRouteId extends string, TLoadContext, TModule, TData> = {
router?: Router<TRouteId, TLoadContext, TModule, TData>;
onNotFound?: () => void;
};
override render(
router: unknown,
retryContext: unknown,
boundaryOptions: RouterOutletBoundaryOptions,
class LitRouterOutletController<
TRouteId extends string,
TLoadContext,
TModule,
TData,
> implements ReactiveController {
private readonly controller: RouterOutletController<TRouteId, TLoadContext, TModule, TData>;
constructor(
host: ReactiveControllerHost,
private readonly inputs: () => RouterOutletInputs<TRouteId, TLoadContext, TModule, TData>,
) {
const nextRouter = router as Router<string, unknown, unknown, unknown>;
this.updateSubscription(nextRouter);
this.router = nextRouter;
this.retryContext = retryContext;
this.boundaryOptions = boundaryOptions;
return this.renderSelection(selectRouterOutletState(nextRouter.getState()));
this.controller = new RouterOutletController(() => host.requestUpdate());
host.addController(this);
}
override disconnected() {
this.unsubscribe?.();
this.unsubscribe = undefined;
this.clearPendingTimer();
this.pendingSelection = undefined;
this.boundaryOptions = undefined;
this.retryContext = undefined;
this.notFoundScheduled = false;
get snapshot(): RouterOutletSnapshot<TRouteId, TModule, TData> {
return this.controller.snapshot;
}
override reconnected() {
if (this.router) {
this.updateSubscription(this.router);
}
hostConnected(): void {
this.controller.setInputs(this.inputs());
this.controller.connect();
}
private updateSubscription(router: Router<string, unknown, unknown, unknown>) {
if (this.router === router && this.unsubscribe) {
return;
}
this.unsubscribe?.();
this.unsubscribe = router.subscribeSelector(
selectRouterOutletState,
(selection) => {
if (this.isConnected) {
this.setValue(this.renderSelection(selection));
}
},
equalRouterOutletState,
);
hostUpdate(): void {
this.controller.setInputs(this.inputs());
}
private renderSelection(selection: RouterOutletSelection) {
this.pendingSelection = selection;
const pending = selection.pending;
const coldPending =
pending?.status === "pending" && pending.module === undefined && pending.error === undefined;
const needsPendingFallback = coldPending && !selection.active;
if (!needsPendingFallback) {
this.clearPendingTimer();
this.pendingMatchId = undefined;
this.showPending = false;
} else if (this.pendingMatchId !== pending.id) {
this.clearPendingTimer();
this.pendingMatchId = pending.id;
this.showPending = false;
this.pendingTimer = globalThis.setTimeout(() => {
this.pendingTimer = undefined;
const pendingSelection = this.pendingSelection;
if (!pendingSelection || pendingSelection.pending?.id !== this.pendingMatchId) {
return;
}
this.showPending = true;
this.setValue(this.renderSelection(pendingSelection));
}, PENDING_UI_DELAY_MS);
}
if (selection.status === "notFound") {
if (!this.notFoundScheduled) {
this.notFoundScheduled = true;
queueMicrotask(() => {
this.notFoundScheduled = false;
this.boundaryOptions?.onNotFound?.();
});
}
} else {
this.notFoundScheduled = false;
}
const router = this.router;
if (!router) {
return nothing;
}
return renderRouterOutlet(
router,
{ ...selection, showPending: this.showPending },
{
retryContext: this.retryContext,
},
);
hostDisconnected(): void {
this.controller.disconnect();
}
private clearPendingTimer() {
if (this.pendingTimer !== undefined) {
globalThis.clearTimeout(this.pendingTimer);
this.pendingTimer = undefined;
}
}
}
const routerOutletDirective = directive(RouterOutletDirective);
function routerOutlet<TRouteId extends string, TModule, TData, TContext>(
router: Router<TRouteId, TContext, TModule, TData>,
boundaryOptions: RouterOutletBoundaryOptions,
options: RouterOutletOptions<TContext> = {},
): unknown {
return routerOutletDirective(router, options.retryContext, boundaryOptions);
}
class OpenClawRouterOutlet<
@@ -292,26 +172,22 @@ class OpenClawRouterOutlet<
TLoadContext = unknown,
TModule = unknown,
TData = unknown,
> extends LitElement {
> extends OpenClawLightDomElement {
@property({ attribute: false }) router?: Router<TRouteId, TLoadContext, TModule, TData>;
@property({ attribute: false }) retryContext?: TLoadContext;
@property({ attribute: false }) onNotFound?: () => void;
override createRenderRoot() {
return this;
}
private readonly outlet = new LitRouterOutletController(this, () => ({
router: this.router,
onNotFound: this.onNotFound,
}));
override render() {
if (!this.router) {
return nothing;
}
return routerOutlet(
this.router,
{ onNotFound: this.onNotFound },
{
retryContext: this.retryContext,
},
);
return renderRouterOutlet(this.router, this.outlet.snapshot, {
retryContext: this.retryContext,
});
}
}
+137
View File
@@ -0,0 +1,137 @@
/* @vitest-environment jsdom */
import { ContextProvider } from "@lit/context";
import { LitElement } from "lit";
import { afterEach, describe, expect, it } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { SessionsListResult } from "../api/types.ts";
import type { RouteId } from "../app-route-paths.ts";
import {
applicationContext,
type ApplicationContext,
type ApplicationGateway,
} from "../app/context.ts";
import type { SessionCapability } from "../lib/sessions/index.ts";
import "./app-sidebar.ts";
const PROVIDER_ELEMENT_NAME = "test-app-sidebar-context-provider";
class AppSidebarContextProvider extends LitElement {
private readonly contextProvider = new ContextProvider(this, {
context: applicationContext,
});
setContext(context: ApplicationContext<RouteId>) {
this.contextProvider.setValue(context);
}
}
if (!customElements.get(PROVIDER_ELEMENT_NAME)) {
customElements.define(PROVIDER_ELEMENT_NAME, AppSidebarContextProvider);
}
type SidebarLifecycleState = HTMLElement & {
sessionRowsByAgent: Record<string, SessionsListResult["sessions"]>;
sessionCreatedOrder: Map<string, number>;
updateComplete: Promise<boolean>;
};
function createGateway(client: GatewayBrowserClient): ApplicationGateway {
return {
snapshot: {
client,
connected: true,
reconnecting: false,
hello: null,
assistantAgentId: "main",
sessionKey: "agent:main:main",
lastError: null,
lastErrorCode: null,
},
subscribe: () => () => undefined,
} as unknown as ApplicationGateway;
}
function createSessions(agentId: string, keys: string[]): SessionCapability {
const result = {
ts: 1,
path: "",
count: keys.length,
defaults: {
modelProvider: null,
model: null,
contextTokens: null,
},
sessions: keys.map((key, index) => ({
key,
kind: "direct" as const,
updatedAt: index + 1,
})),
} satisfies SessionsListResult;
return {
state: {
result,
agentId,
modelOverrides: {},
loading: false,
error: null,
deletedSessions: [],
},
subscribe: () => () => undefined,
subscribeCreated: () => () => undefined,
} as unknown as SessionCapability;
}
function createContext(
gateway: ApplicationGateway,
sessions: SessionCapability,
): ApplicationContext<RouteId> {
return {
gateway,
sessions,
agents: {
state: { agentsList: null },
subscribe: () => () => undefined,
},
agentSelection: {
state: { selectedId: "main" },
set: () => undefined,
subscribe: () => () => undefined,
},
} as unknown as ApplicationContext<RouteId>;
}
afterEach(() => {
document.body.replaceChildren();
});
describe("AppSidebar session source lifecycle", () => {
it("resets cached rows and creation order when the sessions source changes", async () => {
const client = {} as GatewayBrowserClient;
const gateway = createGateway(client);
const provider = document.createElement(PROVIDER_ELEMENT_NAME) as AppSidebarContextProvider;
const sidebar = document.createElement(
"openclaw-app-sidebar",
) as unknown as SidebarLifecycleState;
provider.setContext(createContext(gateway, createSessions("first", ["first-a", "first-b"])));
provider.append(sidebar);
document.body.append(provider);
await sidebar.updateComplete;
expect(Object.keys(sidebar.sessionRowsByAgent)).toEqual(["first"]);
expect([...sidebar.sessionCreatedOrder]).toEqual([
["first-a", 0],
["first-b", 1],
]);
// The Gateway and its client stay unchanged while the sessions capability is replaced.
provider.setContext(createContext(gateway, createSessions("second", ["second-b", "second-a"])));
await sidebar.updateComplete;
expect(Object.keys(sidebar.sessionRowsByAgent)).toEqual(["second"]);
expect([...sidebar.sessionCreatedOrder]).toEqual([
["second-b", 0],
["second-a", 1],
]);
});
});
+44 -59
View File
@@ -1,5 +1,5 @@
import { consume } from "@lit/context";
import { LitElement, html, nothing } from "lit";
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { keyed } from "lit/directives/keyed.js";
import type { GatewayBrowserClient, GatewayControlUiPluginTab } from "../api/gateway.ts";
@@ -56,6 +56,7 @@ import {
compareSessionRowsByUpdatedAt,
resolveSessionNavigation,
searchForSession,
type SessionCapability,
} from "../lib/sessions/index.ts";
import {
buildAgentMainSessionKey,
@@ -69,6 +70,8 @@ import {
resolveSessionAgentFilterOptions,
} from "../lib/sessions/session-options.ts";
import { normalizeOptionalString } from "../lib/string-coerce.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import { pluginTabKey, pluginTabSearch } from "../pages/plugin/route.ts";
import { icons, type IconName } from "./icons.ts";
@@ -161,11 +164,7 @@ function shouldHandleNavigationClick(event: MouseEvent): boolean {
);
}
class AppSidebar extends LitElement {
override createRenderRoot() {
return this;
}
class AppSidebar extends OpenClawLightDomElement {
@property({ attribute: false }) basePath = "";
@property({ attribute: false }) activeRouteId?: NavigationRouteId;
@property({ attribute: false }) activePluginTabId = "";
@@ -187,7 +186,7 @@ class AppSidebar extends LitElement {
onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void;
@property({ attribute: false }) onPreloadRoute?: (routeId: NavigationRouteId) => Promise<void>;
@consume({ context: applicationContext, subscribe: false })
@consume({ context: applicationContext, subscribe: true })
private context?: ApplicationContext<RouteId>;
@state() private customizeMenuPosition: { x: number; y: number } | null = null;
@state() private sessionMenu: SidebarSessionMenuState | null = null;
@@ -204,27 +203,50 @@ class AppSidebar extends LitElement {
@state() private sessionsAgentId: string | null = null;
@state() private sessionsLoading = false;
private stopSessionsSubscription: (() => void) | undefined;
private stopSessionCreatedSubscription: (() => void) | undefined;
private stopAgentsSubscription: (() => void) | undefined;
private stopAgentSelectionSubscription: (() => void) | undefined;
private stopGatewaySubscription: (() => void) | undefined;
private readonly subscriptions = new SubscriptionsController(this);
private customizeMenuTrigger: HTMLElement | null = null;
private sessionMenuTrigger: HTMLElement | null = null;
private sessionGroupMenuTrigger: HTMLElement | null = null;
private sessionSortMenuTrigger: HTMLElement | null = null;
private sessionRowsByAgent: Record<string, SessionsListResult["sessions"]> = {};
private sessionCreatedOrder = new Map<string, number>();
private sessionsSource: SessionCapability | null = null;
private gatewayClient: GatewayBrowserClient | null = null;
private readonly routePreloadTimers = new Map<
EventTarget,
ReturnType<typeof globalThis.setTimeout>
>();
constructor() {
super();
this.subscriptions
.watch(
() => this.context?.gateway,
(gateway, notify) => gateway.subscribe(notify),
(gateway) => this.updateGatewayClient(gateway.snapshot),
)
.watch(
() => this.context?.sessions,
(sessions, notify) => sessions.subscribe(notify),
(sessions) => this.synchronizeSessions(sessions),
)
.effect(
() => this.context?.sessions,
(sessions) => sessions.subscribeCreated((key) => this.promoteCreatedSession(key)),
)
.watch(
() => this.context?.agents,
(agents, notify) => agents.subscribe(notify),
)
.watch(
() => this.context?.agentSelection,
(agentSelection, notify) => agentSelection.subscribe(notify),
);
}
override connectedCallback() {
super.connectedCallback();
this.style.display = "contents";
this.startSubscriptions();
}
override disconnectedCallback() {
@@ -232,16 +254,6 @@ class AppSidebar extends LitElement {
this.closeSessionMenu();
this.closeSessionGroupMenu();
this.closeSessionSortMenu();
this.stopSessionsSubscription?.();
this.stopSessionsSubscription = undefined;
this.stopSessionCreatedSubscription?.();
this.stopSessionCreatedSubscription = undefined;
this.stopAgentsSubscription?.();
this.stopAgentsSubscription = undefined;
this.stopAgentSelectionSubscription?.();
this.stopAgentSelectionSubscription = undefined;
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.gatewayClient = null;
for (const timer of this.routePreloadTimers.values()) {
globalThis.clearTimeout(timer);
@@ -250,42 +262,6 @@ class AppSidebar extends LitElement {
super.disconnectedCallback();
}
private startSubscriptions() {
const context = this.context;
if (
!context ||
this.stopSessionsSubscription ||
this.stopSessionCreatedSubscription ||
this.stopAgentsSubscription ||
this.stopAgentSelectionSubscription ||
this.stopGatewaySubscription
) {
return;
}
this.updateGatewayClient(context.gateway.snapshot);
this.updateSessions(context.sessions.state);
this.stopSessionsSubscription = context.sessions.subscribe((snapshot) => {
this.updateSessions(snapshot);
});
this.stopSessionCreatedSubscription = context.sessions.subscribeCreated((key) => {
this.promoteCreatedSession(key);
});
this.stopAgentsSubscription = context.agents.subscribe(() => {
this.requestUpdate();
});
this.stopAgentSelectionSubscription = context.agentSelection.subscribe(() => {
this.requestUpdate();
});
this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => {
this.updateGatewayClient(snapshot);
this.requestUpdate();
});
}
override updated() {
this.startSubscriptions();
}
private readonly updateSessions = (snapshot: {
result: SessionsListResult | null;
agentId: string | null;
@@ -306,6 +282,15 @@ class AppSidebar extends LitElement {
}
};
private synchronizeSessions(sessions: SessionCapability) {
if (sessions !== this.sessionsSource) {
this.sessionRowsByAgent = {};
this.sessionCreatedOrder.clear();
this.sessionsSource = sessions;
}
this.updateSessions(sessions.state);
}
private updateGatewayClient(snapshot: {
client: GatewayBrowserClient | null;
connected: boolean;
+3 -6
View File
@@ -1,17 +1,14 @@
import { LitElement, html, nothing } from "lit";
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import type { NavigationRouteId } from "../app-navigation.ts";
import { controlUiPublicAssetPath } from "../app/public-assets.ts";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { icons } from "./icons.ts";
import "./dashboard-header.ts";
import "./tooltip.ts";
class AppTopbar extends LitElement {
override createRenderRoot() {
return this;
}
class AppTopbar extends OpenClawLightDomElement {
@property({ attribute: false }) routeId?: NavigationRouteId;
@property({ attribute: false }) navDrawerOpen = false;
@property({ attribute: false }) onboarding = false;
+224
View File
@@ -0,0 +1,224 @@
/* @vitest-environment jsdom */
import { ContextProvider } from "@lit/context";
import { LitElement } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { SessionsListResult } from "../api/types.ts";
import type { RouteId } from "../app-route-paths.ts";
import {
applicationContext,
type ApplicationContext,
type ApplicationGateway,
type ApplicationGatewaySnapshot,
} from "../app/context.ts";
import { installDialogPolyfill } from "../test-helpers/modal-dialog.ts";
import { CommandPalette } from "./command-palette.ts";
const PROVIDER_ELEMENT_NAME = "test-command-palette-context-provider";
class CommandPaletteContextProvider extends LitElement {
private readonly contextProvider = new ContextProvider(this, {
context: applicationContext,
});
setContext(context: ApplicationContext<RouteId>) {
this.contextProvider.setValue(context);
}
}
if (!customElements.get(PROVIDER_ELEMENT_NAME)) {
customElements.define(PROVIDER_ELEMENT_NAME, CommandPaletteContextProvider);
}
type GatewayHarness = {
gateway: ApplicationGateway;
setConnected: (connected: boolean) => void;
};
function createGateway(connected: boolean): GatewayHarness {
const client = {} as GatewayBrowserClient;
let snapshot: ApplicationGatewaySnapshot = {
client,
connected,
reconnecting: !connected,
hello: null,
assistantAgentId: "main",
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const gateway = {
get snapshot() {
return snapshot;
},
connection: { gatewayUrl: "ws://localhost", token: "", password: "" },
eventLog: [],
connect: () => undefined,
setSessionKey: () => undefined,
start: () => undefined,
stop: () => undefined,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeEventLog: () => () => undefined,
subscribeEvents: () => () => undefined,
} satisfies ApplicationGateway;
return {
gateway,
setConnected(nextConnected) {
snapshot = {
...snapshot,
connected: nextConnected,
reconnecting: !nextConnected,
};
for (const listener of listeners) {
listener(snapshot);
}
},
};
}
function createContext(
gateway: ApplicationGateway,
list: ApplicationContext<RouteId>["sessions"]["list"],
): ApplicationContext<RouteId> {
return {
gateway,
sessions: {
list,
},
} as unknown as ApplicationContext<RouteId>;
}
function createSessionResult(key: string, displayName: string): SessionsListResult {
return {
ts: 1,
path: "",
count: 1,
defaults: {},
sessions: [{ key, kind: "direct", displayName, updatedAt: 1 }],
} as SessionsListResult;
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
async function mountPalette(context: ApplicationContext<RouteId>) {
const provider = document.createElement(PROVIDER_ELEMENT_NAME) as CommandPaletteContextProvider;
const palette = document.createElement("openclaw-command-palette") as CommandPalette;
palette.onNavigate = vi.fn();
palette.onSelectSession = vi.fn();
provider.setContext(context);
provider.append(palette);
document.body.append(provider);
await palette.updateComplete;
return { palette, provider };
}
async function enterQuery(palette: CommandPalette, query: string) {
palette.openPalette();
await palette.updateComplete;
const input = palette.querySelector<HTMLInputElement>(".cmd-palette__input");
if (!input) {
throw new Error("Expected command palette input");
}
input.value = query;
input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
await palette.updateComplete;
}
describe("CommandPalette lifecycle", () => {
let restoreDialogPolyfill: () => void;
beforeEach(() => {
vi.useFakeTimers();
restoreDialogPolyfill = installDialogPolyfill();
});
afterEach(() => {
document.body.replaceChildren();
restoreDialogPolyfill();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("closes and clears its query before a retained element reconnects", async () => {
const { gateway } = createGateway(true);
const list = vi.fn(async () => createSessionResult("agent:main:old", "Old chat"));
const { palette, provider } = await mountPalette(createContext(gateway, list));
await enterQuery(palette, "old");
await vi.advanceTimersByTimeAsync(250);
await palette.updateComplete;
expect(palette.textContent).toContain("Old chat");
palette.remove();
provider.append(palette);
expect(palette.querySelector("dialog")?.open).toBe(false);
await palette.updateComplete;
expect(palette.querySelector("dialog")).toBeNull();
palette.openPalette();
await palette.updateComplete;
expect(palette.querySelector<HTMLInputElement>(".cmd-palette__input")?.value).toBe("");
expect(palette.textContent).not.toContain("Old chat");
});
it("retries the pending query after the gateway reconnects", async () => {
const harness = createGateway(true);
const stale = createDeferred<SessionsListResult | null>();
const list = vi
.fn<ApplicationContext<RouteId>["sessions"]["list"]>()
.mockImplementationOnce(() => stale.promise)
.mockResolvedValueOnce(createSessionResult("agent:main:retry", "Retry chat"));
const { palette } = await mountPalette(createContext(harness.gateway, list));
await enterQuery(palette, "retry");
await vi.advanceTimersByTimeAsync(250);
expect(list).toHaveBeenCalledOnce();
harness.setConnected(false);
stale.resolve(createSessionResult("agent:main:stale", "Stale chat"));
await Promise.resolve();
expect(palette.textContent).not.toContain("Stale chat");
harness.setConnected(true);
await palette.updateComplete;
await vi.advanceTimersByTimeAsync(250);
await palette.updateComplete;
expect(list).toHaveBeenCalledTimes(2);
expect(list).toHaveBeenLastCalledWith(expect.objectContaining({ search: "retry" }));
expect(palette.textContent).toContain("Retry chat");
});
it("drops an old provider response and searches the replacement context", async () => {
const initial = createGateway(true);
const replacement = createGateway(true);
const stale = createDeferred<SessionsListResult | null>();
const initialList = vi.fn(() => stale.promise);
const replacementList = vi.fn(async () =>
createSessionResult("agent:main:fresh", "Fresh chat"),
);
const { palette, provider } = await mountPalette(createContext(initial.gateway, initialList));
await enterQuery(palette, "chat");
await vi.advanceTimersByTimeAsync(250);
expect(initialList).toHaveBeenCalledOnce();
stale.resolve(createSessionResult("agent:main:stale", "Stale chat"));
provider.setContext(createContext(replacement.gateway, replacementList));
await palette.updateComplete;
await vi.advanceTimersByTimeAsync(250);
await palette.updateComplete;
expect(replacementList).toHaveBeenCalledOnce();
expect(palette.textContent).toContain("Fresh chat");
expect(palette.textContent).not.toContain("Stale chat");
});
});
+81 -13
View File
@@ -1,6 +1,6 @@
// Control UI component renders the command palette.
import { consume } from "@lit/context";
import { LitElement, html, nothing } from "lit";
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { ref } from "lit/directives/ref.js";
import type { RouteId } from "../app-route-paths.ts";
@@ -10,6 +10,8 @@ import { formatRelativeTimestamp } from "../lib/format.ts";
import { resolveSessionDisplayName } from "../lib/session-display.ts";
import { getVisibleSessionRows } from "../lib/sessions/index.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../lib/string-coerce.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import { icons, type IconName } from "./icons.ts";
type PaletteItem = {
@@ -104,6 +106,8 @@ type CommandPaletteProps = {
onNavigate: (routeId: RouteId) => void;
onSelectSession?: (sessionKey: string) => void;
onSlashCommand?: (command: string) => void;
onDialogRef: (element: Element | undefined) => void;
onInputRef: (element: Element | undefined) => void;
};
function filteredItems(
@@ -334,7 +338,7 @@ function renderCommandPalette(props: CommandPaletteProps) {
return html`
<dialog
${ref(syncDialog)}
${ref(props.onDialogRef)}
class="cmd-palette-overlay"
aria-labelledby=${paletteDialogLabelId}
@cancel=${(e: Event) => {
@@ -356,7 +360,7 @@ function renderCommandPalette(props: CommandPaletteProps) {
>${paletteLabel}</label
>
<input
${ref(focusInput)}
${ref(props.onInputRef)}
id=${paletteInputId}
class="cmd-palette__input"
role="combobox"
@@ -420,23 +424,34 @@ function renderCommandPalette(props: CommandPaletteProps) {
`;
}
export class CommandPalette extends LitElement {
override createRenderRoot() {
return this;
}
export class CommandPalette extends OpenClawLightDomElement {
@property({ attribute: false }) onNavigate?: (routeId: RouteId) => void;
@property({ attribute: false }) onSelectSession?: (sessionKey: string) => void;
@property({ attribute: false }) onSlashCommand?: (command: string) => void;
@consume({ context: applicationContext, subscribe: false })
@consume({ context: applicationContext, subscribe: true })
private context?: ApplicationContext<RouteId>;
@state() private open = false;
@state() private query = "";
@state() private activeIndex = 0;
@state() private sessionItems: readonly PaletteItem[] = [];
private readonly subscriptions = new SubscriptionsController(this);
private sessionSearchTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
private sessionSearchId = 0;
private sessionSearchSource?: {
gateway: ApplicationContext<RouteId>["gateway"];
client: ApplicationContext<RouteId>["gateway"]["snapshot"]["client"];
connected: boolean;
};
constructor() {
super();
this.subscriptions.watch(
() => this.context?.gateway,
(gateway, notify) => gateway.subscribe(notify),
(gateway) => this.synchronizeGateway(gateway),
);
}
override connectedCallback() {
super.connectedCallback();
@@ -446,7 +461,11 @@ export class CommandPalette extends LitElement {
override disconnectedCallback() {
document.removeEventListener("keydown", this.handleGlobalKeydown);
this.open = false;
this.query = "";
this.activeIndex = 0;
this.clearSessionSearch();
this.sessionSearchSource = undefined;
if (activeDialog) {
activeDialog.close();
restoreFocus();
@@ -475,6 +494,42 @@ export class CommandPalette extends LitElement {
this.openPalette();
};
private readonly handleDialogRef = (element: Element | undefined) => {
if (!this.open) {
syncDialog(undefined);
return;
}
syncDialog(element);
};
private readonly handleInputRef = (element: Element | undefined) => {
if (this.open) {
focusInput(element);
}
};
private synchronizeGateway(gateway: ApplicationContext<RouteId>["gateway"]) {
const snapshot = gateway.snapshot;
const previous = this.sessionSearchSource;
const sourceChanged = previous?.gateway !== gateway;
const clientChanged = previous?.client !== snapshot.client;
const reconnected = previous?.connected === false && snapshot.connected;
this.sessionSearchSource = {
gateway,
client: snapshot.client,
connected: snapshot.connected,
};
if (sourceChanged || clientChanged || !snapshot.connected) {
// Query results belong to one runtime/client connection. Discard them as
// soon as that owner changes so detached or reconnecting rows stay inert.
this.clearSessionSearch();
}
if (snapshot.connected && (sourceChanged || clientChanged || reconnected)) {
this.scheduleSessionSearch(this.query);
}
}
private clearSessionSearch() {
if (this.sessionSearchTimer !== null) {
globalThis.clearTimeout(this.sessionSearchTimer);
@@ -494,7 +549,7 @@ export class CommandPalette extends LitElement {
this.sessionSearchId += 1;
this.sessionItems = [];
const search = normalizeOptionalString(query);
if (!search || !this.onSelectSession) {
if (!this.open || !search || !this.onSelectSession) {
return;
}
this.sessionSearchTimer = globalThis.setTimeout(() => {
@@ -504,8 +559,11 @@ export class CommandPalette extends LitElement {
}
private async searchSessions(search: string) {
const sessions = this.context?.sessions;
if (!sessions || !this.context?.gateway.snapshot.connected) {
const context = this.context;
const sessions = context?.sessions;
const gateway = context?.gateway;
const client = gateway?.snapshot.client;
if (!sessions || !gateway?.snapshot.connected || !client) {
return;
}
const requestId = ++this.sessionSearchId;
@@ -524,7 +582,15 @@ export class CommandPalette extends LitElement {
includeUnknown: false,
});
pagesLoaded += 1;
if (requestId !== this.sessionSearchId || !this.open || !result) {
if (
requestId !== this.sessionSearchId ||
!this.open ||
this.context?.sessions !== sessions ||
this.context?.gateway !== gateway ||
gateway.snapshot.client !== client ||
!gateway.snapshot.connected ||
!result
) {
return;
}
const pageRows = getVisibleSessionRows(result, {
@@ -598,6 +664,8 @@ export class CommandPalette extends LitElement {
onNavigate: (routeId) => this.onNavigate?.(routeId),
onSelectSession: this.onSelectSession,
onSlashCommand: this.onSlashCommand,
onDialogRef: this.handleDialogRef,
onInputRef: this.handleInputRef,
});
}
}
+3 -6
View File
@@ -1,8 +1,9 @@
// Control UI component renders the offline/reconnecting banner shown while
// the gateway connection is interrupted but the dashboard stays mounted.
import { LitElement, html, nothing } from "lit";
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { redactLoginFailureError } from "./login-gate.ts";
type ConnectionBannerProps = {
@@ -28,11 +29,7 @@ function renderConnectionBanner(props: ConnectionBannerProps) {
`;
}
class ConnectionBanner extends LitElement {
override createRenderRoot() {
return this;
}
class ConnectionBanner extends OpenClawLightDomElement {
@property({ attribute: false }) props?: ConnectionBannerProps;
override connectedCallback() {
+3 -6
View File
@@ -1,13 +1,10 @@
// Control UI component implements the dashboard header element.
import { LitElement, html, nothing } from "lit";
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { titleForRoute, type NavigationRouteId } from "../app-navigation.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
class DashboardHeader extends LitElement {
override createRenderRoot() {
return this;
}
class DashboardHeader extends OpenClawLightDomElement {
@property() routeId?: NavigationRouteId;
@property() basePath = "";
@property() agentLabel = "";
+3 -6
View File
@@ -1,5 +1,5 @@
// Control UI component renders exec approval.
import { LitElement, html, nothing } from "lit";
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { formatApprovalDisplayPath } from "../../../src/infra/approval-display-paths.ts";
import type {
@@ -9,6 +9,7 @@ import type {
} from "../app/exec-approval.ts";
import "./modal-dialog.ts";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
const DEFAULT_EXEC_APPROVAL_DECISIONS = [
"allow-once",
@@ -224,11 +225,7 @@ function renderExecApprovalPrompt(props: ExecApprovalProps) {
`;
}
class ExecApproval extends LitElement {
override createRenderRoot() {
return this;
}
class ExecApproval extends OpenClawLightDomElement {
@property({ attribute: false }) props?: ExecApprovalProps;
override connectedCallback() {
@@ -139,6 +139,26 @@ describe("openclaw-file-preview-modal", () => {
expect(onSelect.mock.lastCall?.[0].detail).toBe("filters/auto-senders.txt");
});
it("restores modal focus when the same element reconnects", async () => {
const modal = await renderPreview();
const outside = document.createElement("button");
document.body.append(outside);
try {
container.remove();
outside.focus();
expect(document.activeElement).toBe(outside);
document.body.append(container);
await modal.updateComplete;
const input = modal.shadowRoot?.querySelector<HTMLInputElement>(".search");
expect(input).toBeInstanceOf(HTMLInputElement);
expect(modal.shadowRoot?.activeElement).toBe(input);
} finally {
outside.remove();
}
});
it("blocks background arrow-key scrolling even when no files match", async () => {
const modal = await renderPreview({ query: "missing" });
const onDocumentKeydown = vi.fn();
+10 -6
View File
@@ -1,6 +1,7 @@
// Control UI component implements the file preview modal element.
import { LitElement, css, html, type PropertyValues } from "lit";
import { css, html, type PropertyValues } from "lit";
import { property, query } from "lit/decorators.js";
import { OpenClawLitElement } from "../lit/openclaw-element.ts";
import { renderCopyButton } from "./copy-button.ts";
import { icons } from "./icons.ts";
@@ -10,7 +11,7 @@ type FilePreviewModalFile = {
contents: string;
};
export class OpenClawFilePreviewModal extends LitElement {
export class OpenClawFilePreviewModal extends OpenClawLitElement {
@property({ attribute: false }) files: FilePreviewModalFile[] = [];
@property() activePath = "";
@property() query = "";
@@ -31,6 +32,8 @@ export class OpenClawFilePreviewModal extends LitElement {
private codeSource?: string;
private codeChunks: string[] = [];
private resetScrollAfterUpdate = true;
// Reconnection does not rerun firstUpdated; defer focus until shadow DOM is ready.
private focusAfterUpdate = false;
static override styles = css`
:host {
@@ -607,13 +610,10 @@ export class OpenClawFilePreviewModal extends LitElement {
return files.find((file) => file.path === this.activePath) ?? files[0];
}
protected override firstUpdated() {
this.focusModal();
}
override connectedCallback() {
super.connectedCallback();
this.resetScrollAfterUpdate = true;
this.focusAfterUpdate = true;
this.requestUpdate();
}
@@ -629,6 +629,10 @@ export class OpenClawFilePreviewModal extends LitElement {
if (changed.has("activePath") || changed.has("query") || changed.has("files")) {
this.scrollActiveFileIntoView();
}
if (this.focusAfterUpdate && this.isConnected) {
this.focusAfterUpdate = false;
this.focusModal();
}
}
private handleQueryInput = (event: Event) => {
@@ -1,7 +1,8 @@
// Control UI component renders gateway URL confirmation.
import { LitElement, html, nothing } from "lit";
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import "./modal-dialog.ts";
type GatewayUrlConfirmationProps = {
@@ -45,11 +46,7 @@ function renderGatewayUrlConfirmation(props: GatewayUrlConfirmationProps) {
`;
}
class GatewayUrlConfirmation extends LitElement {
override createRenderRoot() {
return this;
}
class GatewayUrlConfirmation extends OpenClawLightDomElement {
@property({ attribute: false }) props?: GatewayUrlConfirmationProps;
override connectedCallback() {
+3 -6
View File
@@ -1,5 +1,5 @@
// Control UI component renders the login gate.
import { LitElement, html, nothing } from "lit";
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js";
import { normalizeBasePath } from "../app-route-paths.ts";
@@ -12,6 +12,7 @@ import {
shouldShowInsecureContextHint,
} from "../lib/overview-hints.ts";
import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { renderConnectCommand } from "./connect-command.ts";
import { icons } from "./icons.ts";
@@ -431,11 +432,7 @@ function renderLoginGate(props: LoginGateProps) {
`;
}
class LoginGate extends LitElement {
override createRenderRoot() {
return this;
}
class LoginGate extends OpenClawLightDomElement {
@property({ attribute: false }) props?: LoginGateProps;
override connectedCallback() {
+14
View File
@@ -149,4 +149,18 @@ describe("openclaw-modal-dialog", () => {
expect(document.activeElement).toBe(returnTarget);
returnTarget.remove();
});
it("reopens the same dialog element after reconnect", async () => {
const { modal, dialog } = await renderModal();
modal.remove();
expect(dialog.open).toBe(false);
container.append(modal);
await modal.updateComplete;
await nextFrame();
expect(dialog.open).toBe(true);
expect(modal.shadowRoot?.activeElement).toBe(dialog);
});
});
+8 -2
View File
@@ -1,7 +1,8 @@
// Control UI component implements the modal dialog element.
import { LitElement, css, html, nothing } from "lit";
import { css, html, nothing } from "lit";
import { property, query } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { OpenClawLitElement } from "../lit/openclaw-element.ts";
const FOCUSABLE_SELECTOR = [
"a[href]",
@@ -13,7 +14,7 @@ const FOCUSABLE_SELECTOR = [
"[tabindex]:not([tabindex='-1'])",
].join(",");
export class OpenClawModalDialog extends LitElement {
export class OpenClawModalDialog extends OpenClawLitElement {
@property() label = "";
@property() description = "";
@@ -85,6 +86,10 @@ export class OpenClawModalDialog extends LitElement {
override connectedCallback() {
super.connectedCallback();
this.previouslyFocused = this.ownerDocument.activeElement;
// firstUpdated only runs once; retained dialogs must reopen on later connection epochs.
if (this.hasUpdated) {
this.openDialog();
}
}
override firstUpdated() {
@@ -152,6 +157,7 @@ export class OpenClawModalDialog extends LitElement {
}
private closeDialog() {
this.opened = false;
const dialog = this.dialogElement;
if (!dialog?.open) {
return;
+3 -2
View File
@@ -1,12 +1,13 @@
// Control UI component implements the resizable divider element.
import { LitElement, css, nothing } from "lit";
import { css, nothing } from "lit";
import { property } from "lit/decorators.js";
import { OpenClawLitElement } from "../lit/openclaw-element.ts";
/**
* An accessible draggable divider for resizable split views.
* Dispatches 'resize' events with { splitRatio: number } detail.
*/
export class ResizableDivider extends LitElement {
export class ResizableDivider extends OpenClawLitElement {
@property({ type: Number }) splitRatio = 0.6;
@property({ type: Number }) minRatio = 0.4;
@property({ type: Number }) maxRatio = 0.7;
+3 -6
View File
@@ -1,6 +1,7 @@
import { LitElement, html, nothing, type PropertyValues } from "lit";
import { html, nothing, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { icons } from "./icons.ts";
export type SessionMenuData = {
@@ -33,11 +34,7 @@ const EMPTY_SESSION: SessionMenuData = {
category: null,
};
class SessionMenu extends LitElement {
override createRenderRoot() {
return this;
}
class SessionMenu extends OpenClawLightDomElement {
@property({ attribute: false }) session: SessionMenuData = EMPTY_SESSION;
@property({ attribute: false }) x = 0;
@property({ attribute: false }) y = 0;
+6 -7
View File
@@ -19,10 +19,9 @@ type SettingsSidebarProps = {
onExit: () => void;
onNavigate: (routeId: RouteId) => void;
onPreload?: (routeId: RouteId) => Promise<void> | void;
preloadTimers: Map<EventTarget, ReturnType<typeof globalThis.setTimeout>>;
};
const preloadTimers = new Map<EventTarget, ReturnType<typeof globalThis.setTimeout>>();
function renderItem(props: SettingsSidebarProps, routeId: RouteId) {
const active = props.activeRouteId === routeId;
return html`
@@ -31,13 +30,13 @@ function renderItem(props: SettingsSidebarProps, routeId: RouteId) {
class="settings-sidebar__item ${active ? "settings-sidebar__item--active" : ""}"
aria-current=${active ? "page" : nothing}
@focus=${(event: Event) =>
scheduleRoutePreload(preloadTimers, routeId, event, props.onPreload, active)}
@blur=${(event: Event) => cancelRoutePreload(preloadTimers, event)}
scheduleRoutePreload(props.preloadTimers, routeId, event, props.onPreload, active)}
@blur=${(event: Event) => cancelRoutePreload(props.preloadTimers, event)}
@pointerenter=${(event: Event) =>
scheduleRoutePreload(preloadTimers, routeId, event, props.onPreload, active)}
@pointerleave=${(event: Event) => cancelRoutePreload(preloadTimers, event)}
scheduleRoutePreload(props.preloadTimers, routeId, event, props.onPreload, active)}
@pointerleave=${(event: Event) => cancelRoutePreload(props.preloadTimers, event)}
@touchstart=${(event: TouchEvent) =>
scheduleRoutePreload(preloadTimers, routeId, event, props.onPreload, active, true)}
scheduleRoutePreload(props.preloadTimers, routeId, event, props.onPreload, active, true)}
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
@@ -1,6 +1,7 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "../../i18n/index.ts";
import type { TerminalGatewayClient } from "./terminal-connection.ts";
type CreateOptions = {
@@ -12,6 +13,39 @@ type CreateOptions = {
const createGhosttyTerminalMock = vi.hoisted(() => vi.fn());
function createTerminalController(dispose: () => void = vi.fn()) {
return {
terminal: {
cols: 100,
rows: 30,
viewportY: 0,
write: vi.fn(),
focus: vi.fn(),
},
write: vi.fn(),
fit: vi.fn(),
dispose,
};
}
function terminalOpenResult(sessionId: string) {
return {
sessionId,
agentId: "ops",
shell: "/bin/zsh",
cwd: "/work/ops",
confined: false,
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
vi.mock("./terminal-runtime.ts", () => {
return { createIsolatedGhosttyTerminal: createGhosttyTerminalMock };
});
@@ -19,11 +53,16 @@ vi.mock("./terminal-runtime.ts", () => {
import { OpenClawTerminalPanel } from "./terminal-panel.ts";
describe("OpenClawTerminalPanel", () => {
afterEach(() => {
beforeEach(async () => {
await i18n.setLocale("en");
});
afterEach(async () => {
document.body.replaceChildren();
localStorage.clear();
sessionStorage.clear();
createGhosttyTerminalMock.mockReset();
await i18n.setLocale("en");
});
it("opens new sessions for the selected agent", async () => {
@@ -230,4 +269,174 @@ describe("OpenClawTerminalPanel", () => {
expect(createGhosttyTerminalMock).toHaveBeenCalledTimes(2);
expect(controllers[1].write).not.toHaveBeenCalled();
});
it("rebinds to a replacement client while availability stays true", async () => {
const controllers = [createTerminalController(), createTerminalController()];
createGhosttyTerminalMock
.mockResolvedValueOnce(controllers[0])
.mockResolvedValueOnce(controllers[1]);
const oldRequests: string[] = [];
const oldUnsubscribe = vi.fn();
const oldClient: TerminalGatewayClient = {
request: async <T>(method: string) => {
oldRequests.push(method);
return (method === "terminal.open" ? terminalOpenResult("old-session") : {}) as T;
},
addEventListener: () => oldUnsubscribe,
};
const newRequests: string[] = [];
const newClient: TerminalGatewayClient = {
request: async <T>(method: string) => {
newRequests.push(method);
if (method === "terminal.list") {
return { sessions: [] } as T;
}
return (method === "terminal.open" ? terminalOpenResult("new-session") : {}) as T;
},
addEventListener: () => () => {},
};
const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel;
panel.client = oldClient;
panel.available = true;
document.body.append(panel);
panel.toggle();
await vi.waitFor(() => {
expect(sessionStorage.getItem("openclaw.terminal.sessions.v1")).toContain("old-session");
});
panel.client = newClient;
await panel.updateComplete;
await vi.waitFor(() => {
expect(newRequests).toContain("terminal.open");
});
expect(oldRequests.filter((method) => method === "terminal.open")).toHaveLength(1);
expect(oldUnsubscribe).toHaveBeenCalledOnce();
expect(controllers[0].dispose).toHaveBeenCalledOnce();
expect(createGhosttyTerminalMock).toHaveBeenCalledTimes(2);
});
it("discards an async boot that finishes after disconnect and reconnect", async () => {
const staleController = createTerminalController();
const currentController = createTerminalController();
const staleBoot = deferred<typeof staleController>();
createGhosttyTerminalMock
.mockImplementationOnce(async () => staleBoot.promise)
.mockResolvedValueOnce(currentController);
const requests: string[] = [];
const client: TerminalGatewayClient = {
request: async <T>(method: string) => {
requests.push(method);
return (method === "terminal.open" ? terminalOpenResult("current-session") : {}) as T;
},
addEventListener: () => () => {},
};
const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel;
panel.client = client;
panel.available = true;
document.body.append(panel);
panel.toggle();
await vi.waitFor(() => {
expect(createGhosttyTerminalMock).toHaveBeenCalledOnce();
});
const staleOptions = createGhosttyTerminalMock.mock.calls[0]![0] as CreateOptions;
const staleHost = staleOptions.parent;
panel.remove();
document.body.append(panel);
await vi.waitFor(() => {
expect(createGhosttyTerminalMock).toHaveBeenCalledTimes(2);
expect(requests.filter((method) => method === "terminal.open")).toHaveLength(1);
});
staleBoot.resolve(staleController);
await vi.waitFor(() => {
expect(staleController.dispose).toHaveBeenCalledOnce();
});
expect(staleHost.isConnected).toBe(false);
expect(requests.filter((method) => method === "terminal.open")).toHaveLength(1);
expect(currentController.dispose).not.toHaveBeenCalled();
});
it("removes resize listeners when disconnected mid-drag", async () => {
createGhosttyTerminalMock.mockResolvedValue(createTerminalController());
const client: TerminalGatewayClient = {
request: async <T>(method: string) =>
(method === "terminal.open" ? terminalOpenResult("session-1") : {}) as T,
addEventListener: () => () => {},
};
const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel;
panel.client = client;
panel.available = true;
document.body.append(panel);
panel.toggle();
await panel.updateComplete;
panel.renderRoot
.querySelector(".tp-resizer")
?.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 20, clientY: 200 }));
panel.remove();
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 20 }));
expect(document.documentElement.style.getPropertyValue("--oc-terminal-reserve-bottom")).toBe(
"0px",
);
expect(document.documentElement.style.getPropertyValue("--oc-terminal-reserve-right")).toBe(
"0px",
);
});
it("removes a tab host even when controller disposal throws", () => {
const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel;
const host = document.createElement("div");
document.body.append(host);
const dispose = vi.fn(() => {
throw new Error("dispose failed");
});
const disposeTab = (
panel as unknown as {
disposeTab(tab: { controller: { dispose(): void }; host: HTMLDivElement }): void;
}
).disposeTab.bind(panel);
expect(() => disposeTab({ controller: { dispose }, host })).not.toThrow();
expect(dispose).toHaveBeenCalledOnce();
expect(host.isConnected).toBe(false);
});
it("retranslates cached exit state when the locale changes", async () => {
createGhosttyTerminalMock.mockResolvedValue(createTerminalController());
let listener: ((event: { event: string; payload: unknown }) => void) | undefined;
const client: TerminalGatewayClient = {
request: async <T>(method: string) =>
(method === "terminal.open" ? terminalOpenResult("session-1") : {}) as T,
addEventListener: (nextListener) => {
listener = nextListener;
return () => {
listener = undefined;
};
},
};
const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel;
panel.client = client;
panel.available = true;
document.body.append(panel);
panel.toggle();
await vi.waitFor(() => {
expect(sessionStorage.getItem("openclaw.terminal.sessions.v1")).toContain("session-1");
});
listener?.({
event: "terminal.exit",
payload: { sessionId: "session-1", exitCode: null, reason: "detached" },
});
await panel.updateComplete;
expect(panel.renderRoot.querySelector(".tp-tab__status")?.textContent).toBe("detached");
await i18n.setLocale("de");
await panel.updateComplete;
expect(panel.renderRoot.querySelector(".tp-tab__status")?.textContent).toBe("getrennt");
});
});
+249 -81
View File
@@ -5,9 +5,10 @@ import type { GhosttyTerminalController } from "@openclaw/libterminal/browser";
// tabs. Each tab hosts one libterminal Ghostty controller wired to a gateway PTY
// session. The browser runtime is dynamically imported on first open so it
// never weighs down the initial Control UI bundle.
import { LitElement, css, html, nothing, svg } from "lit";
import { css, html, nothing, svg } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../../i18n/index.ts";
import { OpenClawLitElement } from "../../lit/openclaw-element.ts";
import { TerminalConnection, type TerminalGatewayClient } from "./terminal-connection.ts";
import { createIsolatedGhosttyTerminal } from "./terminal-runtime.ts";
import { terminalTheme } from "./terminal-theme.ts";
@@ -34,22 +35,25 @@ type PanelLayout = {
type TerminalTabState = {
id: string;
sequence: number;
gatewaySessionId: string;
/** Shell basename shown on the tab, e.g. "zsh". */
shellName: string;
/** Agent + cwd shown on hover. */
hint: string;
shellName: string | null;
agentId: string | null;
cwd: string | null;
controller: GhosttyTerminalController;
host: HTMLDivElement;
status: "live" | "exited";
statusLabel?: string;
/**
* Set when the tab is closed while its terminal.open RPC is still in flight
* (gatewaySessionId is empty in that window, so closeTab cannot close the
* server session). The open continuation checks this and closes the freshly
* created session instead of wiring it to the disposed terminal.
*/
cancelled?: boolean;
exitReason?: string;
exitCode?: number | null;
/** Why an in-flight open/attach must not adopt this disposed terminal. */
cancelled?: "close" | "lifecycle";
};
type TerminalOperation = {
generation: number;
client: TerminalGatewayClient;
signal: AbortSignal;
};
/** Reduces a shell path to a tab label, e.g. "/bin/zsh" -> "zsh". */
@@ -58,6 +62,29 @@ function shellBasename(shell: string): string {
return base && base.length > 0 ? base : "shell";
}
function terminalTabLabel(tab: TerminalTabState): string {
return tab.shellName ?? t("terminal.tabLabel", { n: String(tab.sequence) });
}
function terminalTabHint(tab: TerminalTabState): string | null {
if (tab.agentId === null || tab.cwd === null) {
return null;
}
return t("terminal.tabHint", { agent: tab.agentId, cwd: tab.cwd });
}
function terminalTabStatusLabel(tab: TerminalTabState): string | null {
if (tab.status !== "exited") {
return null;
}
if (tab.exitReason === "detached") {
return t("terminal.detached");
}
return tab.exitReason === "process_exit" && typeof tab.exitCode === "number"
? t("terminal.exitedCode", { code: String(tab.exitCode) })
: t("terminal.exited");
}
const LAYOUT_KEY = "openclaw.terminal.panel.v1";
// Session ids for reattach after a reload/reconnect. Deliberately
// sessionStorage, not localStorage: attach is take-over, and a shared
@@ -125,7 +152,7 @@ function loadPersistedSessionIds(): string[] {
}
/** `<openclaw-terminal-panel>` — the dockable Control UI shell surface. */
export class OpenClawTerminalPanel extends LitElement {
export class OpenClawTerminalPanel extends OpenClawLitElement {
/** Gateway client used for terminal.* RPCs; null until connected. */
@property({ attribute: false }) client: TerminalGatewayClient | null = null;
/** Agent whose workspace and sandbox policy own newly opened sessions. */
@@ -150,6 +177,12 @@ export class OpenClawTerminalPanel extends LitElement {
@state() private errorText: string | null = null;
private connection: TerminalConnection | null = null;
private activeClient: TerminalGatewayClient | null = null;
private activeAvailable = false;
private lifecycleGeneration = 0;
private lifecycleAbortController = new AbortController();
private lifecycleSyncToken = 0;
private resizeCleanup: (() => void) | null = null;
private tabSeq = 0;
private readonly onGlobalKeyDown = (event: KeyboardEvent) => this.handleGlobalKey(event);
private readonly onToggleRequest = (event: Event) => this.handleToggleRequest(event);
@@ -169,6 +202,8 @@ export class OpenClawTerminalPanel extends LitElement {
override connectedCallback(): void {
super.connectedCallback();
this.activeClient = this.client;
this.activeAvailable = this.available;
if (!this.fullscreen) {
const layout = loadLayout();
this.dock = layout.dock;
@@ -198,27 +233,13 @@ export class OpenClawTerminalPanel extends LitElement {
document.documentElement.style.setProperty("--oc-terminal-reserve-bottom", "0px");
document.documentElement.style.setProperty("--oc-terminal-reserve-right", "0px");
this.disposeAllTabs();
this.activeClient = null;
this.activeAvailable = false;
}
override updated(changed: Map<string, unknown>): void {
if (changed.has("available")) {
if (!this.available) {
// The surface disappeared (gateway disconnect/disable). Tear down local
// tabs and the connection (disposeAllTabs drops the gateway
// subscription too). Server sessions survive a disconnect for the
// detach grace period, and their ids stay persisted, so the restore on
// reconnect reattaches them instead of opening fresh shells. Hide the
// panel WITHOUT persisting: a disconnect must not overwrite the user's
// open preference, or the reconnect path would never auto-reopen.
this.open = false;
this.disposeAllTabs();
} else if (!this.open && (this.fullscreen || loadLayout().open)) {
// Hello arrived after mount (or a reconnect); restore the persisted
// open state (fullscreen documents are always open while available)
// and reattach persisted sessions where possible.
this.open = true;
void this.restoreSessions();
}
if (changed.has("client") || changed.has("available")) {
this.scheduleLifecycleSync();
}
if (changed.has("themeMode")) {
const theme = terminalTheme(this.themeMode);
@@ -251,6 +272,61 @@ export class OpenClawTerminalPanel extends LitElement {
this.syncLayoutReservation();
}
private scheduleLifecycleSync(): void {
const token = ++this.lifecycleSyncToken;
const generation = this.lifecycleGeneration;
// State teardown inside Lit's updated hook schedules a nested update.
// Defer it; token + generation reject superseded connection epochs.
queueMicrotask(() => {
if (
token !== this.lifecycleSyncToken ||
generation !== this.lifecycleGeneration ||
!this.isConnected
) {
return;
}
this.synchronizeLifecycle();
});
}
private synchronizeLifecycle(): void {
const clientChanged = this.client !== this.activeClient;
const availabilityChanged = this.available !== this.activeAvailable;
if (!clientChanged && !availabilityChanged) {
return;
}
if (clientChanged) {
this.activeClient = this.client;
}
this.activeAvailable = this.available;
const becameUnavailable = availabilityChanged && !this.available;
if (clientChanged || becameUnavailable) {
this.disposeAllTabs();
}
let shouldRestore = clientChanged && this.available && this.open;
if (availabilityChanged) {
if (!this.available) {
// The surface disappeared (gateway disconnect/disable). Tear down local
// tabs and the connection (disposeAllTabs drops the gateway
// subscription too). Server sessions survive a disconnect for the
// detach grace period, and their ids stay persisted, so the restore on
// reconnect reattaches them instead of opening fresh shells. Hide the
// panel WITHOUT persisting: a disconnect must not overwrite the user's
// open preference, or the reconnect path would never auto-reopen.
this.open = false;
} else if (!this.open && (this.fullscreen || loadLayout().open)) {
// Hello arrived after mount (or a reconnect); restore the persisted
// open state (fullscreen documents are always open while available)
// and reattach persisted sessions where possible.
this.open = true;
shouldRestore = true;
}
}
if (shouldRestore) {
void this.restoreSessions();
}
}
/**
* Publishes the dock's footprint as CSS variables on the document root so the
* Control UI shell reserves space for it (via `.content` margins) instead of
@@ -326,27 +402,39 @@ export class OpenClawTerminalPanel extends LitElement {
* the gateway still has them, otherwise fall back to one fresh session.
*/
private async restoreSessions(): Promise<void> {
if (!this.client || !this.available || this.booting || this.tabs.length > 0) {
await this.ensureInitialSession();
const operation = this.captureTerminalOperation();
if (!operation || this.booting || this.tabs.length > 0) {
return;
}
const persisted = loadPersistedSessionIds();
if (persisted.length > 0) {
this.booting = true;
try {
if (!this.connection) {
this.connection = new TerminalConnection(this.client);
const connection = this.connectionFor(operation);
const listed = await connection.list();
if (!this.isTerminalOperationCurrent(operation)) {
return;
}
const listed = await this.connection.list();
const known = new Set(listed.map((session) => session.sessionId));
for (const sessionId of persisted.filter((id) => known.has(id))) {
await this.attachSession(sessionId);
await this.attachSession(sessionId, operation);
if (!this.isTerminalOperationCurrent(operation)) {
return;
}
}
} catch {
if (!this.isTerminalOperationCurrent(operation)) {
return;
}
// terminal.list failed (older gateway, surface flapping): fall through
// to a fresh session below.
} finally {
this.booting = false;
if (this.isTerminalOperationCurrent(operation)) {
this.booting = false;
}
}
if (!this.isTerminalOperationCurrent(operation)) {
return;
}
// Prune ids the gateway no longer knows (reaped or externally closed).
this.persistLiveSessions();
@@ -361,27 +449,24 @@ export class OpenClawTerminalPanel extends LitElement {
}
/** Boots a tab with a libterminal controller, ready for an open or attach RPC. */
private async bootTab(): Promise<{
private async bootTab(operation: TerminalOperation): Promise<{
tab: TerminalTabState;
connection: TerminalConnection;
cols: number;
rows: number;
}> {
if (!this.client) {
throw new Error("terminal client unavailable");
}
if (!this.connection) {
this.connection = new TerminalConnection(this.client);
}
const connection = this.connectionFor(operation);
// Captured so the cancelled-open cleanup can close the session even if a
// teardown swaps this.connection while the open/attach RPC is in flight.
const connection = this.connection;
const host = document.createElement("div");
host.className = "tp-host";
const id = `tab-${++this.tabSeq}`;
// Wait for the panel (and its .tp-viewport) to render before attaching the
// ghostty host, so the terminal opens into a laid-out, measurable node.
await this.updateComplete;
if (!this.isTerminalOperationCurrent(operation)) {
throw new Error("terminal operation cancelled");
}
const viewport = this.renderRoot.querySelector(".tp-viewport");
if (!viewport) {
throw new Error("terminal viewport unavailable");
@@ -400,6 +485,7 @@ export class OpenClawTerminalPanel extends LitElement {
theme: terminalTheme(this.themeMode),
scrollback: 5000,
},
signal: operation.signal,
// The browser controller owns these subscriptions and their teardown.
// Ignore startup callbacks until the Gateway session is adopted.
onData: (bytes) => {
@@ -419,11 +505,21 @@ export class OpenClawTerminalPanel extends LitElement {
host.remove();
throw error;
}
if (!this.isTerminalOperationCurrent(operation)) {
try {
controller.dispose();
} finally {
host.remove();
}
throw new Error("terminal operation cancelled");
}
const tab: TerminalTabState = {
id,
sequence: this.tabSeq,
gatewaySessionId: "",
shellName: t("terminal.tabLabel", { n: String(this.tabSeq) }),
hint: "",
shellName: null,
agentId: null,
cwd: null,
controller,
host,
status: "live",
@@ -456,7 +552,8 @@ export class OpenClawTerminalPanel extends LitElement {
): void {
tab.gatewaySessionId = result.sessionId;
tab.shellName = shellBasename(result.shell);
tab.hint = t("terminal.tabHint", { agent: result.agentId, cwd: result.cwd });
tab.agentId = result.agentId;
tab.cwd = result.cwd;
// Libterminal observes layout before the Gateway session exists. Resync the
// current grid now so a resize during the open/attach RPC is not lost.
const { cols, rows } = tab.controller.terminal;
@@ -476,7 +573,8 @@ export class OpenClawTerminalPanel extends LitElement {
}
private async openSession(): Promise<void> {
if (!this.client || !this.available || this.booting) {
const operation = this.captureTerminalOperation();
if (!operation || this.booting) {
return;
}
this.booting = true;
@@ -486,43 +584,60 @@ export class OpenClawTerminalPanel extends LitElement {
// Tracked outside the try so the catch can dispose a tab whose open failed.
let createdTab: TerminalTabState | undefined;
try {
const boot = await this.bootTab();
const boot = await this.bootTab(operation);
createdTab = boot.tab;
const result = await boot.connection.open(
{ agentId, cols: boot.cols, rows: boot.rows },
this.tabSink(boot.tab),
);
if (boot.tab.cancelled) {
if (!this.isTerminalOperationCurrent(operation) || boot.tab.cancelled) {
// The tab's close button was clicked while the open RPC was in flight.
// The server session is live and its sink registered; close it now or
// it survives invisibly (eating the session cap) until disconnect.
void boot.connection.close(result.sessionId);
if (this.tabs.includes(boot.tab)) {
boot.tab.cancelled = "lifecycle";
this.dropFailedTab(boot.tab);
}
return;
}
this.adoptSession(boot.tab, result);
boot.tab.controller.terminal.focus();
} catch (err) {
this.errorText = err instanceof Error ? err.message : String(err);
// A failed open (e.g. terminal disabled or a sandboxed agent is refused)
// must not leave a phantom "live" tab with no server session. Drop it but
// keep the panel open so the error stays visible.
if (createdTab && !createdTab.gatewaySessionId) {
if (createdTab && !createdTab.gatewaySessionId && this.tabs.includes(createdTab)) {
this.dropFailedTab(createdTab);
}
if (!this.isTerminalOperationCurrent(operation)) {
return;
}
this.errorText = err instanceof Error ? err.message : String(err);
} finally {
this.booting = false;
if (this.isTerminalOperationCurrent(operation)) {
this.booting = false;
}
}
}
/** Reattaches one persisted session; returns false when it is gone. */
private async attachSession(sessionId: string): Promise<boolean> {
private async attachSession(sessionId: string, operation: TerminalOperation): Promise<boolean> {
let createdTab: TerminalTabState | undefined;
try {
const boot = await this.bootTab();
const boot = await this.bootTab(operation);
createdTab = boot.tab;
const result = await boot.connection.attach(sessionId, this.tabSink(boot.tab));
if (boot.tab.cancelled) {
void boot.connection.close(result.sessionId);
if (!this.isTerminalOperationCurrent(operation) || boot.tab.cancelled) {
// A user close is deliberate; lifecycle cancellation leaves the existing
// server session available for the next reconnect to reattach.
if (boot.tab.cancelled === "close") {
void boot.connection.close(result.sessionId);
}
if (this.tabs.includes(boot.tab)) {
boot.tab.cancelled = "lifecycle";
this.dropFailedTab(boot.tab);
}
return false;
}
this.adoptSession(boot.tab, result);
@@ -531,7 +646,7 @@ export class OpenClawTerminalPanel extends LitElement {
// Session expired between list and attach (reaper race) or an older
// gateway: quietly drop the placeholder tab; restore falls back to a
// fresh session when nothing could be reattached.
if (createdTab && !createdTab.gatewaySessionId) {
if (createdTab && !createdTab.gatewaySessionId && this.tabs.includes(createdTab)) {
this.dropFailedTab(createdTab);
}
return false;
@@ -544,15 +659,8 @@ export class OpenClawTerminalPanel extends LitElement {
return;
}
tab.status = "exited";
if (info.reason === "detached") {
// Another connection attached this session away; it is alive elsewhere.
tab.statusLabel = t("terminal.detached");
} else {
tab.statusLabel =
info.reason === "process_exit" && info.exitCode !== null
? t("terminal.exitedCode", { code: String(info.exitCode) })
: t("terminal.exited");
}
tab.exitReason = info.reason;
tab.exitCode = info.exitCode;
// The connection drops its own sink on exit delivery, so no release() here —
// the session id may not be recorded yet when an early exit is replayed.
this.tabs = [...this.tabs];
@@ -569,7 +677,7 @@ export class OpenClawTerminalPanel extends LitElement {
} else if (!tab.gatewaySessionId && tab.status === "live") {
// Open still in flight: no session id to close yet. Flag it so the open
// continuation closes the server session as soon as the RPC resolves.
tab.cancelled = true;
tab.cancelled = "close";
}
this.disposeTab(tab);
this.tabs = this.tabs.filter((entry) => entry.id !== tabId);
@@ -595,16 +703,55 @@ export class OpenClawTerminalPanel extends LitElement {
});
}
private captureTerminalOperation(): TerminalOperation | null {
const client = this.client;
if (!client || client !== this.activeClient || !this.available || !this.isConnected) {
return null;
}
return {
generation: this.lifecycleGeneration,
client,
signal: this.lifecycleAbortController.signal,
};
}
private isTerminalOperationCurrent(operation: TerminalOperation): boolean {
return (
this.isConnected &&
this.available &&
this.client === operation.client &&
this.activeClient === operation.client &&
this.lifecycleGeneration === operation.generation &&
!operation.signal.aborted
);
}
private connectionFor(operation: TerminalOperation): TerminalConnection {
if (!this.isTerminalOperationCurrent(operation)) {
throw new Error("terminal operation cancelled");
}
this.connection ??= new TerminalConnection(operation.client);
return this.connection;
}
private disposeTab(tab: TerminalTabState): void {
try {
tab.controller.dispose();
tab.host.remove();
} catch {
// Best-effort teardown; a partially-initialized tab may throw.
} finally {
// DOM ownership is independent of controller cleanup; never strand a
// Ghostty canvas when dependency disposal fails partway through.
tab.host.remove();
}
}
private disposeAllTabs(): void {
this.lifecycleGeneration += 1;
this.lifecycleAbortController.abort();
this.lifecycleAbortController = new AbortController();
this.booting = false;
this.clearResizeListeners();
for (const tab of this.tabs) {
// No terminal.close here: this teardown runs for disconnects,
// availability loss, and element removal — exactly the sessions the
@@ -614,7 +761,7 @@ export class OpenClawTerminalPanel extends LitElement {
// The cancelled flag covers a tab whose open RPC is still in flight; its
// continuation closes the fresh session instead of adopting the
// disposed terminal.
tab.cancelled = true;
tab.cancelled = "lifecycle";
this.disposeTab(tab);
}
this.tabs = [];
@@ -668,6 +815,7 @@ export class OpenClawTerminalPanel extends LitElement {
private startResize(event: PointerEvent): void {
event.preventDefault();
this.clearResizeListeners();
const startX = event.clientX;
const startY = event.clientY;
const startHeight = this.height;
@@ -685,13 +833,32 @@ export class OpenClawTerminalPanel extends LitElement {
const active = this.tabs.find((tab) => tab.id === this.activeId);
active?.controller.fit();
};
const onUp = () => {
const cleanup = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
window.removeEventListener("blur", onUp);
if (this.resizeCleanup === cleanup) {
this.resizeCleanup = null;
}
};
const onUp = () => {
cleanup();
if (!this.isConnected) {
return;
}
this.persistLayout();
};
this.resizeCleanup = cleanup;
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
window.addEventListener("blur", onUp);
}
private clearResizeListeners(): void {
this.resizeCleanup?.();
this.resizeCleanup = null;
}
override render() {
@@ -716,22 +883,23 @@ export class OpenClawTerminalPanel extends LitElement {
></div>`}
<header class="tp-header">
<div class="tp-tabs" role="tablist">
${this.tabs.map(
(tab) => html`
${this.tabs.map((tab) => {
const statusLabel = terminalTabStatusLabel(tab);
return html`
<div
class="tp-tab ${tab.id === this.activeId ? "is-active" : ""} ${tab.status ===
"exited"
? "is-exited"
: ""}"
role="tab"
title=${tab.hint || nothing}
title=${terminalTabHint(tab) || nothing}
aria-selected=${tab.id === this.activeId ? "true" : "false"}
@click=${() => this.switchTo(tab.id)}
>
<span class="tp-tab__icon" aria-hidden="true">${TERMINAL_GLYPH}</span>
<span class="tp-tab__label">${tab.shellName}</span>
${tab.statusLabel
? html`<span class="tp-tab__status">${tab.statusLabel}</span>`
<span class="tp-tab__label">${terminalTabLabel(tab)}</span>
${statusLabel
? html`<span class="tp-tab__status">${statusLabel}</span>`
: nothing}
<button
class="tp-tab__close"
@@ -746,8 +914,8 @@ export class OpenClawTerminalPanel extends LitElement {
${CLOSE_GLYPH}
</button>
</div>
`,
)}
`;
})}
<button
class="tp-new"
type="button"
+3 -6
View File
@@ -1,7 +1,8 @@
import { LitElement, html } from "lit";
import { html } from "lit";
import { property } from "lit/decorators.js";
import type { ThemeMode } from "../app/theme.ts";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { icons } from "./icons.ts";
import "./tooltip.ts";
@@ -10,11 +11,7 @@ export type ThemeModeChangeDetail = {
element: HTMLElement;
};
class ThemeModeToggle extends LitElement {
override createRenderRoot() {
return this;
}
class ThemeModeToggle extends OpenClawLightDomElement {
@property({ attribute: false }) mode: ThemeMode = "system";
override connectedCallback() {
+133
View File
@@ -0,0 +1,133 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "./tooltip.ts";
type TooltipElement = HTMLElement & {
content: string;
readonly updateComplete: Promise<boolean>;
};
type TooltipProviderElement = HTMLElement & {
delay: number;
skipDelay: number;
};
function createTooltip(content: string) {
const tooltip = document.createElement("openclaw-tooltip") as TooltipElement;
tooltip.content = content;
const trigger = document.createElement("button");
trigger.textContent = content;
tooltip.append(trigger);
return { tooltip, trigger };
}
function createProvider() {
return document.createElement("openclaw-tooltip-provider") as TooltipProviderElement;
}
function focusTrigger(trigger: HTMLElement) {
trigger.dispatchEvent(new FocusEvent("focusin", { bubbles: true, composed: true }));
}
function hoverTrigger(trigger: HTMLElement) {
const event = new MouseEvent("pointermove", { bubbles: true, buttons: 0 });
Object.defineProperty(event, "pointerType", { value: "mouse" });
trigger.dispatchEvent(event);
}
function expectPortalCount(count: number) {
expect(document.body.querySelectorAll(".openclaw-tooltip")).toHaveLength(count);
}
describe("openclaw-tooltip", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
document.body.replaceChildren();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("reattaches trigger listeners after reconnect", async () => {
const provider = createProvider();
const { tooltip, trigger } = createTooltip("Reconnect tooltip");
provider.append(tooltip);
document.body.append(provider);
await tooltip.updateComplete;
focusTrigger(trigger);
expectPortalCount(1);
provider.remove();
expectPortalCount(0);
document.body.append(provider);
await tooltip.updateComplete;
focusTrigger(trigger);
expectPortalCount(1);
});
it("keeps show reentry idempotent", async () => {
const provider = createProvider();
const { tooltip, trigger } = createTooltip("Single portal");
provider.append(tooltip);
document.body.append(provider);
await tooltip.updateComplete;
focusTrigger(trigger);
focusTrigger(trigger);
expectPortalCount(1);
expect(document.body.querySelector(".openclaw-tooltip")?.textContent).toBe("Single portal");
});
it("restores the normal hover delay after the provider reconnects", async () => {
const provider = createProvider();
provider.delay = 40;
const { tooltip, trigger } = createTooltip("Delayed after reconnect");
provider.append(tooltip);
document.body.append(provider);
await tooltip.updateComplete;
focusTrigger(trigger);
expectPortalCount(1);
provider.remove();
expectPortalCount(0);
document.body.append(provider);
await tooltip.updateComplete;
hoverTrigger(trigger);
vi.advanceTimersByTime(39);
expectPortalCount(0);
vi.advanceTimersByTime(1);
expectPortalCount(1);
});
it("releases the active provider reference when an open tooltip is removed", async () => {
const provider = createProvider();
provider.delay = 40;
provider.skipDelay = 20;
const first = createTooltip("First tooltip");
provider.append(first.tooltip);
document.body.append(provider);
await first.tooltip.updateComplete;
focusTrigger(first.trigger);
expectPortalCount(1);
first.tooltip.remove();
expectPortalCount(0);
vi.advanceTimersByTime(20);
const second = createTooltip("Second tooltip");
provider.append(second.tooltip);
await second.tooltip.updateComplete;
hoverTrigger(second.trigger);
vi.advanceTimersByTime(39);
expectPortalCount(0);
vi.advanceTimersByTime(1);
expectPortalCount(1);
});
});
+42 -18
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { html } from "lit";
import { property } from "lit/decorators.js";
import { OpenClawLitElement } from "../lit/openclaw-element.ts";
const HOVER_DELAY = 150;
const TOUCH_DELAY = 450;
@@ -16,7 +17,7 @@ function createTooltipId() {
return `openclaw-tooltip-${nextTooltipId}`;
}
class TooltipProvider extends LitElement {
class TooltipProvider extends OpenClawLitElement {
@property({ type: Number }) delay = HOVER_DELAY;
@property({ type: Number }) skipDelay = SKIP_DELAY;
@property({ type: Number }) touchDelay = TOUCH_DELAY;
@@ -34,12 +35,13 @@ class TooltipProvider extends LitElement {
override disconnectedCallback() {
this.removeEventListener("pointerdown", this.handlePointerDown, true);
this.activeTooltip?.closeFromProvider();
// Sever ownership first so child teardown cannot start a skip-delay window
// on a provider that is no longer available.
const activeTooltip = this.activeTooltip;
this.activeTooltip = null;
if (this.skipDelayTimer !== null) {
window.clearTimeout(this.skipDelayTimer);
this.skipDelayTimer = null;
}
activeTooltip?.closeFromProvider();
this.clearSkipDelayTimer();
this.delayed = true;
this.suppressFocus = false;
super.disconnectedCallback();
}
@@ -67,9 +69,7 @@ class TooltipProvider extends LitElement {
}
this.activeTooltip = tooltip;
this.delayed = false;
if (this.skipDelayTimer !== null) {
window.clearTimeout(this.skipDelayTimer);
}
this.clearSkipDelayTimer();
}
closeTooltip(tooltip: Tooltip) {
@@ -77,13 +77,11 @@ class TooltipProvider extends LitElement {
return;
}
this.activeTooltip = null;
this.clearSkipDelayTimer();
if (this.skipDelay <= 0) {
this.delayed = true;
return;
}
if (this.skipDelayTimer !== null) {
window.clearTimeout(this.skipDelayTimer);
}
this.skipDelayTimer = window.setTimeout(() => {
this.skipDelayTimer = null;
this.delayed = true;
@@ -94,12 +92,20 @@ class TooltipProvider extends LitElement {
return this.delayed;
}
private clearSkipDelayTimer() {
if (this.skipDelayTimer === null) {
return;
}
window.clearTimeout(this.skipDelayTimer);
this.skipDelayTimer = null;
}
override render() {
return html`<slot></slot>`;
}
}
class Tooltip extends LitElement {
class Tooltip extends OpenClawLitElement {
@property() content = "";
private trigger: HTMLElement | null = null;
@@ -112,6 +118,7 @@ class Tooltip extends LitElement {
private open = false;
private pointerDown = false;
private describedBy: string | null = null;
private activeProvider: TooltipProvider | null = null;
private readonly tooltipId = createTooltipId();
override connectedCallback() {
@@ -119,18 +126,24 @@ class Tooltip extends LitElement {
this.style.display = "contents";
}
protected override firstUpdated() {
this.attachTrigger();
protected override updated() {
if (this.isConnected) {
this.attachTrigger();
}
}
override disconnectedCallback() {
this.close();
document.removeEventListener("pointerup", this.handleDocumentPointerUp);
this.pointerDown = false;
this.detachTrigger();
super.disconnectedCallback();
}
private attachTrigger() {
if (!this.isConnected) {
return;
}
const slot = this.renderRoot.querySelector("slot");
const trigger = slot
?.assignedElements({ flatten: true })
@@ -298,7 +311,16 @@ class Tooltip extends LitElement {
return;
}
this.clearTimers();
this.provider?.openTooltip(this);
if (this.open) {
if (this.portal) {
this.portal.textContent = this.content;
this.positionTooltip();
}
return;
}
const provider = this.provider;
provider?.openTooltip(this);
this.activeProvider = provider;
this.open = true;
this.describedBy ??= trigger.getAttribute("aria-describedby");
this.portal = document.createElement("div");
@@ -324,12 +346,14 @@ class Tooltip extends LitElement {
private close() {
const wasOpen = this.open;
const provider = this.activeProvider;
this.activeProvider = null;
this.clearTimers();
this.touchStart = null;
this.touchOpened = false;
this.open = false;
if (wasOpen) {
this.provider?.closeTooltip(this);
provider?.closeTooltip(this);
}
this.restoreDescription();
this.portal?.remove();
+3 -6
View File
@@ -1,8 +1,9 @@
// Control UI component renders update status and available-update actions.
import { LitElement, html, nothing } from "lit";
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import type { UpdateAvailable } from "../api/types.ts";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import { icons } from "./icons.ts";
@@ -67,11 +68,7 @@ type UpdateBannerProps = {
onDismiss: () => void;
};
class UpdateBanner extends LitElement {
override createRenderRoot() {
return this;
}
class UpdateBanner extends OpenClawLightDomElement {
@property({ attribute: false }) props?: UpdateBannerProps;
override connectedCallback() {
+22 -13
View File
@@ -85,19 +85,28 @@ async function waitForChatScrollIdle(page: Page): Promise<void> {
await expect
.poll(
() =>
page.evaluate(() => {
const app = document.querySelector("openclaw-app") as
| (Element & {
chatIsProgrammaticScroll?: boolean;
chatScrollFrame?: number | null;
chatScrollTimeout?: number | null;
})
| null;
return Boolean(
app &&
app.chatScrollFrame == null &&
app.chatScrollTimeout == null &&
!app.chatIsProgrammaticScroll,
page.locator(".chat-thread").evaluate(async (element) => {
const thread = element as HTMLElement;
const readGeometry = () => ({
clientHeight: thread.clientHeight,
scrollHeight: thread.scrollHeight,
scrollTop: Math.round(thread.scrollTop),
});
const before = readGeometry();
// The chat scroll owner may do one bounded 120/150ms late-size retry.
await new Promise<void>((resolve) => {
globalThis.setTimeout(resolve, 180);
});
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
const after = readGeometry();
return (
before.clientHeight === after.clientHeight &&
before.scrollHeight === after.scrollHeight &&
before.scrollTop === after.scrollTop
);
}),
{ timeout: 10_000 },
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment node
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { I18nController } from "./lit-controller.ts";
import { i18n } from "./translate.ts";
class TestHost implements ReactiveControllerHost {
readonly controllers: ReactiveController[] = [];
readonly requestUpdate = vi.fn();
readonly updateComplete = Promise.resolve(true);
addController(controller: ReactiveController): void {
this.controllers.push(controller);
}
removeController(controller: ReactiveController): void {
const index = this.controllers.indexOf(controller);
if (index !== -1) {
this.controllers.splice(index, 1);
}
}
connect(): void {
for (const controller of this.controllers) {
controller.hostConnected?.();
}
}
disconnect(): void {
for (const controller of this.controllers) {
controller.hostDisconnected?.();
}
}
}
describe("I18nController", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("replaces stale subscriptions and cleans up idempotently", () => {
const firstCleanup = vi.fn();
const secondCleanup = vi.fn();
const subscribe = vi
.spyOn(i18n, "subscribe")
.mockReturnValueOnce(firstCleanup)
.mockReturnValueOnce(secondCleanup);
const host = new TestHost();
const controller = new I18nController(host);
expect(host.controllers).toContain(controller);
host.connect();
host.connect();
expect(subscribe).toHaveBeenCalledTimes(2);
expect(firstCleanup).toHaveBeenCalledOnce();
host.disconnect();
host.disconnect();
expect(secondCleanup).toHaveBeenCalledOnce();
});
it("requests updates on connect and locale notifications", () => {
const cleanup = vi.fn();
let notify: (() => void) | undefined;
vi.spyOn(i18n, "subscribe").mockImplementation((subscriber) => {
notify = () => subscriber("en");
return cleanup;
});
const host = new TestHost();
const controller = new I18nController(host);
expect(host.controllers).toContain(controller);
host.connect();
expect(host.requestUpdate).toHaveBeenCalledOnce();
notify?.();
expect(host.requestUpdate).toHaveBeenCalledTimes(2);
host.disconnect();
expect(cleanup).toHaveBeenCalledOnce();
});
});
+4
View File
@@ -12,12 +12,16 @@ export class I18nController implements ReactiveController {
}
hostConnected() {
this.unsubscribe?.();
this.unsubscribe = i18n.subscribe(() => {
this.host.requestUpdate();
});
// The locale may have changed while the host was disconnected.
this.host.requestUpdate();
}
hostDisconnected() {
this.unsubscribe?.();
this.unsubscribe = undefined;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { AgentIdentityResult } from "../../api/types.ts";
import { createAgentIdentityCapability } from "./identity.ts";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
it("rejects stale identities after reconnecting the same client", async () => {
const oldRequest = deferred<AgentIdentityResult>();
const currentRequest = deferred<AgentIdentityResult>();
const request = vi
.fn()
.mockImplementationOnce(() => oldRequest.promise)
.mockImplementationOnce(() => currentRequest.promise);
const client = { request } as unknown as GatewayBrowserClient;
let snapshot = { client, connected: true };
const listeners = new Set<(next: typeof snapshot) => void>();
const capability = createAgentIdentityCapability({
get snapshot() {
return snapshot;
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
});
const publish = (connected: boolean) => {
snapshot = { client, connected };
for (const listener of listeners) {
listener(snapshot);
}
};
const stale = capability.ensure(["main"]);
publish(false);
publish(true);
const current = capability.ensure(["main"]);
oldRequest.resolve({ agentId: "main", name: "Stale" } as AgentIdentityResult);
await stale;
expect(capability.entries()).toEqual([]);
currentRequest.resolve({ agentId: "main", name: "Current" } as AgentIdentityResult);
await current;
expect(capability.get("main")?.name).toBe("Current");
});
+18 -8
View File
@@ -22,6 +22,8 @@ export function createAgentIdentityCapability(
gateway: AgentIdentityGateway,
): AgentIdentityCapability {
let cachedClient: GatewayBrowserClient | null = gateway.snapshot.client;
let cachedConnected = gateway.snapshot.connected;
let connectionGeneration = 0;
const identities = new Map<string, AgentIdentityResult>();
const inFlight = new Map<string, Promise<AgentIdentityResult | null>>();
const listeners = new Set<() => void>();
@@ -32,12 +34,14 @@ export function createAgentIdentityCapability(
}
};
const resetForClient = (client: GatewayBrowserClient | null) => {
if (client === cachedClient) {
const resetForGateway = (snapshot: AgentIdentityGatewaySnapshot) => {
if (snapshot.client === cachedClient && snapshot.connected === cachedConnected) {
return;
}
const hadIdentities = identities.size > 0;
cachedClient = client;
cachedClient = snapshot.client;
cachedConnected = snapshot.connected;
connectionGeneration += 1;
identities.clear();
inFlight.clear();
if (hadIdentities) {
@@ -45,7 +49,7 @@ export function createAgentIdentityCapability(
}
};
gateway.subscribe((snapshot) => resetForClient(snapshot.client));
gateway.subscribe(resetForGateway);
const normalizeIds = (agentIds: readonly (string | null | undefined)[]) => [
...new Set(
@@ -84,11 +88,13 @@ export function createAgentIdentityCapability(
return [...identities.values()];
},
async ensure(agentIds) {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected) {
const snapshot = gateway.snapshot;
resetForGateway(snapshot);
const client = snapshot.client;
if (!client || !snapshot.connected) {
return;
}
resetForClient(client);
const generation = connectionGeneration;
const missing = normalizeIds(agentIds).filter((agentId) => !identities.has(agentId));
if (missing.length === 0) {
return;
@@ -96,7 +102,11 @@ export function createAgentIdentityCapability(
const results = await Promise.all(
missing.map(async (agentId) => [agentId, await fetchIdentity(client, agentId)] as const),
);
if (gateway.snapshot.client !== client) {
if (
connectionGeneration !== generation ||
gateway.snapshot.client !== client ||
!gateway.snapshot.connected
) {
return;
}
let changed = false;
+181 -1
View File
@@ -1,10 +1,47 @@
// Control UI tests cover agents behavior.
import { describe, expect, it, vi } from "vitest";
import { loadAgents, loadToolsCatalog, loadToolsEffective, setDefaultAgent } from "./index.ts";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import {
createAgentCapability,
loadAgents,
loadToolsCatalog,
loadToolsEffective,
setDefaultAgent,
} from "./index.ts";
import type { AgentsConfigCapability, AgentsState } from "./index.ts";
type TestRequest = (method: string, payload?: unknown) => Promise<unknown>;
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function createGatewayHarness(client: GatewayBrowserClient) {
let snapshot = { client, connected: true };
const listeners = new Set<(next: typeof snapshot) => void>();
return {
gateway: {
get snapshot() {
return snapshot;
},
subscribe(listener: (next: typeof snapshot) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
publish(connected: boolean) {
snapshot = { client, connected };
for (const listener of listeners) {
listener(snapshot);
}
},
};
}
function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn<TestRequest>> } {
const request = vi.fn<TestRequest>();
const state: AgentsState = {
@@ -12,6 +49,7 @@ function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn<T
request,
} as unknown as AgentsState["client"],
connected: true,
requestGeneration: 0,
agentsLoading: false,
agentsError: null,
agentsList: null,
@@ -141,6 +179,82 @@ describe("loadAgents", () => {
});
});
describe("createAgentCapability lifecycle", () => {
it("starts a fresh list request after a same-client reconnect", async () => {
const first = deferred<unknown>();
const second = deferred<unknown>();
const request = vi
.fn<TestRequest>()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const client = { request } as unknown as GatewayBrowserClient;
const harness = createGatewayHarness(client);
const agents = createAgentCapability(harness.gateway);
const staleLoad = agents.refreshList();
harness.publish(false);
harness.publish(true);
const currentLoad = agents.refreshList();
first.resolve({ defaultId: "old", agents: [{ id: "old" }] });
await staleLoad;
expect(agents.state.agentsList).toBeNull();
expect(agents.state.agentsLoading).toBe(true);
const current = { defaultId: "main", agents: [{ id: "main" }] };
second.resolve(current);
await currentLoad;
expect(agents.state.agentsList).toEqual(current);
expect(agents.state.agentsLoading).toBe(false);
agents.dispose();
});
it("isolates file requests across a same-client reconnect", async () => {
const first = deferred<unknown>();
const second = deferred<unknown>();
const request = vi
.fn<TestRequest>()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const client = { request } as unknown as GatewayBrowserClient;
const harness = createGatewayHarness(client);
const agents = createAgentCapability(harness.gateway);
const staleLoad = agents.refreshFiles("main");
harness.publish(false);
harness.publish(true);
const currentLoad = agents.refreshFiles("main");
first.resolve({ agentId: "main", workspace: "old", files: [] });
await staleLoad;
expect(agents.files("main").list).toBeNull();
expect(agents.files("main").loading).toBe(true);
const current = { agentId: "main", workspace: "new", files: [] };
second.resolve(current);
await currentLoad;
expect(agents.files("main").list).toEqual(current);
expect(agents.files("main").loading).toBe(false);
agents.dispose();
});
it("does not commit a list request after disposal", async () => {
const pending = deferred<unknown>();
const request = vi.fn<TestRequest>().mockReturnValue(pending.promise);
const client = { request } as unknown as GatewayBrowserClient;
const harness = createGatewayHarness(client);
const agents = createAgentCapability(harness.gateway);
const load = agents.refreshList();
agents.dispose();
pending.resolve({ defaultId: "stale", agents: [{ id: "stale" }] });
await load;
expect(agents.state.agentsList).toBeNull();
expect(agents.state.agentsLoading).toBe(false);
});
});
describe("loadToolsCatalog", () => {
it("loads catalog and stores result", async () => {
const { state, request } = createState();
@@ -203,6 +317,39 @@ describe("loadToolsCatalog", () => {
expect(state.toolsCatalogError).toBeNull();
expect(state.toolsCatalogLoading).toBe(false);
});
it("keeps a replacement-client catalog load isolated from the old request", async () => {
const { state, request: oldRequest } = createState();
let resolveOld!: (value: unknown) => void;
let resolveNext!: (value: unknown) => void;
oldRequest.mockReturnValue(
new Promise((resolve) => {
resolveOld = resolve;
}),
);
const nextRequest = vi.fn<TestRequest>().mockReturnValue(
new Promise((resolve) => {
resolveNext = resolve;
}),
);
const oldLoad = loadToolsCatalog(state, "main");
state.client = { request: nextRequest } as unknown as AgentsState["client"];
state.requestGeneration += 1;
state.toolsCatalogLoading = false;
state.toolsCatalogLoadingAgentId = null;
const nextLoad = loadToolsCatalog(state, "main");
resolveOld({ agentId: "main", profiles: [], groups: [{ id: "old" }] });
await oldLoad;
expect(state.toolsCatalogResult).toBeNull();
expect(state.toolsCatalogLoading).toBe(true);
resolveNext({ agentId: "main", profiles: [], groups: [{ id: "new" }] });
await nextLoad;
expect(state.toolsCatalogResult?.groups).toEqual([{ id: "new" }]);
expect(state.toolsCatalogLoading).toBe(false);
});
});
describe("loadToolsEffective", () => {
@@ -279,6 +426,39 @@ describe("loadToolsEffective", () => {
expect(state.toolsEffectiveLoading).toBe(false);
});
it("keeps a replacement-client effective-tools load isolated from the old request", async () => {
const { state, request: oldRequest } = createState();
let resolveOld!: (value: unknown) => void;
let resolveNext!: (value: unknown) => void;
oldRequest.mockReturnValue(
new Promise((resolve) => {
resolveOld = resolve;
}),
);
const nextRequest = vi.fn<TestRequest>().mockReturnValue(
new Promise((resolve) => {
resolveNext = resolve;
}),
);
const oldLoad = loadToolsEffective(state, { agentId: "main", sessionKey: "main" });
state.client = { request: nextRequest } as unknown as AgentsState["client"];
state.requestGeneration += 1;
state.toolsEffectiveLoading = false;
state.toolsEffectiveLoadingKey = null;
const nextLoad = loadToolsEffective(state, { agentId: "main", sessionKey: "main" });
resolveOld({ agentId: "main", profile: "old", groups: [] });
await oldLoad;
expect(state.toolsEffectiveResult).toBeNull();
expect(state.toolsEffectiveLoading).toBe(true);
resolveNext({ agentId: "main", profile: "new", groups: [] });
await nextLoad;
expect(state.toolsEffectiveResult?.profile).toBe("new");
expect(state.toolsEffectiveLoading).toBe(false);
});
it("uses the catalog provider when the active session reports a stale provider", async () => {
const { state, request } = createState();
const sessionsResult = state.sessionsResult!;
+71 -17
View File
@@ -24,6 +24,7 @@ export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels"
export type AgentsState = {
client: GatewayBrowserClient | null;
connected: boolean;
requestGeneration: number;
agentsLoading: boolean;
agentsError: string | null;
agentsList: AgentsListResult | null;
@@ -111,19 +112,29 @@ function resolveToolsErrorMessage(
}
export async function loadAgents(state: AgentsState) {
if (!state.client || !state.connected || state.agentsLoading) {
const client = state.client;
if (!client || !state.connected || state.agentsLoading) {
return;
}
const generation = state.requestGeneration;
const isCurrent = () =>
state.client === client && state.connected && state.requestGeneration === generation;
state.agentsLoading = true;
state.agentsError = null;
try {
const res = await loadAgentsList(state.client);
const res = await loadAgentsList(client);
if (!isCurrent()) {
return;
}
state.agentsList = res;
const selected = state.agentsSelectedId;
if (!selected || !res.agents.some((entry) => entry.id === selected)) {
state.agentsSelectedId = res.defaultId ?? res.agents[0]?.id ?? null;
}
} catch (err) {
if (!isCurrent()) {
return;
}
if (isMissingOperatorReadScopeError(err)) {
state.agentsList = null;
state.agentsError = formatMissingOperatorReadScopeMessage("agent list");
@@ -131,21 +142,27 @@ export async function loadAgents(state: AgentsState) {
state.agentsError = String(err);
}
} finally {
state.agentsLoading = false;
if (isCurrent()) {
state.agentsLoading = false;
}
}
}
export async function loadToolsCatalog(state: AgentsState, agentId: string) {
const resolvedAgentId = agentId.trim();
const client = state.client;
if (
!state.client ||
!client ||
!state.connected ||
!resolvedAgentId ||
(state.toolsCatalogLoading && state.toolsCatalogLoadingAgentId === resolvedAgentId)
) {
return;
}
const generation = state.requestGeneration;
const shouldIgnoreResponse = () =>
state.client !== client ||
state.requestGeneration !== generation ||
state.toolsCatalogLoadingAgentId !== resolvedAgentId ||
hasSelectedAgentMismatch(state, resolvedAgentId);
state.toolsCatalogLoading = true;
@@ -153,7 +170,7 @@ export async function loadToolsCatalog(state: AgentsState, agentId: string) {
state.toolsCatalogError = null;
state.toolsCatalogResult = null;
try {
const res = await state.client.request<ToolsCatalogResult>("tools.catalog", {
const res = await client.request<ToolsCatalogResult>("tools.catalog", {
agentId: resolvedAgentId,
includePlugins: true,
});
@@ -167,7 +184,11 @@ export async function loadToolsCatalog(state: AgentsState, agentId: string) {
}
state.toolsCatalogError = resolveToolsErrorMessage(err, "tools catalog");
} finally {
if (state.toolsCatalogLoadingAgentId === resolvedAgentId) {
if (
state.client === client &&
state.requestGeneration === generation &&
state.toolsCatalogLoadingAgentId === resolvedAgentId
) {
state.toolsCatalogLoadingAgentId = null;
state.toolsCatalogLoading = false;
}
@@ -184,7 +205,11 @@ export async function loadToolsEffective(
state: AgentsState,
params: { agentId: string; sessionKey: string },
) {
const client = state.client;
const generation = state.requestGeneration;
await loadToolsEffectiveShared(state, params, {
isCurrent: () =>
state.client === client && state.connected && state.requestGeneration === generation,
ignoreResponse: (agentId, requestKey) =>
state.toolsEffectiveLoadingKey !== requestKey || hasSelectedAgentMismatch(state, agentId),
onError: (err) => resolveToolsErrorMessage(err, "effective tools"),
@@ -226,9 +251,14 @@ export function createAgentCapability(gateway: AgentGateway): AgentCapability {
};
const files = new Map<string, AgentFilesStatus>();
const fileRequests = new Map<string, Promise<AgentsFilesListResult | null>>();
const fileRequestOwners = new Map<string, symbol>();
const listeners = new Set<(state: AgentCapabilityState) => void>();
let disposed = false;
// Transport reconnects reuse the client object, so identity alone cannot
// stop pre-disconnect completions from repopulating capability state.
let requestGeneration = 0;
let agentsRequest: Promise<AgentsListResult | null> | null = null;
let agentsRequestOwner: symbol | null = null;
const publish = () => {
if (disposed) {
@@ -238,6 +268,8 @@ export function createAgentCapability(gateway: AgentGateway): AgentCapability {
listener(state);
}
};
const isCurrentRequest = (client: GatewayBrowserClient, generation: number) =>
!disposed && state.connected && state.client === client && requestGeneration === generation;
const fileStatus = (agentId: string): AgentFilesStatus => {
const existing = files.get(agentId);
@@ -260,16 +292,20 @@ export function createAgentCapability(gateway: AgentGateway): AgentCapability {
state.agentsLoading = true;
state.agentsError = null;
publish();
const generation = requestGeneration;
const owner = Symbol();
agentsRequestOwner = owner;
const request = loadAgentsList(client)
.then((result) => {
if (state.client === client) {
const current = isCurrentRequest(client, generation) && agentsRequestOwner === owner;
if (current) {
state.agentsList = result;
state.agentsError = null;
}
return state.client === client ? result : state.agentsList;
return current ? result : null;
})
.catch((err: unknown) => {
if (state.client === client) {
if (isCurrentRequest(client, generation) && agentsRequestOwner === owner) {
state.agentsError = isMissingOperatorReadScopeError(err)
? formatMissingOperatorReadScopeMessage("agent list")
: String(err);
@@ -277,10 +313,12 @@ export function createAgentCapability(gateway: AgentGateway): AgentCapability {
return null;
})
.finally(() => {
if (agentsRequest === request) {
const currentRequest = agentsRequestOwner === owner;
if (currentRequest) {
agentsRequest = null;
agentsRequestOwner = null;
}
if (state.client === client) {
if (currentRequest && isCurrentRequest(client, generation)) {
state.agentsLoading = false;
publish();
}
@@ -309,25 +347,32 @@ export function createAgentCapability(gateway: AgentGateway): AgentCapability {
status.loading = true;
status.error = null;
publish();
const generation = requestGeneration;
const owner = Symbol();
fileRequestOwners.set(agentId, owner);
const request = loadAgentFilesList(client, agentId)
.then((result) => {
if (state.client === client && result) {
const current =
isCurrentRequest(client, generation) && fileRequestOwners.get(agentId) === owner;
if (current && result) {
status.list = result;
status.error = null;
}
return state.client === client ? status.list : null;
return current ? status.list : null;
})
.catch((err: unknown) => {
if (state.client === client) {
if (isCurrentRequest(client, generation) && fileRequestOwners.get(agentId) === owner) {
status.error = String(err);
}
return null;
})
.finally(() => {
if (fileRequests.get(agentId) === request) {
const currentRequest = fileRequestOwners.get(agentId) === owner;
if (currentRequest) {
fileRequests.delete(agentId);
fileRequestOwners.delete(agentId);
}
if (state.client === client) {
if (currentRequest && isCurrentRequest(client, generation)) {
status.loading = false;
publish();
}
@@ -340,9 +385,14 @@ export function createAgentCapability(gateway: AgentGateway): AgentCapability {
const clientChanged = state.client !== snapshot.client;
state.client = snapshot.client;
state.connected = snapshot.connected;
if (clientChanged) {
if (clientChanged || !snapshot.connected) {
requestGeneration += 1;
agentsRequest = null;
agentsRequestOwner = null;
fileRequests.clear();
fileRequestOwners.clear();
}
if (clientChanged || !snapshot.connected) {
files.clear();
state.agentsList = null;
state.agentsError = null;
@@ -384,11 +434,15 @@ export function createAgentCapability(gateway: AgentGateway): AgentCapability {
},
dispose() {
disposed = true;
requestGeneration += 1;
stopGateway();
listeners.clear();
fileRequests.clear();
fileRequestOwners.clear();
files.clear();
agentsRequest = null;
agentsRequestOwner = null;
state.agentsLoading = false;
},
};
}
+5 -2
View File
@@ -41,6 +41,7 @@ export async function loadToolsEffective(
state: ToolsEffectiveState,
params: { agentId: string; sessionKey: string },
options: {
isCurrent?: () => boolean;
ignoreResponse?: (agentId: string, requestKey: string) => boolean;
onError?: (error: unknown) => string;
} = {},
@@ -60,7 +61,9 @@ export async function loadToolsEffective(
) {
return;
}
const shouldIgnoreResponse = () => options.ignoreResponse?.(resolvedAgentId, requestKey) ?? false;
const isCurrentRequest = () => options.isCurrent?.() ?? true;
const shouldIgnoreResponse = () =>
!isCurrentRequest() || (options.ignoreResponse?.(resolvedAgentId, requestKey) ?? false);
state.toolsEffectiveLoading = true;
state.toolsEffectiveLoadingKey = requestKey;
state.toolsEffectiveResultKey = null;
@@ -82,7 +85,7 @@ export async function loadToolsEffective(
}
state.toolsEffectiveError = options.onError?.(error) ?? String(error);
} finally {
if (state.toolsEffectiveLoadingKey === requestKey) {
if (isCurrentRequest() && state.toolsEffectiveLoadingKey === requestKey) {
state.toolsEffectiveLoadingKey = null;
state.toolsEffectiveLoading = false;
}
+104 -1
View File
@@ -1,7 +1,12 @@
// Channels domain tests.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelsStatusSnapshot } from "../../api/types.ts";
import { loadChannels, waitWhatsAppLogin, type ChannelsState } from "./index.ts";
import {
createChannelCapability,
loadChannels,
waitWhatsAppLogin,
type ChannelsState,
} from "./index.ts";
function createDeferred<T>() {
let resolve: ((value: T) => void) | undefined;
@@ -79,6 +84,104 @@ describe("channels controller WhatsApp wait", () => {
expect(state.whatsappLoginQrDataUrl).toBe("data:image/png;base64,next-qr");
expect(state.whatsappBusy).toBe(false);
});
it("rejects a stale login result after reconnecting with the same client", async () => {
const staleWait = createDeferred<{
message: string;
connected: boolean;
qrDataUrl: string;
}>();
const freshWait = createDeferred<{
message: string;
connected: boolean;
qrDataUrl: string;
}>();
let waitCount = 0;
const request = vi.fn((method: string) => {
if (method === "web.login.wait") {
waitCount += 1;
return waitCount === 1 ? staleWait.promise : freshWait.promise;
}
return Promise.resolve(createChannelsSnapshot("fresh"));
});
const client = { request };
let snapshot = { client, connected: true };
const listeners = new Set<(next: typeof snapshot) => void>();
const gateway = {
get snapshot() {
return snapshot;
},
subscribe(listener: (next: typeof snapshot) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
const channels = createChannelCapability(gateway as never);
const stale = channels.waitWhatsApp();
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
snapshot = { client, connected: false };
for (const listener of listeners) {
listener(snapshot);
}
snapshot = { client, connected: true };
for (const listener of listeners) {
listener(snapshot);
}
const fresh = channels.waitWhatsApp();
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
freshWait.resolve({
message: "fresh login",
connected: false,
qrDataUrl: "data:image/png;base64,fresh-qr",
});
await fresh;
staleWait.resolve({
message: "stale login",
connected: true,
qrDataUrl: "data:image/png;base64,stale-qr",
});
await stale;
expect(channels.state.whatsappLoginMessage).toBe("fresh login");
expect(channels.state.whatsappLoginQrDataUrl).toBe("data:image/png;base64,fresh-qr");
expect(request.mock.calls.filter(([method]) => method === "channels.status")).toHaveLength(1);
channels.dispose();
});
it("does not apply or refresh a login result after its capability is disposed", async () => {
const pending = createDeferred<{
message: string;
connected: boolean;
qrDataUrl: string;
}>();
const request = vi.fn(() => pending.promise);
const client = { request };
const gateway = {
snapshot: { client, connected: true },
subscribe: () => () => undefined,
};
const channels = createChannelCapability(gateway as never);
const wait = channels.waitWhatsApp();
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
channels.dispose();
pending.resolve({
message: "stale login",
connected: true,
qrDataUrl: "data:image/png;base64,stale-qr",
});
await wait;
expect(channels.state.whatsappLoginMessage).toBeNull();
expect(channels.state.whatsappLoginQrDataUrl).toBeNull();
expect(request).toHaveBeenCalledOnce();
await channels.waitWhatsApp();
expect(request).toHaveBeenCalledOnce();
});
});
describe("loadChannels", () => {
+119 -27
View File
@@ -136,13 +136,58 @@ export async function loadChannels(
await refresh;
}
async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
if (!state.client || !state.connected || state.whatsappBusy) {
return;
type WhatsAppOperation = {
client: ChannelGatewayClient;
gatewayEpoch: number;
operationSeq: number;
};
type ChannelsLifecycle = {
gatewayEpoch: number;
whatsappOperationSeq: number;
};
const channelsLifecycles = new WeakMap<ChannelsState, ChannelsLifecycle>();
function getChannelsLifecycle(state: ChannelsState): ChannelsLifecycle {
const existing = channelsLifecycles.get(state);
if (existing) {
return existing;
}
const created = { gatewayEpoch: 0, whatsappOperationSeq: 0 };
channelsLifecycles.set(state, created);
return created;
}
function beginWhatsAppOperation(state: ChannelsState): WhatsAppOperation | null {
const client = state.client;
if (!client || !state.connected || state.whatsappBusy) {
return null;
}
const lifecycle = getChannelsLifecycle(state);
const operationSeq = lifecycle.whatsappOperationSeq + 1;
lifecycle.whatsappOperationSeq = operationSeq;
state.whatsappBusy = true;
return { client, gatewayEpoch: lifecycle.gatewayEpoch, operationSeq };
}
function isCurrentWhatsAppOperation(state: ChannelsState, operation: WhatsAppOperation): boolean {
const lifecycle = getChannelsLifecycle(state);
return (
state.connected &&
state.client === operation.client &&
lifecycle.gatewayEpoch === operation.gatewayEpoch &&
lifecycle.whatsappOperationSeq === operation.operationSeq
);
}
async function startWhatsAppLogin(state: ChannelsState, force: boolean): Promise<boolean> {
const operation = beginWhatsAppOperation(state);
if (!operation) {
return false;
}
try {
const res = await state.client.request<{
const res = await operation.client.request<{
message?: string;
qrDataUrl?: string;
connected?: boolean;
@@ -150,32 +195,45 @@ async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
force,
timeoutMs: 30000,
});
if (!isCurrentWhatsAppOperation(state, operation)) {
return false;
}
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null;
state.whatsappLoginConnected = typeof res.connected === "boolean" ? res.connected : null;
} catch (err) {
if (!isCurrentWhatsAppOperation(state, operation)) {
return false;
}
state.whatsappLoginMessage = String(err);
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
} finally {
state.whatsappBusy = false;
if (isCurrentWhatsAppOperation(state, operation)) {
state.whatsappBusy = false;
}
}
return true;
}
export async function waitWhatsAppLogin(state: ChannelsState) {
if (!state.client || !state.connected || state.whatsappBusy) {
return;
export async function waitWhatsAppLogin(state: ChannelsState): Promise<boolean> {
const operation = beginWhatsAppOperation(state);
if (!operation) {
return false;
}
state.whatsappBusy = true;
const currentQrDataUrl = state.whatsappLoginQrDataUrl ?? undefined;
try {
const res = await state.client.request<{
const res = await operation.client.request<{
message?: string;
connected?: boolean;
qrDataUrl?: string;
}>("web.login.wait", {
timeoutMs: 120000,
currentQrDataUrl: state.whatsappLoginQrDataUrl ?? undefined,
currentQrDataUrl,
});
if (!isCurrentWhatsAppOperation(state, operation)) {
return false;
}
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginConnected = res.connected ?? null;
if (res.qrDataUrl) {
@@ -184,28 +242,43 @@ export async function waitWhatsAppLogin(state: ChannelsState) {
state.whatsappLoginQrDataUrl = null;
}
} catch (err) {
if (!isCurrentWhatsAppOperation(state, operation)) {
return false;
}
state.whatsappLoginMessage = String(err);
state.whatsappLoginConnected = null;
} finally {
state.whatsappBusy = false;
if (isCurrentWhatsAppOperation(state, operation)) {
state.whatsappBusy = false;
}
}
return true;
}
export async function logoutWhatsApp(state: ChannelsState) {
if (!state.client || !state.connected || state.whatsappBusy) {
return;
export async function logoutWhatsApp(state: ChannelsState): Promise<boolean> {
const operation = beginWhatsAppOperation(state);
if (!operation) {
return false;
}
state.whatsappBusy = true;
try {
await state.client.request("channels.logout", { channel: "whatsapp" });
await operation.client.request("channels.logout", { channel: "whatsapp" });
if (!isCurrentWhatsAppOperation(state, operation)) {
return false;
}
state.whatsappLoginMessage = "Logged out.";
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
} catch (err) {
if (!isCurrentWhatsAppOperation(state, operation)) {
return false;
}
state.whatsappLoginMessage = String(err);
} finally {
state.whatsappBusy = false;
if (isCurrentWhatsAppOperation(state, operation)) {
state.whatsappBusy = false;
}
}
return true;
}
export function resolveChannelConfigValue(
@@ -271,20 +344,29 @@ export function createChannelCapability(gateway: ChannelGateway): ChannelCapabil
listener(state);
}
};
const run = async <T>(task: () => Promise<T>): Promise<T> => {
const run = async (task: () => Promise<void>): Promise<void> => {
if (disposed) {
return;
}
const result = task();
publish();
try {
return await result;
await result;
} finally {
publish();
}
};
const stopGateway = gateway.subscribe((snapshot) => {
const clientChanged = state.client !== snapshot.client;
const connectionChanged = state.connected !== snapshot.connected;
state.client = snapshot.client;
state.connected = snapshot.connected;
if (clientChanged || !snapshot.connected) {
if (clientChanged || connectionChanged) {
// Every transport epoch invalidates both channel loads and login work.
// A reconnect may reuse the same client object, so identity alone is insufficient.
const lifecycle = getChannelsLifecycle(state);
lifecycle.gatewayEpoch += 1;
lifecycle.whatsappOperationSeq += 1;
state.channelsLoading = false;
state.channelsLoadingProbe = null;
state.whatsappBusy = false;
@@ -300,25 +382,35 @@ export function createChannelCapability(gateway: ChannelGateway): ChannelCapabil
refresh: (probe, options) => run(() => loadChannels(state, probe ?? false, options)),
startWhatsApp: (force) =>
run(async () => {
await startWhatsAppLogin(state, force);
await loadChannels(state, true);
if (await startWhatsAppLogin(state, force)) {
await loadChannels(state, true);
}
}),
waitWhatsApp: () =>
run(async () => {
await waitWhatsAppLogin(state);
await loadChannels(state, true);
if (await waitWhatsAppLogin(state)) {
await loadChannels(state, true);
}
}),
logoutWhatsApp: () =>
run(async () => {
await logoutWhatsApp(state);
await loadChannels(state, true);
if (await logoutWhatsApp(state)) {
await loadChannels(state, true);
}
}),
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
dispose() {
if (disposed) {
return;
}
disposed = true;
const lifecycle = getChannelsLifecycle(state);
lifecycle.gatewayEpoch += 1;
lifecycle.whatsappOperationSeq += 1;
state.whatsappBusy = false;
stopGateway();
listeners.clear();
},
+140
View File
@@ -1,8 +1,11 @@
// Control UI tests cover config behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ConfigSchemaResponse, ConfigSnapshot } from "../../api/types.ts";
import {
applyConfigSnapshot,
applyConfig,
createRuntimeConfigCapability,
ensureAgentConfigEntry,
findAgentConfigEntryIndex,
loadConfig,
@@ -16,6 +19,38 @@ import {
type ConfigState,
} from "./index.ts";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
function createGatewayHarness(client: GatewayBrowserClient) {
let snapshot = { client, connected: true, sessionKey: "main" };
const listeners = new Set<(next: typeof snapshot) => void>();
return {
gateway: {
get snapshot() {
return snapshot;
},
subscribe(listener: (next: typeof snapshot) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
publish: (connected: boolean) => {
snapshot = { client, connected, sessionKey: "main" };
for (const listener of listeners) {
listener(snapshot);
}
},
};
}
function createState(): ConfigState {
return {
applySessionKey: "main",
@@ -273,6 +308,111 @@ describe("loadConfig", () => {
});
});
describe("createRuntimeConfigCapability", () => {
it("rejects stale config and schema work after reconnecting the same client", async () => {
const firstConfig = deferred<ConfigSnapshot>();
const secondConfig = deferred<ConfigSnapshot>();
const firstSchema = deferred<ConfigSchemaResponse>();
const secondSchema = deferred<ConfigSchemaResponse>();
const configRequests = [firstConfig, secondConfig];
const schemaRequests = [firstSchema, secondSchema];
const request = vi.fn((method: string) => {
const pending = method === "config.get" ? configRequests.shift() : schemaRequests.shift();
if (!pending) {
throw new Error(`unexpected request: ${method}`);
}
return pending.promise;
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway, publish } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
const staleConfigLoad = runtimeConfig.ensureLoaded();
const staleSchemaLoad = runtimeConfig.ensureSchemaLoaded();
publish(false);
publish(true);
const currentConfigLoad = runtimeConfig.ensureLoaded();
const currentSchemaLoad = runtimeConfig.ensureSchemaLoaded();
firstConfig.resolve({ config: { source: "stale" }, valid: true, issues: [], raw: "{}" });
firstSchema.reject(new Error("stale schema failure"));
await Promise.all([staleConfigLoad, staleSchemaLoad]);
expect(runtimeConfig.state.configSnapshot).toBeNull();
expect(runtimeConfig.state.configSchema).toBeNull();
expect(runtimeConfig.state.lastError).toBeNull();
expect(runtimeConfig.state.configLoading).toBe(true);
expect(runtimeConfig.state.configSchemaLoading).toBe(true);
secondConfig.resolve({ config: { source: "current" }, valid: true, issues: [], raw: "{}" });
secondSchema.resolve({
schema: { type: "object" },
uiHints: {},
version: "current",
generatedAt: "2026-07-09T00:00:00.000Z",
});
await Promise.all([currentConfigLoad, currentSchemaLoad]);
expect(runtimeConfig.state.configSnapshot?.config).toEqual({ source: "current" });
expect(runtimeConfig.state.configSchema).toEqual({ type: "object" });
expect(runtimeConfig.state.configSchemaVersion).toBe("current");
expect(runtimeConfig.state.configLoading).toBe(false);
expect(runtimeConfig.state.configSchemaLoading).toBe(false);
runtimeConfig.dispose();
});
it("keeps a replacement save isolated from stale same-client completion", async () => {
const staleSave = deferred<void>();
const currentSave = deferred<void>();
let saveCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "config.set") {
saveCount += 1;
await (saveCount === 1 ? staleSave.promise : currentSave.promise);
return {};
}
if (method === "config.get") {
return {
hash: "current-hash",
config: { source: "current" },
valid: true,
issues: [],
raw: '{"source":"current"}',
};
}
throw new Error(`unexpected request: ${method}`);
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway, publish } = createGatewayHarness(client);
const runtimeConfig = createRuntimeConfigCapability(gateway);
applyConfigSnapshot(runtimeConfig.state, {
hash: "base-hash",
config: { source: "base" },
valid: true,
issues: [],
raw: '{"source":"base"}',
});
updateConfigFormValue(runtimeConfig.state, ["source"], "draft");
const oldOperation = runtimeConfig.save();
publish(false);
publish(true);
const currentOperation = runtimeConfig.save();
staleSave.resolve();
await expect(oldOperation).resolves.toBe(false);
expect(runtimeConfig.state.configSaving).toBe(true);
expect(runtimeConfig.state.configFormDirty).toBe(true);
currentSave.resolve();
await expect(currentOperation).resolves.toBe(true);
expect(runtimeConfig.state.configSaving).toBe(false);
expect(runtimeConfig.state.configFormDirty).toBe(false);
expect(runtimeConfig.state.configSnapshot?.config).toEqual({ source: "current" });
runtimeConfig.dispose();
});
});
describe("openConfigFile", () => {
it("surfaces failed open responses and copies the returned config path", async () => {
const request = vi.fn().mockResolvedValue({
+103 -24
View File
@@ -40,6 +40,7 @@ export type ConfigState = {
const autoAllowlistedPluginIdsByState = new WeakMap<ConfigState, Set<string>>();
const requestVersionsByState = new WeakMap<ConfigState, { config: number; schema: number }>();
const connectionEpochsByState = new WeakMap<object, number>();
type RuntimeConfigGatewaySnapshot = {
client: GatewayBrowserClient | null;
@@ -87,6 +88,11 @@ type ConfigGatewayClient = {
request<T = unknown>(method: string, params?: unknown): Promise<T>;
};
type ConfigConnectionState = {
client: ConfigGatewayClient | null;
connected: boolean;
};
type ConfigGatewayState = Pick<
ConfigState,
"connected" | "applySessionKey" | "configSnapshot" | "lastError" | "chatError"
@@ -130,13 +136,37 @@ function nextRequestVersion(state: ConfigState, key: "config" | "schema"): numbe
return next[key];
}
function currentConfigConnectionEpoch(state: object): number {
return connectionEpochsByState.get(state) ?? 0;
}
function invalidateConfigConnection(state: object): void {
connectionEpochsByState.set(state, currentConfigConnectionEpoch(state) + 1);
}
function isCurrentConfigConnection(
state: ConfigConnectionState,
client: ConfigGatewayClient,
connectionEpoch: number,
): boolean {
return (
state.connected &&
state.client === client &&
currentConfigConnectionEpoch(state) === connectionEpoch
);
}
function isCurrentRequest(
state: ConfigState,
key: "config" | "schema",
version: number,
client: GatewayBrowserClient,
connectionEpoch: number,
): boolean {
return state.client === client && requestVersionsByState.get(state)?.[key] === version;
return (
isCurrentConfigConnection(state, client, connectionEpoch) &&
requestVersionsByState.get(state)?.[key] === version
);
}
export async function loadConfig(state: ConfigState, options: LoadConfigOptions = {}) {
@@ -144,22 +174,23 @@ export async function loadConfig(state: ConfigState, options: LoadConfigOptions
if (!client || !state.connected) {
return;
}
const connectionEpoch = currentConfigConnectionEpoch(state);
const version = nextRequestVersion(state, "config");
state.configLoading = true;
state.lastError = null;
state.chatError = null;
try {
const res = await client.request<ConfigSnapshot>("config.get", {});
if (!isCurrentRequest(state, "config", version, client)) {
if (!isCurrentRequest(state, "config", version, client, connectionEpoch)) {
return;
}
applyConfigSnapshot(state, res, options);
} catch (err) {
if (isCurrentRequest(state, "config", version, client)) {
if (isCurrentRequest(state, "config", version, client, connectionEpoch)) {
state.lastError = String(err);
}
} finally {
if (isCurrentRequest(state, "config", version, client)) {
if (isCurrentRequest(state, "config", version, client, connectionEpoch)) {
state.configLoading = false;
}
}
@@ -173,20 +204,21 @@ async function loadConfigSchema(state: ConfigState) {
if (state.configSchemaLoading) {
return;
}
const connectionEpoch = currentConfigConnectionEpoch(state);
const version = nextRequestVersion(state, "schema");
state.configSchemaLoading = true;
try {
const res = await client.request<ConfigSchemaResponse>("config.schema", {});
if (!isCurrentRequest(state, "schema", version, client)) {
if (!isCurrentRequest(state, "schema", version, client, connectionEpoch)) {
return;
}
applyConfigSchema(state, res);
} catch (err) {
if (isCurrentRequest(state, "schema", version, client)) {
if (isCurrentRequest(state, "schema", version, client, connectionEpoch)) {
state.lastError = String(err);
}
} finally {
if (isCurrentRequest(state, "schema", version, client)) {
if (isCurrentRequest(state, "schema", version, client, connectionEpoch)) {
state.configSchemaLoading = false;
}
}
@@ -442,9 +474,12 @@ async function submitConfigChange(
busyKey: ConfigSubmitBusyKey,
extraParams: Record<string, unknown> = {},
): Promise<boolean> {
if (!state.client || !state.connected) {
const client = state.client;
if (!client || !state.connected) {
return false;
}
const connectionEpoch = currentConfigConnectionEpoch(state);
const isCurrent = () => isCurrentConfigConnection(state, client, connectionEpoch);
state[busyKey] = true;
state.lastError = null;
state.chatError = null;
@@ -455,17 +490,24 @@ async function submitConfigChange(
state.lastError = "Config hash missing; reload and retry.";
return false;
}
await state.client.request(method, { raw, baseHash, ...extraParams });
await client.request(method, { raw, baseHash, ...extraParams });
if (!isCurrent()) {
return false;
}
state.configFormDirty = false;
state.configDraftBaseHash = null;
autoAllowlistedPluginIdsByState.delete(state);
await loadConfig(state);
return true;
return isCurrent();
} catch (err) {
state.lastError = String(err);
if (isCurrent()) {
state.lastError = String(err);
}
return false;
} finally {
state[busyKey] = false;
if (isCurrent()) {
state[busyKey] = false;
}
}
}
@@ -498,6 +540,7 @@ async function patchConfig(
if (!client || !state.connected) {
return false;
}
const connectionEpoch = currentConfigConnectionEpoch(state);
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.lastError = "Config hash missing; refresh and retry.";
@@ -512,9 +555,11 @@ async function patchConfig(
sessionKey: state.applySessionKey,
note: options.note,
});
return true;
return isCurrentConfigConnection(state, client, connectionEpoch);
} catch (err) {
state.lastError = String(err);
if (isCurrentConfigConnection(state, client, connectionEpoch)) {
state.lastError = String(err);
}
return false;
}
}
@@ -527,7 +572,16 @@ async function lookupConfigSchemaPath(
if (!client || !state.connected) {
return null;
}
return client.request("config.schema.lookup", { path });
const connectionEpoch = currentConfigConnectionEpoch(state);
try {
const result = await client.request("config.schema.lookup", { path });
return isCurrentConfigConnection(state, client, connectionEpoch) ? result : null;
} catch (error) {
if (!isCurrentConfigConnection(state, client, connectionEpoch)) {
return null;
}
throw error;
}
}
function mutateConfigForm(state: ConfigState, mutate: (draft: Record<string, unknown>) => void) {
@@ -734,30 +788,42 @@ export function stageDefaultAgentConfigEntry(state: ConfigState, agentId: string
}
export async function openConfigFile(state: ConfigState): Promise<void> {
if (!state.client || !state.connected) {
const client = state.client;
if (!client || !state.connected) {
return;
}
const connectionEpoch = currentConfigConnectionEpoch(state);
const isCurrent = () => isCurrentConfigConnection(state, client, connectionEpoch);
state.lastError = null;
state.chatError = null;
try {
const res = await state.client.request<{ ok: boolean; path?: string; error?: string }>(
const res = await client.request<{ ok: boolean; path?: string; error?: string }>(
"config.openFile",
{},
);
if (!isCurrent()) {
return;
}
if (!res.ok) {
const errorMessage = res.error || "Failed to open config file";
state.lastError = errorMessage;
let errorMessage = res.error || "Failed to open config file";
const path = res.path || state.configSnapshot?.path;
if (path) {
try {
await navigator.clipboard.writeText(path);
state.lastError += `\n\nFile path copied to clipboard: ${path}`;
errorMessage += `\n\nFile path copied to clipboard: ${path}`;
} catch {
state.lastError += `\n\nFile path: ${path}`;
errorMessage += `\n\nFile path: ${path}`;
}
}
if (isCurrent()) {
state.lastError = errorMessage;
}
}
} catch (err) {
if (!isCurrent()) {
return;
}
const errorMessage = String(err);
const path = state.configSnapshot?.path;
if (path) {
try {
@@ -766,7 +832,9 @@ export async function openConfigFile(state: ConfigState): Promise<void> {
// ignore
}
}
state.lastError = String(err);
if (isCurrent()) {
state.lastError = errorMessage;
}
}
}
@@ -823,15 +891,20 @@ export function createRuntimeConfigCapability(
state.configSchema ? Promise.resolve() : loadOnce("schema", () => loadConfigSchema(state));
const stopGateway = gateway.subscribe((snapshot) => {
const clientChanged = state.client !== snapshot.client;
const connectionChanged = state.connected !== snapshot.connected;
state.client = snapshot.client;
state.connected = snapshot.connected;
state.applySessionKey = snapshot.sessionKey;
if (clientChanged) {
if (clientChanged || connectionChanged) {
configLoad = null;
schemaLoad = null;
requestVersionsByState.delete(state);
// A reconnect may reuse the client object. Keep generations monotonic so work
// from the previous connection cannot commit into the new connection epoch.
invalidateConfigConnection(state);
state.configLoading = false;
state.configSchemaLoading = false;
state.configSaving = false;
state.configApplying = false;
}
publish();
});
@@ -879,6 +952,12 @@ export function createRuntimeConfigCapability(
},
dispose() {
disposed = true;
invalidateConfigConnection(state);
state.connected = false;
state.configLoading = false;
state.configSchemaLoading = false;
state.configSaving = false;
state.configApplying = false;
stopGateway();
listeners.clear();
requestVersionsByState.delete(state);
+115
View File
@@ -0,0 +1,115 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStorageMock } from "../../test-helpers/storage.ts";
import {
loadDeviceAuthToken,
revokeDeviceToken,
rotateDeviceToken,
storeDeviceAuthToken,
} from "./index.ts";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function createState(request: (method: string, params?: unknown) => Promise<unknown>) {
return {
client: {
request: request as <T = unknown>(method: string, params?: unknown) => Promise<T>,
},
connected: true,
requestGeneration: 1,
devicesLoading: false,
devicesError: null,
devicesList: null,
};
}
function storeIdentity() {
localStorage.setItem(
"openclaw-device-identity-v1",
JSON.stringify({
version: 1,
deviceId: "00",
publicKey: "AA",
privateKey: "AA",
createdAtMs: 1,
}),
);
}
function deferIdentityFingerprint() {
const digest = deferred<ArrayBuffer>();
const digestMock = vi.fn(() => digest.promise);
vi.stubGlobal("crypto", { subtle: { digest: digestMock } });
return { digest, digestMock };
}
const tokenParams = {
deviceId: "00",
gatewayUrl: "wss://gateway.test",
role: "operator",
};
beforeEach(() => {
vi.stubGlobal("localStorage", createStorageMock());
});
afterEach(() => {
localStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("device token request lifecycle", () => {
it("does not reveal or persist a rotate response from a retired request epoch", async () => {
const response = deferred<unknown>();
const state = createState(() => response.promise);
const prompt = vi.spyOn(window, "prompt").mockImplementation(() => null);
const operation = rotateDeviceToken(state, tokenParams);
state.requestGeneration += 1;
response.resolve({ token: "stale-token", ...tokenParams });
await operation;
expect(prompt).not.toHaveBeenCalled();
expect(loadDeviceAuthToken(tokenParams)).toBeNull();
});
it("rechecks rotate ownership after loading the local identity", async () => {
storeIdentity();
const { digest, digestMock } = deferIdentityFingerprint();
const state = createState(async () => ({ token: "stale-token", ...tokenParams }));
const prompt = vi.spyOn(window, "prompt").mockImplementation(() => null);
const operation = rotateDeviceToken(state, tokenParams);
await vi.waitFor(() => expect(digestMock).toHaveBeenCalledOnce());
state.requestGeneration += 1;
digest.resolve(new Uint8Array([0]).buffer);
await operation;
expect(prompt).not.toHaveBeenCalled();
expect(loadDeviceAuthToken(tokenParams)).toBeNull();
});
it("does not clear a current token when a revoke request retires during identity loading", async () => {
storeIdentity();
storeDeviceAuthToken({ ...tokenParams, token: "current-token", scopes: ["operator.read"] });
const { digest, digestMock } = deferIdentityFingerprint();
const state = createState(async () => ({}));
vi.spyOn(window, "confirm").mockReturnValue(true);
const operation = revokeDeviceToken(state, tokenParams);
await vi.waitFor(() => expect(digestMock).toHaveBeenCalledOnce());
state.requestGeneration += 1;
digest.resolve(new Uint8Array([0]).buffer);
await operation;
expect(loadDeviceAuthToken(tokenParams)?.token).toBe("current-token");
});
});
+39
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import {
createInitialNodesState,
loadExecApprovals,
@@ -6,6 +7,14 @@ import {
updateExecApprovalsFormValue,
} from "./index.ts";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("host-native exec approvals state", () => {
it("keeps native snapshots read-only", async () => {
const request = vi.fn().mockResolvedValue({
@@ -30,4 +39,34 @@ describe("host-native exec approvals state", () => {
expect(request).toHaveBeenCalledTimes(1);
expect(state.lastError).toContain("read-only");
});
it("isolates approval loads across a same-client reconnect", async () => {
const first = deferred<unknown>();
const second = deferred<unknown>();
const request = vi
.fn<(method: string, params?: unknown) => Promise<unknown>>()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const client = { request } as unknown as GatewayBrowserClient;
const state = createInitialNodesState({ client, connected: true });
const staleLoad = loadExecApprovals(state);
state.connected = false;
state.requestGeneration += 1;
state.execApprovalsLoading = false;
state.connected = true;
state.requestGeneration += 1;
const currentLoad = loadExecApprovals(state);
first.resolve({ path: "/old", exists: true, hash: "old", file: {} });
await staleLoad;
expect(state.execApprovalsSnapshot).toBeNull();
expect(state.execApprovalsLoading).toBe(true);
const current = { path: "/new", exists: true, hash: "new", file: {} };
second.resolve(current);
await currentLoad;
expect(state.execApprovalsSnapshot).toEqual(current);
expect(state.execApprovalsLoading).toBe(false);
});
});
+86 -35
View File
@@ -118,26 +118,28 @@ export type ExecApprovalsSnapshot = FileExecApprovalsSnapshot | NativeExecApprov
export type ExecApprovalsTarget = { kind: "gateway" } | { kind: "node"; nodeId: string };
type NodesState = {
type NodesRequestState = {
client: GatewayRequestClient | null;
connected: boolean;
// Auto-reconnect keeps the same client; the page advances this generation
// whenever requests from the previous connection must become inert.
requestGeneration: number;
};
type NodesState = NodesRequestState & {
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
lastError: string | null;
chatError?: string | null;
};
type DevicesState = {
client: GatewayRequestClient | null;
connected: boolean;
type DevicesState = NodesRequestState & {
devicesLoading: boolean;
devicesError: string | null;
devicesList: DevicePairingList | null;
};
export type ExecApprovalsState = {
client: GatewayRequestClient | null;
connected: boolean;
export type ExecApprovalsState = NodesRequestState & {
execApprovalsLoading: boolean;
execApprovalsSaving: boolean;
execApprovalsDirty: boolean;
@@ -174,6 +176,7 @@ export function createInitialNodesState(
return {
client: snapshot.client ?? null,
connected: snapshot.connected ?? false,
requestGeneration: 0,
nodesLoading: false,
nodes: [],
lastError: null,
@@ -189,6 +192,14 @@ export function createInitialNodesState(
};
}
function isCurrentNodesRequest(
state: NodesRequestState,
client: GatewayRequestClient,
generation: number,
): boolean {
return state.connected && state.client === client && state.requestGeneration === generation;
}
export async function loadNodes(state: NodesState, opts?: { quiet?: boolean }) {
const client = state.client;
if (!client || !state.connected || state.nodesLoading) {
@@ -199,17 +210,18 @@ export async function loadNodes(state: NodesState, opts?: { quiet?: boolean }) {
state.lastError = null;
state.chatError = null;
}
const generation = state.requestGeneration;
try {
const res = await client.request<{ nodes?: unknown }>("node.list", {});
if (state.client === client) {
if (isCurrentNodesRequest(state, client, generation)) {
state.nodes = Array.isArray(res.nodes) ? (res.nodes as Array<Record<string, unknown>>) : [];
}
} catch (err) {
if (!opts?.quiet && state.client === client) {
if (!opts?.quiet && isCurrentNodesRequest(state, client, generation)) {
state.lastError = String(err);
}
} finally {
if (state.client === client) {
if (isCurrentNodesRequest(state, client, generation)) {
state.nodesLoading = false;
}
}
@@ -224,53 +236,66 @@ export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean
if (!opts?.quiet) {
state.devicesError = null;
}
const generation = state.requestGeneration;
try {
const res = await client.request<{
pending?: Array<PendingDevice>;
paired?: Array<PairedDevice>;
}>("device.pair.list", {});
if (state.client === client) {
if (isCurrentNodesRequest(state, client, generation)) {
state.devicesList = {
pending: Array.isArray(res?.pending) ? res.pending : [],
paired: Array.isArray(res?.paired) ? res.paired : [],
};
}
} catch (err) {
if (!opts?.quiet && state.client === client) {
if (!opts?.quiet && isCurrentNodesRequest(state, client, generation)) {
state.devicesError = String(err);
}
} finally {
if (state.client === client) {
if (isCurrentNodesRequest(state, client, generation)) {
state.devicesLoading = false;
}
}
}
export async function approveDevicePairing(state: DevicesState, requestId: string) {
if (!state.client || !state.connected) {
const client = state.client;
if (!client || !state.connected) {
return;
}
const generation = state.requestGeneration;
try {
await state.client.request("device.pair.approve", { requestId });
await loadDevices(state);
await client.request("device.pair.approve", { requestId });
if (isCurrentNodesRequest(state, client, generation)) {
await loadDevices(state);
}
} catch (err) {
state.devicesError = String(err);
if (isCurrentNodesRequest(state, client, generation)) {
state.devicesError = String(err);
}
}
}
export async function rejectDevicePairing(state: DevicesState, requestId: string) {
if (!state.client || !state.connected) {
const client = state.client;
if (!client || !state.connected) {
return;
}
const confirmed = window.confirm("Reject this device pairing request?");
if (!confirmed) {
return;
}
const generation = state.requestGeneration;
try {
await state.client.request("device.pair.reject", { requestId });
await loadDevices(state);
await client.request("device.pair.reject", { requestId });
if (isCurrentNodesRequest(state, client, generation)) {
await loadDevices(state);
}
} catch (err) {
state.devicesError = String(err);
if (isCurrentNodesRequest(state, client, generation)) {
state.devicesError = String(err);
}
}
}
@@ -278,19 +303,27 @@ export async function rotateDeviceToken(
state: DevicesState,
params: { deviceId: string; gatewayUrl: string; role: string; scopes?: string[] },
) {
if (!state.client || !state.connected) {
const client = state.client;
if (!client || !state.connected) {
return;
}
const generation = state.requestGeneration;
try {
const { gatewayUrl, ...requestParams } = params;
const res = await state.client.request<{
const res = await client.request<{
token?: string;
role?: string;
deviceId?: string;
scopes?: Array<string>;
}>("device.token.rotate", requestParams);
if (!isCurrentNodesRequest(state, client, generation)) {
return;
}
if (res?.token) {
const identity = await loadOrCreateDeviceIdentity();
if (!isCurrentNodesRequest(state, client, generation)) {
return;
}
const role = res.role ?? params.role;
if (res.deviceId === identity.deviceId || params.deviceId === identity.deviceId) {
storeDeviceAuthToken({
@@ -303,9 +336,13 @@ export async function rotateDeviceToken(
}
window.prompt("New device token (copy and store securely):", res.token);
}
await loadDevices(state);
if (isCurrentNodesRequest(state, client, generation)) {
await loadDevices(state);
}
} catch (err) {
state.devicesError = String(err);
if (isCurrentNodesRequest(state, client, generation)) {
state.devicesError = String(err);
}
}
}
@@ -313,17 +350,25 @@ export async function revokeDeviceToken(
state: DevicesState,
params: { deviceId: string; gatewayUrl: string; role: string },
) {
if (!state.client || !state.connected) {
const client = state.client;
if (!client || !state.connected) {
return;
}
const confirmed = window.confirm(`Revoke token for ${params.deviceId} (${params.role})?`);
if (!confirmed) {
return;
}
const generation = state.requestGeneration;
try {
const { gatewayUrl, ...requestParams } = params;
await state.client.request("device.token.revoke", requestParams);
await client.request("device.token.revoke", requestParams);
if (!isCurrentNodesRequest(state, client, generation)) {
return;
}
const identity = await loadOrCreateDeviceIdentity();
if (!isCurrentNodesRequest(state, client, generation)) {
return;
}
if (params.deviceId === identity.deviceId) {
clearDeviceAuthToken({
deviceId: identity.deviceId,
@@ -331,9 +376,13 @@ export async function revokeDeviceToken(
role: params.role,
});
}
await loadDevices(state);
if (isCurrentNodesRequest(state, client, generation)) {
await loadDevices(state);
}
} catch (err) {
state.devicesError = String(err);
if (isCurrentNodesRequest(state, client, generation)) {
state.devicesError = String(err);
}
}
}
@@ -370,6 +419,7 @@ export async function loadExecApprovals(
state.execApprovalsLoading = true;
state.lastError = null;
state.chatError = null;
const generation = state.requestGeneration;
try {
const rpc = resolveExecApprovalsRpc(target);
if (!rpc) {
@@ -377,15 +427,15 @@ export async function loadExecApprovals(
return;
}
const res = await client.request<ExecApprovalsSnapshot>(rpc.method, rpc.params);
if (state.client === client) {
if (isCurrentNodesRequest(state, client, generation)) {
applyExecApprovalsSnapshot(state, res);
}
} catch (err) {
if (state.client === client) {
if (isCurrentNodesRequest(state, client, generation)) {
state.lastError = String(err);
}
} finally {
if (state.client === client) {
if (isCurrentNodesRequest(state, client, generation)) {
state.execApprovalsLoading = false;
}
}
@@ -420,6 +470,7 @@ export async function saveExecApprovals(
state.execApprovalsSaving = true;
state.lastError = null;
state.chatError = null;
const generation = state.requestGeneration;
try {
if (isNativeExecApprovalsSnapshot(state.execApprovalsSnapshot)) {
state.lastError =
@@ -438,17 +489,17 @@ export async function saveExecApprovals(
return;
}
await client.request(rpc.method, rpc.params);
if (state.client !== client) {
if (!isCurrentNodesRequest(state, client, generation)) {
return;
}
state.execApprovalsDirty = false;
await loadExecApprovals(state, target);
} catch (err) {
if (state.client === client) {
if (isCurrentNodesRequest(state, client, generation)) {
state.lastError = String(err);
}
} finally {
if (state.client === client) {
if (isCurrentNodesRequest(state, client, generation)) {
state.execApprovalsSaving = false;
}
}
+131 -2
View File
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { GatewayEventFrame } from "../../api/gateway.ts";
import type { GatewayBrowserClient, GatewayEventFrame, GatewayHelloOk } from "../../api/gateway.ts";
import type { SessionsListResult } from "../../api/types.ts";
import { createSessionCapability, reconcileSessionRunTerminal } from "./index.ts";
@@ -22,7 +21,137 @@ function deferred<T>() {
return { promise, resolve };
}
function createGatewayHarness(client: GatewayBrowserClient) {
let snapshot: {
client: GatewayBrowserClient | null;
connected: boolean;
sessionKey: string;
assistantAgentId: string | null;
hello: GatewayHelloOk | null;
} = {
client,
connected: true,
sessionKey: "agent:main:main",
assistantAgentId: "main",
hello: null,
};
const listeners = new Set<(next: typeof snapshot) => void>();
return {
gateway: {
get snapshot() {
return snapshot;
},
subscribe(listener: (next: typeof snapshot) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeEvents: () => () => undefined,
},
publish: (connected: boolean) => {
snapshot = { ...snapshot, connected };
for (const listener of listeners) {
listener(snapshot);
}
},
};
}
describe("createSessionCapability", () => {
it("starts a fresh list epoch when the same client reconnects", async () => {
const staleList = deferred<SessionsListResult>();
const currentList = deferred<SessionsListResult>();
let listCalls = 0;
const request = vi.fn(async (method: string) => {
if (method === "sessions.subscribe") {
return {};
}
if (method === "sessions.list") {
listCalls += 1;
return await (listCalls === 1 ? staleList.promise : currentList.promise);
}
throw new Error(`Unexpected request: ${method}`);
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway, publish } = createGatewayHarness(client);
const sessions = createSessionCapability(gateway);
const staleRefresh = sessions.refresh({ force: true });
publish(false);
publish(true);
await vi.waitFor(() => expect(listCalls).toBe(2));
staleList.resolve(sessionsResult([{ key: "stale", kind: "direct", updatedAt: 1 }], 1));
await staleRefresh;
expect(sessions.state.result).toBeNull();
currentList.resolve(sessionsResult([{ key: "current", kind: "direct", updatedAt: 2 }], 2));
await vi.waitFor(() => expect(sessions.state.result?.sessions[0]?.key).toBe("current"));
sessions.dispose();
});
it("does not publish a created session from a retired same-client epoch", async () => {
const staleCreate = deferred<{ key: string }>();
const request = vi.fn(async (method: string) => {
if (method === "sessions.create") {
return await staleCreate.promise;
}
if (method === "sessions.subscribe") {
return {};
}
if (method === "sessions.list") {
return sessionsResult([], 2);
}
throw new Error(`Unexpected request: ${method}`);
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway, publish } = createGatewayHarness(client);
const sessions = createSessionCapability(gateway);
const created = vi.fn();
sessions.subscribeCreated(created);
const operation = sessions.create({ agentId: "main" });
publish(false);
publish(true);
staleCreate.resolve({ key: "agent:main:stale" });
await expect(operation).resolves.toBeNull();
expect(created).not.toHaveBeenCalled();
sessions.dispose();
});
it("rolls back an optimistic model patch when its connection epoch retires", async () => {
const stalePatch = deferred<unknown>();
const request = vi.fn(async (method: string) => {
if (method === "sessions.patch") {
return await stalePatch.promise;
}
if (method === "sessions.subscribe") {
return {};
}
if (method === "sessions.list") {
return sessionsResult([], 2);
}
throw new Error(`Unexpected request: ${method}`);
});
const client = { request } as unknown as GatewayBrowserClient;
const { gateway, publish } = createGatewayHarness(client);
const sessions = createSessionCapability(gateway);
const key = "agent:main:main";
sessions.setModelOverride(key, "openai/gpt-old");
const operation = sessions.patch(key, { model: "openai/gpt-new" });
expect(sessions.state.modelOverrides[key]).toBe("openai/gpt-new");
publish(false);
expect(sessions.state.modelOverrides[key]).toBe("openai/gpt-old");
publish(true);
stalePatch.resolve({});
await expect(operation).resolves.toBeNull();
expect(sessions.state.modelOverrides[key]).toBe("openai/gpt-old");
sessions.dispose();
});
it("passes transcript fork parameters to sessions.create", async () => {
const request = vi.fn(async (method: string) => {
if (method === "sessions.create") {
+185 -88
View File
@@ -134,6 +134,11 @@ type SessionGateway = {
type SessionRequestClient = Pick<GatewayBrowserClient, "request">;
type SessionConnectionScope = {
client: GatewayBrowserClient;
epoch: number;
};
type SessionMessageSubscription = {
key: string;
agentId?: string | null;
@@ -565,20 +570,44 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
let inFlight: Promise<void> | null = null;
let queuedRefresh: SessionRefreshOptions | null = null;
let disposed = false;
let connectionEpoch = 0;
let connectionClient = gateway.snapshot.client;
let connectionConnected = gateway.snapshot.connected;
const pendingModelPatches = new Map<
string,
{ token: symbol; previous: string | null | undefined }
>();
let subscribedClient: GatewayBrowserClient | null = null;
let lastListOptions: SessionListOptions = {};
const listeners = new Set<(next: SessionState) => void>();
const createdListeners = new Set<(key: string) => void>();
const captureConnection = (): SessionConnectionScope | null => {
const snapshot = gateway.snapshot;
return !disposed && snapshot.connected && snapshot.client
? { client: snapshot.client, epoch: connectionEpoch }
: null;
};
const isCurrentConnection = (scope: SessionConnectionScope): boolean => {
const snapshot = gateway.snapshot;
return (
!disposed &&
connectionEpoch === scope.epoch &&
snapshot.connected &&
snapshot.client === scope.client
);
};
const requestList = async (
options: SessionListOptions = {},
): Promise<SessionsListResult | null> => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
return null;
}
const result = await requestSessionList(client, options);
return disposed || gateway.snapshot.client !== client ? null : (result ?? null);
const result = await requestSessionList(scope.client, options);
return isCurrentConnection(scope) ? (result ?? null) : null;
};
const publish = (next: SessionState) => {
@@ -612,9 +641,17 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
publish({ ...state, modelOverrides });
};
const rollbackPendingModelPatches = () => {
const pending = [...pendingModelPatches];
pendingModelPatches.clear();
for (const [key, operation] of pending) {
setModelOverride(key, operation.previous);
}
};
const load = async (options: SessionRefreshOptions) => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
return;
}
const { append = false, force: _force, backgroundHydrate = false, ...requestOptions } = options;
@@ -623,8 +660,8 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
publish({ ...state, loading: true, error: null, deletedSessions: [] });
}
try {
const result = await requestList(requestOptions);
if (disposed || gateway.snapshot.client !== client) {
const result = await requestSessionList(scope.client, requestOptions);
if (!isCurrentConnection(scope)) {
return;
}
let nextResult =
@@ -665,7 +702,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
deletedSessions: [],
});
} catch (error) {
if (!disposed && gateway.snapshot.client === client) {
if (isCurrentConnection(scope)) {
publish({
...state,
loading: backgroundHydrate ? state.loading : false,
@@ -677,9 +714,13 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
};
const drainRefreshQueue = async (options: SessionRefreshOptions) => {
const epoch = connectionEpoch;
let next: SessionRefreshOptions | null = options;
while (next) {
await load(next);
if (disposed || connectionEpoch !== epoch) {
return;
}
next = queuedRefresh;
queuedRefresh = null;
}
@@ -700,27 +741,32 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
return Promise.resolve();
}
const request = drainRefreshQueue(options).finally(() => {
inFlight = null;
if (inFlight === request) {
inFlight = null;
}
});
inFlight = request;
return request;
};
const create = async (params: SessionCreateParams = {}) => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || state.loading || disposed) {
const scope = captureConnection();
if (!scope || state.loading) {
return null;
}
try {
const { currentSessionKey, ...requestParams } = params;
const key = await requestSessionCreate(client, {
const key = await requestSessionCreate(scope.client, {
...requestParams,
...resolveSessionCreateParams(currentSessionKey, params.agentId),
});
if (disposed || gateway.snapshot.client !== client) {
if (!isCurrentConnection(scope)) {
return null;
}
await refresh({ agentId: params.agentId, force: true });
if (!isCurrentConnection(scope)) {
return null;
}
// Creation can originate outside the sidebar. Notify presentation owners
// after refresh so they can reconcile the new row without guessing from list churn.
for (const listener of createdListeners) {
@@ -728,7 +774,9 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
}
return key;
} catch (error) {
publish({ ...state, error: String(error) });
if (isCurrentConnection(scope)) {
publish({ ...state, error: String(error) });
}
return null;
}
};
@@ -738,31 +786,51 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
patchParams: SessionPatch,
options: { agentId?: string } = {},
): Promise<SessionsPatchResult | null> => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
return null;
}
const hasModelPatch = Object.hasOwn(patchParams, "model");
const previousModelOverride = state.modelOverrides[key.trim()];
const normalizedKey = key.trim();
const pendingModelPatch = pendingModelPatches.get(normalizedKey);
const previousModelOverride = pendingModelPatch
? pendingModelPatch.previous
: state.modelOverrides[normalizedKey];
const modelPatchToken = Symbol();
if (hasModelPatch) {
pendingModelPatches.set(normalizedKey, {
token: modelPatchToken,
previous: previousModelOverride,
});
setModelOverride(key, patchParams.model);
}
const restoreModelOverride = () => {
if (pendingModelPatches.get(normalizedKey)?.token !== modelPatchToken) {
return;
}
pendingModelPatches.delete(normalizedKey);
setModelOverride(key, previousModelOverride);
};
try {
const result = await requestSessionPatch(client, key, patchParams, options);
if (disposed || gateway.snapshot.client !== client) {
if (hasModelPatch) {
setModelOverride(key, previousModelOverride);
}
const result = await requestSessionPatch(scope.client, key, patchParams, options);
if (!isCurrentConnection(scope)) {
restoreModelOverride();
return null;
}
await refresh({ agentId: options.agentId, force: true });
if (hasModelPatch) {
if (!isCurrentConnection(scope)) {
restoreModelOverride();
return null;
}
if (pendingModelPatches.get(normalizedKey)?.token === modelPatchToken) {
pendingModelPatches.delete(normalizedKey);
setModelOverride(key, patchParams.model);
}
return result;
} catch (error) {
if (hasModelPatch) {
setModelOverride(key, previousModelOverride);
restoreModelOverride();
if (!isCurrentConnection(scope)) {
return null;
}
publish({ ...state, error: String(error) });
throw error;
@@ -819,20 +887,23 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
};
const remove = async (key: string, options: SessionDeleteOptions = {}): Promise<boolean> => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
return false;
}
try {
await requestSessionDelete(client, key, options);
if (disposed || gateway.snapshot.client !== client) {
await requestSessionDelete(scope.client, key, options);
if (!isCurrentConnection(scope)) {
return false;
}
publish({ ...state, deletedSessions: [{ key, agentId: options.agentId }] });
setModelOverride(key, undefined);
await refresh({ agentId: options.agentId, force: true });
return true;
return isCurrentConnection(scope);
} catch (error) {
if (!isCurrentConnection(scope)) {
return false;
}
publish({ ...state, error: String(error) });
throw error;
}
@@ -841,19 +912,19 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
const removeMany = async (
targets: readonly SessionDeleteTarget[],
): Promise<SessionDeleteBatchResult> => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed || targets.length === 0) {
const scope = captureConnection();
if (!scope || targets.length === 0) {
return { deleted: [], errors: [] };
}
const deleted: string[] = [];
const errors: string[] = [];
for (const target of targets) {
if (disposed || gateway.snapshot.client !== client) {
if (!isCurrentConnection(scope)) {
break;
}
try {
await requestSessionDelete(client, target.key, target);
if (disposed || gateway.snapshot.client !== client) {
await requestSessionDelete(scope.client, target.key, target);
if (!isCurrentConnection(scope)) {
break;
}
deleted.push(target.key);
@@ -861,7 +932,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
errors.push(String(error));
}
}
if (deleted.length > 0 && !disposed && gateway.snapshot.client === client) {
if (deleted.length > 0 && isCurrentConnection(scope)) {
publish({
...state,
deletedSessions: targets.filter((target) => deleted.includes(target.key)),
@@ -871,17 +942,20 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
}
await refresh({ force: true });
}
return { deleted, errors };
return isCurrentConnection(scope) ? { deleted, errors } : { deleted: [], errors: [] };
};
const reset = async (key: string, options: SessionResetOptions = {}): Promise<void> => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
return;
}
try {
await requestSessionReset(client, key, options);
await requestSessionReset(scope.client, key, options);
} catch (error) {
if (!isCurrentConnection(scope)) {
return;
}
publish({ ...state, error: String(error) });
throw error;
}
@@ -891,13 +965,13 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
key: string,
options: { agentId?: string | null } = {},
): Promise<SessionCompactResult> => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
throw new Error("Session compaction requires an active Gateway connection");
}
const result = await requestSessionCompact(client, key, options);
if (disposed || gateway.snapshot.client !== client) {
throw new Error("Session compaction completed on a replaced Gateway client");
const result = await requestSessionCompact(scope.client, key, options);
if (!isCurrentConnection(scope)) {
throw new Error("Session compaction completed on a replaced Gateway connection");
}
return result;
};
@@ -907,13 +981,13 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
message: string,
options: { agentId?: string | null } = {},
): Promise<SessionSteerResult> => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
throw new Error("Session steering requires an active Gateway connection");
}
const result = await requestSessionSteer(client, key, message, options);
if (disposed || gateway.snapshot.client !== client) {
throw new Error("Session steering completed on a replaced Gateway client");
const result = await requestSessionSteer(scope.client, key, message, options);
if (!isCurrentConnection(scope)) {
throw new Error("Session steering completed on a replaced Gateway connection");
}
return result;
};
@@ -922,12 +996,12 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
key: string,
options: { agentId?: string | null; path?: string; search?: string } = {},
): Promise<SessionWorkspaceListResult | null> => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
return null;
}
const result = await requestSessionFilesList(client, key, options);
return disposed || gateway.snapshot.client !== client ? null : result;
const result = await requestSessionFilesList(scope.client, key, options);
return isCurrentConnection(scope) ? result : null;
};
const getFile = async (
@@ -935,47 +1009,47 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
path: string,
options: { agentId?: string | null } = {},
): Promise<SessionWorkspaceGetResult | null> => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
return null;
}
const result = await requestSessionFile(client, key, path, options);
return disposed || gateway.snapshot.client !== client ? null : result;
const result = await requestSessionFile(scope.client, key, path, options);
return isCurrentConnection(scope) ? result : null;
};
const subscribeMessages = async (
key: string,
options: { agentId?: string | null } = {},
): Promise<SessionMessageSubscription> => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
throw new Error("Session message subscription requires an active Gateway connection");
}
const subscription = await subscribeSessionMessages(client, key, options);
if (disposed || gateway.snapshot.client !== client) {
throw new Error("Session message subscription completed on a replaced Gateway client");
const subscription = await subscribeSessionMessages(scope.client, key, options);
if (!isCurrentConnection(scope)) {
throw new Error("Session message subscription completed on a replaced Gateway connection");
}
return subscription;
};
const unsubscribeMessages = async (subscription: SessionMessageSubscription) => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
return;
}
await unsubscribeSessionMessages(client, subscription);
await unsubscribeSessionMessages(scope.client, subscription);
};
const listCheckpoints = async (
key: string,
options: { agentId?: string | null } = {},
): Promise<SessionCompactionCheckpoint[]> => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
return [];
}
const result = await listSessionCheckpoints(client, key, options);
return disposed || gateway.snapshot.client !== client ? [] : (result.checkpoints ?? []);
const result = await listSessionCheckpoints(scope.client, key, options);
return isCurrentConnection(scope) ? (result.checkpoints ?? []) : [];
};
const branchCheckpoint = async (
@@ -983,18 +1057,21 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
checkpointId: string,
options: { agentId?: string | null } = {},
): Promise<SessionsCompactionBranchResult> => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
throw new Error("Session checkpoint operation requires an active Gateway connection");
}
const result = await branchSessionCheckpoint(client, key, checkpointId, options);
if (disposed || gateway.snapshot.client !== client) {
throw new Error("Session checkpoint operation completed on a replaced Gateway client");
const result = await branchSessionCheckpoint(scope.client, key, checkpointId, options);
if (!isCurrentConnection(scope)) {
throw new Error("Session checkpoint operation completed on a replaced Gateway connection");
}
await refresh({
agentId: options.agentId ?? state.agentId ?? undefined,
force: true,
});
if (!isCurrentConnection(scope)) {
throw new Error("Session checkpoint operation completed on a replaced Gateway connection");
}
return result;
};
@@ -1003,22 +1080,35 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
checkpointId: string,
options: { agentId?: string | null } = {},
): Promise<SessionsCompactionRestoreResult> => {
const client = gateway.snapshot.client;
if (!client || !gateway.snapshot.connected || disposed) {
const scope = captureConnection();
if (!scope) {
throw new Error("Session checkpoint operation requires an active Gateway connection");
}
const result = await restoreSessionCheckpoint(client, key, checkpointId, options);
if (disposed || gateway.snapshot.client !== client) {
throw new Error("Session checkpoint operation completed on a replaced Gateway client");
const result = await restoreSessionCheckpoint(scope.client, key, checkpointId, options);
if (!isCurrentConnection(scope)) {
throw new Error("Session checkpoint operation completed on a replaced Gateway connection");
}
await refresh({
agentId: options.agentId ?? state.agentId ?? undefined,
force: true,
});
if (!isCurrentConnection(scope)) {
throw new Error("Session checkpoint operation completed on a replaced Gateway connection");
}
return result;
};
const stopGateway = gateway.subscribe((next) => {
const connectionChanged =
next.client !== connectionClient || next.connected !== connectionConnected;
connectionClient = next.client;
connectionConnected = next.connected;
if (connectionChanged) {
connectionEpoch += 1;
inFlight = null;
queuedRefresh = null;
rollbackPendingModelPatches();
}
if (!next.connected || !next.client) {
subscribedClient = null;
publish({
@@ -1032,17 +1122,20 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
return;
}
if (subscribedClient !== next.client) {
const client = next.client;
subscribedClient = client;
const scope = captureConnection();
if (!scope) {
return;
}
subscribedClient = scope.client;
void (async () => {
try {
await subscribeSessionGateway(client);
await subscribeSessionGateway(scope.client);
} catch (error) {
if (!disposed && gateway.snapshot.client === client) {
if (isCurrentConnection(scope)) {
publish({ ...state, error: String(error) });
}
} finally {
if (!disposed && gateway.snapshot.client === client) {
if (isCurrentConnection(scope)) {
const sessionKey = gateway.snapshot.sessionKey?.trim();
await refresh({
...(sessionKey ? scopedAgentListParamsForSession(gateway.snapshot, sessionKey) : {}),
@@ -1137,12 +1230,16 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
},
dispose() {
disposed = true;
connectionEpoch += 1;
connectionConnected = false;
inFlight = null;
queuedRefresh = null;
subscribedClient = null;
pendingModelPatches.clear();
stopGateway();
stopEvents();
createdListeners.clear();
listeners.clear();
inFlight = null;
queuedRefresh = null;
},
};
}
+36
View File
@@ -599,6 +599,24 @@ describe("searchClawHub", () => {
expect(state.clawhubSearchError).toBeNull();
expect(state.clawhubSearchLoading).toBe(false);
});
it("ignores a same-client search response from an older connection epoch", async () => {
const { state, request } = createState();
const queue = createDeferredRequestQueue(request);
const pending = searchClawHub(state, "github");
state.connected = false;
state.skillsAgentRevision++;
state.clawhubSearchLoading = false;
state.connected = true;
queue.resolveNext({
results: [{ score: 1, slug: "stale", displayName: "Stale" }],
});
await pending;
expect(state.clawhubSearchResults).toBeNull();
expect(state.clawhubSearchLoading).toBe(false);
});
});
describe("loadClawHubDetail", () => {
@@ -622,6 +640,24 @@ describe("loadClawHubDetail", () => {
expect(state.clawhubDetailLoading).toBe(false);
expect(state.clawhubDetail?.skill?.slug).toBe("gitlab");
});
it("ignores a same-client detail response from an older connection epoch", async () => {
const { state, request } = createState();
const queue = createDeferredRequestQueue(request);
const pending = loadClawHubDetail(state, "github");
state.connected = false;
state.skillsAgentRevision++;
state.clawhubDetailLoading = false;
state.connected = true;
queue.resolveNext({
skill: { slug: "stale", displayName: "Stale", createdAt: 1, updatedAt: 2 },
});
await pending;
expect(state.clawhubDetail).toBeNull();
expect(state.clawhubDetailLoading).toBe(false);
});
});
describe("skill mutations", () => {
+12 -2
View File
@@ -537,13 +537,18 @@ export async function searchClawHub(state: SkillsState, query: string) {
return;
}
const client = state.client;
const agentScope = captureSkillsAgentScope(state);
// Clear stale entries as soon as a new search begins so the UI cannot act on
// results that no longer match the current query while the next request is in flight.
state.clawhubSearchResults = null;
state.clawhubSearchLoading = true;
state.clawhubSearchError = null;
await runStaleAwareRequest(
() => query === state.clawhubSearchQuery,
() =>
state.connected &&
state.client === client &&
query === state.clawhubSearchQuery &&
isSkillsAgentScopeCurrent(state, agentScope),
() =>
client.request<{ results: ClawHubSearchResult[] }>("skills.search", {
query,
@@ -566,12 +571,17 @@ export async function loadClawHubDetail(state: SkillsState, slug: string) {
return;
}
const client = state.client;
const agentScope = captureSkillsAgentScope(state);
state.clawhubDetailSlug = slug;
state.clawhubDetailLoading = true;
state.clawhubDetailError = null;
state.clawhubDetail = null;
await runStaleAwareRequest(
() => slug === state.clawhubDetailSlug,
() =>
state.connected &&
state.client === client &&
slug === state.clawhubDetailSlug &&
isSkillsAgentScopeCurrent(state, agentScope),
() => client.request<ClawHubSkillDetail>("skills.detail", { slug }),
(res) => {
state.clawhubDetail = res ?? null;
+74
View File
@@ -0,0 +1,74 @@
import { html } from "lit";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { i18n, t } from "../i18n/index.ts";
import { OpenClawLightDomElement, OpenClawLitElement } from "./openclaw-element.ts";
const LIGHT_ELEMENT_NAME = "test-openclaw-light-dom-element";
const SHADOW_ELEMENT_NAME = "test-openclaw-shadow-dom-element";
class TestLightDomElement extends OpenClawLightDomElement {
renderCount = 0;
override render() {
this.renderCount += 1;
return html`<span>${t("common.refresh")}</span>`;
}
}
class TestShadowDomElement extends OpenClawLitElement {
override render() {
return html`<span>shadow content</span>`;
}
}
if (!customElements.get(LIGHT_ELEMENT_NAME)) {
customElements.define(LIGHT_ELEMENT_NAME, TestLightDomElement);
}
if (!customElements.get(SHADOW_ELEMENT_NAME)) {
customElements.define(SHADOW_ELEMENT_NAME, TestShadowDomElement);
}
describe("OpenClaw Lit elements", () => {
beforeEach(async () => {
await i18n.setLocale("en");
});
afterEach(async () => {
document.body.replaceChildren();
await i18n.setLocale("en");
});
it("provides explicit light- and shadow-DOM bases", async () => {
const light = document.createElement(LIGHT_ELEMENT_NAME) as TestLightDomElement;
const shadow = document.createElement(SHADOW_ELEMENT_NAME) as TestShadowDomElement;
document.body.append(light, shadow);
await Promise.all([light.updateComplete, shadow.updateComplete]);
expect(light.shadowRoot).toBeNull();
expect(light.textContent).toContain("Refresh");
expect(shadow.shadowRoot?.textContent).toContain("shadow content");
});
it("tracks locale changes across disconnect and reconnect", async () => {
const element = document.createElement(LIGHT_ELEMENT_NAME) as TestLightDomElement;
document.body.append(element);
await element.updateComplete;
const initialRenderCount = element.renderCount;
await i18n.setLocale("zh-CN");
await element.updateComplete;
expect(element.textContent).toContain("刷新");
expect(element.renderCount).toBe(initialRenderCount + 1);
element.remove();
const disconnectedRenderCount = element.renderCount;
await i18n.setLocale("en");
expect(element.renderCount).toBe(disconnectedRenderCount);
document.body.append(element);
await element.updateComplete;
expect(element.textContent).toContain("Refresh");
expect(element.renderCount).toBe(disconnectedRenderCount + 1);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { LitElement } from "lit";
import { I18nController } from "../i18n/lib/lit-controller.ts";
/** Lit base that refreshes the element when the active locale changes. */
export abstract class OpenClawLitElement extends LitElement {
protected readonly i18nController = new I18nController(this);
}
/** OpenClaw Lit base for components styled by the shared light-DOM stylesheet. */
export abstract class OpenClawLightDomElement extends OpenClawLitElement {
override createRenderRoot() {
return this;
}
}
+214
View File
@@ -0,0 +1,214 @@
// @vitest-environment node
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SubscriptionsController } from "./subscriptions-controller.ts";
afterEach(() => {
vi.restoreAllMocks();
});
class TestHost implements ReactiveControllerHost {
readonly controllers: ReactiveController[] = [];
readonly requestUpdate = vi.fn();
readonly updateComplete = Promise.resolve(true);
addController(controller: ReactiveController): void {
this.controllers.push(controller);
}
removeController(controller: ReactiveController): void {
const index = this.controllers.indexOf(controller);
if (index !== -1) {
this.controllers.splice(index, 1);
}
}
connect(): void {
for (const controller of this.controllers) {
controller.hostConnected?.();
}
}
update(): void {
for (const controller of this.controllers) {
controller.hostUpdate?.();
}
}
disconnect(): void {
for (const controller of this.controllers) {
controller.hostDisconnected?.();
}
}
}
class TestSource {
private listeners = new Set<() => void>();
readonly cleanups = vi.fn();
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
this.cleanups();
};
}
notify(): void {
for (const listener of this.listeners) {
listener();
}
}
}
describe("SubscriptionsController", () => {
it("waits for a source, synchronizes once, and does not subscribe twice", () => {
const host = new TestHost();
const controller = new SubscriptionsController(host);
const synchronize = vi.fn<(source: TestSource) => void>();
const source: { current?: TestSource } = {};
controller.watch(
() => source.current,
(next, notify) => next.subscribe(notify),
synchronize,
);
host.connect();
host.update();
expect(synchronize).not.toHaveBeenCalled();
source.current = new TestSource();
host.update();
host.update();
expect(synchronize).toHaveBeenCalledTimes(1);
host.requestUpdate.mockClear();
source.current.notify();
expect(synchronize).toHaveBeenCalledTimes(2);
expect(host.requestUpdate).toHaveBeenCalledOnce();
});
it("replaces sources and ignores notifications from a stale subscription", () => {
const host = new TestHost();
const controller = new SubscriptionsController(host);
const first = new TestSource();
const second = new TestSource();
let source = first;
let staleNotify: (() => void) | undefined;
const synchronize = vi.fn<(source: TestSource) => void>();
controller.watch(
() => source,
(next, notify) => {
if (next === first) {
staleNotify = notify;
}
return next.subscribe(notify);
},
synchronize,
);
host.connect();
source = second;
host.update();
expect(first.cleanups).toHaveBeenCalledOnce();
expect(synchronize).toHaveBeenLastCalledWith(second);
host.requestUpdate.mockClear();
staleNotify?.();
expect(host.requestUpdate).not.toHaveBeenCalled();
});
it("cleans up on disconnect and resubscribes once after reconnect", () => {
const host = new TestHost();
const controller = new SubscriptionsController(host);
const source = new TestSource();
const subscribe = vi.fn((next: TestSource, notify: () => void) => next.subscribe(notify));
controller.watch(() => source, subscribe);
host.connect();
host.disconnect();
host.update();
host.disconnect();
expect(source.cleanups).toHaveBeenCalledOnce();
expect(subscribe).toHaveBeenCalledOnce();
host.connect();
host.update();
expect(subscribe).toHaveBeenCalledTimes(2);
});
it("clears idempotently and reconnects on the next update", () => {
const host = new TestHost();
const controller = new SubscriptionsController(host);
const source = new TestSource();
const subscribe = vi.fn((next: TestSource, notify: () => void) => next.subscribe(notify));
controller.watch(() => source, subscribe);
host.connect();
controller.clear();
controller.clear();
expect(source.cleanups).toHaveBeenCalledOnce();
host.update();
expect(subscribe).toHaveBeenCalledTimes(2);
});
it("leaves effect invalidation under caller control", () => {
const host = new TestHost();
const controller = new SubscriptionsController(host);
const source = new TestSource();
const listener = vi.fn();
controller.effect(
() => source,
(next) => next.subscribe(listener),
);
host.connect();
expect(host.requestUpdate).not.toHaveBeenCalled();
source.notify();
expect(listener).toHaveBeenCalledOnce();
expect(host.requestUpdate).not.toHaveBeenCalled();
});
it("cleans up a listener when initial synchronization throws", () => {
const host = new TestHost();
const controller = new SubscriptionsController(host);
const source = new TestSource();
controller.watch(
() => source,
(next, notify) => next.subscribe(notify),
() => {
throw new Error("synchronize failed");
},
);
expect(() => host.connect()).toThrow("synchronize failed");
expect(source.cleanups).toHaveBeenCalledOnce();
});
it("runs later cleanups when an earlier cleanup throws", () => {
const host = new TestHost();
const controller = new SubscriptionsController(host);
const firstCleanup = vi.fn(() => {
throw new Error("cleanup failed");
});
const secondCleanup = vi.fn();
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
controller.effect(
() => "first",
() => firstCleanup,
);
controller.effect(
() => "second",
() => secondCleanup,
);
host.connect();
controller.clear();
expect(firstCleanup).toHaveBeenCalledOnce();
expect(secondCleanup).toHaveBeenCalledOnce();
expect(error).toHaveBeenCalledOnce();
});
});
+149
View File
@@ -0,0 +1,149 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
type Cleanup = () => void;
type SourceEntry<T> = {
readonly getSource: () => T | null | undefined;
readonly connect: (source: T) => Cleanup | undefined;
readonly invalidateOnConnect: boolean;
source: T | undefined;
cleanup: Cleanup | undefined;
generation: number;
};
/**
* Owns subscriptions whose sources can arrive or change after connection.
* Source identity is checked before every render and all cleanup follows the
* host lifecycle, so consumers do not need connected/updated retry loops.
*/
export class SubscriptionsController implements ReactiveController {
private readonly entries: SourceEntry<unknown>[] = [];
private connected = false;
constructor(private readonly host: ReactiveControllerHost) {
host.addController(this);
}
watch<T>(
getSource: () => T | null | undefined,
subscribe: (source: T, notify: () => void) => Cleanup,
synchronize?: (source: T) => void,
): this {
return this.addEntry(
getSource,
(source, entry) => {
const generation = entry.generation;
const notify = () => {
if (
!this.connected ||
entry.generation !== generation ||
!Object.is(entry.source, source)
) {
return;
}
synchronize?.(source);
this.host.requestUpdate();
};
const cleanup = subscribe(source, notify);
// Make cleanup visible before synchronization in case initial state
// projection throws after the external listener is already registered.
entry.cleanup = cleanup;
synchronize?.(source);
return cleanup;
},
true,
);
}
effect<T>(
getSource: () => T | null | undefined,
connect: (source: T) => Cleanup | undefined,
): this {
return this.addEntry(getSource, (source) => connect(source), false);
}
hostConnected(): void {
this.connected = true;
this.refresh(true);
}
hostUpdate(): void {
if (this.connected) {
this.refresh(false);
}
}
clear(): void {
for (const entry of this.entries) {
this.disconnectEntry(entry);
}
}
hostDisconnected(): void {
this.connected = false;
this.clear();
}
private addEntry<T>(
getSource: () => T | null | undefined,
connect: (source: T, entry: SourceEntry<T>) => Cleanup | undefined,
invalidateOnConnect: boolean,
): this {
const entry: SourceEntry<T> = {
getSource,
connect: (source) => connect(source, entry),
invalidateOnConnect,
source: undefined,
cleanup: undefined,
generation: 0,
};
this.entries.push(entry as SourceEntry<unknown>);
if (this.connected) {
this.refreshEntry(entry, invalidateOnConnect);
}
return this;
}
private refresh(requestUpdate: boolean): void {
for (const entry of this.entries) {
this.refreshEntry(entry, requestUpdate && entry.invalidateOnConnect);
}
}
private refreshEntry<T>(entry: SourceEntry<T>, requestUpdate: boolean): void {
const source = entry.getSource() ?? undefined;
if (Object.is(entry.source, source)) {
return;
}
this.disconnectEntry(entry);
if (source === undefined) {
return;
}
entry.source = source;
entry.generation += 1;
try {
entry.cleanup = entry.connect(source);
} catch (error) {
this.disconnectEntry(entry);
throw error;
}
if (requestUpdate) {
this.host.requestUpdate();
}
}
private disconnectEntry<T>(entry: SourceEntry<T>): void {
entry.generation += 1;
entry.source = undefined;
const cleanup = entry.cleanup;
entry.cleanup = undefined;
if (!cleanup) {
return;
}
try {
cleanup();
} catch (error) {
console.error("[openclaw] subscription cleanup failed", error);
}
}
}
@@ -0,0 +1,73 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import type { ActivityEntry } from "./tool-activity.ts";
import "./activity-page.ts";
type TestActivityPage = HTMLElement & {
context: ApplicationContext;
entries: ActivityEntry[];
subscriptions: {
hostConnected: () => void;
hostUpdate: () => void;
hostDisconnected: () => void;
};
};
function gateway(): ApplicationContext["gateway"] {
const snapshot: ApplicationGatewaySnapshot = {
client: null,
connected: false,
reconnecting: false,
hello: null,
assistantAgentId: null,
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
return {
snapshot,
eventLog: [],
subscribe: vi.fn(() => () => undefined),
subscribeEvents: vi.fn(() => () => undefined),
} as unknown as ApplicationContext["gateway"];
}
function staleEntry(): ActivityEntry {
return {
id: "stale",
toolCallId: "stale",
runId: "stale",
toolName: "stale",
status: "done",
startedAt: 0,
updatedAt: 0,
durationMs: 0,
outputTruncated: false,
summary: "stale",
hiddenArgumentCount: 0,
};
}
afterEach(() => {
localStorage.clear();
});
describe("ActivityPage gateway lifecycle", () => {
it("replays the active gateway on initial bind and source replacement", () => {
const page = document.createElement("openclaw-activity-page") as TestActivityPage;
page.context = { gateway: gateway() } as unknown as ApplicationContext;
page.entries = [staleEntry()];
page.subscriptions.hostConnected();
expect(page.entries).toEqual([]);
page.entries = [staleEntry()];
page.context = { gateway: gateway() } as unknown as ApplicationContext;
page.subscriptions.hostUpdate();
expect(page.entries).toEqual([]);
page.subscriptions.hostDisconnected();
});
});
+53 -51
View File
@@ -1,13 +1,19 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { html, type PropertyValues } from "lit";
import { state } from "lit/decorators.js";
import type { EventLogEntry } from "../../api/event-log.ts";
import type { GatewayEventFrame } from "../../api/gateway.ts";
import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import {
applicationContext,
type ApplicationContext,
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { loadSettings } from "../../app/settings.ts";
import { resolveSessionKey } from "../../lib/sessions/index.ts";
import { uiSessionEventMatches } from "../../lib/sessions/session-key.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import {
parseToolActivityEvent,
updateToolActivity,
@@ -18,12 +24,8 @@ import { renderActivity } from "./view.ts";
let activityClearBoundary: EventLogEntry | undefined;
class ActivityPage extends LitElement {
override createRenderRoot() {
return this;
}
@consume({ context: applicationContext, subscribe: false })
class ActivityPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@state() private entries: ActivityEntry[] = [];
@@ -39,50 +41,32 @@ class ActivityPage extends LitElement {
@state() private atBottom = true;
private sessionKey = "";
private replayFrame: number | null = null;
private scrollFrame: number | null = null;
private stopGatewaySubscription?: () => void;
private stopGatewayEvents?: () => void;
private readonly subscriptions = new SubscriptionsController(this).effect(
() => this.context?.gateway,
(gateway) => {
this.applyGatewaySnapshot(gateway, gateway.snapshot, true);
const stopEvents = gateway.subscribeEvents((event) => {
this.applyGatewayEvent(gateway, event, Date.now());
});
const stopGateway = gateway.subscribe((snapshot) =>
this.applyGatewaySnapshot(gateway, snapshot, false),
);
return () => {
stopGateway();
stopEvents();
};
},
);
override connectedCallback() {
super.connectedCallback();
this.syncSessionKey();
this.stopGatewayEvents = this.context.gateway.subscribeEvents((event) => {
this.applyGatewayEvent(event, Date.now());
});
this.stopGatewaySubscription = this.context.gateway.subscribe(() => {
const previousSessionKey = this.sessionKey;
this.syncSessionKey();
if (this.sessionKey !== previousSessionKey) {
this.rebuildEntries();
}
});
}
override firstUpdated() {
this.replayFrame = requestAnimationFrame(() => {
this.replayFrame = null;
if (this.isConnected) {
this.rebuildEntries();
}
});
}
override updated(changed: Map<PropertyKey, unknown>) {
override updated(changed: PropertyValues) {
if (this.autoFollow && this.atBottom && (changed.has("entries") || changed.has("autoFollow"))) {
this.scheduleScroll(changed.has("autoFollow"));
}
}
override disconnectedCallback() {
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.stopGatewayEvents?.();
this.stopGatewayEvents = undefined;
if (this.replayFrame !== null) {
cancelAnimationFrame(this.replayFrame);
this.replayFrame = null;
}
this.subscriptions.clear();
if (this.scrollFrame !== null) {
cancelAnimationFrame(this.scrollFrame);
this.scrollFrame = null;
@@ -90,18 +74,28 @@ class ActivityPage extends LitElement {
super.disconnectedCallback();
}
private syncSessionKey() {
const snapshot = this.context.gateway.snapshot;
private applyGatewaySnapshot(
gateway: ApplicationContext["gateway"],
snapshot: ApplicationGatewaySnapshot,
sourceChanged: boolean,
) {
const previousSessionKey = this.sessionKey;
this.sessionKey = resolveSessionKey(loadSettings().sessionKey, snapshot.hello);
if (sourceChanged || this.sessionKey !== previousSessionKey) {
this.rebuildEntries(gateway, snapshot);
}
}
private rebuildEntries() {
private rebuildEntries(
gateway: ApplicationContext["gateway"],
snapshot: ApplicationGatewaySnapshot,
) {
let entries: ActivityEntry[] = [];
const eventLog = this.context.gateway.eventLog;
const eventLog = gateway.eventLog;
const clearIndex = activityClearBoundary ? eventLog.indexOf(activityClearBoundary) : -1;
const visibleEvents = clearIndex < 0 ? eventLog : eventLog.slice(0, clearIndex);
for (const event of visibleEvents.toReversed()) {
entries = this.reduceGatewayEvent(entries, event.event, event.payload, event.ts);
entries = this.reduceGatewayEvent(entries, snapshot, event.event, event.payload, event.ts);
}
if (entries.length > 0 || this.entries.length > 0) {
this.entries = entries;
@@ -112,9 +106,17 @@ class ActivityPage extends LitElement {
this.atBottom = true;
}
private applyGatewayEvent(event: GatewayEventFrame, receivedAt: number) {
private applyGatewayEvent(
gateway: ApplicationContext["gateway"],
event: GatewayEventFrame,
receivedAt: number,
) {
if (this.context.gateway !== gateway) {
return;
}
const nextEntries = this.reduceGatewayEvent(
this.entries,
gateway.snapshot,
event.event,
event.payload,
receivedAt,
@@ -126,6 +128,7 @@ class ActivityPage extends LitElement {
private reduceGatewayEvent(
entries: ActivityEntry[],
gateway: ApplicationGatewaySnapshot,
eventName: string,
payload: unknown,
receivedAt: number,
@@ -137,7 +140,6 @@ class ActivityPage extends LitElement {
if (!event) {
return entries;
}
const gateway = this.context.gateway.snapshot;
if (
!uiSessionEventMatches(
{
+415
View File
@@ -0,0 +1,415 @@
/* @vitest-environment jsdom */
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type {
AgentsFilesListResult,
AgentsListResult,
ToolsEffectiveResult,
} from "../../api/types.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import type { AgentsRouteData } from "./agents-page.ts";
import "./agents-page.ts";
type TestAgentsPage = HTMLElement & {
context: ApplicationContext;
client: GatewayBrowserClient | null;
connected: boolean;
agentsList: unknown;
agentsSelectedId: string | null;
routeData?: AgentsRouteData;
agentFilesLoading: boolean;
agentFilesList: AgentsFilesListResult | null;
agentFileContents: Record<string, string>;
agentIdentityLoading: boolean;
agentsPanel: string;
toolsEffectiveLoading: boolean;
toolsEffectiveResult: ToolsEffectiveResult | null;
requestGeneration: number;
routeDataInitialized: boolean;
subscriptions: {
hostConnected: () => void;
hostUpdate: () => void;
hostDisconnected: () => void;
};
willUpdate: (changed: Map<PropertyKey, unknown>) => void;
applyGatewaySnapshot: (snapshot: ApplicationGatewaySnapshot, sourceChanged: boolean) => void;
ensureAgentIdentities: () => void;
loadEffectiveToolsForAgent: (agentId: string) => void;
loadAgentFiles: (agentId: string, force?: boolean) => Promise<void>;
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
function snapshot(
client: GatewayBrowserClient | null,
connected = true,
): ApplicationGatewaySnapshot {
return {
client,
connected,
reconnecting: false,
hello: null,
assistantAgentId: null,
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
}
function gateway(current: ApplicationGatewaySnapshot): ApplicationContext["gateway"] {
return {
snapshot: current,
subscribe: vi.fn(() => () => undefined),
} as unknown as ApplicationContext["gateway"];
}
function files(agentId: string, workspace: string): AgentsFilesListResult {
return { agentId, workspace, files: [] };
}
const agentsList: AgentsListResult = {
defaultId: "main",
mainKey: "main",
scope: "per-sender",
agents: [{ id: "main", name: "Main" }],
};
function agentsCapability(ensureFiles: () => Promise<AgentsFilesListResult>) {
return {
state: {
client: null,
connected: true,
agentsLoading: false,
agentsError: null,
agentsList,
},
files: () => ({ list: null, loading: false, error: null }),
ensureList: vi.fn(async () => agentsList),
refreshList: vi.fn(async () => agentsList),
ensureFiles,
refreshFiles: ensureFiles,
subscribe: vi.fn(() => () => undefined),
} as unknown as ApplicationContext["agents"];
}
function pageContext(
currentGateway: ApplicationContext["gateway"],
agents: ApplicationContext["agents"],
options?: {
agentIdentity?: ApplicationContext["agentIdentity"];
sessions?: ApplicationContext["sessions"];
},
): ApplicationContext {
const subscribe = vi.fn(() => () => undefined);
return {
gateway: currentGateway,
agents,
agentIdentity:
options?.agentIdentity ??
({
get: () => ({ agentId: "main" }),
entries: () => [],
ensure: vi.fn(async () => undefined),
subscribe,
} as unknown as ApplicationContext["agentIdentity"]),
sessions:
options?.sessions ??
({
state: { result: null, modelOverrides: {} },
subscribe,
} as unknown as ApplicationContext["sessions"]),
channels: { subscribe },
runtimeConfig: { subscribe },
} as unknown as ApplicationContext;
}
describe("AgentsPage gateway lifecycle", () => {
it("preserves matching initial route data, then resets it on provider replacement", () => {
const client = {} as GatewayBrowserClient;
const currentGateway = gateway(snapshot(client, false));
const preloadedAgents = {
defaultId: "main",
mainKey: "main",
scope: "per-sender",
agents: [{ id: "main", name: "Main" }],
};
const page = document.createElement("openclaw-agents-page") as TestAgentsPage;
page.routeData = {
gateway: currentGateway,
gatewaySnapshot: currentGateway.snapshot,
agentsList: preloadedAgents,
selectedAgentId: "main",
error: null,
};
page.context = { gateway: currentGateway } as unknown as ApplicationContext;
page.willUpdate(new Map([["routeData", undefined]]));
page.subscriptions.hostConnected();
expect(page.client).toBe(client);
expect(page.agentsList).toBe(preloadedAgents);
expect(page.agentsSelectedId).toBe("main");
expect(page.requestGeneration).toBe(0);
page.context = { gateway: gateway(snapshot(client, false)) } as unknown as ApplicationContext;
page.subscriptions.hostUpdate();
expect(page.agentsList).toBeNull();
expect(page.agentsSelectedId).toBeNull();
expect(page.requestGeneration).toBeGreaterThan(0);
page.subscriptions.hostDisconnected();
});
it("rejects preloaded data after a same-client gateway source replacement", async () => {
const client = {} as GatewayBrowserClient;
const preloadedSnapshot = snapshot(client);
const preloadedGateway = gateway(preloadedSnapshot);
const currentGateway = gateway(preloadedSnapshot);
const ensureList = vi.fn(async () => null);
const page = document.createElement("openclaw-agents-page") as TestAgentsPage;
page.client = client;
page.connected = true;
page.routeData = {
gateway: preloadedGateway,
gatewaySnapshot: preloadedSnapshot,
agentsList,
selectedAgentId: "main",
error: null,
};
page.context = {
gateway: currentGateway,
agents: {
state: { agentsLoading: false, agentsError: null, agentsList: null },
ensureList,
files: () => ({ list: null, loading: false, error: null }),
},
agentIdentity: { get: () => null },
runtimeConfig: { state: { configSnapshot: {}, configLoading: false } },
} as unknown as ApplicationContext;
page.willUpdate(new Map([["routeData", undefined]]));
await Promise.resolve();
expect(page.agentsList).toBeNull();
expect(ensureList).toHaveBeenCalledOnce();
});
it("does not let an old-client file load overwrite a replacement load", async () => {
let resolveFirst!: (value: AgentsFilesListResult) => void;
let resolveSecond!: (value: AgentsFilesListResult) => void;
const first = new Promise<AgentsFilesListResult>((resolve) => {
resolveFirst = resolve;
});
const second = new Promise<AgentsFilesListResult>((resolve) => {
resolveSecond = resolve;
});
const ensureFiles = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(second);
const page = document.createElement("openclaw-agents-page") as TestAgentsPage;
const oldClient = {} as GatewayBrowserClient;
const nextClient = {} as GatewayBrowserClient;
page.client = oldClient;
page.connected = true;
page.agentsSelectedId = "main";
page.context = {
agents: {
files: () => ({ list: null, loading: false, error: null }),
ensureFiles,
refreshFiles: ensureFiles,
},
} as unknown as ApplicationContext;
const oldLoad = page.loadAgentFiles("main");
expect(page.agentFilesLoading).toBe(true);
page.applyGatewaySnapshot(snapshot(nextClient), false);
page.agentsSelectedId = "main";
const replacementLoad = page.loadAgentFiles("main");
expect(page.agentFilesLoading).toBe(true);
resolveFirst(files("main", "old"));
await oldLoad;
expect(page.agentFilesList).toBeNull();
expect(page.agentFilesLoading).toBe(true);
resolveSecond(files("main", "new"));
await replacementLoad;
expect(page.agentFilesList?.workspace).toBe("new");
expect(page.agentFilesLoading).toBe(false);
});
it("retries an in-flight panel load after a same-client disconnect", async () => {
let resolveFirst!: (value: AgentsFilesListResult) => void;
let resolveSecond!: (value: AgentsFilesListResult) => void;
const first = new Promise<AgentsFilesListResult>((resolve) => {
resolveFirst = resolve;
});
const second = new Promise<AgentsFilesListResult>((resolve) => {
resolveSecond = resolve;
});
const ensureFiles = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(second);
const client = {} as GatewayBrowserClient;
const page = document.createElement("openclaw-agents-page") as TestAgentsPage;
page.client = client;
page.connected = true;
page.agentsList = {
defaultId: "main",
mainKey: "main",
scope: "per-sender",
agents: [{ id: "main", name: "Main" }],
};
page.agentsSelectedId = "main";
page.agentFileContents = { "cached.md": "keep" };
page.routeDataInitialized = true;
page.context = {
agents: {
files: () => ({ list: null, loading: false, error: null }),
ensureFiles,
refreshFiles: ensureFiles,
},
agentIdentity: { get: () => ({ agentId: "main" }) },
runtimeConfig: { state: { configSnapshot: {}, configLoading: false } },
} as unknown as ApplicationContext;
const oldLoad = page.loadAgentFiles("main");
expect(page.agentFilesLoading).toBe(true);
page.applyGatewaySnapshot(snapshot(client, false), false);
expect(page.agentFilesLoading).toBe(false);
expect(page.agentFileContents).toEqual({ "cached.md": "keep" });
page.applyGatewaySnapshot(snapshot(client), false);
expect(ensureFiles).toHaveBeenCalledTimes(2);
expect(page.agentFilesLoading).toBe(true);
resolveFirst(files("main", "old"));
await oldLoad;
expect(page.agentFilesList).toBeNull();
expect(page.agentFilesLoading).toBe(true);
resolveSecond(files("main", "new"));
await vi.waitFor(() => expect(page.agentFilesList?.workspace).toBe("new"));
expect(page.agentFilesLoading).toBe(false);
});
it("rejects a file result from a replaced agents capability", async () => {
const oldFiles = deferred<AgentsFilesListResult>();
const nextFiles = deferred<AgentsFilesListResult>();
const client = {} as GatewayBrowserClient;
const currentGateway = gateway(snapshot(client));
const oldAgents = agentsCapability(() => oldFiles.promise);
const nextAgents = agentsCapability(() => nextFiles.promise);
const page = document.createElement("openclaw-agents-page") as TestAgentsPage;
const context = pageContext(currentGateway, oldAgents);
page.context = context;
page.subscriptions.hostConnected();
const oldLoad = page.loadAgentFiles("main");
expect(page.agentFilesLoading).toBe(true);
page.context = { ...context, agents: nextAgents };
page.subscriptions.hostUpdate();
const nextLoad = page.loadAgentFiles("main");
expect(page.agentFilesLoading).toBe(true);
oldFiles.resolve(files("main", "old"));
await oldLoad;
expect(page.agentFilesList).toBeNull();
expect(page.agentFilesLoading).toBe(true);
nextFiles.resolve(files("main", "new"));
await nextLoad;
expect(page.agentFilesList?.workspace).toBe("new");
expect(page.agentFilesLoading).toBe(false);
page.subscriptions.hostDisconnected();
});
it("keeps replacement identity loading active when the old capability settles", async () => {
const oldEnsure = deferred<void>();
const nextEnsure = deferred<void>();
const client = {} as GatewayBrowserClient;
const currentGateway = gateway(snapshot(client));
const agents = agentsCapability(async () => files("main", "unused"));
const identity = (ensure: () => Promise<void>) =>
({
get: () => null,
entries: () => [],
ensure: vi.fn(ensure),
subscribe: vi.fn(() => () => undefined),
}) as unknown as ApplicationContext["agentIdentity"];
const page = document.createElement("openclaw-agents-page") as TestAgentsPage;
const context = pageContext(currentGateway, agents, {
agentIdentity: identity(() => oldEnsure.promise),
});
page.context = context;
page.subscriptions.hostConnected();
page.ensureAgentIdentities();
expect(page.agentIdentityLoading).toBe(true);
page.context = {
...context,
agentIdentity: identity(() => nextEnsure.promise),
};
page.subscriptions.hostUpdate();
expect(page.agentIdentityLoading).toBe(true);
oldEnsure.resolve();
await oldEnsure.promise;
await Promise.resolve();
expect(page.agentIdentityLoading).toBe(true);
nextEnsure.resolve();
await nextEnsure.promise;
await vi.waitFor(() => expect(page.agentIdentityLoading).toBe(false));
page.subscriptions.hostDisconnected();
});
it("rejects effective-tools results from a replaced sessions capability", async () => {
const oldResult = deferred<ToolsEffectiveResult>();
const nextResult = deferred<ToolsEffectiveResult>();
const request = vi
.fn()
.mockReturnValueOnce(oldResult.promise)
.mockReturnValueOnce(nextResult.promise);
const client = { request } as unknown as GatewayBrowserClient;
const currentGateway = gateway(snapshot(client));
const agents = agentsCapability(async () => files("main", "unused"));
const oldSessions = {
state: { result: null, modelOverrides: {} },
subscribe: vi.fn(() => () => undefined),
} as unknown as ApplicationContext["sessions"];
const nextSessions = {
state: { result: null, modelOverrides: {} },
subscribe: vi.fn(() => () => undefined),
} as unknown as ApplicationContext["sessions"];
const page = document.createElement("openclaw-agents-page") as TestAgentsPage;
const context = pageContext(currentGateway, agents, { sessions: oldSessions });
page.context = context;
page.subscriptions.hostConnected();
page.agentsPanel = "overview";
page.loadEffectiveToolsForAgent("main");
expect(page.toolsEffectiveLoading).toBe(true);
page.context = { ...context, sessions: nextSessions };
page.subscriptions.hostUpdate();
page.loadEffectiveToolsForAgent("main");
expect(page.toolsEffectiveLoading).toBe(true);
oldResult.resolve({ profile: "old" } as ToolsEffectiveResult);
await oldResult.promise;
await Promise.resolve();
expect(page.toolsEffectiveResult).toBeNull();
expect(page.toolsEffectiveLoading).toBe(true);
nextResult.resolve({ profile: "new" } as ToolsEffectiveResult);
await nextResult.promise;
await vi.waitFor(() => expect(page.toolsEffectiveResult?.profile).toBe("new"));
expect(page.toolsEffectiveLoading).toBe(false);
page.subscriptions.hostDisconnected();
});
});
+296 -94
View File
@@ -1,5 +1,5 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { html, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type {
@@ -12,7 +12,11 @@ import type {
ToolsEffectiveResult,
} from "../../api/types.ts";
import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import {
applicationContext,
type ApplicationContext,
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import {
resolveAgentConfig,
@@ -38,23 +42,27 @@ import {
} from "../../lib/cron/index.ts";
import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts";
import { normalizeStringEntries } from "../../lib/string-coerce.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { loadAgentFileContent, saveAgentFile } from "./files.ts";
import { loadAgentSkills } from "./skills.ts";
import { renderAgents } from "./view.ts";
export type AgentsRouteData = {
connected: boolean;
// Client identity alone cannot distinguish provider replacement or reconnect epochs.
gateway: ApplicationContext["gateway"];
gatewaySnapshot: ApplicationGatewaySnapshot;
agentsList: AgentsListResult | null;
selectedAgentId: string | null;
error: string | null;
};
class AgentsPage extends LitElement implements AgentsState {
override createRenderRoot() {
return this;
}
type AgentsRequestSources = Partial<
Pick<ApplicationContext, "agents" | "agentIdentity" | "sessions">
>;
@consume({ context: applicationContext, subscribe: false })
class AgentsPage extends OpenClawLightDomElement implements AgentsState {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@property({ attribute: false }) routeData?: AgentsRouteData;
@@ -92,13 +100,127 @@ class AgentsPage extends LitElement implements AgentsState {
@state() skillsFilter = "";
@state() private cron = createInitialCronState();
requestGeneration = 0;
private routeDataInitialized = false;
private stopGatewaySubscription?: () => void;
private stopAgentsSubscription?: () => void;
private stopAgentIdentitySubscription?: () => void;
private stopChannelsSubscription?: () => void;
private stopConfigSubscription?: () => void;
private stopSessionsSubscription?: () => void;
private hasBoundGateway = false;
private gatewaySource: ApplicationContext["gateway"] | null = null;
private hasBoundAgents = false;
private agentsSource: ApplicationContext["agents"] | null = null;
private hasBoundAgentIdentity = false;
private agentIdentitySource: ApplicationContext["agentIdentity"] | null = null;
private hasBoundSessions = false;
private sessionsSource: ApplicationContext["sessions"] | null = null;
private readonly subscriptions = new SubscriptionsController(this)
.effect(
() => this.context?.agents,
(agents) => {
const resetForSourceBind = this.hasBoundAgents;
this.hasBoundAgents = true;
this.agentsSource = agents;
if (resetForSourceBind) {
this.resetForAgentsSourceChange();
}
this.syncAgentState(agents);
this.ensureInitialData();
const stop = agents.subscribe(() => {
if (this.agentsSource !== agents || this.context.agents !== agents) {
return;
}
this.syncAgentState(agents);
this.ensureAgentIdentities();
this.loadActivePanelData();
this.requestUpdate();
});
return () => {
stop();
if (this.agentsSource === agents) {
this.agentsSource = null;
}
};
},
)
.effect(
() => this.context?.agentIdentity,
(agentIdentity) => {
const resetForSourceBind = this.hasBoundAgentIdentity;
this.hasBoundAgentIdentity = true;
this.agentIdentitySource = agentIdentity;
if (resetForSourceBind) {
this.invalidateTransientRequests();
this.agentIdentityError = null;
}
this.ensureAgentIdentities();
this.ensureInitialData();
const stop = agentIdentity.subscribe(() => {
if (
this.agentIdentitySource === agentIdentity &&
this.context.agentIdentity === agentIdentity
) {
this.requestUpdate();
}
});
return () => {
stop();
if (this.agentIdentitySource === agentIdentity) {
this.agentIdentitySource = null;
}
};
},
)
.watch(
() => this.context?.channels,
(channels, notify) => channels.subscribe(notify),
)
.watch(
() => this.context?.runtimeConfig,
(runtimeConfig, notify) => runtimeConfig.subscribe(notify),
)
.effect(
() => this.context?.sessions,
(sessions) => {
const resetForSourceBind = this.hasBoundSessions;
this.hasBoundSessions = true;
this.sessionsSource = sessions;
if (resetForSourceBind) {
this.invalidateTransientRequests();
resetToolsEffectiveState(this);
this.loadActivePanelData();
}
const stop = sessions.subscribe(() => {
if (this.sessionsSource !== sessions || this.context.sessions !== sessions) {
return;
}
void refreshVisibleToolsEffectiveForCurrentSession(this);
this.requestUpdate();
});
return () => {
stop();
if (this.sessionsSource === sessions) {
this.sessionsSource = null;
}
};
},
)
.effect(
() => this.context?.gateway,
(gateway) => {
const initialBind = !this.hasBoundGateway;
this.hasBoundGateway = true;
this.gatewaySource = gateway;
this.applyGatewaySnapshot(gateway.snapshot, !initialBind, initialBind);
const stop = gateway.subscribe((snapshot) => {
if (this.gatewaySource === gateway && this.context.gateway === gateway) {
this.applyGatewaySnapshot(snapshot, false);
}
});
return () => {
stop();
if (this.gatewaySource === gateway) {
this.gatewaySource = null;
}
};
},
);
get sessions() {
return this.context.sessions;
@@ -112,79 +234,56 @@ class AgentsPage extends LitElement implements AgentsState {
return this.context.gateway.snapshot.sessionKey;
}
override connectedCallback() {
super.connectedCallback();
this.syncGatewayState();
this.syncAgentState();
this.stopGatewaySubscription = this.context.gateway.subscribe((snapshot) => {
const previousClient = this.client;
this.syncGatewayState();
if (previousClient !== snapshot.client) {
this.resetForClientChange();
}
this.ensureInitialData();
});
this.stopAgentsSubscription = this.context.agents.subscribe(() => {
this.syncAgentState();
this.ensureAgentIdentities();
this.loadActivePanelData();
this.requestUpdate();
});
this.stopAgentIdentitySubscription = this.context.agentIdentity.subscribe(() =>
this.requestUpdate(),
);
this.stopChannelsSubscription = this.context.channels.subscribe(() => this.requestUpdate());
this.stopConfigSubscription = this.context.runtimeConfig.subscribe(() => this.requestUpdate());
this.stopSessionsSubscription = this.context.sessions.subscribe(() => {
void refreshVisibleToolsEffectiveForCurrentSession(this);
this.requestUpdate();
});
this.ensureInitialData();
override disconnectedCallback() {
this.subscriptions.clear();
this.requestGeneration += 1;
this.client = null;
this.connected = false;
super.disconnectedCallback();
}
override willUpdate(changed: Map<PropertyKey, unknown>) {
override willUpdate(changed: PropertyValues<this>) {
if (changed.has("routeData")) {
this.applyRouteData();
this.ensureInitialData();
}
}
override disconnectedCallback() {
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.stopAgentsSubscription?.();
this.stopAgentsSubscription = undefined;
this.stopAgentIdentitySubscription?.();
this.stopAgentIdentitySubscription = undefined;
this.stopChannelsSubscription?.();
this.stopChannelsSubscription = undefined;
this.stopConfigSubscription?.();
this.stopConfigSubscription = undefined;
this.stopSessionsSubscription?.();
this.stopSessionsSubscription = undefined;
super.disconnectedCallback();
private applyGatewaySnapshot(
snapshot: ApplicationGatewaySnapshot,
forceReset: boolean,
initialBind = false,
) {
const connectionChanged = this.connected !== snapshot.connected;
const clientChanged = this.client !== snapshot.client;
this.syncGatewayState(snapshot);
if (forceReset || (!initialBind && clientChanged)) {
this.resetForClientChange();
} else if (!initialBind && connectionChanged) {
this.invalidateTransientRequests();
}
this.ensureInitialData();
}
private syncGatewayState() {
const gateway = this.context.gateway.snapshot;
this.client = gateway.client;
this.connected = gateway.connected;
private syncGatewayState(snapshot: ApplicationGatewaySnapshot) {
this.client = snapshot.client;
this.connected = snapshot.connected;
this.cron = {
...this.cron,
client: gateway.client,
connected: gateway.connected,
client: snapshot.client,
connected: snapshot.connected,
};
}
private syncAgentState() {
const agentState = this.context.agents.state;
private syncAgentState(agents = this.context.agents) {
const agentState = agents.state;
this.agentsLoading = agentState.agentsLoading;
this.agentsError = agentState.agentsError;
this.agentsList = agentState.agentsList;
if (agentState.agentsList) {
this.ensureSelectedAgentInList(agentState.agentsList);
}
this.syncCurrentAgentFiles();
this.syncCurrentAgentFiles(agents);
}
private ensureSelectedAgentInList(agentsList: AgentsListResult) {
@@ -194,12 +293,12 @@ class AgentsPage extends LitElement implements AgentsState {
}
}
private syncCurrentAgentFiles() {
private syncCurrentAgentFiles(agents = this.context.agents) {
const agentId = this.resolveSelectedAgentId();
if (!agentId || this.agentsPanel !== "files") {
return;
}
const status = this.context.agents.files(agentId);
const status = agents.files(agentId);
if (!status.list) {
return;
}
@@ -225,12 +324,46 @@ class AgentsPage extends LitElement implements AgentsState {
});
}
private resetForAgentsSourceChange() {
this.agentsLoading = false;
this.agentsError = null;
this.agentsList = null;
this.agentsSelectedId = null;
this.resetSelectionState();
}
private invalidateTransientRequests() {
this.requestGeneration += 1;
this.agentsLoading = false;
this.agentFilesLoading = false;
this.agentFileSaving = false;
this.agentIdentityLoading = false;
this.agentSkillsLoading = false;
this.toolsCatalogLoading = false;
this.toolsCatalogLoadingAgentId = null;
this.toolsEffectiveLoading = false;
this.toolsEffectiveLoadingKey = null;
this.cron = {
...this.cron,
cronLoading: false,
cronJobsLoadingMore: false,
cronJobsReloadPending: false,
cronJobsReloadPendingTableFilters: false,
cronRunsLoadingMore: false,
cronBusy: false,
};
}
private applyRouteData() {
const data = this.routeData;
if (!data) {
return;
}
this.routeDataInitialized = true;
const gateway = this.context.gateway;
if (data.gateway !== gateway || data.gatewaySnapshot !== gateway.snapshot) {
return;
}
this.agentsLoading = false;
this.agentsError = data.error;
if (data.agentsList) {
@@ -281,23 +414,45 @@ class AgentsPage extends LitElement implements AgentsState {
this.loadActivePanelData();
}
private isCurrentRequest(
client: GatewayBrowserClient,
generation: number,
agentId?: string,
sources: AgentsRequestSources = {},
): boolean {
return (
this.client === client &&
this.connected &&
this.requestGeneration === generation &&
(!sources.agents || this.context.agents === sources.agents) &&
(!sources.agentIdentity || this.context.agentIdentity === sources.agentIdentity) &&
(!sources.sessions || this.context.sessions === sources.sessions) &&
(!agentId || this.resolveSelectedAgentId() === agentId)
);
}
private ensureAgentIdentities() {
const client = this.client;
const agentIdentity = this.context.agentIdentity;
const ids =
this.agentsList?.agents
.map((entry) => entry.id)
.filter((id) => !this.context.agentIdentity.get(id)) ?? [];
if (ids.length === 0 || this.agentIdentityLoading) {
this.agentsList?.agents.map((entry) => entry.id).filter((id) => !agentIdentity.get(id)) ?? [];
if (!client || !this.connected || ids.length === 0 || this.agentIdentityLoading) {
return;
}
const generation = this.requestGeneration;
this.agentIdentityLoading = true;
this.agentIdentityError = null;
void this.context.agentIdentity
void agentIdentity
.ensure(ids)
.catch((err: unknown) => {
this.agentIdentityError = String(err);
if (this.isCurrentRequest(client, generation, undefined, { agentIdentity })) {
this.agentIdentityError = String(err);
}
})
.finally(() => {
this.agentIdentityLoading = false;
if (this.isCurrentRequest(client, generation, undefined, { agentIdentity })) {
this.agentIdentityLoading = false;
}
});
}
@@ -331,32 +486,42 @@ class AgentsPage extends LitElement implements AgentsState {
}
private async loadAgentsAndCommit() {
await this.context.agents.ensureList();
this.syncAgentState();
const client = this.client;
const generation = this.requestGeneration;
const agents = this.context.agents;
if (!client) {
return;
}
await agents.ensureList();
if (!this.isCurrentRequest(client, generation, undefined, { agents })) {
return;
}
this.syncAgentState(agents);
this.ensureAgentIdentities();
this.loadActivePanelData();
}
private async loadAgentFiles(agentId: string, force = false) {
if (!this.client || !this.connected || this.agentFilesLoading) {
const client = this.client;
const agents = this.context.agents;
if (!client || !this.connected || this.agentFilesLoading) {
return;
}
const cached = this.context.agents.files(agentId);
const cached = agents.files(agentId);
if (cached.list && !force) {
this.syncCurrentAgentFiles();
this.syncCurrentAgentFiles(agents);
return;
}
const generation = this.requestGeneration;
this.agentFilesLoading = true;
this.agentFilesError = null;
try {
const list = force
? await this.context.agents.refreshFiles(agentId)
: await this.context.agents.ensureFiles(agentId);
if (this.resolveSelectedAgentId() !== agentId) {
const list = force ? await agents.refreshFiles(agentId) : await agents.ensureFiles(agentId);
if (!this.isCurrentRequest(client, generation, agentId, { agents })) {
return;
}
this.agentFilesList = list ?? this.context.agents.files(agentId).list;
this.agentFilesError = this.context.agents.files(agentId).error;
this.agentFilesList = list ?? agents.files(agentId).list;
this.agentFilesError = agents.files(agentId).error;
if (
this.agentFileActive &&
!this.agentFilesList?.files.some((file) => file.name === this.agentFileActive)
@@ -364,7 +529,7 @@ class AgentsPage extends LitElement implements AgentsState {
this.agentFileActive = null;
}
} finally {
if (this.resolveSelectedAgentId() === agentId) {
if (this.isCurrentRequest(client, generation, agentId, { agents })) {
this.agentFilesLoading = false;
}
}
@@ -385,18 +550,24 @@ class AgentsPage extends LitElement implements AgentsState {
}
private resetSelectionState() {
this.requestGeneration += 1;
this.agentFilesList = null;
this.agentFilesError = null;
this.agentFileActive = null;
this.agentFileContents = {};
this.agentFileDrafts = {};
this.agentFilesLoading = false;
this.agentFileSaving = false;
this.agentSkillsReport = null;
this.agentSkillsLoading = false;
this.agentSkillsError = null;
this.agentSkillsAgentId = null;
this.agentIdentityLoading = false;
this.agentIdentityError = null;
this.toolsCatalogResult = null;
this.toolsCatalogError = null;
this.toolsCatalogLoading = false;
this.toolsCatalogLoadingAgentId = null;
resetToolsEffectiveState(this);
}
@@ -459,19 +630,37 @@ class AgentsPage extends LitElement implements AgentsState {
}
private refreshAgents() {
const client = this.client;
const generation = this.requestGeneration;
const agents = this.context.agents;
if (!client) {
return;
}
void (async () => {
await this.context.agents.refreshList();
this.syncAgentState();
await agents.refreshList();
if (!this.isCurrentRequest(client, generation, undefined, { agents })) {
return;
}
this.syncAgentState(agents);
this.loadActivePanelData();
})();
}
private saveAgentConfig() {
const client = this.client;
const generation = this.requestGeneration;
const agents = this.context.agents;
if (!client) {
return;
}
const selectedBefore = this.agentsSelectedId;
void (async () => {
await this.context.runtimeConfig.save();
await this.context.agents.refreshList();
this.syncAgentState();
await agents.refreshList();
if (!this.isCurrentRequest(client, generation, undefined, { agents })) {
return;
}
this.syncAgentState(agents);
if (selectedBefore && this.agentsList?.agents.some((entry) => entry.id === selectedBefore)) {
this.agentsSelectedId = selectedBefore;
}
@@ -480,6 +669,20 @@ class AgentsPage extends LitElement implements AgentsState {
})();
}
private saveSelectedAgentFile(agentId: string, name: string, content: string) {
const client = this.client;
const generation = this.requestGeneration;
const agents = this.context.agents;
if (!client) {
return;
}
void saveAgentFile(this, agentId, name, content).then(() => {
if (this.isCurrentRequest(client, generation, agentId, { agents })) {
void this.loadAgentFiles(agentId, true);
}
});
}
private reloadConfig() {
void this.context.runtimeConfig.refresh({ discardPendingChanges: true });
}
@@ -584,12 +787,11 @@ class AgentsPage extends LitElement implements AgentsState {
},
onFileSave: (name) => {
if (selectedAgentId) {
void saveAgentFile(
this,
this.saveSelectedAgentFile(
selectedAgentId,
name,
this.agentFileDrafts[name] ?? this.agentFileContents[name] ?? "",
).then(() => this.loadAgentFiles(selectedAgentId, true));
);
}
},
onToolsProfileChange: (agentId, profile, clearAllow) => {
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { AgentsFilesGetResult, AgentsFilesSetResult } from "../../api/types.ts";
import { loadAgentFileContent, saveAgentFile } from "./files.ts";
type FilesState = Parameters<typeof loadAgentFileContent>[0];
function createState(client: GatewayBrowserClient): FilesState {
return {
client,
connected: true,
requestGeneration: 0,
agentFilesLoading: false,
agentFilesError: null,
agentFilesList: { agentId: "main", workspace: "workspace", files: [] },
agentFileContents: {},
agentFileDrafts: {},
agentFileActive: null,
agentFileSaving: false,
};
}
function fileResult(content: string): AgentsFilesGetResult {
return {
agentId: "main",
workspace: "workspace",
file: { name: "AGENTS.md", path: "AGENTS.md", missing: false, content },
};
}
describe("agent file requests", () => {
it("does not let an old-client read overwrite or finish a replacement read", async () => {
let resolveOld!: (value: AgentsFilesGetResult) => void;
let resolveNext!: (value: AgentsFilesGetResult) => void;
const oldClient = {
request: vi.fn(
() =>
new Promise<AgentsFilesGetResult>((resolve) => {
resolveOld = resolve;
}),
),
} as unknown as GatewayBrowserClient;
const nextClient = {
request: vi.fn(
() =>
new Promise<AgentsFilesGetResult>((resolve) => {
resolveNext = resolve;
}),
),
} as unknown as GatewayBrowserClient;
const state = createState(oldClient);
const oldLoad = loadAgentFileContent(state, "main", "AGENTS.md");
state.client = nextClient;
state.requestGeneration += 1;
state.agentFilesLoading = false;
const nextLoad = loadAgentFileContent(state, "main", "AGENTS.md");
resolveOld(fileResult("old"));
await oldLoad;
expect(state.agentFileContents).toEqual({});
expect(state.agentFilesLoading).toBe(true);
resolveNext(fileResult("new"));
await nextLoad;
expect(state.agentFileContents).toEqual({ "AGENTS.md": "new" });
expect(state.agentFilesLoading).toBe(false);
});
it("ignores an old-client save completion", async () => {
let resolveSave!: (value: AgentsFilesSetResult) => void;
const oldClient = {
request: vi.fn(
() =>
new Promise<AgentsFilesSetResult>((resolve) => {
resolveSave = resolve;
}),
),
} as unknown as GatewayBrowserClient;
const state = createState(oldClient);
const save = saveAgentFile(state, "main", "AGENTS.md", "old");
state.client = { request: vi.fn() } as unknown as GatewayBrowserClient;
state.requestGeneration += 1;
state.agentFileSaving = false;
resolveSave({ ok: true, ...fileResult("old") });
await save;
expect(state.agentFileContents).toEqual({});
expect(state.agentFileSaving).toBe(false);
});
});
+27 -10
View File
@@ -10,6 +10,7 @@ import type {
type AgentFilesState = {
client: GatewayBrowserClient | null;
connected: boolean;
requestGeneration: number;
agentFilesLoading: boolean;
agentFilesError: string | null;
agentFilesList: AgentsFilesListResult | null;
@@ -39,20 +40,24 @@ export async function loadAgentFileContent(
name: string,
opts?: { force?: boolean; preserveDraft?: boolean },
): Promise<boolean> {
if (!state.client || !state.connected || state.agentFilesLoading) {
const client = state.client;
if (!client || !state.connected || state.agentFilesLoading) {
return false;
}
if (!opts?.force && Object.hasOwn(state.agentFileContents, name)) {
return true;
}
const generation = state.requestGeneration;
const isCurrent = () =>
state.client === client && state.connected && state.requestGeneration === generation;
state.agentFilesLoading = true;
state.agentFilesError = null;
try {
const res = await state.client.request<AgentsFilesGetResult | null>("agents.files.get", {
const res = await client.request<AgentsFilesGetResult | null>("agents.files.get", {
agentId,
name,
});
if (res?.file) {
if (res?.file && isCurrent()) {
const content = res.file.content ?? "";
const previousBase = state.agentFileContents[name] ?? "";
const currentDraft = state.agentFileDrafts[name];
@@ -69,10 +74,14 @@ export async function loadAgentFileContent(
return true;
}
} catch (err) {
state.agentFilesError = String(err);
if (isCurrent()) {
state.agentFilesError = String(err);
}
return false;
} finally {
state.agentFilesLoading = false;
if (isCurrent()) {
state.agentFilesLoading = false;
}
}
return false;
}
@@ -83,25 +92,33 @@ export async function saveAgentFile(
name: string,
content: string,
) {
if (!state.client || !state.connected || state.agentFileSaving) {
const client = state.client;
if (!client || !state.connected || state.agentFileSaving) {
return;
}
const generation = state.requestGeneration;
const isCurrent = () =>
state.client === client && state.connected && state.requestGeneration === generation;
state.agentFileSaving = true;
state.agentFilesError = null;
try {
const res = await state.client.request<AgentsFilesSetResult | null>("agents.files.set", {
const res = await client.request<AgentsFilesSetResult | null>("agents.files.set", {
agentId,
name,
content,
});
if (res?.file) {
if (res?.file && isCurrent()) {
state.agentFilesList = mergeFileEntry(state.agentFilesList, res.file);
state.agentFileContents = { ...state.agentFileContents, [name]: content };
state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content };
}
} catch (err) {
state.agentFilesError = String(err);
if (isCurrent()) {
state.agentFilesError = String(err);
}
} finally {
state.agentFileSaving = false;
if (isCurrent()) {
state.agentFileSaving = false;
}
}
}
+4 -2
View File
@@ -4,10 +4,12 @@ import type { ApplicationContext } from "../../app/context.ts";
import type { AgentsRouteData } from "./agents-page.ts";
async function loadAgentsRouteData(context: ApplicationContext): Promise<AgentsRouteData> {
const gateway = context.gateway.snapshot;
const gateway = context.gateway;
const gatewaySnapshot = gateway.snapshot;
const agentsList = context.agents.state.agentsList;
return {
connected: gateway.connected,
gateway,
gatewaySnapshot,
agentsList,
selectedAgentId: agentsList?.defaultId ?? agentsList?.agents[0]?.id ?? null,
error: context.agents.state.agentsError,
+14 -5
View File
@@ -6,6 +6,7 @@ import { loadSkillStatusReport } from "../../lib/skills/index.ts";
type AgentSkillsState = {
client: GatewayBrowserClient | null;
connected: boolean;
requestGeneration: number;
agentSkillsLoading: boolean;
agentSkillsError: string | null;
agentSkillsReport: SkillStatusReport | null;
@@ -13,23 +14,31 @@ type AgentSkillsState = {
};
export async function loadAgentSkills(state: AgentSkillsState, agentId: string) {
if (!state.client || !state.connected) {
const client = state.client;
if (!client || !state.connected) {
return;
}
if (state.agentSkillsLoading) {
return;
}
const generation = state.requestGeneration;
const isCurrent = () =>
state.client === client && state.connected && state.requestGeneration === generation;
state.agentSkillsLoading = true;
state.agentSkillsError = null;
try {
const res = await loadSkillStatusReport(state.client, agentId);
if (res) {
const res = await loadSkillStatusReport(client, agentId);
if (res && isCurrent()) {
state.agentSkillsReport = res;
state.agentSkillsAgentId = agentId;
}
} catch (err) {
state.agentSkillsError = String(err);
if (isCurrent()) {
state.agentSkillsError = String(err);
}
} finally {
state.agentSkillsLoading = false;
if (isCurrent()) {
state.agentSkillsLoading = false;
}
}
}
+225
View File
@@ -0,0 +1,225 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { NostrProfile } from "../../api/types.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import { createChannelCapability } from "../../lib/channels/index.ts";
import { createRuntimeConfigCapability } from "../../lib/config/index.ts";
import "./channels-page.ts";
type ChannelsPageTestElement = HTMLElement & {
context: ApplicationContext;
updateComplete: Promise<boolean>;
requestUpdate: () => void;
};
type NostrTestPage = ChannelsPageTestElement & {
nostrProfileFormState: {
values: NostrProfile;
saving: boolean;
importing: boolean;
} | null;
nostrProfileAccountId: string | null;
editNostrProfile: (accountId: string, profile: NostrProfile | null) => void;
saveNostrProfile: () => Promise<void>;
importNostrProfile: () => Promise<void>;
};
type TestGateway = ApplicationContext["gateway"] & {
emit: (patch: Partial<ApplicationGatewaySnapshot>) => void;
};
function createDeferred<T>() {
let resolve: ((value: T) => void) | undefined;
const promise = new Promise<T>((res) => {
resolve = res;
});
if (!resolve) {
throw new Error("Expected deferred callback to be initialized");
}
return { promise, resolve };
}
function createGateway(): TestGateway {
const client = { request: vi.fn(async () => ({})) } as unknown as GatewayBrowserClient;
const snapshot: ApplicationGatewaySnapshot = {
client,
connected: true,
reconnecting: false,
hello: null,
assistantAgentId: null,
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
return {
snapshot,
connection: { gatewayUrl: "", token: "", password: "" },
subscribe(listener: (next: ApplicationGatewaySnapshot) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
emit(patch: Partial<ApplicationGatewaySnapshot>) {
Object.assign(snapshot, patch);
for (const listener of listeners) {
listener(snapshot);
}
},
} as unknown as TestGateway;
}
function createContext(gateway: ApplicationContext["gateway"]) {
const channels = createChannelCapability(gateway);
channels.state.channelsSnapshot = {
ts: 0,
channelOrder: [],
channelLabels: {},
channels: {},
channelAccounts: {},
channelDefaultAccountId: {},
};
const runtimeConfig = createRuntimeConfigCapability(gateway);
runtimeConfig.state.configSnapshot = { config: {}, hash: "test" };
const ensureSchemaLoaded = vi.spyOn(runtimeConfig, "ensureSchemaLoaded").mockResolvedValue();
const context = {
basePath: "",
gateway,
channels,
runtimeConfig,
navigate: vi.fn(),
preload: vi.fn(async () => undefined),
} as unknown as ApplicationContext;
return { context, ensureSchemaLoaded, runtimeConfig, channels };
}
afterEach(() => {
document.body.replaceChildren();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("ChannelsPage lifecycle", () => {
it("loads schema again when the runtime-config source changes", async () => {
const gateway = createGateway();
const first = createContext(gateway);
const second = createContext(gateway);
const page = document.createElement("openclaw-channels-page") as ChannelsPageTestElement;
page.context = first.context;
document.body.append(page);
await vi.waitFor(() => expect(first.ensureSchemaLoaded).toHaveBeenCalledOnce());
page.context = second.context;
page.requestUpdate();
await page.updateComplete;
await vi.waitFor(() => expect(second.ensureSchemaLoaded).toHaveBeenCalledOnce());
first.runtimeConfig.dispose();
second.runtimeConfig.dispose();
first.channels.dispose();
second.channels.dispose();
});
it("drops a profile save when the channel source is replaced", async () => {
const gateway = createGateway();
const first = createContext(gateway);
const second = createContext(gateway);
const firstRefresh = vi.spyOn(first.channels, "refresh").mockResolvedValue();
const secondRefresh = vi.spyOn(second.channels, "refresh").mockResolvedValue();
const response = createDeferred<Response>();
const fetchMock = vi.fn(() => response.promise);
vi.stubGlobal("fetch", fetchMock);
const page = document.createElement("openclaw-channels-page") as NostrTestPage;
page.context = first.context;
document.body.append(page);
await page.updateComplete;
page.editNostrProfile("old-account", { name: "old" });
const save = page.saveNostrProfile();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
page.context = second.context;
page.requestUpdate();
await page.updateComplete;
expect(page.nostrProfileFormState).toBeNull();
response.resolve(
new Response(JSON.stringify({ ok: true, persisted: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await save;
expect(page.nostrProfileFormState).toBeNull();
expect(firstRefresh).not.toHaveBeenCalled();
expect(secondRefresh).not.toHaveBeenCalled();
first.runtimeConfig.dispose();
second.runtimeConfig.dispose();
first.channels.dispose();
second.channels.dispose();
});
it("drops a profile import when the gateway disconnects", async () => {
const gateway = createGateway();
const source = createContext(gateway);
const refresh = vi.spyOn(source.channels, "refresh").mockResolvedValue();
const response = createDeferred<Response>();
const fetchMock = vi.fn(() => response.promise);
vi.stubGlobal("fetch", fetchMock);
const page = document.createElement("openclaw-channels-page") as NostrTestPage;
page.context = source.context;
document.body.append(page);
await page.updateComplete;
page.editNostrProfile("old-account", { name: "old" });
const load = page.importNostrProfile();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
gateway.emit({ connected: false });
expect(page.nostrProfileFormState).toBeNull();
response.resolve(
new Response(JSON.stringify({ ok: true, saved: true, merged: { name: "stale import" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await load;
expect(page.nostrProfileFormState).toBeNull();
expect(refresh).not.toHaveBeenCalled();
source.runtimeConfig.dispose();
source.channels.dispose();
});
it("does not overwrite a replacement profile form", async () => {
const gateway = createGateway();
const source = createContext(gateway);
const refresh = vi.spyOn(source.channels, "refresh").mockResolvedValue();
const response = createDeferred<Response>();
const fetchMock = vi.fn(() => response.promise);
vi.stubGlobal("fetch", fetchMock);
const page = document.createElement("openclaw-channels-page") as NostrTestPage;
page.context = source.context;
document.body.append(page);
await page.updateComplete;
page.editNostrProfile("old-account", { name: "old" });
const load = page.importNostrProfile();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
page.editNostrProfile("new-account", { name: "fresh" });
response.resolve(
new Response(JSON.stringify({ ok: true, saved: true, merged: { name: "stale import" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await load;
expect(page.nostrProfileAccountId).toBe("new-account");
expect(page.nostrProfileFormState?.values.name).toBe("fresh");
expect(refresh).not.toHaveBeenCalled();
source.runtimeConfig.dispose();
source.channels.dispose();
});
});
+203 -70
View File
@@ -1,16 +1,29 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { html } from "lit";
import { state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { NostrProfile } from "../../api/types.ts";
import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import { resolveControlUiAuthHeader } from "../../app/control-ui-auth.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { createNostrProfileFormState } from "./view.nostr-profile-form.ts";
import { renderChannels } from "./view.ts";
type NostrProfileFormState = ReturnType<typeof createNostrProfileFormState> | null;
type NostrOperation = {
generation: number;
gateway: ApplicationContext["gateway"];
channels: ApplicationContext["channels"];
client: GatewayBrowserClient;
formAccountId: string | null;
accountId: string;
headers: Record<string, string>;
};
function parseValidationErrors(details: unknown): Record<string, string> {
if (!Array.isArray(details)) {
return {};
@@ -37,12 +50,8 @@ function buildNostrProfileUrl(accountId: string, suffix = ""): string {
return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`;
}
class ChannelsPage extends LitElement {
override createRenderRoot() {
return this;
}
@consume({ context: applicationContext, subscribe: false })
class ChannelsPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@state()
@@ -51,36 +60,87 @@ class ChannelsPage extends LitElement {
@state()
private nostrProfileAccountId: string | null = null;
private stopChannelsSubscription?: () => void;
private stopConfigSubscription?: () => void;
private stopGatewaySubscription?: () => void;
private schemaLoadStarted = false;
private gatewaySource?: ApplicationContext["gateway"];
private channelsSource?: ApplicationContext["channels"];
private gatewayClient: GatewayBrowserClient | null = null;
private gatewayConnected = false;
private hasGatewaySnapshot = false;
private nostrOperationGeneration = 0;
private readonly requestPageUpdate = () => this.requestUpdate();
override connectedCallback() {
super.connectedCallback();
this.ensureSubscriptions();
this.ensureInitialData();
}
private ensureSubscriptions() {
const context = this.context;
if (!context || this.stopChannelsSubscription) {
return;
}
this.stopChannelsSubscription = context.channels.subscribe(this.requestPageUpdate);
this.stopConfigSubscription = context.runtimeConfig.subscribe(() => {
this.requestPageUpdate();
this.ensureInitialData();
});
this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => {
if (snapshot.connected && snapshot.client) {
this.ensureInitialData();
} else {
private readonly subscriptions = new SubscriptionsController(this)
.effect(
() => this.context?.channels,
(channels) => {
const sourceChanged = this.channelsSource !== undefined && this.channelsSource !== channels;
this.channelsSource = channels;
if (sourceChanged) {
this.invalidateNostrForm();
}
const handleChange = () => {
if (this.channelsSource === channels) {
this.requestUpdate();
}
};
handleChange();
return channels.subscribe(handleChange);
},
)
.effect(
() => this.context?.runtimeConfig,
(runtimeConfig) => {
this.schemaLoadStarted = false;
}
});
const handleChange = () => {
if (this.context.runtimeConfig !== runtimeConfig) {
return;
}
this.requestUpdate();
this.ensureInitialData();
};
handleChange();
const unsubscribe = runtimeConfig.subscribe(handleChange);
return () => {
unsubscribe();
this.schemaLoadStarted = false;
};
},
)
.effect(
() => this.context?.gateway,
(gateway) => {
const sourceChanged = this.gatewaySource !== undefined && this.gatewaySource !== gateway;
this.gatewaySource = gateway;
this.applyGatewaySnapshot(gateway.snapshot, sourceChanged);
return gateway.subscribe((snapshot) => {
if (this.gatewaySource !== gateway) {
return;
}
this.applyGatewaySnapshot(snapshot, false);
});
},
);
private applyGatewaySnapshot(
snapshot: ApplicationContext["gateway"]["snapshot"],
sourceChanged: boolean,
) {
const clientChanged = this.hasGatewaySnapshot && this.gatewayClient !== snapshot.client;
const connectionChanged =
this.hasGatewaySnapshot && this.gatewayConnected !== snapshot.connected;
if (!this.hasGatewaySnapshot || sourceChanged || clientChanged || connectionChanged) {
this.nostrOperationGeneration += 1;
}
if (sourceChanged || clientChanged || !snapshot.connected) {
this.clearNostrForm();
}
this.hasGatewaySnapshot = true;
this.gatewayClient = snapshot.client;
this.gatewayConnected = snapshot.connected;
if (snapshot.connected && snapshot.client) {
this.ensureInitialData();
} else {
this.schemaLoadStarted = false;
}
}
private ensureInitialData() {
@@ -106,12 +166,13 @@ class ChannelsPage extends LitElement {
}
override disconnectedCallback() {
this.stopChannelsSubscription?.();
this.stopChannelsSubscription = undefined;
this.stopConfigSubscription?.();
this.stopConfigSubscription = undefined;
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.gatewaySource = undefined;
this.channelsSource = undefined;
this.gatewayClient = null;
this.gatewayConnected = false;
this.hasGatewaySnapshot = false;
this.invalidateNostrForm();
this.subscriptions.clear();
this.schemaLoadStarted = false;
super.disconnectedCallback();
}
@@ -145,30 +206,79 @@ class ChannelsPage extends LitElement {
private resolveNostrAccountId(): string {
const accounts = this.context?.channels.state.channelsSnapshot?.channelAccounts?.nostr ?? [];
return accounts[0]?.accountId ?? this.nostrProfileAccountId ?? "default";
return this.nostrProfileAccountId ?? accounts[0]?.accountId ?? "default";
}
private buildGatewayHttpHeaders(): Record<string, string> {
const context = this.context;
if (!context) {
return {};
}
private buildGatewayHttpHeaders(gateway: ApplicationContext["gateway"]): Record<string, string> {
const authorization = resolveControlUiAuthHeader({
hello: context.gateway.snapshot.hello,
settings: { token: context.gateway.connection.token },
password: context.gateway.connection.password,
hello: gateway.snapshot.hello,
settings: { token: gateway.connection.token },
password: gateway.connection.password,
});
return authorization ? { Authorization: authorization } : {};
}
private clearNostrForm() {
this.nostrProfileFormState = null;
this.nostrProfileAccountId = null;
}
private invalidateNostrForm() {
this.nostrOperationGeneration += 1;
this.clearNostrForm();
}
private beginNostrOperation(): NostrOperation | null {
const gateway = this.context.gateway;
const channels = this.context.channels;
const client = gateway.snapshot.client;
if (
!this.isConnected ||
this.gatewaySource !== gateway ||
this.channelsSource !== channels ||
!gateway.snapshot.connected ||
!client
) {
return null;
}
const generation = this.nostrOperationGeneration + 1;
this.nostrOperationGeneration = generation;
return {
generation,
gateway,
channels,
client,
formAccountId: this.nostrProfileAccountId,
accountId: this.resolveNostrAccountId(),
headers: this.buildGatewayHttpHeaders(gateway),
};
}
private currentNostrForm(operation: NostrOperation): NonNullable<NostrProfileFormState> | null {
const form = this.nostrProfileFormState;
if (
!form ||
!this.isConnected ||
this.nostrOperationGeneration !== operation.generation ||
this.nostrProfileAccountId !== operation.formAccountId ||
this.context.gateway !== operation.gateway ||
this.context.channels !== operation.channels ||
operation.gateway.snapshot.client !== operation.client ||
!operation.gateway.snapshot.connected
) {
return null;
}
return form;
}
private editNostrProfile(accountId: string, profile: NostrProfile | null) {
this.nostrOperationGeneration += 1;
this.nostrProfileAccountId = accountId;
this.nostrProfileFormState = createNostrProfileFormState(profile ?? undefined);
}
private cancelNostrProfile() {
this.nostrProfileFormState = null;
this.nostrProfileAccountId = null;
this.invalidateNostrForm();
}
private changeNostrProfileField(field: keyof NostrProfile, value: string) {
@@ -193,24 +303,28 @@ class ChannelsPage extends LitElement {
private async saveNostrProfile() {
const form = this.nostrProfileFormState;
if (!form || form.saving) {
if (!form || form.saving || form.importing) {
return;
}
const accountId = this.resolveNostrAccountId();
this.nostrProfileFormState = {
const operation = this.beginNostrOperation();
if (!operation) {
return;
}
const pendingForm = {
...form,
saving: true,
error: null,
success: null,
fieldErrors: {},
};
this.nostrProfileFormState = pendingForm;
try {
const response = await fetch(buildNostrProfileUrl(accountId), {
const response = await fetch(buildNostrProfileUrl(operation.accountId), {
method: "PUT",
headers: {
"Content-Type": "application/json",
...this.buildGatewayHttpHeaders(),
...operation.headers,
},
body: JSON.stringify(form.values),
});
@@ -221,9 +335,13 @@ class ChannelsPage extends LitElement {
persisted?: boolean;
} | null;
const currentForm = this.currentNostrForm(operation);
if (!currentForm) {
return;
}
if (!response.ok || data?.ok === false || !data) {
this.nostrProfileFormState = {
...form,
...currentForm,
saving: false,
error: data?.error ?? `Profile update failed (${response.status})`,
success: null,
@@ -234,7 +352,7 @@ class ChannelsPage extends LitElement {
if (!data.persisted) {
this.nostrProfileFormState = {
...form,
...currentForm,
saving: false,
error: "Profile publish failed on all relays.",
success: null,
@@ -243,17 +361,21 @@ class ChannelsPage extends LitElement {
}
this.nostrProfileFormState = {
...form,
...currentForm,
saving: false,
error: null,
success: "Profile published to relays.",
fieldErrors: {},
original: { ...form.values },
};
await this.context?.channels.refresh(true);
await operation.channels.refresh(true);
} catch (err) {
const currentForm = this.currentNostrForm(operation);
if (!currentForm) {
return;
}
this.nostrProfileFormState = {
...form,
...currentForm,
saving: false,
error: `Profile update failed: ${String(err)}`,
success: null,
@@ -263,10 +385,13 @@ class ChannelsPage extends LitElement {
private async importNostrProfile() {
const form = this.nostrProfileFormState;
if (!form || form.importing) {
if (!form || form.importing || form.saving) {
return;
}
const operation = this.beginNostrOperation();
if (!operation) {
return;
}
const accountId = this.resolveNostrAccountId();
this.nostrProfileFormState = {
...form,
importing: true,
@@ -275,11 +400,11 @@ class ChannelsPage extends LitElement {
};
try {
const response = await fetch(buildNostrProfileUrl(accountId, "/import"), {
const response = await fetch(buildNostrProfileUrl(operation.accountId, "/import"), {
method: "POST",
headers: {
"Content-Type": "application/json",
...this.buildGatewayHttpHeaders(),
...operation.headers,
},
body: JSON.stringify({ autoMerge: true }),
});
@@ -291,9 +416,13 @@ class ChannelsPage extends LitElement {
saved?: boolean;
} | null;
const currentForm = this.currentNostrForm(operation);
if (!currentForm) {
return;
}
if (!response.ok || data?.ok === false || !data) {
this.nostrProfileFormState = {
...form,
...currentForm,
importing: false,
error: data?.error ?? `Profile import failed (${response.status})`,
success: null,
@@ -302,9 +431,9 @@ class ChannelsPage extends LitElement {
}
const merged = data.merged ?? data.imported ?? null;
const values = merged ? { ...form.values, ...merged } : form.values;
const values = merged ? { ...currentForm.values, ...merged } : currentForm.values;
this.nostrProfileFormState = {
...form,
...currentForm,
importing: false,
values,
error: null,
@@ -315,11 +444,15 @@ class ChannelsPage extends LitElement {
};
if (data.saved) {
await this.context?.channels.refresh(true);
await operation.channels.refresh(true);
}
} catch (err) {
const currentForm = this.currentNostrForm(operation);
if (!currentForm) {
return;
}
this.nostrProfileFormState = {
...form,
...currentForm,
importing: false,
error: `Profile import failed: ${String(err)}`,
success: null,
+77
View File
@@ -36,6 +36,7 @@ function createState(overrides: Partial<ChatState> = {}): ChatState {
chatVerboseLevel: null,
client: null,
connected: true,
connectionEpoch: 0,
hello: null,
lastError: null,
sessionKey: "main",
@@ -3607,6 +3608,82 @@ describe("loadChatHistory retry handling", () => {
]);
});
it("rejects stale success and cleanup after a same-client reconnect", async () => {
const staleRequest = createDeferred<{ messages: Array<unknown>; thinkingLevel?: string }>();
const freshRequest = createDeferred<{ messages: Array<unknown>; thinkingLevel?: string }>();
const request = vi
.fn()
.mockImplementationOnce(() => staleRequest.promise)
.mockImplementationOnce(() => freshRequest.promise);
const client = { request } as unknown as NonNullable<ChatState["client"]>;
const visibleMessage = {
role: "assistant",
content: [{ type: "text", text: "visible before reconnect" }],
};
const state = createState({
chatMessages: [visibleMessage],
client,
connected: true,
connectionEpoch: 1,
});
const staleLoad = loadChatHistory(state);
state.connected = false;
state.connectionEpoch = 2;
state.connected = true;
state.connectionEpoch = 3;
const freshLoad = loadChatHistory(state);
expect(request).toHaveBeenCalledTimes(2);
staleRequest.resolve({
messages: [{ role: "assistant", content: [{ type: "text", text: "stale history" }] }],
thinkingLevel: "high",
});
await staleLoad;
expect(state.chatMessages).toEqual([visibleMessage]);
expect(state.chatThinkingLevel).toBeNull();
expect(state.chatLoading).toBe(true);
freshRequest.resolve({
messages: [{ role: "assistant", content: [{ type: "text", text: "fresh history" }] }],
thinkingLevel: "low",
});
await freshLoad;
expect(state.chatMessages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "fresh history" }] },
]);
expect(state.chatThinkingLevel).toBe("low");
expect(state.chatLoading).toBe(false);
});
it("rejects stale errors and cleanup after a same-client reconnect", async () => {
const staleRequest = createDeferred<{ messages: Array<unknown> }>();
const request = vi.fn(() => staleRequest.promise);
const client = { request } as unknown as NonNullable<ChatState["client"]>;
const state = createState({
client,
connected: true,
connectionEpoch: 1,
});
const staleLoad = loadChatHistory(state);
state.connected = false;
state.connectionEpoch = 2;
state.connected = true;
state.connectionEpoch = 3;
// The connection owner has already prepared the new epoch. The stale
// finalizer must not clear its loading state.
state.chatLoading = true;
staleRequest.reject(new Error("stale history failure"));
await staleLoad;
expect(state.lastError).toBeNull();
expect(state.chatError).toBeNull();
expect(state.chatLoading).toBe(true);
});
it("ignores stale history responses after switching sessions", async () => {
const mainRequest = createDeferred<{
messages: Array<unknown>;
+58 -20
View File
@@ -79,28 +79,51 @@ const STARTUP_CHAT_HISTORY_MAX_RETRY_MS = 5_000;
const chatHistoryRequestVersions = new WeakMap<object, number>();
const selectedSessionMessageSubscriptionGenerations = new WeakMap<object, number>();
function beginChatHistoryRequest(state: ChatState): number {
type ChatHistoryRequestOwnership = {
version: number;
client: GatewayBrowserClient;
connectionEpoch: number;
sessionKey: string;
agentId?: string;
};
function beginChatHistoryRequest(
state: ChatState,
client: GatewayBrowserClient,
connectionEpoch: number,
sessionKey: string,
agentId?: string,
): ChatHistoryRequestOwnership {
const key = state as object;
const nextVersion = (chatHistoryRequestVersions.get(key) ?? 0) + 1;
chatHistoryRequestVersions.set(key, nextVersion);
return nextVersion;
return {
version: nextVersion,
client,
connectionEpoch,
sessionKey,
agentId,
};
}
function isLatestChatHistoryRequest(state: ChatState, version: number): boolean {
return chatHistoryRequestVersions.get(state as object) === version;
function ownsChatHistoryRequest(state: ChatState, ownership: ChatHistoryRequestOwnership): boolean {
return (
chatHistoryRequestVersions.get(state as object) === ownership.version &&
state.client === ownership.client &&
state.connected &&
state.connectionEpoch === ownership.connectionEpoch
);
}
function shouldApplyChatHistoryResult(
state: ChatState,
version: number,
sessionKey: string,
agentId?: string,
ownership: ChatHistoryRequestOwnership,
): boolean {
if (!isLatestChatHistoryRequest(state, version) || state.sessionKey !== sessionKey) {
return false;
}
return (
!isUiSelectedGlobalSessionKey(sessionKey) || resolveUiSelectedSessionAgentId(state) === agentId
ownsChatHistoryRequest(state, ownership) &&
state.sessionKey === ownership.sessionKey &&
(!isUiSelectedGlobalSessionKey(ownership.sessionKey) ||
resolveUiSelectedSessionAgentId(state) === ownership.agentId)
);
}
@@ -403,6 +426,8 @@ function sleep(ms: number): Promise<void> {
export type ChatState = {
client: GatewayBrowserClient | null;
connected: boolean;
/** Monotonic owner epoch; reconnects can reuse the same client object. */
connectionEpoch: number;
sessionKey: string;
currentSessionId?: string | null;
reconnectResumeSessionId?: string | null;
@@ -675,6 +700,7 @@ export async function syncSelectedSessionMessageSubscription(
type InFlightChatHistoryRequest = {
client: NonNullable<ChatState["client"]>;
connectionEpoch: number;
key: string;
messages: unknown[];
promise: Promise<ChatHistoryResult | undefined>;
@@ -789,17 +815,21 @@ export async function loadChatHistory(
const method =
opts.startup === true && startupAdvertised !== false ? "chat.startup" : "chat.history";
const requestKey = `${method}\0${sessionKey}\0${requestAgentId ?? ""}`;
const client = state.client;
const connectionEpoch = state.connectionEpoch;
const inFlight = inFlightChatHistoryRequests.get(state);
if (
inFlight?.key === requestKey &&
inFlight.client === state.client &&
inFlight.client === client &&
inFlight.connectionEpoch === connectionEpoch &&
inFlight.messages === state.chatMessages
) {
return inFlight.promise;
}
const promise = loadChatHistoryUncached(
state,
state.client,
client,
connectionEpoch,
sessionKey,
requestAgentId,
method,
@@ -809,7 +839,8 @@ export async function loadChatHistory(
}
});
inFlightChatHistoryRequests.set(state, {
client: state.client,
client,
connectionEpoch,
key: requestKey,
messages: state.chatMessages,
promise,
@@ -844,11 +875,18 @@ export function applyChatAgentsList(
async function loadChatHistoryUncached(
state: ChatState,
client: NonNullable<ChatState["client"]>,
connectionEpoch: number,
sessionKey: string,
requestAgentId: string | undefined,
method: "chat.history" | "chat.startup",
): Promise<ChatHistoryResult | undefined> {
const requestVersion = beginChatHistoryRequest(state);
const ownership = beginChatHistoryRequest(
state,
client,
connectionEpoch,
sessionKey,
requestAgentId,
);
const startedAt = Date.now();
const startedAtMs = controlUiNowMs();
const previousMessages = state.chatMessages;
@@ -874,7 +912,7 @@ async function loadChatHistoryUncached(
});
break;
} catch (err) {
if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey, requestAgentId)) {
if (!shouldApplyChatHistoryResult(state, ownership)) {
recordChatHistoryTiming(state, "stale", startedAtMs, {
requestSessionKey: sessionKey,
requestAgentId,
@@ -895,7 +933,7 @@ async function loadChatHistoryUncached(
}
if (withinStartupRetryWindow && isRetryableStartupUnavailable(err, method)) {
await sleep(resolveStartupRetryDelayMs(err));
if (!state.client || !state.connected) {
if (!shouldApplyChatHistoryResult(state, ownership)) {
return undefined;
}
continue;
@@ -903,7 +941,7 @@ async function loadChatHistoryUncached(
throw err;
}
}
if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey, requestAgentId)) {
if (!shouldApplyChatHistoryResult(state, ownership)) {
recordChatHistoryTiming(state, "stale", startedAtMs, {
requestSessionKey: sessionKey,
requestAgentId,
@@ -1019,7 +1057,7 @@ async function loadChatHistoryUncached(
});
return res;
} catch (err) {
if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey, requestAgentId)) {
if (!shouldApplyChatHistoryResult(state, ownership)) {
recordChatHistoryTiming(state, "stale", startedAtMs, {
requestSessionKey: sessionKey,
requestAgentId,
@@ -1042,7 +1080,7 @@ async function loadChatHistoryUncached(
setChatError(state, String(err));
}
} finally {
if (isLatestChatHistoryRequest(state, requestVersion)) {
if (ownsChatHistoryRequest(state, ownership)) {
state.chatLoading = false;
}
}
+42
View File
@@ -182,6 +182,48 @@ describe("chat page split layout host", () => {
expect(cleanup).toHaveBeenCalledOnce();
});
it("moves session updates to a replacement context source", async () => {
const firstCleanup = vi.fn();
const secondCleanup = vi.fn();
let notifyFirst = () => {};
let notifySecond = () => {};
const firstSessions = {
state: { result: null },
subscribe: vi.fn((listener: () => void) => {
notifyFirst = listener;
return firstCleanup;
}),
};
const secondSessions = {
state: { result: null },
subscribe: vi.fn((listener: () => void) => {
notifySecond = listener;
return secondCleanup;
}),
};
const page = new ChatPage();
(page as unknown as { context: unknown }).context = { sessions: firstSessions };
document.body.append(page);
await page.updateComplete;
expect(firstSessions.subscribe).toHaveBeenCalledOnce();
(page as unknown as { context: unknown }).context = { sessions: secondSessions };
page.requestUpdate();
await page.updateComplete;
expect(firstCleanup).toHaveBeenCalledOnce();
expect(secondSessions.subscribe).toHaveBeenCalledOnce();
const requestUpdate = vi.spyOn(page, "requestUpdate");
notifyFirst();
expect(requestUpdate).not.toHaveBeenCalled();
notifySecond();
expect(requestUpdate).toHaveBeenCalledOnce();
page.remove();
expect(secondCleanup).toHaveBeenCalledOnce();
});
it("routes a classic-mode center drop without creating a layout", () => {
const page = new ChatPage();
page.data = { sessionKey: "main" };
+10 -11
View File
@@ -1,5 +1,5 @@
import { consume } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
@@ -11,6 +11,8 @@ import { t } from "../../i18n/index.ts";
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
import { readSessionDragData, sessionDragActive } from "../../lib/sessions/drag.ts";
import { searchForSession } from "../../lib/sessions/index.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import "./chat-pane.ts";
import {
resolveSplitDropZone,
@@ -43,8 +45,8 @@ const NARROW_SPLIT_QUERY = "(max-width: 1099px)";
type DropIndicator = { paneId: string; zone: SplitDropZone; rect: SplitDropRect };
type ChatPaneElement = HTMLElement & { paneId?: string };
export class ChatPage extends LitElement {
@consume({ context: applicationContext, subscribe: false })
export class ChatPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@property({ attribute: false }) data!: ChatRouteData;
@state() private layout: ChatSplitLayout | undefined;
@@ -54,25 +56,23 @@ export class ChatPage extends LitElement {
// the fixed toolbar track mirrors it so segments stay over their panes.
@state() private splitScrollLeft = 0;
private readonly subscriptions = new SubscriptionsController(this).watch(
() => this.context?.sessions,
(sessions, notify) => sessions.subscribe(notify),
);
private mediaQuery: MediaQueryList | null = null;
private sessionsCleanup: (() => void) | null = null;
// Light-DOM enter/leave events bubble from every nested child, so only clear
// the shared preview after the whole balanced drag has left the page.
private dragDepth = 0;
private dragFrame = 0;
private pendingDragOver: { pane: ChatPaneElement; x: number; y: number } | null = null;
override createRenderRoot() {
return this;
}
override connectedCallback() {
super.connectedCallback();
this.layout = loadSettings().chatSplitLayout;
this.mediaQuery = window.matchMedia(NARROW_SPLIT_QUERY);
this.narrow = this.mediaQuery.matches;
this.mediaQuery.addEventListener("change", this.handleViewportChange);
this.sessionsCleanup = this.context?.sessions?.subscribe(() => this.requestUpdate()) ?? null;
this.addEventListener("dragenter", this.handleDragEnter);
this.addEventListener("dragover", this.handleDragOver);
this.addEventListener("dragleave", this.handleDragLeave);
@@ -82,10 +82,9 @@ export class ChatPage extends LitElement {
}
override disconnectedCallback() {
this.subscriptions.clear();
this.mediaQuery?.removeEventListener("change", this.handleViewportChange);
this.mediaQuery = null;
this.sessionsCleanup?.();
this.sessionsCleanup = null;
this.removeEventListener("dragenter", this.handleDragEnter);
this.removeEventListener("dragover", this.handleDragOver);
this.removeEventListener("dragleave", this.handleDragLeave);
+226
View File
@@ -0,0 +1,226 @@
/* @vitest-environment jsdom */
import { describe, expect, it, vi } from "vitest";
import type {
TaskSuggestion,
TaskSuggestionEvent,
TaskSuggestionsAcceptResult,
TaskSuggestionsListResult,
} from "../../../../packages/gateway-protocol/src/index.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationContext } from "../../app/context.ts";
import type { SessionCapability } from "../../lib/sessions/index.ts";
import "./chat-pane.ts";
import type { ChatPageHost } from "./chat-state.ts";
type TestChatPane = HTMLElement & {
context: ApplicationContext;
state: ChatPageHost;
connectedClient: GatewayBrowserClient | null;
connectionGeneration: number;
createSession: () => Promise<boolean>;
acceptTaskSuggestion: (suggestion: TaskSuggestion) => Promise<void>;
handleTaskSuggestionEvent: (event: TaskSuggestionEvent) => void;
refreshTaskSuggestions: () => Promise<void>;
taskSuggestions: TaskSuggestion[];
onPaneSessionChange?: (paneId: string, sessionKey: string) => void;
};
const suggestion: TaskSuggestion = {
id: "task_123",
title: "Remove stale adapter",
prompt: "Delete the stale adapter and update tests.",
tldr: "The adapter is unreachable and adds maintenance cost.",
cwd: "/repo",
sessionKey: "agent:main:current",
agentId: "main",
createdAt: 1,
};
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function createSessionContext(
client: GatewayBrowserClient,
sessions: SessionCapability,
): ApplicationContext {
return {
gateway: {
snapshot: {
client,
connected: true,
hello: { features: { methods: ["taskSuggestions.list"] } },
},
},
agents: { state: { agentsList: null } },
sessions,
} as unknown as ApplicationContext;
}
function createTestChatPane(params: { client: GatewayBrowserClient; sessions: SessionCapability }) {
const pane = document.createElement("openclaw-chat-pane") as unknown as TestChatPane;
Object.defineProperty(pane, "isConnected", {
configurable: true,
value: true,
});
const requestUpdate = vi.fn();
const state = {
agentsList: null,
assistantAgentId: null,
chatError: null,
chatLoading: false,
chatQueue: [],
chatRunId: null,
chatSending: false,
chatStream: null,
client: params.client,
connected: true,
connectionEpoch: 4,
hello: null,
lastError: null,
requestUpdate,
sessionKey: "agent:main:current",
sessions: params.sessions,
sessionsError: null,
sessionsLoading: false,
} as unknown as ChatPageHost;
pane.context = createSessionContext(params.client, params.sessions);
pane.state = state;
pane.connectedClient = params.client;
pane.connectionGeneration = 4;
return { pane, requestUpdate, state };
}
describe("chat pane session creation lifecycle", () => {
it("drops a created session after a same-client reconnect", async () => {
const created = createDeferred<string | null>();
const sessions = {
create: vi.fn(() => created.promise),
} as unknown as SessionCapability;
const client = {} as GatewayBrowserClient;
const { pane, state } = createTestChatPane({ client, sessions });
const navigate = vi.fn();
pane.onPaneSessionChange = navigate;
const pending = pane.createSession();
state.connected = false;
pane.connectionGeneration += 1;
state.connectionEpoch = pane.connectionGeneration;
state.connected = true;
pane.connectionGeneration += 1;
state.connectionEpoch = pane.connectionGeneration;
created.resolve("agent:main:new");
await expect(pending).resolves.toBe(false);
expect(navigate).not.toHaveBeenCalled();
});
it("does not publish a stale creation error after the context is replaced", async () => {
const created = createDeferred<string | null>();
const sessions = {
create: vi.fn(() => created.promise),
} as unknown as SessionCapability;
const client = {} as GatewayBrowserClient;
const { pane, requestUpdate, state } = createTestChatPane({ client, sessions });
const replacementSessions = {} as SessionCapability;
const pending = pane.createSession();
state.sessionsError = "stale sessions.create failure";
pane.context = createSessionContext(client, replacementSessions);
created.resolve(null);
await expect(pending).resolves.toBe(false);
expect(state.lastError).toBeNull();
expect(state.chatError).toBeNull();
expect(requestUpdate).not.toHaveBeenCalled();
});
it("does not publish a stale creation error after the pane detaches", async () => {
const created = createDeferred<string | null>();
const sessions = {
create: vi.fn(() => created.promise),
} as unknown as SessionCapability;
const client = {} as GatewayBrowserClient;
const { pane, requestUpdate, state } = createTestChatPane({ client, sessions });
const pending = pane.createSession();
state.sessionsError = "stale sessions.create failure";
Object.defineProperty(pane, "isConnected", {
configurable: true,
value: false,
});
created.resolve(null);
await expect(pending).resolves.toBe(false);
expect(state.lastError).toBeNull();
expect(state.chatError).toBeNull();
expect(requestUpdate).not.toHaveBeenCalled();
});
});
describe("chat pane task suggestion lifecycle", () => {
it("keeps accept ownership when the resolved event arrives before the response", async () => {
const accepted = createDeferred<TaskSuggestionsAcceptResult>();
const client = {
request: vi.fn((method: string) =>
method === "taskSuggestions.accept"
? accepted.promise
: Promise.resolve({ suggestions: [] } satisfies TaskSuggestionsListResult),
),
} as unknown as GatewayBrowserClient;
const sessions = {} as SessionCapability;
const { pane } = createTestChatPane({ client, sessions });
const navigate = vi.fn();
pane.onPaneSessionChange = navigate;
const pending = pane.acceptTaskSuggestion(suggestion);
pane.handleTaskSuggestionEvent({
action: "resolved",
taskId: suggestion.id,
resolution: "accepted",
});
accepted.resolve({ taskId: suggestion.id, key: "agent:main:task" });
await pending;
expect(navigate).toHaveBeenCalledWith("single", "agent:main:task");
});
it("drops an accept response after a same-client reconnect", async () => {
const accepted = createDeferred<TaskSuggestionsAcceptResult>();
const client = {
request: vi.fn(() => accepted.promise),
} as unknown as GatewayBrowserClient;
const sessions = {} as SessionCapability;
const { pane } = createTestChatPane({ client, sessions });
const navigate = vi.fn();
pane.onPaneSessionChange = navigate;
const pending = pane.acceptTaskSuggestion(suggestion);
pane.connectionGeneration += 1;
accepted.resolve({ taskId: suggestion.id, key: "agent:main:stale" });
await pending;
expect(navigate).not.toHaveBeenCalled();
});
it("drops a list response after a same-client reconnect", async () => {
const listed = createDeferred<TaskSuggestionsListResult>();
const client = {
request: vi.fn(() => listed.promise),
} as unknown as GatewayBrowserClient;
const sessions = {} as SessionCapability;
const { pane } = createTestChatPane({ client, sessions });
const pending = pane.refreshTaskSuggestions();
pane.connectionGeneration += 1;
listed.resolve({ suggestions: [suggestion] });
await pending;
expect(pane.taskSuggestions).toEqual([]);
});
});
+155 -46
View File
@@ -1,5 +1,5 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { html } from "lit";
import { property } from "lit/decorators.js";
import type {
TaskSuggestion,
@@ -32,6 +32,7 @@ import {
uiSessionEventMatches,
} from "../../lib/sessions/session-key.ts";
import { SessionUnreadPatchGuard } from "../../lib/sessions/unread.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { refreshChatAvatar } from "./chat-avatar.ts";
import {
applyChatAgentsList,
@@ -89,6 +90,13 @@ import { clearChatMessagesFromCache } from "./session-message-cache.ts";
type ChatPageContext = ApplicationContext;
type PaneSessionChangeOptions = { replace?: boolean };
type ChatPaneConnectionScope = {
context: ChatPageContext;
state: ChatPageHost;
client: GatewayBrowserClient;
generation: number;
sessions: ChatPageContext["sessions"];
};
const CHAT_OPEN_DETAILS_SELECTOR =
".chat-controls__inline-select[open], .context-usage details[open], .agent-chat__talk-select[open], .agent-chat__attach-menu[open]";
@@ -112,8 +120,8 @@ function keyboardEventPathMatches(event: KeyboardEvent, selector: string): boole
.some((target) => target instanceof Element && target.matches(selector));
}
class ChatPane extends LitElement {
@consume({ context: applicationContext, subscribe: false })
class ChatPane extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ChatPageContext;
@property({ attribute: false }) paneId = "single";
// Empty means "no route/layout opinion yet": the pane boots on the page
@@ -138,12 +146,51 @@ class ChatPane extends LitElement {
private readonly unreadPatchGuard = new SessionUnreadPatchGuard();
private taskSuggestions: TaskSuggestion[] = [];
private readonly taskSuggestionBusyIds = new Set<string>();
private readonly taskSuggestionOperations = new Map<string, symbol>();
private taskSuggestionsRequestVersion = 0;
private captureConnectionScope(): ChatPaneConnectionScope | null {
const context = this.context;
const state = this.state;
const client = state?.client;
if (
!this.isConnected ||
!state?.connected ||
!client ||
this.connectedClient !== client ||
!context.gateway.snapshot.connected ||
context.gateway.snapshot.client !== client
) {
return null;
}
return {
context,
state,
client,
generation: this.connectionGeneration,
sessions: context.sessions,
};
}
private isConnectionScopeCurrent(scope: ChatPaneConnectionScope): boolean {
return (
this.isConnected &&
this.context === scope.context &&
this.context.sessions === scope.sessions &&
this.state === scope.state &&
scope.state.connected &&
scope.state.client === scope.client &&
this.connectedClient === scope.client &&
scope.context.gateway.snapshot.connected &&
scope.context.gateway.snapshot.client === scope.client &&
this.connectionGeneration === scope.generation
);
}
private taskSuggestionMatchesCurrentSession(suggestion: TaskSuggestion): boolean {
const state = this.state;
return Boolean(
state &&
state?.connected &&
uiSessionEventMatches(
{
agentsList: this.context.agents.state.agentsList,
@@ -157,28 +204,26 @@ class ChatPane extends LitElement {
}
private async refreshTaskSuggestions(): Promise<void> {
const state = this.state;
const client = state?.client;
const requestVersion = ++this.taskSuggestionsRequestVersion;
const scope = this.captureConnectionScope();
if (
!state?.connected ||
!client ||
!isGatewayMethodAdvertised(this.context.gateway.snapshot, "taskSuggestions.list")
!scope ||
!isGatewayMethodAdvertised(scope.context.gateway.snapshot, "taskSuggestions.list")
) {
this.taskSuggestions = [];
this.requestUpdate();
return;
}
const sessionKey = state.sessionKey;
const agentId = resolveChatAgentId(state);
const sessionKey = scope.state.sessionKey;
const agentId = resolveChatAgentId(scope.state);
try {
const result = await client.request<TaskSuggestionsListResult>("taskSuggestions.list", {
const result = await scope.client.request<TaskSuggestionsListResult>("taskSuggestions.list", {
agentId,
});
if (
requestVersion !== this.taskSuggestionsRequestVersion ||
client !== this.state?.client ||
sessionKey !== this.state?.sessionKey
!this.isConnectionScopeCurrent(scope) ||
sessionKey !== scope.state.sessionKey
) {
return;
}
@@ -213,56 +258,88 @@ class ChatPane extends LitElement {
}
private readonly acceptTaskSuggestion = async (suggestion: TaskSuggestion): Promise<void> => {
const state = this.state;
const client = state?.client;
const scope = this.captureConnectionScope();
if (
!state ||
!client ||
!scope ||
!this.taskSuggestionMatchesCurrentSession(suggestion) ||
this.taskSuggestionBusyIds.has(suggestion.id)
this.taskSuggestionOperations.has(suggestion.id)
) {
return;
}
const sessionKey = state.sessionKey;
const sessionKey = scope.state.sessionKey;
const operation = Symbol();
const isCurrent = () =>
this.isConnectionScopeCurrent(scope) &&
scope.state.sessionKey === sessionKey &&
this.taskSuggestionOperations.get(suggestion.id) === operation;
this.taskSuggestionOperations.set(suggestion.id, operation);
this.taskSuggestionBusyIds.add(suggestion.id);
this.requestUpdate();
try {
const result = await client.request<TaskSuggestionsAcceptResult>("taskSuggestions.accept", {
taskId: suggestion.id,
});
this.taskSuggestions = this.taskSuggestions.filter((item) => item.id !== suggestion.id);
if (this.state?.sessionKey === sessionKey) {
this.onPaneSessionChange?.(this.paneId, result.key);
const result = await scope.client.request<TaskSuggestionsAcceptResult>(
"taskSuggestions.accept",
{ taskId: suggestion.id },
);
if (!isCurrent()) {
return;
}
this.taskSuggestions = this.taskSuggestions.filter((item) => item.id !== suggestion.id);
this.onPaneSessionChange?.(this.paneId, result.key);
} catch (error) {
state.lastError = error instanceof Error ? error.message : String(error);
state.chatError = state.lastError;
if (!isCurrent()) {
return;
}
scope.state.lastError = error instanceof Error ? error.message : String(error);
scope.state.chatError = scope.state.lastError;
} finally {
this.taskSuggestionBusyIds.delete(suggestion.id);
this.requestUpdate();
if (this.taskSuggestionOperations.get(suggestion.id) === operation) {
this.taskSuggestionOperations.delete(suggestion.id);
this.taskSuggestionBusyIds.delete(suggestion.id);
if (this.isConnectionScopeCurrent(scope) && scope.state.sessionKey === sessionKey) {
this.requestUpdate();
}
}
}
};
private readonly dismissTaskSuggestion = async (suggestion: TaskSuggestion): Promise<void> => {
const state = this.state;
const scope = this.captureConnectionScope();
if (
!state?.client ||
!scope ||
!this.taskSuggestionMatchesCurrentSession(suggestion) ||
this.taskSuggestionBusyIds.has(suggestion.id)
this.taskSuggestionOperations.has(suggestion.id)
) {
return;
}
const sessionKey = scope.state.sessionKey;
const operation = Symbol();
const isCurrent = () =>
this.isConnectionScopeCurrent(scope) &&
scope.state.sessionKey === sessionKey &&
this.taskSuggestionOperations.get(suggestion.id) === operation;
this.taskSuggestionOperations.set(suggestion.id, operation);
this.taskSuggestionBusyIds.add(suggestion.id);
this.requestUpdate();
try {
await state.client.request("taskSuggestions.dismiss", { taskId: suggestion.id });
await scope.client.request("taskSuggestions.dismiss", { taskId: suggestion.id });
if (!isCurrent()) {
return;
}
this.taskSuggestions = this.taskSuggestions.filter((item) => item.id !== suggestion.id);
} catch (error) {
state.lastError = error instanceof Error ? error.message : String(error);
state.chatError = state.lastError;
if (!isCurrent()) {
return;
}
scope.state.lastError = error instanceof Error ? error.message : String(error);
scope.state.chatError = scope.state.lastError;
} finally {
this.taskSuggestionBusyIds.delete(suggestion.id);
this.requestUpdate();
if (this.taskSuggestionOperations.get(suggestion.id) === operation) {
this.taskSuggestionOperations.delete(suggestion.id);
this.taskSuggestionBusyIds.delete(suggestion.id);
if (this.isConnectionScopeCurrent(scope) && scope.state.sessionKey === sessionKey) {
this.requestUpdate();
}
}
}
};
@@ -326,6 +403,8 @@ class ChatPane extends LitElement {
resetChatStateForRouteSession(state, nextSessionKey);
this.taskSuggestionsRequestVersion += 1;
this.taskSuggestions = [];
this.taskSuggestionBusyIds.clear();
this.taskSuggestionOperations.clear();
this.markSessionRead(nextSessionRow);
if (previousSessionKey !== nextSessionKey) {
state.announceSessionSwitch?.(nextSessionKey, nextSessionLabel);
@@ -392,6 +471,21 @@ class ChatPane extends LitElement {
if (!state || !state.client || !state.connected) {
return false;
}
const context = this.context;
const sessions = context.sessions;
const client = state.client;
const connectionGeneration = this.connectionGeneration;
const isCurrent = () =>
this.isConnected &&
this.state === state &&
this.context === context &&
this.context.sessions === sessions &&
state.client === client &&
state.connected &&
this.connectedClient === client &&
context.gateway.snapshot.client === client &&
context.gateway.snapshot.connected &&
this.connectionGeneration === connectionGeneration;
if (!canCreateChatSession(state)) {
state.lastError = NEW_SESSION_ACTIVE_RUN_MESSAGE;
state.chatError = state.lastError;
@@ -408,12 +502,15 @@ class ChatPane extends LitElement {
state.lastError = null;
state.chatError = null;
const previousSessionKey = state.sessionKey;
const nextSessionKey = await this.context.sessions.create({
const nextSessionKey = await sessions.create({
currentSessionKey: previousSessionKey,
agentId:
scopedAgentParamsForSession(state, previousSessionKey).agentId ??
resolveAgentIdFromSessionKey(previousSessionKey),
});
if (!isCurrent()) {
return false;
}
if (
!nextSessionKey ||
state.sessionKey !== previousSessionKey ||
@@ -554,10 +651,6 @@ class ChatPane extends LitElement {
state.setChatMobileControlsOpen(false);
};
override createRenderRoot() {
return this;
}
override connectedCallback() {
super.connectedCallback();
this.addEventListener("pointerdown", this.handlePaneFocus);
@@ -571,7 +664,7 @@ class ChatPane extends LitElement {
this.removeEventListener("pointerdown", this.handlePaneFocus);
this.removeEventListener("focusin", this.handlePaneFocus);
});
const pageState = createPageState(this.context, chatState.requestUpdate, this);
const pageState = createPageState(this.context, chatState.createRenderLifecycle(), this);
pageState.createChatSession = async () => {
await this.createSession();
};
@@ -659,6 +752,11 @@ class ChatPane extends LitElement {
}
override disconnectedCallback() {
this.connectionGeneration += 1;
this.taskSuggestionsRequestVersion += 1;
this.taskSuggestions = [];
this.taskSuggestionBusyIds.clear();
this.taskSuggestionOperations.clear();
this.nativeDraftCleanup?.();
this.nativeDraftCleanup = null;
this.announceCommandPaletteTarget(null);
@@ -759,9 +857,21 @@ class ChatPane extends LitElement {
return;
}
const wasConnected = state.connected;
const sourceChanged = state.client !== snapshot.client || wasConnected !== snapshot.connected;
const clientChanged = this.connectedClient !== snapshot.client;
if (sourceChanged) {
// A reconnect can retain the browser client. Keep async ownership tied
// to the logical connection, not only the transport object identity.
this.connectionGeneration += 1;
this.taskSuggestionsRequestVersion += 1;
this.taskSuggestions = [];
this.taskSuggestionBusyIds.clear();
this.taskSuggestionOperations.clear();
state.chatLoading = false;
}
state.client = snapshot.client;
state.connected = snapshot.connected;
state.connectionEpoch = this.connectionGeneration;
state.hello = snapshot.hello;
state.terminalAvailable =
this.context.config.current.terminalEnabled &&
@@ -785,7 +895,6 @@ class ChatPane extends LitElement {
state.assistantName = this.context.config.current.assistantIdentity.name;
if (!snapshot.connected) {
if (wasConnected) {
this.connectionGeneration += 1;
const currentSessionId =
typeof state.currentSessionId === "string" ? state.currentSessionId.trim() : "";
if (currentSessionId) {
@@ -805,7 +914,7 @@ class ChatPane extends LitElement {
}
if (clientChanged && snapshot.client) {
const startupClient = snapshot.client;
const startupGeneration = ++this.connectionGeneration;
const startupGeneration = this.connectionGeneration;
const startupSessionKey = state.sessionKey;
const agentsListBeforeStartup = this.context.agents.state.agentsList;
const clientIsCurrent = () =>
+2 -1
View File
@@ -9,6 +9,7 @@ import {
roundedControlUiDurationMs,
scheduleControlUiAfterPaint,
} from "./performance.ts";
import type { RenderLifecycle } from "./render-lifecycle.ts";
type ChatSendTimingPhase =
| "pending-visible"
@@ -35,7 +36,7 @@ type ChatSendTimingHost = SessionScopeHost & {
chatQueueBySession?: Record<string, ChatQueueItem[]>;
chatSendTimingsByRun?: Map<string, ChatSendTimingEntry>;
eventLogBuffer?: unknown[];
updateComplete?: Promise<unknown>;
renderLifecycle?: RenderLifecycle;
};
type ChatSendServerTimingPhase =
+31 -1
View File
@@ -16,6 +16,7 @@ import type { executeSlashCommand } from "./chat-command-executor.ts";
import type { ChatHost } from "./chat-send.ts";
import { buildChatSessionListOptions } from "./chat-session.ts";
import type { ChatPageHost } from "./chat-state.ts";
import type { RenderLifecycle } from "./render-lifecycle.ts";
type ExecuteSlashCommand = typeof executeSlashCommand;
type TestChatHost = Omit<ChatHost, "settings"> & {
@@ -158,6 +159,21 @@ function fetchUrl(source: MockCallSource, callIndex: number) {
}
function makeHost(overrides?: Partial<TestChatHost>): TestChatHost {
const renderLifecycle: RenderLifecycle = {
invalidate: vi.fn(),
afterCommit: (effect) => {
let active = true;
renderLifecycle.invalidate();
queueMicrotask(() => {
if (active) {
effect(() => undefined);
}
});
return () => {
active = false;
};
},
};
const host = {
client: null,
chatMessages: [],
@@ -199,7 +215,21 @@ function makeHost(overrides?: Partial<TestChatHost>): TestChatHost {
toolStreamById: new Map(),
toolStreamOrder: [],
toolStreamSyncTimer: null,
updateComplete: Promise.resolve(),
renderLifecycle,
querySelector: () => null,
chatScrollCommitCleanup: null,
chatScrollFrame: null,
chatScrollGuardFrame: null,
chatScrollTimeout: null,
chatScrollGeneration: 0,
chatLastScrollTop: 0,
chatLastScrollHeight: 0,
chatHasAutoScrolled: false,
chatUserNearBottom: true,
chatFollowLocked: false,
chatNewMessagesBelow: false,
chatIsProgrammaticScroll: false,
chatProgrammaticScrollTarget: 0,
...overrides,
};
const sessions = createSessionCapability({
+2 -1
View File
@@ -77,6 +77,7 @@ import {
type ChatInputHistoryState,
} from "./input-history.ts";
import { controlUiNowMs, roundedControlUiDurationMs } from "./performance.ts";
import type { RenderLifecycle } from "./render-lifecycle.ts";
import {
handleAbortChat,
isChatBusy,
@@ -102,7 +103,7 @@ export type ChatHost = ChatInputHistoryState &
chatError?: string | null;
hello: GatewayHelloOk | null;
chatModelSwitchPromises?: Record<string, Promise<boolean>>;
updateComplete?: Promise<unknown>;
renderLifecycle?: RenderLifecycle;
requestUpdate?: () => void;
refreshSessionsAfterChat: Map<string, SessionRefreshTarget>;
chatSubmitGuards?: Map<string, Promise<void>>;
+240 -1
View File
@@ -1,10 +1,19 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SLASH_COMMANDS } from "../../lib/chat/commands.ts";
import {
applyRemoteSlashCommandsResult,
resetChatSlashCommandMetadataForTest,
} from "./chat-commands.ts";
import { refreshChatMetadata, resolveChatAvatarUrl, type ChatPageHost } from "./chat-state.ts";
import {
ChatStateController,
handleChatManualRefresh,
refreshChatMetadata,
resolveChatAvatarUrl,
type ChatPageHost,
} from "./chat-state.ts";
import { scheduleControlUiAfterPaint } from "./performance.ts";
import type { RenderLifecycle } from "./render-lifecycle.ts";
vi.mock("../../app/assistant-identity.ts", async (importOriginal) => ({
...(await importOriginal<typeof import("../../app/assistant-identity.ts")>()),
@@ -13,6 +22,236 @@ vi.mock("../../app/assistant-identity.ts", async (importOriginal) => ({
afterEach(() => {
resetChatSlashCommandMetadataForTest();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("ChatStateController render lifecycle", () => {
it("requests a render before selecting the commit promise", async () => {
let resolveCommit: (value: boolean) => void = () => {};
const nextCommit = new Promise<boolean>((resolve) => {
resolveCommit = resolve;
});
let completion = Promise.resolve(true);
const controllers: ReactiveController[] = [];
const requestUpdate = vi.fn(() => {
completion = nextCommit;
});
const host = {
addController: (controller: ReactiveController) => controllers.push(controller),
removeController: () => undefined,
requestUpdate,
get updateComplete() {
return completion;
},
} satisfies ReactiveControllerHost;
const controller = new ChatStateController<ChatPageHost>(host);
controller.hostConnected();
const renderLifecycle = controller.createRenderLifecycle();
const effect = vi.fn();
renderLifecycle.afterCommit(effect);
await Promise.resolve();
expect(requestUpdate).toHaveBeenCalledOnce();
expect(effect).not.toHaveBeenCalled();
resolveCommit(true);
await nextCommit;
expect(effect).toHaveBeenCalledOnce();
expect(controllers).toContain(controller);
});
it("cancels pending commit effects on disconnect", async () => {
let resolveCommit: (value: boolean) => void = () => {};
const completion = new Promise<boolean>((resolve) => {
resolveCommit = resolve;
});
const host = {
addController: () => undefined,
removeController: () => undefined,
requestUpdate: () => undefined,
updateComplete: completion,
} satisfies ReactiveControllerHost;
const controller = new ChatStateController<ChatPageHost>(host);
controller.hostConnected();
const renderLifecycle = controller.createRenderLifecycle();
const effect = vi.fn();
renderLifecycle.afterCommit(effect);
controller.hostDisconnected();
resolveCommit(true);
await completion;
expect(effect).not.toHaveBeenCalled();
});
it("rejects lifecycle work from detached and replaced state epochs", async () => {
const requestUpdate = vi.fn();
const host = {
addController: () => undefined,
removeController: () => undefined,
requestUpdate,
updateComplete: Promise.resolve(true),
} satisfies ReactiveControllerHost;
const controller = new ChatStateController<ChatPageHost>(host);
controller.hostConnected();
const first = controller.createRenderLifecycle();
const replacement = controller.createRenderLifecycle();
const staleEffect = vi.fn();
const staleCancel = vi.fn();
first.invalidate();
first.afterCommit(staleEffect, staleCancel);
expect(requestUpdate).not.toHaveBeenCalled();
expect(staleEffect).not.toHaveBeenCalled();
expect(staleCancel).toHaveBeenCalledOnce();
controller.hostDisconnected();
replacement.invalidate();
replacement.afterCommit(staleEffect, staleCancel);
expect(requestUpdate).not.toHaveBeenCalled();
expect(staleEffect).not.toHaveBeenCalled();
expect(staleCancel).toHaveBeenCalledTimes(2);
controller.hostConnected();
const current = controller.createRenderLifecycle();
const currentEffect = vi.fn();
current.afterCommit(currentEffect);
await Promise.resolve();
expect(requestUpdate).toHaveBeenCalledOnce();
expect(currentEffect).toHaveBeenCalledOnce();
});
it("cancels post-commit paint frames on disconnect", async () => {
let nextFrame = 1;
const frames = new Map<number, FrameRequestCallback>();
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
const id = nextFrame++;
frames.set(id, callback);
return id;
});
const cancelAnimationFrame = vi
.spyOn(window, "cancelAnimationFrame")
.mockImplementation((id) => {
frames.delete(id);
});
const host = {
addController: () => undefined,
removeController: () => undefined,
requestUpdate: vi.fn(),
updateComplete: Promise.resolve(true),
} satisfies ReactiveControllerHost;
const controller = new ChatStateController<ChatPageHost>(host);
controller.hostConnected();
const renderLifecycle = controller.createRenderLifecycle();
const painted = vi.fn();
scheduleControlUiAfterPaint({ renderLifecycle }, painted);
await Promise.resolve();
const firstFrame = frames.get(1);
expect(firstFrame).toBeDefined();
frames.delete(1);
firstFrame?.(0);
const secondFrame = frames.get(2);
expect(secondFrame).toBeDefined();
controller.hostDisconnected();
secondFrame?.(0);
expect(cancelAnimationFrame).toHaveBeenCalledWith(2);
expect(painted).not.toHaveBeenCalled();
});
it("resolves a canceled commit wait without starting manual refresh RPCs", async () => {
const cancelAnimationFrame = vi.fn();
vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame);
let cancelCommit = () => {};
const invalidate = vi.fn();
const renderLifecycle: RenderLifecycle = {
invalidate,
afterCommit: (_effect, onCancel) => {
cancelCommit = () => onCancel?.();
return cancelCommit;
},
};
const resetToolStream = vi.fn();
const scrollToBottom = vi.fn();
const state = {
chatManualRefreshFrame: 40,
chatManualRefreshGeneration: 0,
chatManualRefreshInFlight: false,
chatNewMessagesBelow: true,
renderLifecycle,
resetToolStream,
scrollToBottom,
} as unknown as ChatPageHost;
const refresh = handleChatManualRefresh(state);
cancelCommit();
await refresh;
expect(state.chatManualRefreshInFlight).toBe(false);
expect(cancelAnimationFrame).toHaveBeenCalledWith(40);
expect(resetToolStream).not.toHaveBeenCalled();
expect(scrollToBottom).not.toHaveBeenCalled();
expect(invalidate).not.toHaveBeenCalled();
});
it("cancels pending manual refresh frames when state is replaced or disconnected", () => {
const cancelAnimationFrame = vi.fn();
vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame);
const host = {
addController: () => undefined,
removeController: () => undefined,
requestUpdate: () => undefined,
updateComplete: Promise.resolve(true),
} satisfies ReactiveControllerHost;
const controller = new ChatStateController<ChatPageHost>(host);
controller.hostConnected();
const createState = (frame: number, renderLifecycle: RenderLifecycle) =>
({
chatLoading: false,
chatMessages: [],
chatToolMessages: [],
chatStream: null,
realtimeTalkConversation: [],
handleSendChat: async () => undefined,
handleChatDraftChange: () => undefined,
handleChatInputHistoryKey: () => ({ handled: false }),
chatManualRefreshFrame: frame,
chatManualRefreshGeneration: 1,
chatManualRefreshInFlight: true,
renderLifecycle,
chatScrollCommitCleanup: null,
chatScrollFrame: null,
chatScrollGuardFrame: null,
chatScrollTimeout: null,
chatScrollGeneration: 0,
chatIsProgrammaticScroll: false,
sessionWorkspaceState: undefined,
realtimeTalkSession: null,
resetToolStream: vi.fn(),
}) as unknown as ChatPageHost;
const first = createState(41, controller.createRenderLifecycle());
controller.attach(first);
const second = createState(42, controller.createRenderLifecycle());
controller.attach(second);
expect(cancelAnimationFrame).toHaveBeenCalledWith(41);
expect(first.chatManualRefreshFrame).toBeNull();
expect(first.chatManualRefreshInFlight).toBe(false);
controller.hostDisconnected();
expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
expect(second.chatManualRefreshFrame).toBeNull();
expect(second.chatManualRefreshInFlight).toBe(false);
});
});
describe("resolveChatAvatarUrl", () => {
+231 -38
View File
@@ -78,7 +78,7 @@ import {
} from "./components/chat-session-workspace.ts";
import type { SidebarContent } from "./components/chat-sidebar.ts";
import {
ChatComposerPersistenceController,
ChatComposerPersistence,
persistChatComposerState,
restoreChatComposerState,
} from "./composer-persistence.ts";
@@ -90,6 +90,8 @@ import {
type ChatInputHistoryKeyResult,
} from "./input-history.ts";
import { applyModelCatalogResult, loadModels } from "./models.ts";
import type { AfterCommitEffect, RenderLifecycle } from "./render-lifecycle.ts";
import { waitForCommit } from "./render-lifecycle.ts";
import {
handleAbortChat,
reconcileChatRunFromCurrentSessionRow,
@@ -97,7 +99,13 @@ import {
reconcileChatRunLifecycle,
reconcileStaleChatRunAfterSessionStatePublication,
} from "./run-lifecycle.ts";
import { scheduleChatScroll, handleChatScroll, resetChatScroll } from "./scroll.ts";
import {
cancelChatScroll,
handleChatScroll,
resetChatScroll,
scheduleChatScroll,
scheduleCommittedChatScroll,
} from "./scroll.ts";
import { cacheChatMessages, readChatMessagesFromCache } from "./session-message-cache.ts";
import {
handleAgentEvent,
@@ -110,7 +118,6 @@ import {
type ChatPageElement = {
querySelector: (selectors: string) => Element | null;
readonly updateComplete: Promise<unknown>;
};
export type ChatPageHost = ChatHost &
@@ -169,6 +176,8 @@ export type ChatPageHost = ChatHost &
chatRunStatus: ChatProps["runStatus"];
chatNewMessagesBelow: boolean;
chatManualRefreshInFlight: boolean;
chatManualRefreshFrame: number | null;
chatManualRefreshGeneration: number;
chatMetadataRequestVersion: number;
chatModelsLoading: boolean;
chatMobileControlsOpen: boolean;
@@ -181,21 +190,23 @@ export type ChatPageHost = ChatHost &
chatInputHistoryItems: string[] | null;
chatInputHistoryIndex: number;
chatDraftBeforeHistory: string | null;
chatScrollCommitCleanup: (() => void) | null;
chatScrollFrame: number | null;
chatScrollGuardFrame: number | null;
chatScrollTimeout: number | null;
chatScrollGeneration: number;
chatLastScrollTop: number;
chatLastScrollHeight: number;
chatHasAutoScrolled: boolean;
chatUserNearBottom: boolean;
chatFollowLocked: boolean;
chatHeaderControlsHidden: boolean;
chatIsProgrammaticScroll: boolean;
chatProgrammaticScrollTarget: number;
sidebarOpen: boolean;
sidebarContent: SidebarContent | null;
splitRatio: number;
querySelector: (selectors: string) => Element | null;
updateComplete: Promise<unknown>;
renderLifecycle: RenderLifecycle;
requestUpdate: () => void;
onModelChanged: () => Promise<void> | void;
resetToolStream: () => void;
@@ -248,25 +259,62 @@ export function canCreateChatSession(
}
export async function handleChatManualRefresh(state: ChatPageHost): Promise<void> {
if (state.chatManualRefreshFrame !== null) {
cancelAnimationFrame(state.chatManualRefreshFrame);
state.chatManualRefreshFrame = null;
}
const lifecycle = state.renderLifecycle;
const generation = ++state.chatManualRefreshGeneration;
state.chatManualRefreshInFlight = true;
state.chatNewMessagesBelow = false;
await state.updateComplete;
const committed = await waitForCommit(lifecycle);
if (!committed || generation !== state.chatManualRefreshGeneration) {
if (generation === state.chatManualRefreshGeneration) {
state.chatManualRefreshInFlight = false;
}
return;
}
state.resetToolStream();
try {
await Promise.allSettled([
refreshPageChat(state, { awaitHistory: true, scheduleScroll: false }),
refreshChatModelAuthStatus(state, { refresh: true }),
]);
state.scrollToBottom({ smooth: true });
if (generation === state.chatManualRefreshGeneration) {
state.scrollToBottom({ smooth: true });
}
} finally {
requestAnimationFrame(() => {
state.chatManualRefreshInFlight = false;
state.chatNewMessagesBelow = false;
state.requestUpdate();
});
if (generation === state.chatManualRefreshGeneration) {
let finalized = false;
const frame = requestAnimationFrame(() => {
finalized = true;
state.chatManualRefreshFrame = null;
if (
generation !== state.chatManualRefreshGeneration ||
lifecycle !== state.renderLifecycle
) {
return;
}
state.chatManualRefreshInFlight = false;
state.chatNewMessagesBelow = false;
lifecycle.invalidate();
});
if (!finalized) {
state.chatManualRefreshFrame = frame;
}
}
}
}
function cancelChatManualRefresh(state: ChatPageHost): void {
state.chatManualRefreshGeneration += 1;
if (state.chatManualRefreshFrame !== null) {
cancelAnimationFrame(state.chatManualRefreshFrame);
state.chatManualRefreshFrame = null;
}
state.chatManualRefreshInFlight = false;
}
export function resolveAssistantAttachmentAuthToken(state: ChatPageHost) {
return resolveControlUiAuthToken(state);
}
@@ -954,7 +1002,7 @@ async function loadPageAssistantIdentity(
export function createPageState(
context: ApplicationContext,
requestUpdate: () => void,
renderLifecycle: RenderLifecycle,
page: ChatPageElement,
): ChatPageHost {
const settings = loadSettings();
@@ -979,6 +1027,7 @@ export function createPageState(
chatMessageMaxWidth: appConfig.chatMessageMaxWidth,
client: null,
connected: false,
connectionEpoch: 0,
hello: null,
terminalAvailable: false,
assistantAgentId: context.agentSelection.state.selectedId,
@@ -1035,6 +1084,8 @@ export function createPageState(
basePath: context.basePath,
chatNewMessagesBelow: false,
chatManualRefreshInFlight: false,
chatManualRefreshFrame: null,
chatManualRefreshGeneration: 0,
chatMobileControlsOpen: false,
chatMobileControlsTrigger: null,
sessionsHideCron: true,
@@ -1043,14 +1094,16 @@ export function createPageState(
chatInputHistoryItems: null,
chatInputHistoryIndex: -1,
chatDraftBeforeHistory: null,
chatScrollCommitCleanup: null,
chatScrollFrame: null,
chatScrollGuardFrame: null,
chatScrollTimeout: null,
chatScrollGeneration: 0,
chatLastScrollTop: 0,
chatLastScrollHeight: 0,
chatHasAutoScrolled: false,
chatUserNearBottom: true,
chatFollowLocked: false,
chatHeaderControlsHidden: false,
chatIsProgrammaticScroll: false,
chatProgrammaticScrollTarget: 0,
sidebarOpen: false,
@@ -1060,16 +1113,12 @@ export function createPageState(
toolStreamOrder: [] as string[],
toolStreamSyncTimer: null,
...createInitialChatRealtimeState(settings.realtimeTalkInputDeviceId),
requestUpdate,
renderLifecycle,
requestUpdate: () => renderLifecycle.invalidate(),
sessionWorkspaceState: undefined,
sessionWorkspaceOpenRequest: undefined,
querySelector: page.querySelector.bind(page),
} as unknown as ChatPageHost;
Object.defineProperty(state, "updateComplete", {
configurable: true,
enumerable: false,
get: () => page.updateComplete,
});
state.resetToolStream = () => resetToolStream(state as never);
state.onModelChanged = () => undefined;
@@ -1092,19 +1141,19 @@ export function createPageState(
splitRatio: next.splitRatio,
});
state.splitRatio = state.settings.splitRatio;
requestUpdate();
renderLifecycle.invalidate();
};
state.setChatMobileControlsOpen = (open, options) => {
if (open) {
state.chatMobileControlsTrigger = options?.trigger ?? state.chatMobileControlsTrigger;
state.chatMobileControlsOpen = true;
requestUpdate();
renderLifecycle.invalidate();
return;
}
const focusTarget = options?.restoreFocus ? state.chatMobileControlsTrigger : null;
state.chatMobileControlsOpen = false;
state.chatMobileControlsTrigger = null;
requestUpdate();
renderLifecycle.invalidate();
if (!(focusTarget instanceof HTMLElement) || !focusTarget.isConnected) {
return;
}
@@ -1122,28 +1171,28 @@ export function createPageState(
handleSendChat(state, messageOverride, options as never);
state.handleAbortChat = async (options) => {
await handleAbortChat(state, options as never);
requestUpdate();
renderLifecycle.invalidate();
};
state.removeQueuedMessage = (id) => {
removeQueuedMessage(state, id);
requestUpdate();
renderLifecycle.invalidate();
};
state.retryQueuedChatMessage = async (id) => {
await retryQueuedChatMessage(state, id);
requestUpdate();
renderLifecycle.invalidate();
};
state.steerQueuedChatMessage = async (id) => {
await steerQueuedChatMessage(state, id);
requestUpdate();
renderLifecycle.invalidate();
};
state.handleOpenSidebar = (content) => {
state.sidebarContent = content;
state.sidebarOpen = true;
requestUpdate();
renderLifecycle.invalidate();
};
state.handleCloseSidebar = () => {
state.sidebarOpen = false;
requestUpdate();
renderLifecycle.invalidate();
};
state.handleSplitRatioChange = (ratio) => {
const next = Math.max(0.4, Math.min(0.7, ratio));
@@ -1197,8 +1246,13 @@ function requestPageUpdate(state: ChatPageHost) {
state.requestUpdate?.();
}
type ChatRenderLifecycleScope = {
connectionEpoch: number;
cancellations: Set<() => void>;
};
export class ChatStateController<TState extends ChatPageHost> implements ReactiveController {
private readonly composerPersistence: ChatComposerPersistenceController;
private readonly composerPersistence: ChatComposerPersistence;
private stateValue: TState | undefined;
private previousChatLoading = false;
private previousChatMessages: unknown[] = [];
@@ -1217,32 +1271,56 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
| undefined;
private pendingCreatedSessionComposer: PendingCreatedSessionComposer | null = null;
private readonly cleanups: Array<() => void> = [];
private renderLifecycleConnected = false;
private renderLifecycleConnectionEpoch = 0;
private renderLifecycleScope: ChatRenderLifecycleScope | undefined;
constructor(private readonly host: ReactiveControllerHost) {
this.composerPersistence = new ChatComposerPersistence(() => this.stateValue);
host.addController(this);
this.composerPersistence = new ChatComposerPersistenceController(host, () => this.stateValue);
}
get state(): TState | undefined {
return this.stateValue;
}
createRenderLifecycle(): RenderLifecycle {
this.cancelRenderLifecycleScope();
const scope: ChatRenderLifecycleScope = {
connectionEpoch: this.renderLifecycleConnectionEpoch,
cancellations: new Set(),
};
this.renderLifecycleScope = scope;
return {
invalidate: () => {
this.requestUpdateForScope(scope);
},
afterCommit: (effect, onCancel) => this.afterCommit(scope, effect, onCancel),
};
}
attach(state: TState) {
if (this.stateValue && this.stateValue !== state) {
this.composerPersistence.stop();
cancelChatManualRefresh(this.stateValue);
cancelChatScroll(this.stateValue);
}
this.stateValue = state;
this.previousChatLoading = state.chatLoading;
this.previousChatMessages = state.chatMessages;
this.previousChatToolMessages = state.chatToolMessages;
this.previousChatStream = state.chatStream;
this.previousRealtimeConversation = state.realtimeTalkConversation;
state.requestUpdate = this.requestUpdate;
const renderLifecycle = state.renderLifecycle;
state.requestUpdate = () => renderLifecycle.invalidate();
const sendChat = state.handleSendChat;
state.handleSendChat = async (messageOverride, options) => {
const pending = sendChat(messageOverride, options);
this.requestUpdate();
renderLifecycle.invalidate();
try {
await pending;
} finally {
this.requestUpdate();
renderLifecycle.invalidate();
}
};
const commitDraftChange = state.handleChatDraftChange;
@@ -1250,17 +1328,118 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
commitDraftChange(next);
this.composerPersistence.schedule();
};
const navigateInputHistory = state.handleChatInputHistoryKey;
state.handleChatInputHistoryKey = (input) => {
const result = navigateInputHistory(input);
if (result.handled) {
this.composerPersistence.schedule();
}
return result;
};
}
addCleanup(cleanup: () => void) {
this.cleanups.push(cleanup);
}
readonly requestUpdate = () => {
private isRenderLifecycleScopeActive(scope: ChatRenderLifecycleScope): boolean {
return (
this.renderLifecycleConnected &&
this.renderLifecycleScope === scope &&
scope.connectionEpoch === this.renderLifecycleConnectionEpoch
);
}
private requestUpdateForScope(scope: ChatRenderLifecycleScope): boolean {
if (!this.isRenderLifecycleScopeActive(scope)) {
return false;
}
this.composerPersistence.persistChangedState();
this.captureRenderLifecycleChanges();
this.host.requestUpdate();
};
return true;
}
private cancelRenderLifecycleScope(): void {
const scope = this.renderLifecycleScope;
if (!scope) {
return;
}
this.renderLifecycleScope = undefined;
for (const cancel of scope.cancellations) {
cancel();
}
}
private afterCommit(
scope: ChatRenderLifecycleScope,
effect: AfterCommitEffect,
onCancel?: () => void,
): () => void {
if (!this.isRenderLifecycleScopeActive(scope)) {
onCancel?.();
return () => undefined;
}
let active = true;
let committed = false;
let cleanup: (() => void) | undefined;
const complete = () => {
if (!active) {
return;
}
active = false;
cleanup = undefined;
scope.cancellations.delete(cancel);
};
const cancel = () => {
if (!active) {
return;
}
active = false;
scope.cancellations.delete(cancel);
try {
cleanup?.();
} finally {
cleanup = undefined;
if (!committed) {
onCancel?.();
}
}
};
scope.cancellations.add(cancel);
// Request first so updateComplete represents the render this effect needs.
if (!this.requestUpdateForScope(scope)) {
cancel();
return cancel;
}
const completion = this.host.updateComplete;
void completion.then(() => {
if (!active) {
return;
}
if (!this.isRenderLifecycleScopeActive(scope)) {
cancel();
return;
}
committed = true;
try {
const nextCleanup = effect(complete);
if (typeof nextCleanup === "function") {
if (active && this.isRenderLifecycleScopeActive(scope)) {
cleanup = nextCleanup;
} else {
nextCleanup();
}
} else {
complete();
}
} catch (error) {
complete();
throw error;
}
}, cancel);
return cancel;
}
private captureRenderLifecycleChanges() {
const state = this.stateValue;
@@ -1317,13 +1496,20 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
if (!currentState || currentState.chatManualRefreshInFlight) {
return;
}
scheduleChatScroll(currentState, false, false, { source: "resize" });
scheduleCommittedChatScroll(currentState, false, false, { source: "resize" });
});
this.chatThreadResizeObserver.observe(thread);
this.chatThreadResizeObserver.observe(content);
this.chatThreadResizeTargets = { thread, content };
}
hostConnected() {
this.renderLifecycleConnectionEpoch += 1;
this.renderLifecycleConnected = true;
// A lifecycle created while detached must never become active on reconnect.
this.cancelRenderLifecycleScope();
}
hostUpdated() {
const state = this.stateValue;
if (state) {
@@ -1340,7 +1526,7 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
if (!state || state.chatManualRefreshInFlight) {
return;
}
scheduleChatScroll(state, force, false, { contentChanged });
scheduleCommittedChatScroll(state, force, false, { contentChanged });
}
restoreComposer(options: { preserveCurrent?: boolean } = {}) {
@@ -1385,6 +1571,8 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
}
const state = this.stateValue;
if (state) {
cancelChatManualRefresh(state);
cancelChatScroll(state);
clearSessionWorkspaceTimers(state);
}
state?.realtimeTalkSession?.stop();
@@ -1395,6 +1583,11 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
}
hostDisconnected() {
this.renderLifecycleConnected = false;
this.cancelRenderLifecycleScope();
// Flush while stateValue still points at the active session. Composer
// persistence is owned here so controller registration order cannot lose it.
this.composerPersistence.stop();
this.stopChatEffects();
this.stateValue = undefined;
this.scrollAfterUpdate = false;
+32
View File
@@ -2220,6 +2220,38 @@ describe("chat composer IME composition", () => {
expect(onHistoryKeydown).not.toHaveBeenCalled();
});
it("invalidates after handled input history navigation", () => {
const onRequestUpdate = vi.fn();
const onHistoryKeydown = vi.fn(() => ({
handled: true,
preventDefault: true,
restoreCaret: "up" as const,
decision: "handled:history-up" as const,
historyNavigationActiveBefore: false,
historyNavigationActiveAfter: true,
selectionStart: 0,
selectionEnd: 0,
valueLength: 0,
}));
const container = renderChatView({ onHistoryKeydown, onRequestUpdate });
const textarea = requireElement(
container,
".agent-chat__composer-combobox > textarea",
"composer textarea",
) as HTMLTextAreaElement;
const arrowEvent = new KeyboardEvent("keydown", {
key: "ArrowUp",
bubbles: true,
cancelable: true,
});
textarea.dispatchEvent(arrowEvent);
expect(arrowEvent.defaultPrevented).toBe(true);
expect(onHistoryKeydown).toHaveBeenCalledOnce();
expect(onRequestUpdate).toHaveBeenCalledOnce();
});
it("does not force textarea resize during IME composition", () => {
const container = renderChatView({});
const textarea = requireElement(
@@ -2068,6 +2068,9 @@ export function renderChatComposer(props: ChatComposerProps) {
if (result.preventDefault) {
event.preventDefault();
}
// History navigation updates the renderer-owned draft outside a
// reactive property; commit it before placing the caret in the DOM.
requestUpdate();
if (result.restoreCaret) {
restoreHistoryCaret(target, result.restoreCaret);
}
+4 -7
View File
@@ -1,5 +1,5 @@
import DOMPurify from "dompurify";
import { LitElement, html, nothing } from "lit";
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { keyed } from "lit/directives/keyed.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
@@ -10,14 +10,15 @@ import {
markdownFileLinkFromEvent,
toSanitizedMarkdownHtml,
} from "../../../components/markdown.ts";
import "../../../components/tooltip.ts";
import { extractRawText } from "../../../lib/chat/message-extract.ts";
import "../../../components/tooltip.ts";
import {
resolveCanvasIframeUrl,
resolveEmbedSandbox,
type EmbedSandboxMode,
} from "../../../lib/chat/tool-display.ts";
import { copyToClipboard } from "../../../lib/clipboard.ts";
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
export const CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS = 500_000;
@@ -596,7 +597,7 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
`;
}
class ChatDetailPanel extends LitElement {
class ChatDetailPanel extends OpenClawLightDomElement {
@property({ attribute: false }) content: SidebarContent | null = null;
@property({ attribute: false }) loadFullMessage?:
| ((request: SidebarFullMessageRequest) => Promise<DetailFullMessageResult | null | undefined>)
@@ -621,10 +622,6 @@ class ChatDetailPanel extends LitElement {
private showingRawText = false;
private copyFeedbackTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
override createRenderRoot() {
return this;
}
override connectedCallback() {
super.connectedCallback();
document.addEventListener("pointerdown", this.handleDocumentPointerDown);
+32 -1
View File
@@ -3,13 +3,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
import { createStorageMock } from "../../test-helpers/storage.ts";
import {
ChatComposerPersistence,
loadChatComposerSnapshot,
persistChatComposerState,
removeStoredChatComposerQueueItem,
restoreChatComposerState,
} from "./composer-persistence.ts";
function createState(overrides: Partial<Parameters<typeof persistChatComposerState>[0]> = {}) {
function createState(
overrides: Partial<Parameters<typeof persistChatComposerState>[0]> = {},
): Parameters<typeof persistChatComposerState>[0] {
return {
settings: { gatewayUrl: "ws://gateway.test/control" },
sessionKey: "agent:lily:main",
@@ -24,10 +27,38 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("chat composer persistence", () => {
it("flushes a debounced draft before its owner releases state", () => {
vi.useFakeTimers();
const state = createState();
const persistence = new ChatComposerPersistence(() => state);
persistence.start();
state.chatMessage = "persist during disconnect";
persistence.schedule();
persistence.stop();
expect(loadChatComposerSnapshot(state, state.sessionKey)).toEqual({
draft: "persist during disconnect",
queue: [],
});
});
it("persists queue reference changes immediately", () => {
const state = createState();
const persistence = new ChatComposerPersistence(() => state);
persistence.start();
state.chatQueue = [{ id: "queued-now", text: "keep me", createdAt: 1 }];
persistence.persistChangedState();
expect(loadChatComposerSnapshot(state, state.sessionKey)?.queue).toEqual(state.chatQueue);
});
it("restores draft text and queued messages for the same gateway session", () => {
const queue: ChatQueueItem[] = [
{
+2 -12
View File
@@ -1,4 +1,3 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import type {
ChatAttachment,
ChatQueueItem,
@@ -489,7 +488,7 @@ export function restoreChatComposerState(
return true;
}
export class ChatComposerPersistenceController implements ReactiveController {
export class ChatComposerPersistence {
private timer: ReturnType<typeof globalThis.setTimeout> | null = null;
private ready = false;
private lastPersisted: {
@@ -498,16 +497,7 @@ export class ChatComposerPersistenceController implements ReactiveController {
chatQueue: ChatQueueItem[];
} | null = null;
constructor(
host: ReactiveControllerHost,
private readonly getState: () => ChatComposerPersistenceState | undefined,
) {
host.addController(this);
}
hostDisconnected() {
this.stop();
}
constructor(private readonly getState: () => ChatComposerPersistenceState | undefined) {}
start() {
const state = this.getState();
+60 -9
View File
@@ -1,8 +1,9 @@
import type { EventLogEntry } from "../../api/event-log.ts";
import type { RenderLifecycle } from "./render-lifecycle.ts";
type ChatPerformanceHost = {
eventLogBuffer?: unknown[];
updateComplete?: Promise<unknown>;
renderLifecycle?: RenderLifecycle;
};
const EVENT_LOG_LIMIT = 250;
@@ -17,12 +18,59 @@ export function roundedControlUiDurationMs(durationMs: number): number {
return Math.max(0, Math.round(durationMs));
}
function runAfterPaint(callback: () => void): void {
function runAfterPaint(callback: () => void, complete: () => void): () => void {
let active = true;
let firstFrame: number | null = null;
let secondFrame: number | null = null;
const run = () => {
if (!active) {
return;
}
active = false;
try {
callback();
} finally {
complete();
}
};
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") {
queueMicrotask(callback);
return;
queueMicrotask(run);
} else {
let firstFrameCompleted = false;
const scheduledFirstFrame = window.requestAnimationFrame(() => {
firstFrameCompleted = true;
firstFrame = null;
if (!active) {
return;
}
let secondFrameCompleted = false;
const scheduledSecondFrame = window.requestAnimationFrame(() => {
secondFrameCompleted = true;
secondFrame = null;
run();
});
if (!secondFrameCompleted) {
secondFrame = scheduledSecondFrame;
}
});
if (!firstFrameCompleted) {
firstFrame = scheduledFirstFrame;
}
}
window.requestAnimationFrame(() => window.requestAnimationFrame(callback));
return () => {
if (!active) {
return;
}
active = false;
if (firstFrame !== null) {
window.cancelAnimationFrame(firstFrame);
firstFrame = null;
}
if (secondFrame !== null) {
window.cancelAnimationFrame(secondFrame);
secondFrame = null;
}
};
}
function keepLatestBufferedEventsForType(
@@ -71,10 +119,13 @@ export function recordControlUiPerformanceEvent(
}
export function scheduleControlUiAfterPaint(
host: Pick<ChatPerformanceHost, "updateComplete">,
host: Pick<ChatPerformanceHost, "renderLifecycle">,
callback: () => void,
): void {
void Promise.resolve(host.updateComplete)
.catch(() => undefined)
.then(() => runAfterPaint(callback));
if (host.renderLifecycle) {
host.renderLifecycle.afterCommit((complete) => runAfterPaint(callback, complete));
return;
}
// Renderer-free unit hosts have no DOM commit to await.
runAfterPaint(callback, () => undefined);
}
+25
View File
@@ -0,0 +1,25 @@
export type CancelAfterCommit = () => void;
export type CompleteAfterCommit = () => void;
export type AfterCommitEffect = (complete: CompleteAfterCommit) => CancelAfterCommit | void;
/**
* Renderer-neutral boundary for state invalidation and DOM-dependent effects.
* `afterCommit` must request a render before waiting for its commit.
*/
export interface RenderLifecycle {
invalidate(): void;
/**
* Run after the next commit. Async follow-up work returns its cleanup and
* calls `complete` when done so the lifecycle owns it through teardown.
*/
afterCommit(effect: AfterCommitEffect, onCancel?: () => void): CancelAfterCommit;
}
export function waitForCommit(renderLifecycle: RenderLifecycle): Promise<boolean> {
return new Promise((resolve) => {
renderLifecycle.afterCommit(
() => resolve(true),
() => resolve(false),
);
});
}
+82 -34
View File
@@ -1,7 +1,13 @@
// Control UI tests cover app scroll behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatAutoScrollMode } from "../../app/settings.ts";
import { handleChatScroll, scheduleChatScroll, resetChatScroll } from "./scroll.ts";
import type { RenderLifecycle } from "./render-lifecycle.ts";
import {
cancelChatScroll,
handleChatScroll,
resetChatScroll,
scheduleChatScroll,
} from "./scroll.ts";
/* ------------------------------------------------------------------ */
/* Helpers */
@@ -42,23 +48,33 @@ function createScrollHost(
settings.chatAutoScroll = chatAutoScroll;
}
const renderLifecycle: RenderLifecycle = {
invalidate: vi.fn(),
afterCommit: vi.fn((effect) => {
renderLifecycle.invalidate();
effect(() => undefined);
return vi.fn();
}),
};
const host = {
renderLifecycle,
updateComplete: Promise.resolve(),
querySelector: vi.fn().mockReturnValue(container),
style: { setProperty: vi.fn() } as unknown as CSSStyleDeclaration,
chatScrollCommitCleanup: null as (() => void) | null,
chatScrollFrame: null as number | null,
chatScrollGuardFrame: null as number | null,
chatScrollTimeout: null as number | null,
chatScrollGeneration: 0,
chatLastScrollTop: 0,
chatLastScrollHeight: 0,
chatHasAutoScrolled: false,
chatUserNearBottom: true,
chatFollowLocked: false,
chatHeaderControlsHidden: false,
chatNewMessagesBelow: false,
chatIsProgrammaticScroll: false,
chatProgrammaticScrollTarget: 0,
settings,
topbarObserver: null as ResizeObserver | null,
};
return { host, container };
@@ -115,36 +131,16 @@ describe("handleChatScroll", () => {
expect(host.chatUserNearBottom).toBe(false);
});
it("hides chat header controls when scrolling down through transcript history", () => {
it("publishes the indicator transition when the user returns to bottom", () => {
const { host } = createScrollHost({});
host.chatLastScrollTop = 100;
const event = createScrollEvent(3000, 260, 500);
host.chatNewMessagesBelow = true;
const invalidate = vi.fn();
host.renderLifecycle.invalidate = invalidate;
handleChatScroll(host, event);
handleChatScroll(host, createScrollEvent(2000, 1600, 400));
expect(host.chatHeaderControlsHidden).toBe(true);
});
it("shows chat header controls again when scrolling up", () => {
const { host } = createScrollHost({});
host.chatLastScrollTop = 800;
host.chatHeaderControlsHidden = true;
const event = createScrollEvent(3000, 700, 500);
handleChatScroll(host, event);
expect(host.chatHeaderControlsHidden).toBe(false);
});
it("keeps chat header controls visible near the bottom", () => {
const { host } = createScrollHost({});
host.chatLastScrollTop = 1900;
host.chatHeaderControlsHidden = true;
const event = createScrollEvent(3000, 2500, 500);
handleChatScroll(host, event);
expect(host.chatHeaderControlsHidden).toBe(false);
expect(host.chatNewMessagesBelow).toBe(false);
expect(invalidate).toHaveBeenCalledOnce();
});
});
@@ -166,6 +162,44 @@ describe("scheduleChatScroll", () => {
vi.restoreAllMocks();
});
it("does not read layout until the requested render commits", () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
scrollTop: 1600,
clientHeight: 400,
});
let commit: (() => void) | undefined;
host.renderLifecycle.afterCommit = vi.fn((effect) => {
host.renderLifecycle.invalidate();
commit = () => effect(() => undefined);
return vi.fn();
});
scheduleChatScroll(host);
expect(host.querySelector).not.toHaveBeenCalled();
expect(container.scrollTop).toBe(1600);
commit?.();
expect(container.scrollTop).toBe(container.scrollHeight);
});
it("cancels a pending commit before it can touch detached DOM", () => {
const { host } = createScrollHost({});
let commit: (() => void) | undefined;
const cancelCommit = vi.fn();
host.renderLifecycle.afterCommit = vi.fn((effect) => {
commit = () => effect(() => undefined);
return cancelCommit;
});
scheduleChatScroll(host);
cancelChatScroll(host);
commit?.();
expect(cancelCommit).toHaveBeenCalledOnce();
expect(host.querySelector).not.toHaveBeenCalled();
});
it("scrolls to bottom when user is near bottom (no force)", async () => {
const { host, container } = createScrollHost({
scrollHeight: 2000,
@@ -469,7 +503,6 @@ describe("resetChatScroll", () => {
host.chatUserNearBottom = false;
host.chatFollowLocked = true;
host.chatLastScrollTop = 300;
host.chatHeaderControlsHidden = true;
resetChatScroll(host);
@@ -477,10 +510,27 @@ describe("resetChatScroll", () => {
expect(host.chatUserNearBottom).toBe(true);
expect(host.chatFollowLocked).toBe(false);
expect(host.chatLastScrollTop).toBe(0);
expect(host.chatHeaderControlsHidden).toBe(false);
expect(host.chatIsProgrammaticScroll).toBe(false);
expect(host.chatProgrammaticScrollTarget).toBe(0);
});
it("cancels frame id zero and the late-size retry", () => {
const { host } = createScrollHost({});
const cancelFrame = vi.spyOn(window, "cancelAnimationFrame");
const clearTimer = vi.spyOn(window, "clearTimeout");
host.chatScrollFrame = 0;
host.chatScrollGuardFrame = 7;
host.chatScrollTimeout = 9;
cancelChatScroll(host);
expect(cancelFrame).toHaveBeenCalledWith(0);
expect(cancelFrame).toHaveBeenCalledWith(7);
expect(clearTimer).toHaveBeenCalledWith(9);
expect(host.chatScrollFrame).toBeNull();
expect(host.chatScrollGuardFrame).toBeNull();
expect(host.chatScrollTimeout).toBeNull();
});
});
/* ------------------------------------------------------------------ */
@@ -618,7 +668,6 @@ describe("programmatic scroll guard", () => {
it("suppressed programmatic scroll preserves direction bookkeeping for the next user scroll-up", () => {
const { host } = createScrollHost({});
host.chatUserNearBottom = true;
host.chatHeaderControlsHidden = true;
host.chatIsProgrammaticScroll = true;
host.chatProgrammaticScrollTarget = 3000;
host.chatLastScrollTop = 0;
@@ -629,7 +678,6 @@ describe("programmatic scroll guard", () => {
host.chatIsProgrammaticScroll = false;
handleChatScroll(host, createScrollEvent(3000, 2000, 400));
expect(host.chatHeaderControlsHidden).toBe(false);
expect(host.chatUserNearBottom).toBe(false);
});
+164 -111
View File
@@ -1,23 +1,24 @@
// Control UI module implements app scroll behavior.
import { normalizeChatAutoScrollMode, type ChatAutoScrollMode } from "../../app/settings.ts";
import type { RenderLifecycle } from "./render-lifecycle.ts";
/** Distance (px) from the bottom within which we consider the user "near bottom". */
const NEAR_BOTTOM_THRESHOLD = 450;
const FOLLOW_REACQUIRE_THRESHOLD = 8;
const HEADER_HIDE_SCROLL_DELTA = 12;
const HEADER_SHOW_TOP_THRESHOLD = 24;
type ChatScrollHost = {
updateComplete: Promise<unknown>;
renderLifecycle: RenderLifecycle;
querySelector: (selectors: string) => Element | null;
chatScrollCommitCleanup: (() => void) | null;
chatScrollFrame: number | null;
chatScrollGuardFrame: number | null;
chatScrollTimeout: number | null;
chatScrollGeneration: number;
chatLastScrollTop: number;
chatLastScrollHeight?: number;
chatHasAutoScrolled: boolean;
chatUserNearBottom: boolean;
chatFollowLocked: boolean;
chatHeaderControlsHidden: boolean;
chatNewMessagesBelow: boolean;
chatIsProgrammaticScroll: boolean;
chatProgrammaticScrollTarget: number;
@@ -35,120 +36,183 @@ type ChatScrollOptions = {
source?: "auto" | "manual" | "resize";
};
export function scheduleChatScroll(
host: ChatScrollHost,
force = false,
smooth = false,
options: ChatScrollOptions = {},
) {
if (host.chatScrollFrame) {
function cancelCommittedChatScroll(host: ChatScrollHost): void {
if (host.chatScrollFrame != null) {
cancelAnimationFrame(host.chatScrollFrame);
host.chatScrollFrame = null;
}
if (host.chatScrollGuardFrame != null) {
cancelAnimationFrame(host.chatScrollGuardFrame);
host.chatScrollGuardFrame = null;
}
if (host.chatScrollTimeout != null) {
clearTimeout(host.chatScrollTimeout);
host.chatScrollTimeout = null;
}
const pickScrollTarget = () => {
const container = queryHost(host, ".chat-thread") as HTMLElement | null;
if (container) {
const overflowY = getComputedStyle(container).overflowY;
const canScroll =
overflowY === "auto" ||
overflowY === "scroll" ||
container.scrollHeight - container.clientHeight > 1;
if (canScroll) {
return container;
}
host.chatIsProgrammaticScroll = false;
}
export function cancelChatScroll(host: ChatScrollHost): void {
host.chatScrollGeneration += 1;
host.chatScrollCommitCleanup?.();
host.chatScrollCommitCleanup = null;
cancelCommittedChatScroll(host);
}
function setNewMessagesBelow(host: ChatScrollHost, next: boolean): void {
if (host.chatNewMessagesBelow === next) {
return;
}
host.chatNewMessagesBelow = next;
// Scroll effects run after the render that caused them. Publish the semantic
// state transition so the indicator cannot wait for an unrelated update.
host.renderLifecycle.invalidate();
}
function scheduleProgrammaticScrollGuardClear(host: ChatScrollHost, generation: number): void {
if (host.chatScrollGuardFrame != null) {
cancelAnimationFrame(host.chatScrollGuardFrame);
}
host.chatScrollGuardFrame = requestAnimationFrame(() => {
host.chatScrollGuardFrame = null;
if (generation === host.chatScrollGeneration) {
host.chatIsProgrammaticScroll = false;
}
return (document.scrollingElement ?? document.documentElement) as HTMLElement | null;
};
// Wait for Lit render to complete, then scroll
void host.updateComplete.then(() => {
host.chatScrollFrame = requestAnimationFrame(() => {
host.chatScrollFrame = null;
const target = pickScrollTarget();
if (!target) {
});
}
function pickScrollTarget(host: ChatScrollHost): HTMLElement | null {
const container = queryHost(host, ".chat-thread") as HTMLElement | null;
if (container) {
const overflowY = getComputedStyle(container).overflowY;
const canScroll =
overflowY === "auto" ||
overflowY === "scroll" ||
container.scrollHeight - container.clientHeight > 1;
if (canScroll) {
return container;
}
}
return (document.scrollingElement ?? document.documentElement) as HTMLElement | null;
}
/** Schedule layout work when the caller already runs after the DOM commit. */
export function scheduleCommittedChatScroll(
host: ChatScrollHost,
force = false,
smooth = false,
options: ChatScrollOptions = {},
): void {
cancelCommittedChatScroll(host);
const generation = host.chatScrollGeneration;
host.chatScrollFrame = requestAnimationFrame(() => {
host.chatScrollFrame = null;
if (generation !== host.chatScrollGeneration) {
return;
}
const target = pickScrollTarget(host);
if (!target) {
return;
}
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
const contentGrew = target.scrollHeight > (host.chatLastScrollHeight ?? 0) + 1;
host.chatLastScrollHeight = target.scrollHeight;
const contentChanged = options.contentChanged ?? options.source !== "resize";
const autoScrollMode = normalizeChatAutoScrollMode(host.settings?.chatAutoScroll);
const manualScroll = options.source === "manual";
// force=true only overrides when we haven't auto-scrolled yet (initial load).
// After initial load, respect the user's scroll position.
const effectiveForce = force && !host.chatHasAutoScrolled;
const shouldStick =
manualScroll ||
autoScrollMode === "always" ||
(autoScrollMode === "near-bottom" &&
(effectiveForce ||
(!host.chatFollowLocked &&
(host.chatUserNearBottom || distanceFromBottom < NEAR_BOTTOM_THRESHOLD))));
if (!shouldStick) {
if (contentChanged || (options.source === "resize" && contentGrew)) {
setNewMessagesBelow(host, true);
}
return;
}
if (effectiveForce) {
host.chatHasAutoScrolled = true;
}
host.chatFollowLocked = false;
const smoothEnabled =
smooth &&
(typeof window === "undefined" ||
typeof window.matchMedia !== "function" ||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches);
const scrollTop = target.scrollHeight;
host.chatProgrammaticScrollTarget = scrollTop;
host.chatIsProgrammaticScroll = true;
if (typeof target.scrollTo === "function") {
target.scrollTo({ top: scrollTop, behavior: smoothEnabled ? "smooth" : "auto" });
} else {
target.scrollTop = scrollTop;
}
scheduleProgrammaticScrollGuardClear(host, generation);
host.chatUserNearBottom = true;
setNewMessagesBelow(host, false);
// Markdown, images, and mobile controls can grow after the first layout.
const retryDelay = effectiveForce ? 150 : 120;
host.chatScrollTimeout = window.setTimeout(() => {
host.chatScrollTimeout = null;
if (generation !== host.chatScrollGeneration) {
return;
}
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
const contentGrew = target.scrollHeight > (host.chatLastScrollHeight ?? 0) + 1;
host.chatLastScrollHeight = target.scrollHeight;
const contentChanged = options.contentChanged ?? options.source !== "resize";
const autoScrollMode = normalizeChatAutoScrollMode(host.settings?.chatAutoScroll);
const manualScroll = options.source === "manual";
// force=true only overrides when we haven't auto-scrolled yet (initial load).
// After initial load, respect the user's scroll position.
const effectiveForce = force && !host.chatHasAutoScrolled;
const shouldStick =
const latest = pickScrollTarget(host);
if (!latest) {
return;
}
const latestDistanceFromBottom = latest.scrollHeight - latest.scrollTop - latest.clientHeight;
const shouldStickRetry =
manualScroll ||
autoScrollMode === "always" ||
(autoScrollMode === "near-bottom" &&
(effectiveForce ||
(!host.chatFollowLocked &&
(host.chatUserNearBottom || distanceFromBottom < NEAR_BOTTOM_THRESHOLD))));
if (!shouldStick) {
if (contentChanged || (options.source === "resize" && contentGrew)) {
host.chatNewMessagesBelow = true;
}
(host.chatUserNearBottom || latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD))));
if (!shouldStickRetry) {
return;
}
if (effectiveForce) {
host.chatHasAutoScrolled = true;
}
host.chatFollowLocked = false;
const smoothEnabled =
smooth &&
(typeof window === "undefined" ||
typeof window.matchMedia !== "function" ||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches);
const scrollTop = target.scrollHeight;
host.chatProgrammaticScrollTarget = scrollTop;
host.chatProgrammaticScrollTarget = latest.scrollHeight;
host.chatIsProgrammaticScroll = true;
if (typeof target.scrollTo === "function") {
target.scrollTo({ top: scrollTop, behavior: smoothEnabled ? "smooth" : "auto" });
} else {
target.scrollTop = scrollTop;
}
// Clear the flag after the scroll event has fired (sync or next microtask).
requestAnimationFrame(() => {
host.chatIsProgrammaticScroll = false;
});
latest.scrollTop = latest.scrollHeight;
scheduleProgrammaticScrollGuardClear(host, generation);
host.chatUserNearBottom = true;
host.chatNewMessagesBelow = false;
const retryDelay = effectiveForce ? 150 : 120;
host.chatScrollTimeout = window.setTimeout(() => {
host.chatScrollTimeout = null;
const latest = pickScrollTarget();
if (!latest) {
return;
}
const latestDistanceFromBottom =
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
const shouldStickRetry =
manualScroll ||
autoScrollMode === "always" ||
(autoScrollMode === "near-bottom" &&
(effectiveForce ||
(!host.chatFollowLocked &&
(host.chatUserNearBottom || latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD))));
if (!shouldStickRetry) {
return;
}
host.chatProgrammaticScrollTarget = latest.scrollHeight;
host.chatIsProgrammaticScroll = true;
latest.scrollTop = latest.scrollHeight;
requestAnimationFrame(() => {
host.chatIsProgrammaticScroll = false;
});
host.chatUserNearBottom = true;
}, retryDelay);
});
}, retryDelay);
});
}
export function handleChatScroll(host: ChatScrollHost, event: Event) {
export function scheduleChatScroll(
host: ChatScrollHost,
force = false,
smooth = false,
options: ChatScrollOptions = {},
): void {
cancelChatScroll(host);
const generation = host.chatScrollGeneration;
let committed = false;
const cancelCommit = host.renderLifecycle.afterCommit(() => {
committed = true;
if (generation !== host.chatScrollGeneration) {
return;
}
host.chatScrollCommitCleanup = null;
scheduleCommittedChatScroll(host, force, smooth, options);
});
if (!committed) {
host.chatScrollCommitCleanup = cancelCommit;
}
}
export function handleChatScroll(host: ChatScrollHost, event: Event): void {
const container = event.currentTarget as HTMLElement | null;
if (!container) {
return;
@@ -163,7 +227,6 @@ export function handleChatScroll(host: ChatScrollHost, event: Event) {
// if it dropped below, the user scrolled up during the guard window and we must
// process the event so streaming stops pinning them back to the bottom.
const isUserScrollUp = delta < 0;
const isDeliberateScrollUp = delta < -HEADER_HIDE_SCROLL_DELTA;
if (
host.chatIsProgrammaticScroll &&
!isUserScrollUp &&
@@ -178,29 +241,19 @@ export function handleChatScroll(host: ChatScrollHost, event: Event) {
host.chatFollowLocked = false;
}
host.chatUserNearBottom = !host.chatFollowLocked && distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
const hasUsefulScroll = container.scrollHeight - container.clientHeight > NEAR_BOTTOM_THRESHOLD;
if (!hasUsefulScroll || scrollTop <= HEADER_SHOW_TOP_THRESHOLD || host.chatUserNearBottom) {
host.chatHeaderControlsHidden = false;
} else if (delta > HEADER_HIDE_SCROLL_DELTA) {
host.chatHeaderControlsHidden = true;
} else if (isDeliberateScrollUp) {
host.chatHeaderControlsHidden = false;
}
// Clear the "new messages below" indicator when user scrolls back to bottom.
if (host.chatUserNearBottom) {
host.chatNewMessagesBelow = false;
setNewMessagesBelow(host, false);
}
}
export function resetChatScroll(host: ChatScrollHost) {
export function resetChatScroll(host: ChatScrollHost): void {
cancelChatScroll(host);
host.chatHasAutoScrolled = false;
host.chatUserNearBottom = true;
host.chatFollowLocked = false;
host.chatLastScrollTop = 0;
host.chatLastScrollHeight = 0;
host.chatHeaderControlsHidden = false;
host.chatNewMessagesBelow = false;
host.chatIsProgrammaticScroll = false;
host.chatProgrammaticScrollTarget = 0;
+114 -2
View File
@@ -1,10 +1,29 @@
/* @vitest-environment jsdom */
import { describe, expect, it } from "vitest";
import type { ReactiveController } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SystemInfoResult } from "../../../../packages/gateway-protocol/src/index.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationGatewaySnapshot } from "../../app/context.ts";
import type {
ApplicationContext,
ApplicationGateway,
ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { ConfigPage, configSelectionFromSearch, supportsSystemInfo } from "./config-page.ts";
import type { ConfigViewState } from "./view.ts";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});
describe("configSelectionFromSearch", () => {
it("opens a valid linked Settings section", () => {
@@ -60,4 +79,97 @@ describe("ConfigPage system info", () => {
expect(state.systemInfo).toBeNull();
});
it("rejects an old Gateway source response when the replacement reuses its client", async () => {
const firstResponse = deferred<SystemInfoResult>();
const secondResponse = deferred<SystemInfoResult>();
const client = {
request: vi
.fn()
.mockImplementationOnce(() => firstResponse.promise)
.mockImplementationOnce(() => secondResponse.promise),
} as unknown as GatewayBrowserClient;
const snapshot = {
client,
connected: true,
hello: { features: { methods: ["system.info"] } },
} as ApplicationGatewaySnapshot;
const firstGateway = { snapshot } as ApplicationGateway;
const secondGateway = { snapshot } as ApplicationGateway;
const page = new ConfigPage();
const state = page as unknown as {
context: ApplicationContext;
subscriptions: ReactiveController;
shouldUpdate: () => boolean;
syncSystemInfoPolling: () => void;
synchronizeSystemInfoGateway: (gateway: ApplicationGateway) => void;
loadSystemInfo: () => Promise<void>;
systemInfo: SystemInfoResult | null;
systemInfoUnavailable: boolean;
};
page.removeController(state.subscriptions);
state.shouldUpdate = () => false;
state.syncSystemInfoPolling = () => undefined;
state.context = { gateway: firstGateway } as ApplicationContext;
document.body.append(page);
state.synchronizeSystemInfoGateway(firstGateway);
const firstLoad = state.loadSystemInfo();
state.systemInfo = {} as SystemInfoResult;
state.systemInfoUnavailable = true;
state.context = { gateway: secondGateway } as ApplicationContext;
state.synchronizeSystemInfoGateway(secondGateway);
const secondLoad = state.loadSystemInfo();
const stale = { platform: "stale" } as unknown as SystemInfoResult;
firstResponse.resolve(stale);
await firstLoad;
expect(state.systemInfo).toBeNull();
expect(state.systemInfoUnavailable).toBe(false);
const current = { platform: "current" } as unknown as SystemInfoResult;
secondResponse.resolve(current);
await secondLoad;
expect(state.systemInfo).toBe(current);
page.remove();
});
});
describe("ConfigPage runtime config lifecycle", () => {
it("loads replacement sources and clears sensitive reveal state", async () => {
const page = new ConfigPage();
const state = page as unknown as {
configViewState: ConfigViewState;
synchronizeRuntimeConfig: (runtimeConfig: ApplicationContext["runtimeConfig"]) => void;
};
const createRuntimeConfig = () =>
({
state: {
configSnapshot: null,
configLoading: false,
configSchema: null,
configSchemaLoading: false,
},
ensureLoaded: vi.fn(() => Promise.resolve()),
ensureSchemaLoaded: vi.fn(() => Promise.resolve()),
}) as unknown as ApplicationContext["runtimeConfig"];
const first = createRuntimeConfig();
const second = createRuntimeConfig();
state.synchronizeRuntimeConfig(first);
await Promise.resolve();
state.configViewState.rawRevealed = true;
state.configViewState.envRevealed = true;
state.configViewState.revealedSensitivePaths.add("gateway.auth.token");
state.synchronizeRuntimeConfig(second);
await Promise.resolve();
expect(first.ensureLoaded).toHaveBeenCalledOnce();
expect(first.ensureSchemaLoaded).toHaveBeenCalledOnce();
expect(second.ensureLoaded).toHaveBeenCalledOnce();
expect(second.ensureSchemaLoaded).toHaveBeenCalledOnce();
expect(state.configViewState.rawRevealed).toBe(false);
expect(state.configViewState.envRevealed).toBe(false);
expect(state.configViewState.revealedSensitivePaths.size).toBe(0);
});
});
+98 -42
View File
@@ -1,5 +1,5 @@
import { consume } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { html, nothing, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import type { SystemInfoResult } from "../../../../packages/gateway-protocol/src/index.js";
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
@@ -23,6 +23,8 @@ import { resolveTheme, type ThemeMode, type ThemeName } from "../../app/theme.ts
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { isMissingOperatorReadScopeError } from "../../lib/gateway-errors.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { renderMcp } from "./mcp.ts";
import {
renderQuickSettings,
@@ -271,8 +273,8 @@ function applyTextScale(value: unknown) {
);
}
export class ConfigPage extends LitElement {
@consume({ context: applicationContext, subscribe: false })
export class ConfigPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@property({ attribute: "page-id" }) pageId: ConfigPageId = "config";
@@ -315,16 +317,43 @@ export class ConfigPage extends LitElement {
@state() private customThemeImportExpanded = false;
@state() private customThemeImportFocusToken = 0;
private customThemeImportSelectOnSuccess = false;
private readonly configViewState: ConfigViewState = createConfigViewState();
private configViewState: ConfigViewState = createConfigViewState();
private runtimeConfigSource: ApplicationContext["runtimeConfig"] | null = null;
private systemInfoGatewaySource: ApplicationContext["gateway"] | null = null;
private systemInfoClient: GatewayBrowserClient | null = null;
private systemInfoLoading = false;
private systemInfoRequestId = 0;
private systemInfoPollInterval: ReturnType<typeof globalThis.setInterval> | null = null;
private stops: Array<() => void> = [];
override createRenderRoot() {
return this;
}
private readonly subscriptions = new SubscriptionsController(this)
.watch(
() => this.context?.runtimeConfig,
(runtimeConfig, notify) => runtimeConfig.subscribe(notify),
(runtimeConfig) => this.synchronizeRuntimeConfig(runtimeConfig),
)
.watch(
() => this.context?.overlays,
(overlays, notify) => overlays.subscribe(notify),
)
.watch(
() => this.context?.config,
(config, notify) => config.subscribe(notify),
)
.watch(
() => this.context?.gateway,
(gateway, notify) => gateway.subscribe(notify),
(gateway) => this.synchronizeSystemInfoGateway(gateway),
)
.watch(
() => this.context?.webPush,
(webPush, notify) => webPush.subscribe(notify),
)
.watch(
() => this.context?.theme,
(theme, notify) => theme.subscribe(notify),
() => {
this.settings = loadSettings();
},
);
override connectedCallback() {
super.connectedCallback();
@@ -334,42 +363,20 @@ export class ConfigPage extends LitElement {
globalThis.location?.search ?? "",
);
this.selections = { ...this.selections, [this.pageId]: linkedSelection };
this.stops = [
this.context.runtimeConfig.subscribe(() => this.requestUpdate()),
this.context.overlays.subscribe(() => this.requestUpdate()),
this.context.config.subscribe(() => this.requestUpdate()),
this.context.gateway.subscribe((snapshot) => {
this.handleSystemInfoGatewaySnapshot(snapshot);
this.requestUpdate();
}),
this.context.webPush.subscribe(() => this.requestUpdate()),
this.context.theme.subscribe(() => {
this.settings = loadSettings();
}),
];
this.handleSystemInfoGatewaySnapshot(this.context.gateway.snapshot);
const config = this.context.runtimeConfig.state;
if (!config.configSnapshot && !config.configLoading) {
void this.context.runtimeConfig
.ensureLoaded()
.then(() => this.context.runtimeConfig.ensureSchemaLoaded());
} else if (!config.configSchema && !config.configSchemaLoading) {
void this.context.runtimeConfig.ensureSchemaLoaded();
}
}
override disconnectedCallback() {
this.stopSystemInfoPolling();
this.invalidateSystemInfoRequest();
this.runtimeConfigSource = null;
this.resetConfigViewState();
this.systemInfoGatewaySource = null;
this.systemInfoClient = null;
for (const stop of this.stops) {
stop();
}
this.stops = [];
this.subscriptions.clear();
super.disconnectedCallback();
}
override updated(changed: Map<PropertyKey, unknown>) {
override updated(changed: PropertyValues) {
const pageChanged = changed.has("pageId") && changed.get("pageId") !== undefined;
const modeChanged = changed.has("settingsMode") && changed.get("settingsMode") !== undefined;
if (pageChanged || modeChanged) {
@@ -382,6 +389,45 @@ export class ConfigPage extends LitElement {
return this.pageId === "config" && this.settingsMode === "quick";
}
private synchronizeRuntimeConfig(runtimeConfig: ApplicationContext["runtimeConfig"]) {
if (runtimeConfig !== this.runtimeConfigSource) {
this.runtimeConfigSource = runtimeConfig;
this.resetConfigViewState();
}
const config = runtimeConfig.state;
if (!config.configSnapshot && !config.configLoading) {
void runtimeConfig
.ensureLoaded()
.then(() =>
this.runtimeConfigSource === runtimeConfig
? runtimeConfig.ensureSchemaLoaded()
: undefined,
);
return;
}
if (!config.configSchema && !config.configSchemaLoading) {
void runtimeConfig.ensureSchemaLoaded();
}
}
private synchronizeSystemInfoGateway(gateway: ApplicationContext["gateway"]) {
if (gateway !== this.systemInfoGatewaySource) {
this.stopSystemInfoPolling();
this.invalidateSystemInfoRequest();
this.systemInfoGatewaySource = gateway;
this.resetConfigViewState();
this.systemInfoClient = null;
this.systemInfo = null;
this.systemInfoUnavailable = false;
}
this.handleSystemInfoGatewaySnapshot(gateway.snapshot);
}
private resetConfigViewState() {
// Revealed secrets and raw caches never cross a capability/source epoch.
this.configViewState = createConfigViewState();
}
private handleSystemInfoGatewaySnapshot(snapshot: ApplicationGatewaySnapshot) {
const clientChanged = snapshot.client !== this.systemInfoClient;
const hasSystemInfo = supportsSystemInfo(snapshot.hello);
@@ -439,19 +485,29 @@ export class ConfigPage extends LitElement {
this.systemInfoLoading = false;
}
private isCurrentSystemInfoRequest(requestId: number, client: GatewayBrowserClient): boolean {
const gateway = this.context.gateway.snapshot;
private isCurrentSystemInfoRequest(
requestId: number,
client: GatewayBrowserClient,
gatewaySource: ApplicationContext["gateway"],
): boolean {
const gateway = gatewaySource.snapshot;
return (
this.isConnected &&
this.isSystemInfoVisible() &&
requestId === this.systemInfoRequestId &&
this.systemInfoGatewaySource === gatewaySource &&
this.context.gateway === gatewaySource &&
gateway.connected &&
gateway.client === client
);
}
private async loadSystemInfo() {
const gateway = this.context.gateway.snapshot;
const gatewaySource = this.systemInfoGatewaySource;
if (!gatewaySource || gatewaySource !== this.context.gateway) {
return;
}
const gateway = gatewaySource.snapshot;
const client = gateway.client;
if (
!gateway.connected ||
@@ -467,12 +523,12 @@ export class ConfigPage extends LitElement {
this.systemInfoLoading = true;
try {
const response = await client.request("system.info", {});
if (!this.isCurrentSystemInfoRequest(requestId, client)) {
if (!this.isCurrentSystemInfoRequest(requestId, client, gatewaySource)) {
return;
}
this.systemInfo = response as SystemInfoResult;
} catch (error) {
if (!this.isCurrentSystemInfoRequest(requestId, client)) {
if (!this.isCurrentSystemInfoRequest(requestId, client, gatewaySource)) {
return;
}
if (isMissingOperatorReadScopeError(error) || isUnknownSystemInfoMethodError(error)) {
@@ -481,7 +537,7 @@ export class ConfigPage extends LitElement {
this.stopSystemInfoPolling();
}
} finally {
if (this.isCurrentSystemInfoRequest(requestId, client)) {
if (this.isCurrentSystemInfoRequest(requestId, client, gatewaySource)) {
this.systemInfoLoading = false;
}
}
+219
View File
@@ -0,0 +1,219 @@
import { nothing } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient, GatewayEventListener } from "../../api/gateway.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import type { CronState } from "../../lib/cron/index.ts";
import "./cron-page.ts";
type CronTestPage = HTMLElement & {
context: ApplicationContext;
updateComplete: Promise<boolean>;
requestUpdate: () => void;
render: () => typeof nothing;
cron: CronState;
cronModelSuggestions: string[];
quickCreateOpen: boolean;
};
type TestGateway = ApplicationContext["gateway"] & {
emitSnapshot: (patch: Partial<ApplicationGatewaySnapshot>) => void;
emitRetiredEvent: (event: Parameters<GatewayEventListener>[0]) => void;
};
function createDeferred<T>() {
let resolve: ((value: T) => void) | undefined;
const promise = new Promise<T>((res) => {
resolve = res;
});
if (!resolve) {
throw new Error("Expected deferred callback to be initialized");
}
return { promise, resolve };
}
function createGateway(client: GatewayBrowserClient, connected: boolean): TestGateway {
const snapshot: ApplicationGatewaySnapshot = {
client,
connected,
reconnecting: false,
hello: null,
assistantAgentId: null,
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
const snapshotListeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const eventListeners = new Set<GatewayEventListener>();
const allEventListeners: GatewayEventListener[] = [];
return {
snapshot,
connection: { gatewayUrl: "", token: "", password: "" },
subscribe(listener: (next: ApplicationGatewaySnapshot) => void) {
snapshotListeners.add(listener);
return () => snapshotListeners.delete(listener);
},
subscribeEvents(listener: GatewayEventListener) {
eventListeners.add(listener);
allEventListeners.push(listener);
return () => eventListeners.delete(listener);
},
emitSnapshot(patch: Partial<ApplicationGatewaySnapshot>) {
Object.assign(snapshot, patch);
for (const listener of snapshotListeners) {
listener(snapshot);
}
},
emitRetiredEvent(event: Parameters<GatewayEventListener>[0]) {
for (const listener of allEventListeners) {
listener(event);
}
},
} as unknown as TestGateway;
}
function createContext(gateway: TestGateway): ApplicationContext {
const subscribe = () => () => undefined;
return {
basePath: "",
gateway,
agents: {
state: {
agentsList: { defaultId: "main", agents: [{ id: "main" }] },
agentsLoading: false,
agentsError: null,
},
ensureList: vi.fn(async () => undefined),
subscribe,
},
channels: {
state: {
channelsSnapshot: null,
},
refresh: vi.fn(async () => undefined),
subscribe,
},
runtimeConfig: {
state: { configSnapshot: null },
subscribe,
},
navigate: vi.fn(),
preload: vi.fn(async () => undefined),
} as unknown as ApplicationContext;
}
function createPage(context: ApplicationContext): CronTestPage {
const page = document.createElement("openclaw-cron-page") as CronTestPage;
page.context = context;
page.render = () => nothing;
document.body.append(page);
return page;
}
function createRequest() {
return vi.fn(async (method: string) => {
if (method === "cron.list") {
return { jobs: [], total: 0, offset: 0, hasMore: false };
}
if (method === "cron.runs") {
return { entries: [], total: 0, offset: 0, hasMore: false };
}
if (method === "models.list") {
return { models: [] };
}
return {};
});
}
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});
describe("CronPage lifecycle", () => {
it("replaces all mutable page state on each connection epoch", async () => {
const request = createRequest();
const client = { request } as unknown as GatewayBrowserClient;
const gateway = createGateway(client, true);
const page = createPage(createContext(gateway));
await page.updateComplete;
const connectedState = page.cron;
page.cron = {
...connectedState,
cronStatus: { enabled: true, jobs: 1 },
cronJobs: [{ id: "old" } as never],
};
page.cronModelSuggestions = ["old/model"];
page.quickCreateOpen = true;
gateway.emitSnapshot({ connected: false });
const disconnectedState = page.cron;
expect(disconnectedState).not.toBe(connectedState);
expect(disconnectedState.cronStatus).toBeNull();
expect(disconnectedState.cronJobs).toEqual([]);
expect(page.cronModelSuggestions).toEqual([]);
expect(page.quickCreateOpen).toBe(false);
gateway.emitSnapshot({ connected: true });
expect(page.cron).not.toBe(disconnectedState);
});
it("rejects model suggestions from an earlier connection epoch", async () => {
const staleModels = createDeferred<{ models: Array<{ id: string }> }>();
let modelRequestCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "models.list") {
modelRequestCount += 1;
return modelRequestCount === 1 ? staleModels.promise : { models: [{ id: "fresh/model" }] };
}
if (method === "cron.list") {
return { jobs: [], total: 0, offset: 0, hasMore: false };
}
if (method === "cron.runs") {
return { entries: [], total: 0, offset: 0, hasMore: false };
}
return {};
});
const client = { request } as unknown as GatewayBrowserClient;
const gateway = createGateway(client, false);
const page = createPage(createContext(gateway));
await page.updateComplete;
gateway.emitSnapshot({ connected: true });
await vi.waitFor(() => expect(modelRequestCount).toBe(1));
gateway.emitSnapshot({ connected: false });
gateway.emitSnapshot({ connected: true });
await vi.waitFor(() => expect(page.cronModelSuggestions).toEqual(["fresh/model"]));
staleModels.resolve({ models: [{ id: "stale/model" }] });
await Promise.resolve();
await Promise.resolve();
expect(page.cronModelSuggestions).toEqual(["fresh/model"]);
});
it("ignores a cron event callback retained by a replaced gateway source", async () => {
const request = createRequest();
const client = { request } as unknown as GatewayBrowserClient;
const firstGateway = createGateway(client, true);
const secondGateway = createGateway(client, true);
const firstContext = createContext(firstGateway);
const secondContext = createContext(secondGateway);
const page = createPage(firstContext);
await vi.waitFor(() => expect(request).toHaveBeenCalled());
page.context = secondContext;
page.requestUpdate();
await page.updateComplete;
await vi.waitFor(() => expect(page.cron.client).toBe(client));
request.mockClear();
vi.mocked(secondContext.channels.refresh).mockClear();
firstGateway.emitRetiredEvent({ event: "cron" } as never);
await Promise.resolve();
await Promise.resolve();
expect(request).not.toHaveBeenCalled();
expect(secondContext.channels.refresh).not.toHaveBeenCalled();
});
});
+92 -67
View File
@@ -1,7 +1,6 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { html } from "lit";
import { state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { AgentsListResult, CronJob } from "../../api/types.ts";
import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
@@ -35,6 +34,8 @@ import {
} from "../../lib/cron/index.ts";
import { searchForSession } from "../../lib/sessions/index.ts";
import { sortUniqueStrings } from "../../lib/string-coerce.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { createDefaultDraft, draftToCronFormPatch, renderCronQuickCreate } from "./quick-create.ts";
import type { CronQuickCreateDraft, CronQuickCreateStep } from "./quick-create.ts";
import { renderCron } from "./view.ts";
@@ -55,12 +56,8 @@ function unique(values: string[]): string[] {
return sortUniqueStrings(values.map((value) => value.trim()).filter(Boolean));
}
class CronPage extends LitElement {
override createRenderRoot() {
return this;
}
@consume({ context: applicationContext, subscribe: false })
class CronPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@state() private cron = createInitialCronState();
@@ -70,62 +67,82 @@ class CronPage extends LitElement {
@state() private quickCreateStep: CronQuickCreateStep = "what";
@state() private quickCreateDraft: CronQuickCreateDraft | null = null;
private stopGatewaySubscription?: () => void;
private stopGatewayEvents?: () => void;
private stopAgentsSubscription?: () => void;
private stopChannelsSubscription?: () => void;
private stopConfigSubscription?: () => void;
private modelSuggestionsClient: GatewayBrowserClient | null = null;
override connectedCallback() {
super.connectedCallback();
this.syncGatewayState();
this.syncAgentsState();
this.stopGatewaySubscription = this.context.gateway.subscribe(() => {
this.syncGatewayState();
this.ensureInitialData();
});
this.stopGatewayEvents = this.context.gateway.subscribeEvents((event) => {
if (event.event === "cron") {
void this.refreshCron({ tableFilters: true });
}
});
this.stopAgentsSubscription = this.context.agents.subscribe(() => {
this.syncAgentsState();
this.requestUpdate();
});
this.stopChannelsSubscription = this.context.channels.subscribe(() => this.requestUpdate());
this.stopConfigSubscription = this.context.runtimeConfig.subscribe(() => this.requestUpdate());
this.ensureInitialData();
}
private modelSuggestionsState: CronState | null = null;
private gatewaySource?: ApplicationContext["gateway"];
private readonly subscriptions = new SubscriptionsController(this)
.watch(
() => this.context?.agents,
(agents, notify) => agents.subscribe(notify),
() => this.syncAgentsState(),
)
.watch(
() => this.context?.channels,
(channels, notify) => channels.subscribe(notify),
)
.watch(
() => this.context?.runtimeConfig,
(runtimeConfig, notify) => runtimeConfig.subscribe(notify),
)
.effect(
() => this.context?.gateway,
(gateway) => {
const sourceChanged = this.gatewaySource !== undefined && this.gatewaySource !== gateway;
this.gatewaySource = gateway;
this.syncGatewayState(gateway.snapshot, sourceChanged);
this.ensureInitialData();
return gateway.subscribe((snapshot) => {
if (this.gatewaySource === gateway) {
this.syncGatewayState(snapshot, false);
this.ensureInitialData();
}
});
},
)
.effect(
() => this.context?.gateway,
(gateway) =>
gateway.subscribeEvents((event) => {
if (
this.gatewaySource === gateway &&
gateway.snapshot.connected &&
gateway.snapshot.client &&
event.event === "cron"
) {
void this.refreshCron({ tableFilters: true });
}
}),
);
override disconnectedCallback() {
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.stopGatewayEvents?.();
this.stopGatewayEvents = undefined;
this.stopAgentsSubscription?.();
this.stopAgentsSubscription = undefined;
this.stopChannelsSubscription?.();
this.stopChannelsSubscription = undefined;
this.stopConfigSubscription?.();
this.stopConfigSubscription = undefined;
this.gatewaySource = undefined;
this.resetGatewayState();
this.subscriptions.clear();
super.disconnectedCallback();
}
private syncGatewayState() {
const gateway = this.context.gateway.snapshot;
if (this.cron.client !== gateway.client) {
this.cron = createInitialCronState(gateway);
this.cronModelSuggestions = [];
this.modelSuggestionsClient = null;
return;
private resetGatewayState(snapshot: Partial<Pick<CronState, "client" | "connected">> = {}) {
this.cron = createInitialCronState(snapshot);
this.agentsList = snapshot.connected ? this.context.agents.state.agentsList : null;
this.cronModelSuggestions = [];
this.modelSuggestionsState = null;
this.quickCreateOpen = false;
this.quickCreateStep = "what";
this.quickCreateDraft = null;
}
private syncGatewayState(
snapshot: ApplicationContext["gateway"]["snapshot"],
sourceChanged: boolean,
) {
if (
sourceChanged ||
this.cron.client !== snapshot.client ||
this.cron.connected !== snapshot.connected
) {
// Each connection epoch owns a fresh mutable state object. In-flight work
// can finish against the old object without leaking into the next session.
this.resetGatewayState(snapshot);
}
if (this.cron.connected === gateway.connected) {
return;
}
this.cron.connected = gateway.connected;
this.requestUpdate();
}
private syncAgentsState() {
@@ -144,9 +161,10 @@ class CronPage extends LitElement {
} else if (!this.cron.cronRuns.length && !this.cron.cronRunsLoadingMore) {
void this.loadRuns(this.cron.cronRunsScope === "all" ? null : this.cron.cronRunsJobId);
}
if (this.modelSuggestionsClient !== this.cron.client) {
this.modelSuggestionsClient = this.cron.client;
void this.loadModelSuggestions();
if (this.modelSuggestionsState !== this.cron) {
const cronState = this.cron;
this.modelSuggestionsState = cronState;
void this.loadModelSuggestions(cronState);
}
}
@@ -176,14 +194,20 @@ class CronPage extends LitElement {
return this.runCronTask((cronState) => loadCronRuns(cronState, jobId));
}
private async loadModelSuggestions() {
private async loadModelSuggestions(cronState: CronState) {
const suggestionState: CronModelSuggestionsState = {
client: this.cron.client,
connected: this.cron.connected,
client: cronState.client,
connected: cronState.connected,
cronModelSuggestions: this.cronModelSuggestions,
};
await loadCronModelSuggestions(suggestionState);
if (suggestionState.client === this.cron.client) {
if (
this.isConnected &&
this.cron === cronState &&
this.modelSuggestionsState === cronState &&
cronState.connected &&
suggestionState.client === cronState.client
) {
this.cronModelSuggestions = suggestionState.cronModelSuggestions;
}
}
@@ -222,8 +246,9 @@ class CronPage extends LitElement {
private async createFromQuickCreate() {
this.draftToForm();
const saved = await this.runCronTask((cronState) => addCronJob(cronState));
if (saved) {
const cronState = this.cron;
const saved = await this.runCronTask((current) => addCronJob(current));
if (saved && this.cron === cronState) {
this.quickCreateOpen = false;
this.quickCreateStep = "what";
this.quickCreateDraft = null;
+98 -48
View File
@@ -1,23 +1,31 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { html } from "lit";
import { state } from "lit/decorators.js";
import type { EventLogEntry } from "../../api/event-log.ts";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { HealthSnapshot, StatusSummary } from "../../api/types.ts";
import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import {
applicationContext,
type ApplicationContext,
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { loadGatewayDiagnostics } from "../../lib/gateway-diagnostics.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { renderDebug } from "./view.ts";
const DEBUG_POLL_INTERVAL_MS = 3000;
class DebugPage extends LitElement {
override createRenderRoot() {
return this;
}
type DebugRequestScope = {
gateway: ApplicationContext["gateway"];
client: GatewayBrowserClient;
generation: number;
};
@consume({ context: applicationContext, subscribe: false })
class DebugPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@state() private client: GatewayBrowserClient | null = null;
@@ -34,42 +42,58 @@ class DebugPage extends LitElement {
@state() private eventLog: readonly EventLogEntry[] = [];
private debugPollInterval: ReturnType<typeof globalThis.setInterval> | null = null;
private stopGatewaySubscription?: () => void;
private stopEventLogSubscription?: () => void;
override connectedCallback() {
super.connectedCallback();
this.eventLog = this.context.gateway.eventLog;
this.syncGatewayState();
this.stopGatewaySubscription = this.context.gateway.subscribe((snapshot) => {
const previousClient = this.client;
this.syncGatewayState();
if (previousClient !== snapshot.client) {
this.resetServerState();
}
this.syncPolling();
this.ensureInitialDebug();
});
this.stopEventLogSubscription = this.context.gateway.subscribeEventLog((events) => {
this.eventLog = events;
});
this.syncPolling();
this.ensureInitialDebug();
}
private hasBoundGatewaySource = false;
private gatewaySource: ApplicationContext["gateway"] | null = null;
private requestGeneration = 0;
private readonly subscriptions = new SubscriptionsController(this)
.effect(
() => this.context?.gateway,
(gateway) => {
const resetForSourceBind = this.hasBoundGatewaySource;
this.hasBoundGatewaySource = true;
this.gatewaySource = gateway;
this.requestGeneration += 1;
const cleanup = gateway.subscribe((snapshot) => {
if (this.gatewaySource === gateway && this.context.gateway === gateway) {
this.applyGatewaySnapshot(snapshot);
}
});
this.applyGatewaySnapshot(gateway.snapshot, resetForSourceBind);
return cleanup;
},
)
.watch(
() => this.context?.gateway,
(gateway, notify) => gateway.subscribeEventLog(notify),
(gateway) => {
this.eventLog = gateway.eventLog;
},
);
override disconnectedCallback() {
this.stopPolling();
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.stopEventLogSubscription?.();
this.stopEventLogSubscription = undefined;
this.subscriptions.clear();
this.requestGeneration += 1;
this.gatewaySource = null;
this.debugLoading = false;
super.disconnectedCallback();
}
private syncGatewayState() {
const gateway = this.context.gateway.snapshot;
this.client = gateway.client;
this.connected = gateway.connected;
private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot, resetForSourceBind = false) {
const connectionChanged = snapshot.connected !== this.connected;
const clientChanged = resetForSourceBind || snapshot.client !== this.client;
if (clientChanged || connectionChanged) {
this.requestGeneration += 1;
}
this.client = snapshot.client;
this.connected = snapshot.connected;
if (clientChanged) {
this.resetServerState();
} else if (connectionChanged) {
this.debugLoading = false;
}
this.syncPolling();
this.ensureInitialDebug();
}
private resetServerState() {
@@ -110,15 +134,41 @@ class DebugPage extends LitElement {
void this.loadDiagnostics();
}
private async loadDiagnostics() {
private captureRequestScope(): DebugRequestScope | null {
const gateway = this.gatewaySource;
const client = this.client;
if (!client || !this.connected || this.debugLoading) {
if (
!gateway ||
!client ||
!this.connected ||
!this.isConnected ||
this.context.gateway !== gateway
) {
return null;
}
return { gateway, client, generation: this.requestGeneration };
}
private isRequestScopeCurrent(scope: DebugRequestScope): boolean {
return (
this.isConnected &&
this.gatewaySource === scope.gateway &&
this.context.gateway === scope.gateway &&
this.requestGeneration === scope.generation &&
this.client === scope.client &&
this.connected
);
}
private async loadDiagnostics() {
const scope = this.captureRequestScope();
if (!scope || this.debugLoading) {
return;
}
this.debugLoading = true;
try {
const result = await loadGatewayDiagnostics(client);
if (this.client !== client || !this.connected) {
const result = await loadGatewayDiagnostics(scope.client);
if (!this.isRequestScopeCurrent(scope)) {
return;
}
this.debugStatus = result.status;
@@ -126,19 +176,19 @@ class DebugPage extends LitElement {
this.debugModels = result.models;
this.debugHeartbeat = result.heartbeat;
} catch (err) {
if (this.client === client && this.connected) {
if (this.isRequestScopeCurrent(scope)) {
this.debugCallError = String(err);
}
} finally {
if (this.client === client) {
if (this.isRequestScopeCurrent(scope)) {
this.debugLoading = false;
}
}
}
private async callDebugMethod() {
const client = this.client;
if (!client || !this.connected) {
const scope = this.captureRequestScope();
if (!scope) {
return;
}
this.debugCallError = null;
@@ -147,12 +197,12 @@ class DebugPage extends LitElement {
const params = this.debugCallParams.trim()
? (JSON.parse(this.debugCallParams) as unknown)
: {};
const res = await client.request(this.debugCallMethod.trim(), params);
if (this.client === client) {
const res = await scope.client.request(this.debugCallMethod.trim(), params);
if (this.isRequestScopeCurrent(scope)) {
this.debugCallResult = JSON.stringify(res, null, 2);
}
} catch (err) {
if (this.client === client) {
if (this.isRequestScopeCurrent(scope)) {
this.debugCallError = String(err);
}
}
+199
View File
@@ -0,0 +1,199 @@
/* @vitest-environment jsdom */
import { nothing } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import { createDreamingState, type DreamingState } from "./dreaming.ts";
import type { DreamsRouteData } from "./dreams-page.ts";
import type { DreamingViewState } from "./view.ts";
import "./dreams-page.ts";
type TestDreamsPage = HTMLElement & {
context: ApplicationContext;
routeData?: DreamsRouteData;
dreaming: DreamingState;
viewState: DreamingViewState;
restartConfirmOpen: boolean;
restartConfirmLoading: boolean;
pendingEnabled: boolean | null;
applyRouteData: () => void;
applyGatewaySnapshot: (snapshot: ApplicationGatewaySnapshot) => void;
loadAll: () => Promise<void>;
openWikiPage: (lookup: string) => Promise<unknown>;
render: () => unknown;
requestUpdate: () => void;
readonly updateComplete: Promise<boolean>;
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function contextWithGateway(client: GatewayBrowserClient, connected: boolean): ApplicationContext {
const snapshot: ApplicationGatewaySnapshot = {
client,
connected,
reconnecting: false,
hello: null,
assistantAgentId: null,
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
const subscribe = () => () => undefined;
return {
gateway: { snapshot, subscribe },
agents: {
state: { agentsList: null },
subscribe,
},
runtimeConfig: {
state: { configSnapshot: null },
refresh: vi.fn(async () => undefined),
subscribe,
},
} as unknown as ApplicationContext;
}
function createPage(context: ApplicationContext): TestDreamsPage {
const page = document.createElement("openclaw-dreams-page") as TestDreamsPage;
page.context = context;
page.render = () => nothing;
return page;
}
async function replaceContext(page: TestDreamsPage, context: ApplicationContext) {
page.context = context;
page.requestUpdate();
await page.updateComplete;
}
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});
describe("DreamsPage gateway lifecycle", () => {
it("preserves matching route data on the first gateway bind", async () => {
const request = vi.fn();
const client = { request } as unknown as GatewayBrowserClient;
const context = contextWithGateway(client, true);
const status = { enabled: true } as DreamingState["dreamingStatus"];
const state = createDreamingState({ client, connected: true });
state.dreamingStatus = status;
const page = createPage(context);
page.routeData = {
gateway: context.gateway,
gatewaySnapshot: context.gateway.snapshot,
state,
};
page.applyRouteData();
document.body.append(page);
await page.updateComplete;
expect(page.dreaming.dreamingStatus).toBe(status);
expect(request).not.toHaveBeenCalled();
});
it("rejects preloaded data after a same-client gateway epoch change", async () => {
const client = {} as GatewayBrowserClient;
const context = contextWithGateway(client, true);
const staleState = createDreamingState({ client, connected: true });
staleState.dreamDiaryContent = "stale";
const page = createPage(context);
page.loadAll = vi.fn(async () => undefined);
page.routeData = {
gateway: context.gateway,
gatewaySnapshot: { ...context.gateway.snapshot },
state: staleState,
};
document.body.append(page);
await page.updateComplete;
expect(page.dreaming).not.toBe(staleState);
expect(page.dreaming.dreamDiaryContent).toBeNull();
expect(page.loadAll).toHaveBeenCalledOnce();
});
it("resets provider and modal state when the gateway source changes", async () => {
const client = {} as GatewayBrowserClient;
const page = createPage(contextWithGateway(client, false));
document.body.append(page);
await page.updateComplete;
const previousState = page.dreaming;
previousState.dreamDiaryContent = "old provider";
page.viewState.wikiPreviewOpen = true;
page.viewState.wikiPreviewLoading = true;
page.viewState.wikiPreviewTitle = "Old page";
page.viewState.wikiPreviewContent = "old wiki";
page.restartConfirmOpen = true;
page.restartConfirmLoading = true;
page.pendingEnabled = true;
await replaceContext(page, contextWithGateway(client, false));
expect(page.dreaming).not.toBe(previousState);
expect(page.dreaming.dreamDiaryContent).toBeNull();
expect(page.viewState.wikiPreviewOpen).toBe(false);
expect(page.viewState.wikiPreviewLoading).toBe(false);
expect(page.viewState.wikiPreviewTitle).toBe("");
expect(page.viewState.wikiPreviewContent).toBe("");
expect(page.restartConfirmOpen).toBe(false);
expect(page.restartConfirmLoading).toBe(false);
expect(page.pendingEnabled).toBeNull();
page.viewState.wikiPreviewOpen = true;
page.restartConfirmOpen = true;
page.restartConfirmLoading = true;
page.pendingEnabled = false;
page.remove();
expect(page.viewState.wikiPreviewOpen).toBe(false);
expect(page.restartConfirmOpen).toBe(false);
expect(page.restartConfirmLoading).toBe(false);
expect(page.pendingEnabled).toBeNull();
});
it("discards a wiki response from a replaced gateway source", async () => {
const pending = deferred<unknown>();
const client = {
request: vi.fn(() => pending.promise),
} as unknown as GatewayBrowserClient;
const page = createPage(contextWithGateway(client, true));
document.body.append(page);
await page.updateComplete;
const preview = page.openWikiPage("old.md");
await replaceContext(page, contextWithGateway(client, false));
pending.resolve({ title: "Old", path: "old.md", content: "stale" });
await expect(preview).resolves.toBeNull();
});
it("discards a wiki response across a same-client reconnect", async () => {
const pending = deferred<unknown>();
const client = {
request: vi.fn(() => pending.promise),
} as unknown as GatewayBrowserClient;
const page = createPage(contextWithGateway(client, true));
document.body.append(page);
await page.updateComplete;
const previousState = page.dreaming;
const preview = page.openWikiPage("old.md");
page.applyGatewaySnapshot({ client, connected: false } as ApplicationGatewaySnapshot);
page.applyGatewaySnapshot({ client, connected: true } as ApplicationGatewaySnapshot);
pending.resolve({ title: "Old", path: "old.md", content: "stale" });
await expect(preview).resolves.toBeNull();
expect(page.dreaming).not.toBe(previousState);
expect(page.viewState.wikiPreviewContent).toBe("");
});
});
+184 -66
View File
@@ -1,10 +1,11 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { html, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts";
import {
applicationContext,
type ApplicationContext,
type ApplicationGateway,
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { t } from "../../i18n/index.ts";
@@ -15,6 +16,8 @@ import {
resolveSessionAgentFilterId,
resolveSessionAgentFilterOptions,
} from "../../lib/sessions/session-options.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import {
backfillDreamDiary,
copyDreamingArchivePath,
@@ -35,6 +38,9 @@ import { renderDreamingRestartConfirmation } from "./restart-confirmation.ts";
import { createDreamingViewState, renderDreaming, type DreamingViewState } from "./view.ts";
export type DreamsRouteData = {
// Client identity alone cannot distinguish provider replacement or reconnect epochs.
gateway: ApplicationGateway;
gatewaySnapshot: ApplicationGatewaySnapshot;
state: DreamingState;
};
@@ -47,6 +53,12 @@ type WikiPagePreview = {
updatedAt?: string;
};
type DreamingTaskScope = {
gateway: ApplicationGateway;
epoch: number;
state: DreamingState;
};
function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null {
return formatTimeMs(nextRunAtMs, { hour: "numeric", minute: "2-digit" }, "") || null;
}
@@ -97,12 +109,8 @@ function readWikiPagePreview(value: unknown, lookup: string): WikiPagePreview {
};
}
class DreamsPage extends LitElement {
override createRenderRoot() {
return this;
}
@consume({ context: applicationContext, subscribe: false })
class DreamsPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@property({ attribute: false }) routeData?: DreamsRouteData;
@@ -115,38 +123,104 @@ class DreamsPage extends LitElement {
private readonly viewState: DreamingViewState = createDreamingViewState();
private routeDataEnabled = true;
private subscriptions: Array<() => void> = [];
override connectedCallback() {
super.connectedCallback();
this.applyGatewaySnapshot(this.context.gateway.snapshot, true);
this.syncConfigSnapshot();
this.subscriptions = [
this.context.gateway.subscribe((snapshot) => this.applyGatewaySnapshot(snapshot)),
this.context.agents.subscribe(() => this.applyAgentsState()),
this.context.runtimeConfig.subscribe(() => {
private gatewaySource: ApplicationGateway | null = null;
private gatewayBindingEpoch = 0;
private gatewayEpoch = 0;
private hasBoundGatewaySource = false;
private readonly subscriptions = new SubscriptionsController(this)
.effect(
() => this.context?.gateway,
(gateway) => {
const sourceReplaced = this.hasBoundGatewaySource;
this.hasBoundGatewaySource = true;
this.gatewaySource = gateway;
const bindingEpoch = ++this.gatewayBindingEpoch;
this.gatewayEpoch += 1;
const cleanup = gateway.subscribe((snapshot) => {
if (this.isGatewayBindingCurrent(gateway, bindingEpoch)) {
this.applyGatewaySnapshot(snapshot);
}
});
this.applyGatewaySnapshot(gateway.snapshot, sourceReplaced ? "replacement" : "initial");
return cleanup;
},
)
.effect(
() => this.context?.agents,
(agents) => agents.subscribe(() => this.applyAgentsState()),
)
.effect(
() => this.context?.runtimeConfig,
(runtimeConfig) => {
this.syncConfigSnapshot();
this.requestUpdate();
}),
];
}
return runtimeConfig.subscribe(() => {
this.syncConfigSnapshot();
this.requestUpdate();
});
},
);
override willUpdate(changed: Map<PropertyKey, unknown>) {
override willUpdate(changed: PropertyValues<this>) {
if (changed.has("routeData")) {
this.applyRouteData();
}
}
override disconnectedCallback() {
for (const unsubscribe of this.subscriptions) {
unsubscribe();
}
this.subscriptions = [];
this.viewState.wikiPreviewRequestId += 1;
this.subscriptions.clear();
this.gatewayBindingEpoch += 1;
this.gatewayEpoch += 1;
this.gatewaySource = null;
this.resetTransientState();
this.awaitingRouteData = true;
this.routeDataEnabled = true;
this.dreaming = createDreamingState();
super.disconnectedCallback();
}
private isGatewayBindingCurrent(gateway: ApplicationGateway, bindingEpoch: number): boolean {
return (
this.isConnected &&
this.gatewaySource === gateway &&
this.gatewayBindingEpoch === bindingEpoch &&
this.context.gateway === gateway
);
}
private captureTaskScope(): DreamingTaskScope | null {
const gateway = this.gatewaySource;
if (!gateway) {
return null;
}
return { gateway, epoch: this.gatewayEpoch, state: this.dreaming };
}
private isTaskScopeCurrent(scope: DreamingTaskScope): boolean {
return (
this.isConnected &&
this.gatewaySource === scope.gateway &&
this.gatewayEpoch === scope.epoch &&
this.context.gateway === scope.gateway &&
this.dreaming === scope.state
);
}
private resetTransientState() {
this.viewState.wikiPreviewRequestId += 1;
this.viewState.wikiPreviewOpen = false;
this.viewState.wikiPreviewLoading = false;
this.viewState.wikiPreviewTitle = "";
this.viewState.wikiPreviewPath = "";
this.viewState.wikiPreviewUpdatedAt = null;
this.viewState.wikiPreviewContent = "";
this.viewState.wikiPreviewTotalLines = null;
this.viewState.wikiPreviewTruncated = false;
this.viewState.wikiPreviewError = null;
this.restartConfirmOpen = false;
this.restartConfirmLoading = false;
this.pendingEnabled = null;
}
private createGatewayState(snapshot = this.context.gateway.snapshot): DreamingState {
return createDreamingState({
client: snapshot.client,
@@ -158,21 +232,30 @@ class DreamsPage extends LitElement {
});
}
private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot, initial = false) {
private applyGatewaySnapshot(
snapshot: ApplicationGatewaySnapshot,
sourceBind?: "initial" | "replacement",
) {
const clientChanged = this.dreaming.client !== snapshot.client;
const connectionChanged = this.dreaming.connected !== snapshot.connected;
const becameConnected = snapshot.connected && !this.dreaming.connected;
if (clientChanged) {
const replaceState = sourceBind === "replacement" || clientChanged || connectionChanged;
if (connectionChanged) {
this.gatewayEpoch += 1;
}
if (replaceState) {
this.dreaming = this.createGatewayState(snapshot);
if (!initial) {
if (sourceBind !== "initial") {
this.routeDataEnabled = false;
this.awaitingRouteData = false;
this.resetTransientState();
}
} else {
this.dreaming.connected = snapshot.connected;
this.dreaming.hello = snapshot.hello;
this.dreaming.applySessionKey = snapshot.sessionKey;
}
if (!this.awaitingRouteData && snapshot.connected && (clientChanged || becameConnected)) {
if (!this.awaitingRouteData && snapshot.connected && (replaceState || becameConnected)) {
void this.loadAll();
}
this.requestUpdate();
@@ -200,10 +283,11 @@ class DreamsPage extends LitElement {
if (!this.routeDataEnabled) {
return;
}
const gateway = this.context.gateway.snapshot;
if (data.state.client !== gateway.client || data.state.connected !== gateway.connected) {
const gateway = this.context.gateway;
const snapshot = gateway.snapshot;
if (data.gateway !== gateway || data.gatewaySnapshot !== snapshot) {
this.routeDataEnabled = false;
this.dreaming = this.createGatewayState(gateway);
this.dreaming = this.createGatewayState(snapshot);
void this.loadAll();
return;
}
@@ -236,43 +320,52 @@ class DreamsPage extends LitElement {
});
}
private async runDreamingTask<T>(task: (state: DreamingState) => Promise<T>): Promise<T> {
const dreamingState = this.dreaming;
const result = task(dreamingState);
private async runDreamingTask<T>(
task: (state: DreamingState) => Promise<T>,
scope = this.captureTaskScope(),
): Promise<T | undefined> {
if (!scope || !this.isTaskScopeCurrent(scope)) {
return undefined;
}
const result = task(scope.state);
this.requestUpdate();
try {
return await result;
const value = await result;
return this.isTaskScopeCurrent(scope) ? value : undefined;
} finally {
if (this.dreaming === dreamingState) {
if (this.isTaskScopeCurrent(scope)) {
this.requestUpdate();
}
}
}
private async loadAll(refreshConfig = false) {
if (!this.dreaming.client || !this.dreaming.connected) {
const scope = this.captureTaskScope();
if (!scope || !scope.state.client || !scope.state.connected) {
return;
}
this.routeDataEnabled = false;
if (refreshConfig) {
await this.context.runtimeConfig.refresh();
if (!this.dreaming.client || !this.dreaming.connected) {
const runtimeConfig = this.context.runtimeConfig;
await runtimeConfig.refresh();
if (!this.isTaskScopeCurrent(scope) || this.context.runtimeConfig !== runtimeConfig) {
return;
}
}
this.syncConfigSnapshot();
await Promise.all([
this.runDreamingTask(loadDreamingStatus),
this.runDreamingTask(loadDreamDiary),
this.runDreamingTask(loadWikiImportInsights),
this.runDreamingTask(loadWikiMemoryPalace),
this.runDreamingTask(loadDreamingStatus, scope),
this.runDreamingTask(loadDreamDiary, scope),
this.runDreamingTask(loadWikiImportInsights, scope),
this.runDreamingTask(loadWikiMemoryPalace, scope),
]);
}
private loadSelectedAgentData() {
const scope = this.captureTaskScope();
void Promise.all([
this.runDreamingTask(loadDreamingStatus),
this.runDreamingTask(loadDreamDiary),
this.runDreamingTask(loadDreamingStatus, scope),
this.runDreamingTask(loadDreamDiary, scope),
]);
}
@@ -316,27 +409,46 @@ class DreamsPage extends LitElement {
this.routeDataEnabled = false;
this.restartConfirmLoading = true;
this.dreaming.dreamingStatusError = null;
const scope = this.captureTaskScope();
const runtimeConfig = this.context.runtimeConfig;
if (!scope) {
this.restartConfirmLoading = false;
return;
}
try {
const updated = await this.runDreamingTask((dreamingState) =>
updateDreamingEnabled(dreamingState, this.context.runtimeConfig, enabled),
const updated = await this.runDreamingTask(
(dreamingState) => updateDreamingEnabled(dreamingState, runtimeConfig, enabled),
scope,
);
if (!this.isTaskScopeCurrent(scope) || this.context.runtimeConfig !== runtimeConfig) {
return;
}
if (!updated) {
this.dreaming.dreamingStatusError ??= t("dreaming.restartConfirmation.failed");
return;
}
await this.context.runtimeConfig.refresh();
await runtimeConfig.refresh();
if (!this.isTaskScopeCurrent(scope) || this.context.runtimeConfig !== runtimeConfig) {
return;
}
this.syncConfigSnapshot();
await this.runDreamingTask(loadDreamingStatus);
await this.runDreamingTask(loadDreamingStatus, scope);
if (!this.isTaskScopeCurrent(scope)) {
return;
}
this.restartConfirmOpen = false;
this.pendingEnabled = null;
} finally {
this.restartConfirmLoading = false;
if (this.isTaskScopeCurrent(scope)) {
this.restartConfirmLoading = false;
}
}
}
private async openWikiPage(lookup: string): Promise<WikiPagePreview | null> {
const client = this.dreaming.client;
if (!client || !this.dreaming.connected) {
const scope = this.captureTaskScope();
const client = scope?.state.client;
if (!scope || !client || !scope.state.connected) {
return null;
}
const payload = await client.request("wiki.get", {
@@ -344,12 +456,26 @@ class DreamsPage extends LitElement {
fromLine: 1,
lineCount: 5000,
});
if (this.dreaming.client !== client || !this.dreaming.connected) {
if (!this.isTaskScopeCurrent(scope)) {
return null;
}
return readWikiPagePreview(payload, lookup);
}
private async refreshWikiData(task: (state: DreamingState) => Promise<void>) {
const scope = this.captureTaskScope();
if (!scope) {
return;
}
const runtimeConfig = this.context.runtimeConfig;
await runtimeConfig.refresh();
if (!this.isTaskScopeCurrent(scope) || this.context.runtimeConfig !== runtimeConfig) {
return;
}
this.syncConfigSnapshot();
await this.runDreamingTask(task, scope);
}
override render() {
const dreaming = this.dreaming;
const configState = this.context.runtimeConfig.state;
@@ -429,16 +555,8 @@ class DreamsPage extends LitElement {
onRefresh: () => void this.loadAll(true),
onSelectAgent: (agentId) => this.selectAgent(agentId),
onRefreshDiary: () => void this.runDreamingTask(loadDreamDiary),
onRefreshImports: () =>
void this.context.runtimeConfig.refresh().then(() => {
this.syncConfigSnapshot();
return this.runDreamingTask(loadWikiImportInsights);
}),
onRefreshMemoryPalace: () =>
void this.context.runtimeConfig.refresh().then(() => {
this.syncConfigSnapshot();
return this.runDreamingTask(loadWikiMemoryPalace);
}),
onRefreshImports: () => void this.refreshWikiData(loadWikiImportInsights),
onRefreshMemoryPalace: () => void this.refreshWikiData(loadWikiMemoryPalace),
onOpenConfig: () => void this.context.runtimeConfig.openFile(),
onOpenWikiPage: (lookup) => this.openWikiPage(lookup),
onBackfillDiary: () => void this.runDreamingTask(backfillDreamDiary),
+7 -6
View File
@@ -12,13 +12,14 @@ import {
import type { DreamsRouteData } from "./dreams-page.ts";
async function loadDreamsRoute(context: ApplicationContext): Promise<DreamsRouteData> {
const gateway = context.gateway;
const gatewaySnapshot = gateway.snapshot;
await Promise.all([context.runtimeConfig.ensureLoaded(), context.agents.ensureList()]);
const gateway = context.gateway.snapshot;
const sessionKey = gateway.sessionKey;
const sessionKey = gatewaySnapshot.sessionKey;
const state = createDreamingState({
client: gateway.client,
connected: gateway.connected,
hello: gateway.hello,
client: gatewaySnapshot.client,
connected: gatewaySnapshot.connected,
hello: gatewaySnapshot.hello,
configSnapshot: context.runtimeConfig.state.configSnapshot,
applySessionKey: sessionKey,
selectedAgentId: resolveSessionAgentFilterId(
@@ -35,7 +36,7 @@ async function loadDreamsRoute(context: ApplicationContext): Promise<DreamsRoute
loadWikiImportInsights(state),
loadWikiMemoryPalace(state),
]);
return { state };
return { gateway, gatewaySnapshot, state };
}
export const page = definePage({
@@ -0,0 +1,499 @@
/* @vitest-environment jsdom */
import { nothing } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../app/context.ts";
import type { SessionsRouteData } from "./sessions/sessions-page.ts";
import type { SkillsRouteData } from "./skills/skills-page.ts";
import type { UsageRouteData } from "./usage/usage-page.ts";
import "./cron/cron-page.ts";
import "./debug/debug-page.ts";
import "./instances/instances-page.ts";
import "./logs/logs-page.ts";
import "./sessions/sessions-page.ts";
import "./skills/skills-page.ts";
import "./tasks/tasks-page.ts";
import "./usage/usage-page.ts";
type TestPage = HTMLElement & {
context: ApplicationContext;
render: () => unknown;
readonly updateComplete: Promise<boolean>;
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function gatewayWithClient(
client: GatewayBrowserClient,
connected: boolean,
): ApplicationContext["gateway"] {
const snapshot: ApplicationGatewaySnapshot = {
client,
connected,
reconnecting: false,
hello: null,
assistantAgentId: null,
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
return {
snapshot,
eventLog: [],
subscribe: () => () => undefined,
subscribeEvents: () => () => undefined,
subscribeEventLog: () => () => undefined,
} as unknown as ApplicationContext["gateway"];
}
function contextWithClient(
client: GatewayBrowserClient,
options: {
connected?: boolean;
agentsList?: unknown;
ensureList?: () => Promise<unknown>;
} = {},
): ApplicationContext {
const subscribe = () => () => undefined;
const agentsList = options.agentsList ?? null;
return {
basePath: "",
gateway: gatewayWithClient(client, options.connected ?? false),
agents: {
state: { agentsList, agentsLoading: false, agentsError: null },
ensureList: options.ensureList ?? vi.fn(async () => agentsList),
subscribe,
},
agentIdentity: { get: () => undefined, ensure: vi.fn(async () => undefined), subscribe },
agentSelection: { subscribe },
channels: { subscribe },
runtimeConfig: { state: { configSnapshot: null }, subscribe },
sessions: {
state: { result: null, loading: false },
list: vi.fn(async () => null),
subscribe,
},
workboard: { subscribe },
navigate: vi.fn(),
preload: vi.fn(async () => undefined),
} as unknown as ApplicationContext;
}
function createPage(tagName: string, context: ApplicationContext): TestPage {
const page = document.createElement(tagName) as TestPage;
page.context = context;
page.render = () => nothing;
return page;
}
async function replaceContext(
page: TestPage,
replacementClient: GatewayBrowserClient,
options: { connected?: boolean; agentsList?: unknown } = {},
): Promise<void> {
page.remove();
page.context = contextWithClient(replacementClient, options);
document.body.append(page);
await page.updateComplete;
}
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});
describe("gateway source replacement across reconnect with a reused client", () => {
it("preserves matching sessions route data on the first bind", async () => {
const client = {} as GatewayBrowserClient;
const context = contextWithClient(client, { connected: true });
const routeData = {
gateway: context.gateway,
gatewaySnapshot: context.gateway.snapshot,
result: { count: 1, sessions: [{ key: "old" }] },
error: null,
expandedSessionKey: null,
showArchived: false,
} as unknown as SessionsRouteData;
const page = createPage("openclaw-sessions-page", context) as TestPage & {
routeData: SessionsRouteData;
result: SessionsRouteData["result"];
};
page.routeData = routeData;
document.body.append(page);
await page.updateComplete;
expect(page.result?.sessions.map((session) => session.key)).toEqual(["old"]);
expect(context.sessions.list).not.toHaveBeenCalled();
});
it("preserves matching usage route data on the first bind", async () => {
const request = vi.fn();
const client = { request } as unknown as GatewayBrowserClient;
const context = contextWithClient(client, { connected: true });
const result = { sessions: [{ key: "old" }] } as unknown as UsageRouteData["result"];
const routeData = {
gateway: context.gateway,
gatewaySnapshot: context.gateway.snapshot,
query: {
startDate: "2026-07-08",
endDate: "2026-07-08",
scope: "family",
timeZone: "local",
agentId: null,
},
result,
costSummary: null,
providerUsageSummary: null,
error: null,
} satisfies UsageRouteData;
const page = createPage("openclaw-usage-page", context) as TestPage & {
routeData: UsageRouteData;
usageResult: UsageRouteData["result"];
};
page.routeData = routeData;
document.body.append(page);
await page.updateComplete;
expect(page.usageResult).toBe(result);
expect(request).not.toHaveBeenCalled();
});
it("rejects usage route data from an earlier same-client gateway epoch", async () => {
const freshResult = { sessions: [{ key: "fresh" }] } as unknown as UsageRouteData["result"];
const request = vi.fn(async (method: string) => {
if (method === "sessions.usage") {
return freshResult;
}
return {};
});
const client = { request } as unknown as GatewayBrowserClient;
const context = contextWithClient(client, { connected: true });
const staleResult = { sessions: [{ key: "stale" }] } as unknown as UsageRouteData["result"];
const page = createPage("openclaw-usage-page", context) as TestPage & {
routeData: UsageRouteData;
usageResult: UsageRouteData["result"];
};
page.routeData = {
gateway: context.gateway,
gatewaySnapshot: { ...context.gateway.snapshot },
query: {
startDate: "2026-07-08",
endDate: "2026-07-08",
scope: "family",
timeZone: "local",
agentId: null,
},
result: staleResult,
costSummary: null,
providerUsageSummary: null,
error: null,
};
document.body.append(page);
await page.updateComplete;
await vi.waitFor(() => expect(page.usageResult).toBe(freshResult));
expect(request).toHaveBeenCalledWith("sessions.usage", expect.any(Object));
expect(page.usageResult).not.toBe(staleResult);
});
it("preserves matching skills route data on the first bind", async () => {
const request = vi.fn();
const client = { request } as unknown as GatewayBrowserClient;
const agentsList = { defaultId: "main", agents: [{ id: "main" }] };
const context = contextWithClient(client, { connected: true, agentsList });
const report = { skills: [{ skillKey: "old" }] } as unknown as SkillsRouteData["report"];
const routeData = {
gateway: context.gateway,
gatewaySnapshot: context.gateway.snapshot,
agents: context.agents,
agentsList,
selectedAgentId: "main",
report,
error: null,
} as unknown as SkillsRouteData;
const page = createPage("openclaw-skills-page", context) as TestPage & {
routeData: SkillsRouteData;
skillsReport: SkillsRouteData["report"];
};
page.routeData = routeData;
document.body.append(page);
await page.updateComplete;
expect(page.skillsReport).toBe(report);
expect(request).not.toHaveBeenCalled();
});
it("rejects skills route data from an earlier same-client gateway epoch", async () => {
const freshReport = { skills: [{ skillKey: "fresh" }] } as unknown as SkillsRouteData["report"];
const request = vi.fn(async (method: string) =>
method === "skills.status" ? freshReport : undefined,
);
const client = { request } as unknown as GatewayBrowserClient;
const agentsList = { defaultId: "main", agents: [{ id: "main" }] };
const context = contextWithClient(client, { connected: true, agentsList });
const staleReport = { skills: [{ skillKey: "stale" }] } as unknown as SkillsRouteData["report"];
const page = createPage("openclaw-skills-page", context) as TestPage & {
routeData: SkillsRouteData;
skillsReport: SkillsRouteData["report"];
};
page.routeData = {
gateway: context.gateway,
gatewaySnapshot: { ...context.gateway.snapshot },
agents: context.agents,
agentsList,
selectedAgentId: "main",
report: staleReport,
error: null,
} as unknown as SkillsRouteData;
document.body.append(page);
await page.updateComplete;
await vi.waitFor(() => expect(page.skillsReport).toBe(freshReport));
expect(page.skillsReport).not.toBe(staleReport);
});
it("clears sessions loaded by the previous provider", async () => {
const client = {} as GatewayBrowserClient;
const page = createPage("openclaw-sessions-page", contextWithClient(client)) as TestPage & {
result: unknown;
selectedKeys: Set<string>;
checkpointItemsByKey: Record<string, unknown>;
};
document.body.append(page);
await page.updateComplete;
page.result = { sessions: [{ key: "old" }] };
page.selectedKeys = new Set(["old"]);
page.checkpointItemsByKey = { old: [{}] };
await replaceContext(page, client);
expect(page.result).toBeNull();
expect(page.selectedKeys.size).toBe(0);
expect(page.checkpointItemsByKey).toEqual({});
});
it("clears usage loaded by the previous provider", async () => {
const client = {} as GatewayBrowserClient;
const page = createPage("openclaw-usage-page", contextWithClient(client)) as TestPage & {
usageResult: unknown;
providerUsageSummary: unknown;
usageSelectedSessions: string[];
};
document.body.append(page);
await page.updateComplete;
page.usageResult = { sessions: [{ key: "old" }] };
page.providerUsageSummary = { providers: [{ provider: "old" }] };
page.usageSelectedSessions = ["old"];
await replaceContext(page, client);
expect(page.usageResult).toBeNull();
expect(page.providerUsageSummary).toBeNull();
expect(page.usageSelectedSessions).toEqual([]);
});
it("clears skills loaded by the previous provider", async () => {
const client = {} as GatewayBrowserClient;
const page = createPage("openclaw-skills-page", contextWithClient(client)) as TestPage & {
agentsList: unknown;
skillsReport: unknown;
skillCardContents: Record<string, string>;
};
document.body.append(page);
await page.updateComplete;
page.agentsList = { agents: [{ id: "old" }] };
page.skillsReport = { skills: [{ key: "old" }] };
page.skillCardContents = { old: "stale" };
await replaceContext(page, client);
expect(page.agentsList).toBeNull();
expect(page.skillsReport).toBeNull();
expect(page.skillCardContents).toEqual({});
});
it("discards an agent list from a replaced skills source that reuses its client", async () => {
const pending = deferred<SkillsRouteData["agentsList"]>();
const ensureList = vi.fn(() => pending.promise);
const request = vi.fn(async () => ({ skills: [] }));
const client = { request } as unknown as GatewayBrowserClient;
const context = contextWithClient(client, { ensureList });
const page = createPage("openclaw-skills-page", context) as TestPage & {
agentsList: SkillsRouteData["agentsList"];
connected: boolean;
loadAgents: () => Promise<void>;
};
document.body.append(page);
await page.updateComplete;
(context.gateway.snapshot as ApplicationGatewaySnapshot).connected = true;
page.connected = true;
const load = page.loadAgents();
await vi.waitFor(() => expect(ensureList).toHaveBeenCalledOnce());
const replacementAgents = {
defaultId: "fresh",
mainKey: "agent:fresh:main",
scope: "all",
agents: [{ id: "fresh" }],
} as unknown as NonNullable<SkillsRouteData["agentsList"]>;
await replaceContext(page, client, { connected: true, agentsList: replacementAgents });
pending.resolve({
defaultId: "stale",
mainKey: "agent:stale:main",
scope: "all",
agents: [{ id: "stale" }],
} as unknown as NonNullable<SkillsRouteData["agentsList"]>);
await load;
expect(page.agentsList).toBe(replacementAgents);
});
it("clears logs loaded by the previous provider", async () => {
const client = {} as GatewayBrowserClient;
const page = createPage("openclaw-logs-page", contextWithClient(client)) as TestPage & {
logsEntries: unknown[];
logsFile: string | null;
logsCursor: number | null;
};
document.body.append(page);
await page.updateComplete;
page.logsEntries = [{ raw: "old" }];
page.logsFile = "/old/provider.log";
page.logsCursor = 42;
await replaceContext(page, client);
expect(page.logsEntries).toEqual([]);
expect(page.logsFile).toBeNull();
expect(page.logsCursor).toBeNull();
});
it("clears diagnostics loaded by the previous provider", async () => {
const client = {} as GatewayBrowserClient;
const page = createPage("openclaw-debug-page", contextWithClient(client)) as TestPage & {
debugStatus: unknown;
debugHealth: unknown;
debugModels: unknown[];
debugHeartbeat: unknown;
};
document.body.append(page);
await page.updateComplete;
page.debugStatus = { version: "old" };
page.debugHealth = { ok: true };
page.debugModels = [{ id: "old" }];
page.debugHeartbeat = { provider: "old" };
await replaceContext(page, client);
expect(page.debugStatus).toBeNull();
expect(page.debugHealth).toBeNull();
expect(page.debugModels).toEqual([]);
expect(page.debugHeartbeat).toBeNull();
});
it("discards diagnostics from a replaced provider that reuses its client", async () => {
const pending = deferred<unknown>();
const request = vi.fn(() => pending.promise);
const client = { request } as unknown as GatewayBrowserClient;
const context = contextWithClient(client);
const page = createPage("openclaw-debug-page", context) as TestPage & {
connected: boolean;
debugLoading: boolean;
debugStatus: unknown;
loadDiagnostics: () => Promise<void>;
};
document.body.append(page);
await page.updateComplete;
(context.gateway.snapshot as ApplicationGatewaySnapshot).connected = true;
page.connected = true;
const load = page.loadDiagnostics();
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(4));
await replaceContext(page, client);
pending.resolve({ models: [{ id: "stale" }], stale: true });
await load;
expect(page.debugLoading).toBe(false);
expect(page.debugStatus).toBeNull();
});
it("clears cron data loaded by the previous provider", async () => {
const client = {} as GatewayBrowserClient;
const page = createPage("openclaw-cron-page", contextWithClient(client)) as TestPage & {
cron: {
client: GatewayBrowserClient | null;
connected: boolean;
cronStatus: unknown;
cronJobs: unknown[];
};
};
document.body.append(page);
await page.updateComplete;
page.cron = {
...page.cron,
cronStatus: { enabled: true },
cronJobs: [{ id: "old" }],
};
await replaceContext(page, client);
expect(page.cron.cronStatus).toBeNull();
expect(page.cron.cronJobs).toEqual([]);
});
it("clears presence loaded by the previous provider", async () => {
const client = {} as GatewayBrowserClient;
const page = createPage("openclaw-instances-page", contextWithClient(client)) as TestPage & {
entries: unknown[];
error: string | null;
status: string | null;
hostsRevealed: boolean;
};
document.body.append(page);
await page.updateComplete;
page.entries = [{ instanceId: "old" }];
page.error = "old error";
page.status = "old status";
page.hostsRevealed = true;
await replaceContext(page, client);
expect(page.entries).toEqual([]);
expect(page.error).toBeNull();
expect(page.status).toBeNull();
expect(page.hostsRevealed).toBe(false);
});
it("clears tasks loaded by the previous provider", async () => {
const client = {} as GatewayBrowserClient;
const page = createPage("openclaw-tasks-page", contextWithClient(client)) as TestPage & {
tasks: unknown[];
error: string | null;
cancellingTaskIds: Set<string>;
};
document.body.append(page);
await page.updateComplete;
page.tasks = [{ taskId: "old" }];
page.error = "old error";
page.cancellingTaskIds = new Set(["old"]);
await replaceContext(page, client);
expect(page.tasks).toEqual([]);
expect(page.error).toBeNull();
expect(page.cancellingTaskIds.size).toBe(0);
});
});
+36 -24
View File
@@ -1,5 +1,5 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { html } from "lit";
import { state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { PresenceEntry } from "../../api/types.ts";
@@ -13,6 +13,8 @@ import {
formatMissingOperatorReadScopeMessage,
isMissingOperatorReadScopeError,
} from "../../lib/gateway-errors.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { renderInstances } from "./view.ts";
function readPresence(value: unknown): PresenceEntry[] | null {
@@ -21,12 +23,8 @@ function readPresence(value: unknown): PresenceEntry[] | null {
return Array.isArray(presence) ? (presence as PresenceEntry[]) : null;
}
class InstancesPage extends LitElement {
override createRenderRoot() {
return this;
}
@consume({ context: applicationContext, subscribe: false })
class InstancesPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@state() private loading = false;
@@ -38,39 +36,53 @@ class InstancesPage extends LitElement {
private client: GatewayBrowserClient | null = null;
private connected = false;
private requestId = 0;
private subscriptions: Array<() => void> = [];
override connectedCallback() {
super.connectedCallback();
this.subscriptions = [
this.context.gateway.subscribeEvents((event) => {
private gatewaySource?: ApplicationContext["gateway"];
private readonly subscriptions = new SubscriptionsController(this).effect(
() => this.context?.gateway,
(gateway) => {
const sourceChanged = this.gatewaySource !== undefined && this.gatewaySource !== gateway;
this.gatewaySource = gateway;
const stopEvents = gateway.subscribeEvents((event) => {
if (this.gatewaySource !== gateway) {
return;
}
const presence = event.event === "presence" ? readPresence(event.payload) : null;
if (presence) {
this.applyPresence(presence);
}
}),
this.context.gateway.subscribe((snapshot) => this.applyGatewaySnapshot(snapshot)),
];
this.applyGatewaySnapshot(this.context.gateway.snapshot);
}
});
const stopGateway = gateway.subscribe((snapshot) => {
if (this.gatewaySource === gateway) {
this.applyGatewaySnapshot(snapshot);
}
});
this.applyGatewaySnapshot(gateway.snapshot, sourceChanged);
return () => {
stopEvents();
stopGateway();
};
},
);
override disconnectedCallback() {
for (const unsubscribe of this.subscriptions) {
unsubscribe();
}
this.subscriptions = [];
this.subscriptions.clear();
this.invalidateRequest();
this.client = null;
this.connected = false;
this.hostsRevealed = false;
super.disconnectedCallback();
}
private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot) {
const clientChanged = snapshot.client !== this.client;
private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot, sourceChanged = false) {
const clientChanged = sourceChanged || snapshot.client !== this.client;
const connectionChanged = snapshot.connected !== this.connected;
const becameConnected = snapshot.connected && !this.connected;
this.client = snapshot.client;
this.connected = snapshot.connected;
if (clientChanged || connectionChanged) {
this.hostsRevealed = false;
}
if (clientChanged) {
this.invalidateRequest();
this.entries = [];
+175
View File
@@ -0,0 +1,175 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import "./logs-page.ts";
type TestLogsPage = HTMLElement & {
context: ApplicationContext;
connected: boolean;
logsEntries: unknown[];
readonly updateComplete: Promise<boolean>;
applyGatewaySnapshot: (snapshot: ApplicationGatewaySnapshot) => void;
loadLogs: (opts?: { reset?: boolean; quiet?: boolean }) => Promise<boolean>;
requestUpdate: () => void;
scheduleScroll: (force?: boolean) => void;
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function contextWithClient(client: GatewayBrowserClient): ApplicationContext {
return {
basePath: "",
gateway: {
snapshot: { client, connected: false },
subscribe: () => () => undefined,
},
navigate: vi.fn(),
preload: vi.fn(async () => undefined),
} as unknown as ApplicationContext;
}
describe("LogsPage lifecycle", () => {
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});
it("does not schedule scroll work after disconnect", async () => {
const page = document.createElement("openclaw-logs-page") as TestLogsPage;
page.context = {
basePath: "",
gateway: {
snapshot: { client: null, connected: false },
subscribe: () => () => undefined,
},
navigate: vi.fn(),
preload: vi.fn(async () => undefined),
} as unknown as ApplicationContext;
const requestFrame = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(1);
document.body.append(page);
await page.updateComplete;
await Promise.resolve();
requestFrame.mockClear();
page.scheduleScroll();
page.remove();
await Promise.resolve();
expect(requestFrame).not.toHaveBeenCalled();
});
it("discards a log response from a replaced gateway source that reuses its client", async () => {
const pending = deferred<{ cursor: number; lines: string[]; reset: boolean }>();
const client = {
request: vi.fn(() => pending.promise),
} as unknown as GatewayBrowserClient;
const page = document.createElement("openclaw-logs-page") as TestLogsPage;
page.context = contextWithClient(client);
document.body.append(page);
await page.updateComplete;
page.connected = true;
const load = page.loadLogs({ reset: true });
page.context = contextWithClient(client);
page.requestUpdate();
await page.updateComplete;
pending.resolve({ cursor: 1, lines: ["stale"], reset: true });
await load;
expect(page.logsEntries).toEqual([]);
});
it("discards a log response that completes after disconnect", async () => {
const pending = deferred<{ cursor: number; lines: string[]; reset: boolean }>();
const client = {
request: vi.fn(() => pending.promise),
} as unknown as GatewayBrowserClient;
const page = document.createElement("openclaw-logs-page") as TestLogsPage;
page.context = contextWithClient(client);
document.body.append(page);
await page.updateComplete;
page.connected = true;
const load = page.loadLogs({ reset: true });
page.remove();
pending.resolve({ cursor: 1, lines: ["stale"], reset: true });
await load;
expect(page.logsEntries).toEqual([]);
});
it("discards a log response when the gateway disconnects with the same client", async () => {
const pending = deferred<{ cursor: number; lines: string[]; reset: boolean }>();
const client = {
request: vi.fn(() => pending.promise),
} as unknown as GatewayBrowserClient;
const page = document.createElement("openclaw-logs-page") as TestLogsPage;
page.context = contextWithClient(client);
document.body.append(page);
await page.updateComplete;
page.connected = true;
const load = page.loadLogs({ reset: true });
page.applyGatewaySnapshot({ client, connected: false } as ApplicationGatewaySnapshot);
pending.resolve({ cursor: 1, lines: ["stale"], reset: true });
await load;
expect(page.logsEntries).toEqual([]);
});
it("serializes quiet polls so an older cursor cannot overwrite a newer one", async () => {
const pending = deferred<{ cursor: number; lines: string[]; reset: boolean }>();
const request = vi.fn(() => pending.promise);
const client = {
request,
} as unknown as GatewayBrowserClient;
const page = document.createElement("openclaw-logs-page") as TestLogsPage;
page.context = contextWithClient(client);
document.body.append(page);
await page.updateComplete;
page.connected = true;
const first = page.loadLogs({ quiet: true });
const second = page.loadLogs({ quiet: true });
expect(request).toHaveBeenCalledTimes(1);
expect(await second).toBe(false);
pending.resolve({ cursor: 2, lines: ["fresh"], reset: true });
expect(await first).toBe(true);
expect(page.logsEntries).toHaveLength(1);
});
it("drops deferred scroll work after a same-client reconnect", async () => {
const client = {
request: vi.fn(
() =>
new Promise(() => {
// Keep both connection-epoch requests pending while scroll ownership changes.
}),
),
} as unknown as GatewayBrowserClient;
const page = document.createElement("openclaw-logs-page") as TestLogsPage;
page.context = contextWithClient(client);
const requestFrame = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(1);
document.body.append(page);
await page.updateComplete;
page.applyGatewaySnapshot({ client, connected: true } as ApplicationGatewaySnapshot);
requestFrame.mockClear();
page.scheduleScroll();
page.applyGatewaySnapshot({ client, connected: false } as ApplicationGatewaySnapshot);
page.applyGatewaySnapshot({ client, connected: true } as ApplicationGatewaySnapshot);
await Promise.resolve();
expect(requestFrame).not.toHaveBeenCalled();
});
});
+132 -46
View File
@@ -1,14 +1,20 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { html, type PropertyValues } from "lit";
import { state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { subtitleForRoute, titleForRoute } from "../../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import {
applicationContext,
type ApplicationContext,
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import {
formatMissingOperatorReadScopeMessage,
isMissingOperatorReadScopeError,
} from "../../lib/gateway-errors.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import {
DEFAULT_LOG_LEVEL_FILTERS,
parseLogLine,
@@ -20,12 +26,14 @@ import { renderLogs } from "./view.ts";
const LOG_BUFFER_LIMIT = 2000;
const LOGS_POLL_INTERVAL_MS = 2000;
class LogsPage extends LitElement {
override createRenderRoot() {
return this;
}
type LogsRequestScope = {
gateway: ApplicationContext["gateway"];
client: GatewayBrowserClient;
generation: number;
};
@consume({ context: applicationContext, subscribe: false })
class LogsPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@state() private client: GatewayBrowserClient | null = null;
@@ -46,24 +54,27 @@ class LogsPage extends LitElement {
private logsPollInterval: ReturnType<typeof globalThis.setInterval> | null = null;
private logsScrollFrame: number | null = null;
private contentScrollFrame: number | null = null;
private stopGatewaySubscription?: () => void;
override connectedCallback() {
super.connectedCallback();
this.syncGatewayState();
this.stopGatewaySubscription = this.context.gateway.subscribe((snapshot) => {
const previousClient = this.client;
this.syncGatewayState();
if (previousClient !== snapshot.client) {
this.resetServerState();
}
this.syncPolling();
this.ensureInitialLogs();
});
this.logsAtBottom = true;
this.syncPolling();
this.ensureInitialLogs();
}
private hasBoundGatewaySource = false;
private gatewaySource: ApplicationContext["gateway"] | null = null;
private requestGeneration = 0;
private activeRequest: LogsRequestScope | null = null;
private readonly subscriptions = new SubscriptionsController(this).effect(
() => this.context?.gateway,
(gateway) => {
const resetForSourceBind = this.hasBoundGatewaySource;
this.hasBoundGatewaySource = true;
this.gatewaySource = gateway;
this.requestGeneration += 1;
const cleanup = gateway.subscribe((snapshot) => {
if (this.gatewaySource === gateway && this.context.gateway === gateway) {
this.applyGatewaySnapshot(snapshot);
}
});
this.applyGatewaySnapshot(gateway.snapshot, resetForSourceBind);
this.logsAtBottom = true;
return cleanup;
},
);
override firstUpdated() {
this.resetContentScroll();
@@ -73,7 +84,7 @@ class LogsPage extends LitElement {
});
}
override updated(changed: Map<PropertyKey, unknown>) {
override updated(changed: PropertyValues) {
if (
this.logsAutoFollow &&
this.logsAtBottom &&
@@ -85,8 +96,11 @@ class LogsPage extends LitElement {
override disconnectedCallback() {
this.stopPolling();
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.subscriptions.clear();
this.requestGeneration += 1;
this.activeRequest = null;
this.gatewaySource = null;
this.logsLoading = false;
if (this.logsScrollFrame !== null) {
cancelAnimationFrame(this.logsScrollFrame);
this.logsScrollFrame = null;
@@ -106,10 +120,22 @@ class LogsPage extends LitElement {
}
}
private syncGatewayState() {
const gateway = this.context.gateway.snapshot;
this.client = gateway.client;
this.connected = gateway.connected;
private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot, resetForSourceBind = false) {
const connectionChanged = snapshot.connected !== this.connected;
const clientChanged = resetForSourceBind || snapshot.client !== this.client;
if (clientChanged || connectionChanged) {
this.requestGeneration += 1;
this.activeRequest = null;
}
this.client = snapshot.client;
this.connected = snapshot.connected;
if (clientChanged) {
this.resetServerState();
} else if (connectionChanged) {
this.logsLoading = false;
}
this.syncPolling();
this.ensureInitialLogs();
}
private resetServerState() {
@@ -147,27 +173,60 @@ class LogsPage extends LitElement {
if (!this.connected || !this.client || this.logsEntries.length > 0 || this.logsLoading) {
return;
}
void this.loadLogs({ reset: true }).then(() => this.scheduleScroll(true));
void this.loadLogs({ reset: true }).then((current) => {
if (current) {
this.scheduleScroll(true);
}
});
}
private async loadLogs(opts?: { reset?: boolean; quiet?: boolean }) {
private captureRequestScope(): LogsRequestScope | null {
const gateway = this.gatewaySource;
const client = this.client;
const quiet = opts?.quiet === true;
if (!client || !this.connected || (this.logsLoading && !quiet)) {
return;
if (
!gateway ||
!client ||
!this.connected ||
!this.isConnected ||
this.context.gateway !== gateway
) {
return null;
}
return { gateway, client, generation: this.requestGeneration };
}
private isRequestScopeCurrent(scope: LogsRequestScope): boolean {
return (
this.isConnected &&
this.gatewaySource === scope.gateway &&
this.context.gateway === scope.gateway &&
this.requestGeneration === scope.generation &&
this.client === scope.client &&
this.connected
);
}
private async loadLogs(opts?: { reset?: boolean; quiet?: boolean }): Promise<boolean> {
const scope = this.captureRequestScope();
const quiet = opts?.quiet === true;
if (!scope || (this.activeRequest && this.isRequestScopeCurrent(this.activeRequest))) {
return false;
}
this.activeRequest = scope;
const isCurrentOperation = () =>
this.activeRequest === scope && this.isRequestScopeCurrent(scope);
if (!quiet) {
this.logsLoading = true;
}
this.logsError = null;
try {
const res = await client.request("logs.tail", {
const res = await scope.client.request("logs.tail", {
cursor: opts?.reset ? undefined : (this.logsCursor ?? undefined),
limit: this.logsLimit,
maxBytes: this.logsMaxBytes,
});
if (this.client !== client) {
return;
if (!isCurrentOperation()) {
return false;
}
const payload = res as {
file?: string;
@@ -187,9 +246,10 @@ class LogsPage extends LitElement {
this.logsCursor = typeof payload.cursor === "number" ? payload.cursor : this.logsCursor;
this.logsFile = typeof payload.file === "string" ? payload.file : this.logsFile;
this.logsTruncated = Boolean(payload.truncated);
return true;
} catch (err) {
if (this.client !== client) {
return;
if (!isCurrentOperation()) {
return false;
}
if (isMissingOperatorReadScopeError(err)) {
this.logsEntries = [];
@@ -197,9 +257,13 @@ class LogsPage extends LitElement {
} else {
this.logsError = String(err);
}
return true;
} finally {
if (this.client === client && !quiet) {
this.logsLoading = false;
if (this.activeRequest === scope) {
this.activeRequest = null;
if (this.isRequestScopeCurrent(scope) && !quiet) {
this.logsLoading = false;
}
}
}
}
@@ -208,9 +272,24 @@ class LogsPage extends LitElement {
if (this.logsScrollFrame !== null) {
cancelAnimationFrame(this.logsScrollFrame);
}
const gateway = this.gatewaySource;
const generation = this.requestGeneration;
const isCurrent = () =>
this.isConnected &&
this.connected &&
gateway !== null &&
this.gatewaySource === gateway &&
this.context.gateway === gateway &&
this.requestGeneration === generation;
void this.updateComplete.then(() => {
if (!isCurrent()) {
return;
}
this.logsScrollFrame = requestAnimationFrame(() => {
this.logsScrollFrame = null;
if (!isCurrent()) {
return;
}
const container = this.querySelector(".log-stream") as HTMLElement | null;
if (!container) {
return;
@@ -263,7 +342,12 @@ class LogsPage extends LitElement {
this.logsLevelFilters = { ...this.logsLevelFilters, [level]: enabled };
},
onToggleAutoFollow: (next) => (this.logsAutoFollow = next),
onRefresh: () => void this.loadLogs({ reset: true }).then(() => this.scheduleScroll(true)),
onRefresh: () =>
void this.loadLogs({ reset: true }).then((current) => {
if (current) {
this.scheduleScroll(true);
}
}),
onExport: (lines, label) => this.exportLogs(lines, label),
onScroll: (event) => this.handleScroll(event),
});
@@ -279,4 +363,6 @@ class LogsPage extends LitElement {
}
}
customElements.define("openclaw-logs-page", LogsPage);
if (!customElements.get("openclaw-logs-page")) {
customElements.define("openclaw-logs-page", LogsPage);
}
+159
View File
@@ -0,0 +1,159 @@
/* @vitest-environment jsdom */
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import { createInitialNodesState, loadNodes } from "../../lib/nodes/index.ts";
import type { NodesRouteData } from "./nodes-page.ts";
import "./nodes-page.ts";
type TestNodesPage = HTMLElement & {
context: ApplicationContext;
client: GatewayBrowserClient | null;
connected: boolean;
requestGeneration: number;
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
lastError: string | null;
chatError: string | null;
routeData?: NodesRouteData;
subscriptions: {
hostConnected: () => void;
hostUpdate: () => void;
hostDisconnected: () => void;
};
willUpdate: (changed: Map<PropertyKey, unknown>) => void;
applyGatewaySnapshot: (
snapshot: ApplicationGatewaySnapshot,
forceReset: boolean,
initialBind?: boolean,
) => void;
ensureInitialData: () => void;
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function gatewaySnapshot(
client: GatewayBrowserClient | null,
connected: boolean,
): ApplicationGatewaySnapshot {
return {
client,
connected,
reconnecting: !connected,
hello: null,
assistantAgentId: null,
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
}
function gateway(client: GatewayBrowserClient | null): ApplicationContext["gateway"] {
const snapshot: ApplicationGatewaySnapshot = {
client,
connected: false,
reconnecting: false,
hello: null,
assistantAgentId: null,
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
return {
snapshot,
subscribe: vi.fn(() => () => undefined),
subscribeEvents: vi.fn(() => () => undefined),
} as unknown as ApplicationContext["gateway"];
}
describe("NodesPage gateway lifecycle", () => {
it("preserves matching initial route data, then resets it on provider replacement", () => {
const client = null;
const currentGateway = gateway(client);
const preloadedNodes = [{ id: "preloaded" }];
const page = document.createElement("openclaw-nodes-page") as TestNodesPage;
page.routeData = {
gateway: currentGateway,
gatewaySnapshot: currentGateway.snapshot,
nodes: {
...createInitialNodesState(currentGateway.snapshot),
nodes: preloadedNodes,
},
};
page.context = { gateway: currentGateway } as unknown as ApplicationContext;
page.willUpdate(new Map([["routeData", undefined]]));
page.subscriptions.hostConnected();
expect(page.client).toBeNull();
expect(page.nodes).toBe(preloadedNodes);
page.context = { gateway: gateway(client) } as unknown as ApplicationContext;
page.subscriptions.hostUpdate();
expect(page.nodes).toEqual([]);
expect(page.requestGeneration).toBeGreaterThan(0);
page.subscriptions.hostDisconnected();
});
it("rejects preloaded data after a same-client gateway epoch change", () => {
const client = {} as GatewayBrowserClient;
const currentGateway = gateway(client);
const preloadedNodes = [{ id: "stale" }];
const page = document.createElement("openclaw-nodes-page") as TestNodesPage;
page.ensureInitialData = vi.fn();
page.routeData = {
gateway: currentGateway,
gatewaySnapshot: gatewaySnapshot(client, false),
nodes: {
...createInitialNodesState(gatewaySnapshot(client, true)),
nodes: preloadedNodes,
},
};
page.context = { gateway: currentGateway } as unknown as ApplicationContext;
page.willUpdate(new Map([["routeData", undefined]]));
expect(page.nodes).toEqual([]);
expect(page.ensureInitialData).toHaveBeenCalledOnce();
});
it("retries a node load after a same-client disconnect", async () => {
const first = deferred<{ nodes: Array<Record<string, unknown>> }>();
const second = deferred<{ nodes: Array<Record<string, unknown>> }>();
const request = vi
.fn<(method: string, params?: unknown) => Promise<unknown>>()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const client = { request } as unknown as GatewayBrowserClient;
const page = document.createElement("openclaw-nodes-page") as TestNodesPage;
page.client = client;
page.connected = true;
page.context = {
runtimeConfig: { state: { configSnapshot: null, configLoading: false } },
} as unknown as ApplicationContext;
const staleLoad = loadNodes(page);
page.applyGatewaySnapshot(gatewaySnapshot(client, false), false);
page.applyGatewaySnapshot(gatewaySnapshot(client, true), false);
const currentLoad = loadNodes(page);
first.resolve({ nodes: [{ id: "old" }] });
await staleLoad;
expect(page.nodes).toEqual([]);
expect(page.nodesLoading).toBe(true);
second.resolve({ nodes: [{ id: "new" }] });
await currentLoad;
expect(page.nodes).toEqual([{ id: "new" }]);
expect(page.nodesLoading).toBe(false);
page.applyGatewaySnapshot(gatewaySnapshot(client, false), false);
});
});

Some files were not shown because too many files have changed in this diff Show More