mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
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:
committed by
GitHub
parent
e45e755fb9
commit
deb7faf7b0
Generated
+3
@@ -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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 = () =>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user