refactor(ui): adopt @lit/task for async data lifecycle (#115131)

* refactor(ui): adopt @lit/task for async data lifecycle

- migrate chat diff/discussion, new-session discovery, and MCP app setup reads
- migrate usage, plugins, transcript/checkpoint, worktree, memory-import, and model-setup reads
- delete read-lane request counters, abort-controller epochs, and current-request predicates while retaining mutation epochs and keyed caches

* chore(ui): drop dead plugin search helper (knip)

* chore(deps): allowlist @lit/task as root UI-workspace dependency in knip

* fix(ui): preserve usage refresh lifecycle

* test(ui): source workboard fixture copy from en locale (raw-copy gate)

* test(ui): read workboard fixture copy via t() (typed locale access)

* style: format test-support imports
This commit is contained in:
Peter Steinberger
2026-07-28 08:37:47 -04:00
committed by GitHub
parent 74954fd931
commit 053384fa01
29 changed files with 1964 additions and 1611 deletions
+1
View File
@@ -248,6 +248,7 @@ const rootToolingAndWorkspaceDependencies = [
"@copilotkit/aimock",
"@lit-labs/signals",
"@lit/context",
"@lit/task",
// scripts/ui.js anchors these lookups at ui/package.json before invoking the UI workspace.
"@vitest/browser-playwright",
"dompurify",
+1
View File
@@ -1980,6 +1980,7 @@
"@copilotkit/aimock": "1.37.2",
"@lit-labs/signals": "0.3.0",
"@lit/context": "1.1.6",
"@lit/task": "1.0.3",
"@mdx-js/mdx": "3.1.1",
"@shikijs/core": "4.3.1",
"@shikijs/engine-javascript": "4.3.1",
+13
View File
@@ -244,6 +244,9 @@ importers:
'@lit/context':
specifier: 1.1.6
version: 1.1.6
'@lit/task':
specifier: 1.0.3
version: 1.0.3
'@mdx-js/mdx':
specifier: 3.1.1
version: 3.1.1(supports-color@10.2.2)
@@ -2339,6 +2342,9 @@ importers:
'@lit/context':
specifier: 1.1.6
version: 1.1.6
'@lit/task':
specifier: 1.0.3
version: 1.0.3
'@modelcontextprotocol/ext-apps':
specifier: 1.7.4
version: 1.7.4(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3)
@@ -3611,6 +3617,9 @@ packages:
'@lit/reactive-element@2.1.2':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
'@lit/task@1.0.3':
resolution: {integrity: sha512-1gJGJl8WON+2j0y9xfcD+XsS1rvcy3XDgsIhcdUW++yTR8ESjZW6o7dn8M8a4SZM8NnJe6ynS2cKWwsbfLOurg==}
'@lydell/node-pty-darwin-arm64@1.2.0-beta.12':
resolution: {integrity: sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ==}
cpu: [arm64]
@@ -10561,6 +10570,10 @@ snapshots:
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@lit/task@1.0.3':
dependencies:
'@lit/reactive-element': 2.1.2
'@lydell/node-pty-darwin-arm64@1.2.0-beta.12':
optional: true
+1
View File
@@ -18,6 +18,7 @@
"@create-markdown/preview": "2.0.3",
"@lezer/highlight": "1.2.3",
"@lit/context": "1.1.6",
"@lit/task": "1.0.3",
"@modelcontextprotocol/ext-apps": "1.7.4",
"@modelcontextprotocol/sdk": "1.30.0",
"@noble/ed25519": "3.1.0",
+108 -77
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { Task, TaskStatus } from "@lit/task";
import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge";
import {
type CallToolResult,
@@ -8,7 +9,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import { isMcpAppViewExpiredError } from "@openclaw/gateway-protocol";
import { LitElement, css, html, nothing, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import { property } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import { applicationContext, type ApplicationContext } from "../app/context.ts";
import { I18nController, t } from "../i18n/index.ts";
@@ -45,6 +46,12 @@ type McpAppResources = {
frameHeight: number;
iframe: HTMLIFrameElement;
transport: { close(): Promise<void> } | null;
disposed: boolean;
};
type McpAppBinding = {
client: NonNullable<ApplicationContext["gateway"]["snapshot"]["client"]>;
sessionKey: string;
viewId: string;
};
const MCP_APP_TEARDOWN_TIMEOUT_MS = 250;
@@ -145,15 +152,26 @@ export class McpAppView extends LitElement {
@property({ type: Number }) height = 600;
@property({ type: Boolean }) fixedHeight = false;
@property() override title = "";
@state() private error: string | null = null;
protected readonly i18nController = new I18nController(this);
private readonly mount = createRef<HTMLDivElement>();
private resources: McpAppResources | null = null;
private teardownPromise: Promise<void> | null = null;
private setupKey = "";
private setupClient: object | null = null;
private setupGeneration = 0;
private readonly setupTask = new Task(this, {
autoRun: "afterUpdate",
args: () =>
[this.context?.gateway.snapshot.client ?? null, this.sessionKey, this.viewId] as const,
task: async ([client, sessionKey, viewId], { signal }) => {
await this.teardownResources(this.resources);
if (!sessionKey || !viewId) {
return null;
}
if (!client) {
throw new Error("MCP App gateway unavailable");
}
return this.setupResources({ client, sessionKey, viewId }, signal);
},
});
override disconnectedCallback() {
void this.teardown();
@@ -172,26 +190,23 @@ export class McpAppView extends LitElement {
this.resources.bridge?.setHostContext(hostContext(this.mount.value, this.height));
}
}
const nextKey = `${this.sessionKey}\0${this.viewId}`;
const nextClient = this.context?.gateway.snapshot.client ?? null;
if (nextKey !== this.setupKey || nextClient !== this.setupClient) {
this.setupKey = nextKey;
this.setupClient = nextClient;
void this.setup();
}
}
private async request(method: string, params: Record<string, unknown>): Promise<unknown> {
const client = this.context?.gateway.snapshot.client;
if (!client || !this.sessionKey || !this.viewId) {
throw new Error("MCP App gateway unavailable");
}
private async request(
binding: McpAppBinding,
method: string,
params: Record<string, unknown>,
signal?: AbortSignal,
): Promise<unknown> {
try {
return await client.request(method, {
sessionKey: this.sessionKey,
viewId: this.viewId,
const requestParams = {
sessionKey: binding.sessionKey,
viewId: binding.viewId,
...params,
});
};
return await (signal
? binding.client.request(method, requestParams, { signal })
: binding.client.request(method, requestParams));
} catch (error) {
if (isMcpAppViewExpiredError(error)) {
this.dispatchEvent(
@@ -218,14 +233,15 @@ export class McpAppView extends LitElement {
}
}
private async teardownCurrentResources() {
const resources = this.resources;
if (!resources) {
private async teardownResources(resources: McpAppResources | null | undefined) {
if (!resources || resources.disposed) {
await this.teardownPromise;
return;
}
// Release ownership before awaiting so this generation can never close a replacement.
this.resources = null;
resources.disposed = true;
if (this.resources === resources) {
this.resources = null;
}
this.runResourceCleanups(resources);
const teardown = (async () => {
if (resources.bridge) {
@@ -258,8 +274,8 @@ export class McpAppView extends LitElement {
/** Parent render owners await this before removing the connected view. */
async teardown() {
this.setupGeneration += 1;
await this.teardownCurrentResources();
this.setupTask.abort();
await this.teardownResources(this.resources);
}
/** Restarts a torn-down view only when its parent kept the element connected. */
@@ -267,22 +283,26 @@ export class McpAppView extends LitElement {
if (!this.isConnected || this.resources || this.teardownPromise) {
return;
}
this.setupKey = `${this.sessionKey}\0${this.viewId}`;
this.setupClient = this.context?.gateway.snapshot.client ?? null;
void this.setup();
void this.setupTask.run();
}
private async setup() {
const generation = ++this.setupGeneration;
await this.teardownCurrentResources();
if (!this.sessionKey || !this.viewId || generation !== this.setupGeneration) {
return;
}
private async setupResources(
binding: McpAppBinding,
signal: AbortSignal,
): Promise<McpAppResources> {
const { sessionKey, viewId } = binding;
let resources: McpAppResources | null = null;
try {
const payload = (await this.request("mcp.app.view", {})) as McpAppViewPayload;
const payload = (await this.request(
binding,
"mcp.app.view",
{},
signal,
)) as McpAppViewPayload;
const mount = this.mount.value;
if (!mount || generation !== this.setupGeneration) {
return;
signal.throwIfAborted();
if (!mount) {
throw new Error("MCP App mount unavailable");
}
const iframe = document.createElement("iframe");
iframe.title = this.title || t("mcpApp.title");
@@ -294,14 +314,19 @@ export class McpAppView extends LitElement {
// so Apps retain their required origin capabilities without reaching Control UI.
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
mount.appendChild(iframe);
const resources: McpAppResources = {
const createdResources: McpAppResources = {
bridge: null,
cleanups: new Set(),
frameHeight: this.height,
iframe,
transport: null,
disposed: false,
};
this.resources = resources;
resources = createdResources;
this.resources = createdResources;
signal.addEventListener("abort", () => void this.teardownResources(createdResources), {
once: true,
});
const proxyReady = new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(() => {
@@ -317,7 +342,7 @@ export class McpAppView extends LitElement {
resolve();
}
};
const cleanupProxyReady = this.addResourceCleanup(resources, () => {
const cleanupProxyReady = this.addResourceCleanup(createdResources, () => {
window.clearTimeout(timeout);
window.removeEventListener("message", onMessage);
});
@@ -331,8 +356,9 @@ export class McpAppView extends LitElement {
window.location.origin,
);
await proxyReady;
if (!iframe.contentWindow || generation !== this.setupGeneration) {
return;
signal.throwIfAborted();
if (!iframe.contentWindow) {
throw new Error("MCP App sandbox unavailable");
}
const bridge = new OpenClawAppBridge(
@@ -345,18 +371,20 @@ export class McpAppView extends LitElement {
),
{ hostContext: hostContext(mount, this.height) },
);
resources.bridge = bridge;
createdResources.bridge = bridge;
const request = (method: string, params: Record<string, unknown>) =>
this.request(binding, method, params);
const handleRequestTeardown = () => {
void this.teardown();
};
bridge.onrequestteardown = handleRequestTeardown;
this.addResourceCleanup(resources, () => {
this.addResourceCleanup(createdResources, () => {
if (bridge.onrequestteardown === handleRequestTeardown) {
bridge.onrequestteardown = undefined;
}
});
if (payload.messageSupported === true) {
const promptRateKey = `${this.sessionKey}\0${this.viewId}`;
const promptRateKey = `${sessionKey}\0${viewId}`;
bridge.setMessageHandler(async ({ content }) => {
const block = content.length === 1 ? content[0] : undefined;
const text = block?.type === "text" ? block.text : null;
@@ -368,39 +396,39 @@ export class McpAppView extends LitElement {
}
if (payload.updateModelContextSupported === true) {
bridge.setUpdateModelContextHandler(async (params) => {
await this.request("mcp.app.updateModelContext", { ...params });
await request("mcp.app.updateModelContext", { ...params });
return {};
});
}
bridge.oncalltool = async (params) =>
(await this.request("mcp.app.callTool", {
(await request("mcp.app.callTool", {
toolName: params.name,
arguments: params.arguments,
})) as CallToolResult;
bridge.setListToolsHandler(
async (params) =>
(await this.request(
(await request(
"mcp.app.listTools",
params?.cursor ? { cursor: params.cursor } : {},
)) as ListToolsResult,
);
bridge.onlistresources = async (params) =>
(await this.request(
(await request(
"mcp.app.listResources",
params?.cursor ? { cursor: params.cursor } : {},
)) as never;
bridge.onlistresourcetemplates = async (params) =>
(await this.request(
(await request(
"mcp.app.listResourceTemplates",
params?.cursor ? { cursor: params.cursor } : {},
)) as never;
bridge.onreadresource = async (params) =>
(await this.request("mcp.app.readResource", { uri: params.uri })) as never;
(await request("mcp.app.readResource", { uri: params.uri })) as never;
bridge.onopenlink = async ({ url }) => (openExternalUrlSafe(url) ? {} : { isError: true });
bridge.onsizechange = ({ height }) => {
if (height !== undefined && !this.fixedHeight) {
const nextHeight = Math.min(1200, Math.max(160, Math.round(height)));
resources.frameHeight = nextHeight;
createdResources.frameHeight = nextHeight;
iframe.style.height = `${nextHeight}px`;
bridge.setHostContext(hostContext(mount, nextHeight));
}
@@ -409,14 +437,15 @@ export class McpAppView extends LitElement {
bridge.oninitialized = () => resolve();
});
const transport = new PostMessageTransport(iframe.contentWindow, iframe.contentWindow);
resources.transport = transport;
createdResources.transport = transport;
await bridge.connect(transport);
signal.throwIfAborted();
await bridge.sendSandboxResourceReady({
html: payload.html,
csp: payload.csp,
});
let initializationTimeout: number | undefined;
const cleanupInitializationTimeout = this.addResourceCleanup(resources, () => {
const cleanupInitializationTimeout = this.addResourceCleanup(createdResources, () => {
if (initializationTimeout !== undefined) {
window.clearTimeout(initializationTimeout);
}
@@ -434,24 +463,20 @@ export class McpAppView extends LitElement {
} finally {
cleanupInitializationTimeout();
}
if (generation !== this.setupGeneration) {
return;
}
signal.throwIfAborted();
const updateHostContext = () =>
bridge.setHostContext(hostContext(mount, resources.frameHeight));
bridge.setHostContext(hostContext(mount, createdResources.frameHeight));
const hostContextCleanup = this.context?.theme.subscribe(updateHostContext);
if (hostContextCleanup) {
this.addResourceCleanup(resources, hostContextCleanup);
this.addResourceCleanup(createdResources, hostContextCleanup);
}
if (typeof ResizeObserver !== "undefined") {
const hostResizeObserver = new ResizeObserver(updateHostContext);
hostResizeObserver.observe(mount);
this.addResourceCleanup(resources, () => hostResizeObserver.disconnect());
this.addResourceCleanup(createdResources, () => hostResizeObserver.disconnect());
}
await waitForMcpAppHandlerRegistration();
if (generation !== this.setupGeneration) {
return;
}
signal.throwIfAborted();
await bridge.sendToolInput({
arguments:
payload.toolInput &&
@@ -461,22 +486,28 @@ export class McpAppView extends LitElement {
: {},
});
await bridge.sendToolResult(payload.toolResult as never);
if (generation === this.setupGeneration) {
this.error = null;
}
signal.throwIfAborted();
return createdResources;
} catch (error) {
if (generation === this.setupGeneration) {
await this.teardownCurrentResources();
this.error = error instanceof Error ? error.message : String(error);
}
await this.teardownResources(resources);
throw error;
}
}
override render() {
const error = this.setupTask.status === TaskStatus.ERROR ? this.setupTask.error : null;
const errorText = error
? t("mcpApp.unavailable", {
error:
error instanceof Error
? error.message
: typeof error === "string"
? error
: "request failed",
})
: null;
return html`<div ${ref(this.mount)} class="mount"></div>
${this.error
? html`<div class="error">${t("mcpApp.unavailable", { error: this.error })}</div>`
: nothing}`;
${errorText ? html`<div class="error">${errorText}</div>` : nothing}`;
}
}
@@ -9,6 +9,7 @@ import "./onboarding-memory-import.ts";
type OnboardingMemoryImportElement = HTMLElement & {
active: boolean;
context: ApplicationContext<RouteId>;
requestUpdate: () => void;
updateComplete: Promise<boolean>;
};
@@ -166,12 +167,18 @@ describe("OnboardingMemoryImport", () => {
it("waits for the agents list and triggers loading it", async () => {
const request = vi.fn();
const context = createContext(request, { agentsLoaded: false });
await mount(context);
const element = await mount(context);
await waitForOnboardingMemoryImport(() =>
expect(context.agents.ensureList).toHaveBeenCalledTimes(1),
);
expect(request).not.toHaveBeenCalled();
await Promise.resolve();
element.requestUpdate();
await waitForOnboardingMemoryImport(() =>
expect(context.agents.ensureList).toHaveBeenCalledTimes(2),
);
});
it("sets the guard after a successful plan with no offers", async () => {
@@ -181,10 +188,11 @@ describe("OnboardingMemoryImport", () => {
await waitForOnboardingMemoryImport(() =>
expect(sessionStorage.getItem(guardKey)).toBe("done"),
);
expect(request).toHaveBeenCalledWith("migrations.memory.plan", {
agentId: "research",
overwrite: false,
});
expect(request).toHaveBeenCalledWith(
"migrations.memory.plan",
{ agentId: "research", overwrite: false },
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(element.querySelector("openclaw-modal-dialog")).toBeNull();
});
+93 -90
View File
@@ -1,3 +1,4 @@
import { initialState, Task, TaskStatus } from "@lit/task";
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type {
@@ -5,7 +6,6 @@ import type {
MigrationsMemoryApplyResult,
MigrationsMemoryPlanResult,
} from "../../../packages/gateway-protocol/src/schema/migrations.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { RouteId } from "../app-routes.ts";
import type { ApplicationContext } from "../app/context.ts";
import { hasOperatorAdminAccess } from "../app/operator-access.ts";
@@ -71,17 +71,13 @@ class OnboardingMemoryImport extends OpenClawLightDomElement {
@property({ attribute: false }) context?: ApplicationContext<RouteId>;
@property({ type: Boolean }) active = false;
@state() private plan: MigrationsMemoryPlanResult | null = null;
@state() private selectedByProvider: Record<string, boolean> = {};
@state() private applyingProviderId: string | null = null;
@state() private results: Record<string, ProviderResult> = {};
@state() private done = false;
@state() private closed = false;
private agentsListRequest: ApplicationContext<RouteId>["agents"] | undefined;
private requestedClient: GatewayBrowserClient | null = null;
private requestedAgentId: string | null = null;
private planClient: GatewayBrowserClient | null = null;
private requestEpoch = 0;
private readonly subscriptions = new SubscriptionsController(this)
.watch(
() => this.context?.gateway,
@@ -96,14 +92,85 @@ class OnboardingMemoryImport extends OpenClawLightDomElement {
(selection, notify) => selection.subscribe(notify),
);
private readonly planTask = new Task(this, {
args: () => {
const snapshot = this.context?.gateway.snapshot;
return [
this.active,
this.closed,
guardIsDone(),
this.isConnected && snapshot?.phase === "connected" ? (snapshot.client ?? null) : null,
snapshot ? hasOperatorAdminAccess(snapshot.hello?.auth ?? null) : false,
this.currentAgentId(),
] as const;
},
task: async ([active, closed, guarded, client, admin, agentId], { signal }) => {
if (
!active ||
closed ||
guarded ||
!client ||
!admin ||
!agentId ||
this.applyingProviderId !== null ||
this.done
) {
return initialState;
}
const plan = await client.request<MigrationsMemoryPlanResult>(
"migrations.memory.plan",
{ agentId, overwrite: false },
{ signal },
);
if (plan.agentId !== agentId) {
return initialState;
}
if (
offeredProviders(plan).length === 0 &&
plan.providers.some((provider) => provider.error)
) {
return initialState;
}
return { client, agentId, plan };
},
onComplete: ({ plan }) => {
const providers = offeredProviders(plan);
if (providers.length === 0) {
if (!plan.providers.some((provider) => provider.error)) {
setGuardDone();
this.closed = true;
}
return;
}
this.results = {};
this.done = false;
this.selectedByProvider = Object.fromEntries(
providers.map((provider) => [provider.providerId, true]),
);
},
});
override disconnectedCallback() {
this.requestEpoch += 1;
void this.planTask.run([false, true, true, null, false, null]);
this.subscriptions.clear();
super.disconnectedCallback();
}
protected override updated() {
void this.loadOfferIfReady();
if (this.context?.agents.state.agentsList) {
this.agentsListRequest = undefined;
} else if (this.context && this.agentsListRequest !== this.context.agents) {
const agents = this.context.agents;
this.agentsListRequest = agents;
void agents
.ensureList()
.catch(() => null)
.then(() => {
if (this.context?.agents === agents && !agents.state.agentsList) {
this.agentsListRequest = undefined;
}
});
}
}
private currentAgentId(): string | null {
@@ -118,87 +185,18 @@ class OnboardingMemoryImport extends OpenClawLightDomElement {
return list.defaultId ?? list.agents[0]?.id ?? null;
}
private async loadOfferIfReady() {
const context = this.context;
if (!this.active || this.closed || guardIsDone() || !context) {
return;
}
// A displayed offer is frozen to its gateway client and agent. If either
// changes while idle, drop the stale offer so an apply can never target
// the previously selected workspace.
if (this.plan && this.applyingProviderId === null && !this.done) {
const bindingClient = context.gateway.snapshot.client;
const bindingAgent = this.currentAgentId();
if (this.planClient !== bindingClient || this.plan.agentId !== bindingAgent) {
this.plan = null;
this.planClient = null;
this.requestedClient = null;
this.requestedAgentId = null;
this.selectedByProvider = {};
this.results = {};
}
}
if (this.plan || this.applyingProviderId !== null || this.done) {
return;
}
const snapshot = context.gateway.snapshot;
if (
snapshot?.phase !== "connected" ||
!snapshot.client ||
!hasOperatorAdminAccess(snapshot.hello?.auth ?? null)
) {
return;
}
if (!context.agents.state.agentsList) {
void context.agents.ensureList();
return;
}
private get planBinding() {
const value = this.planTask.value;
const snapshot = this.context?.gateway.snapshot;
const agentId = this.currentAgentId();
if (
!agentId ||
(this.requestedClient === snapshot.client && this.requestedAgentId === agentId)
) {
return;
if (this.planTask.status !== TaskStatus.COMPLETE || !value) {
return null;
}
return value.client === snapshot?.client && value.agentId === agentId ? value : null;
}
const client = snapshot.client;
const epoch = ++this.requestEpoch;
this.requestedClient = client;
this.requestedAgentId = agentId;
this.plan = null;
this.planClient = null;
this.results = {};
this.done = false;
try {
const plan = await client.request<MigrationsMemoryPlanResult>("migrations.memory.plan", {
agentId,
overwrite: false,
});
if (
epoch !== this.requestEpoch ||
plan.agentId !== agentId ||
this.context?.gateway.snapshot.client !== client ||
this.currentAgentId() !== agentId
) {
return;
}
const providers = offeredProviders(plan);
if (providers.length === 0) {
if (plan.providers.some((provider) => provider.error)) {
return;
}
setGuardDone();
this.closed = true;
return;
}
this.planClient = client;
this.plan = plan;
this.selectedByProvider = Object.fromEntries(
providers.map((provider) => [provider.providerId, true]),
);
} catch {
// Transient planning failures stay silent and unguarded so a reload can retry.
}
private get plan(): MigrationsMemoryPlanResult | null {
return this.planBinding?.plan ?? null;
}
private toggleProvider(providerId: string, selected: boolean) {
@@ -207,9 +205,10 @@ class OnboardingMemoryImport extends OpenClawLightDomElement {
private async importSelected() {
const context = this.context;
const plan = this.plan;
const client = this.planClient;
const agentId = plan?.agentId;
const binding = this.planBinding;
const plan = binding?.plan;
const client = binding?.client;
const agentId = binding?.agentId;
if (!context || !client || !plan || !agentId || this.applyingProviderId !== null || this.done) {
return;
}
@@ -271,7 +270,11 @@ class OnboardingMemoryImport extends OpenClawLightDomElement {
}
}
this.applyingProviderId = null;
this.done = true;
this.done =
this.context?.gateway.snapshot.client === client && this.currentAgentId() === agentId;
if (!this.done) {
void this.planTask.run();
}
}
private finish() {
-8
View File
@@ -18,7 +18,6 @@ import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gatewa
export type PluginCatalogItem = PluginCatalogEntry;
export type PluginListResult = ProtocolPluginsListResult;
export type PluginSearchResult = ProtocolPluginsSearchResult["results"][number];
type PluginSearchResponse = ProtocolPluginsSearchResult;
export type PluginInstallRequest = PluginsInstallParams;
export type PluginMutationResult = PluginsInstallResult | PluginsSetEnabledResult;
type PluginUninstallResult = PluginsUninstallResult;
@@ -29,13 +28,6 @@ export function loadPluginCatalog(client: GatewayBrowserClient): Promise<PluginL
return client.request<PluginListResult>("plugins.list", {});
}
export function searchPluginCatalog(
client: GatewayBrowserClient,
query: string,
): Promise<PluginSearchResponse> {
return client.request<PluginSearchResponse>("plugins.search", { query, limit: 20 });
}
export function installPlugin(
client: GatewayBrowserClient,
request: PluginInstallRequest,
@@ -0,0 +1,58 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SessionsDiffResult } from "../../../../../packages/gateway-protocol/src/index.js";
import type { SessionDiffLoader } from "./session-diff-panel.ts";
import "./session-diff-panel.ts";
type SessionDiffElement = HTMLElement & {
loader: SessionDiffLoader | null;
readonly updateComplete: Promise<boolean>;
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function result(branch: string): SessionsDiffResult {
return {
sessionKey: "agent:main:test",
branch,
baseRef: "main",
files: [],
additions: 0,
deletions: 0,
};
}
afterEach(() => {
document.body.replaceChildren();
});
describe("SessionDiffPanel", () => {
it("commits only the latest loader result after a rapid loader change", async () => {
const first = deferred<SessionsDiffResult>();
const second = deferred<SessionsDiffResult>();
const firstLoader = vi.fn(() => first.promise);
const secondLoader = vi.fn(() => second.promise);
const panel = document.createElement("openclaw-session-diff") as SessionDiffElement;
panel.loader = firstLoader;
document.body.append(panel);
await vi.waitFor(() => expect(firstLoader).toHaveBeenCalledOnce());
panel.loader = secondLoader;
await vi.waitFor(() => expect(secondLoader).toHaveBeenCalledOnce());
second.resolve(result("feature/latest"));
await vi.waitFor(() => expect(panel.textContent).toContain("feature/latest"));
first.resolve(result("feature/stale"));
await panel.updateComplete;
expect(panel.textContent).toContain("feature/latest");
expect(panel.textContent).not.toContain("feature/stale");
});
});
@@ -1,3 +1,4 @@
import { Task, TaskStatus } from "@lit/task";
// Session diff panel: renders the sessions.diff RPC result (branch +
// working-tree changes per file) inside the chat detail sidebar.
import { html, nothing, type TemplateResult } from "lit";
@@ -20,6 +21,11 @@ type FileView = {
parsed: ParsedFilePatch | null;
};
type SessionDiffTaskResult = {
result: SessionsDiffResult;
views: FileView[];
};
function statusLabel(file: SessionDiffFile): string {
switch (file.status) {
case "added":
@@ -36,55 +42,38 @@ function statusLabel(file: SessionDiffFile): string {
class SessionDiffPanel extends OpenClawLightDomElement {
@property({ attribute: false }) loader: SessionDiffLoader | null = null;
@state() private result: SessionsDiffResult | null = null;
@state() private views: FileView[] = [];
@state() private loading = false;
@state() private error: string | null = null;
@state() private collapsedPaths = new Set<string>();
private requestVersion = 0;
private readonly diffTask = new Task(this, {
args: () => [this.loader] as const,
task: async ([loader]): Promise<SessionDiffTaskResult | null> => {
if (!loader) {
return null;
}
const result = await loader();
return {
result,
views: result.files.map((file) => ({
file,
parsed: file.patch
? parseSessionDiffPatch(file.patch, (count) =>
t("chat.sessionDiff.unmodifiedLines", { count: String(count) }),
)
: null,
})),
};
},
onComplete: () => {
this.collapsedPaths = new Set<string>();
},
});
protected override updated(changed: Map<string, unknown>) {
if (changed.has("loader")) {
void this.refresh();
}
private get loading(): boolean {
return this.diffTask.status === TaskStatus.PENDING;
}
private async refresh(): Promise<void> {
const loader = this.loader;
const version = ++this.requestVersion;
if (!loader) {
this.result = null;
this.views = [];
return;
}
this.loading = true;
this.error = null;
try {
const result = await loader();
if (version !== this.requestVersion) {
return;
}
this.result = result;
this.views = result.files.map((file) => ({
file,
parsed: file.patch
? parseSessionDiffPatch(file.patch, (count) =>
t("chat.sessionDiff.unmodifiedLines", { count: String(count) }),
)
: null,
}));
this.collapsedPaths = new Set<string>();
} catch (error) {
if (version !== this.requestVersion) {
return;
}
this.error = error instanceof Error ? error.message : String(error);
} finally {
if (version === this.requestVersion) {
this.loading = false;
}
}
private refresh(): Promise<void> {
return this.diffTask.run();
}
private toggleFile(path: string): void {
@@ -174,13 +163,17 @@ class SessionDiffPanel extends OpenClawLightDomElement {
}
private renderBody(): TemplateResult {
if (this.error) {
return html`<div class="callout danger">${this.error}</div>`;
if (this.diffTask.status === TaskStatus.ERROR) {
const error = this.diffTask.error;
return html`<div class="callout danger">
${error instanceof Error ? error.message : String(error)}
</div>`;
}
const result = this.result;
if (!result) {
const value = this.diffTask.value;
if (!value) {
return html`<div class="session-diff__note">${t("chat.sessionDiff.loading")}</div>`;
}
const { result, views } = value;
if (result.unavailableReason === "not_git") {
return html`<div class="session-diff__note">${t("chat.sessionDiff.notGit")}</div>`;
}
@@ -191,7 +184,7 @@ class SessionDiffPanel extends OpenClawLightDomElement {
${this.renderSummary(result)}
${result.files.length === 0
? html`<div class="session-diff__note">${t("chat.sessionDiff.empty")}</div>`
: this.views.map((view) => this.renderFile(view))}
: views.map((view) => this.renderFile(view))}
${result.truncated === true
? html`<div class="session-diff__note">${t("chat.sessionDiff.truncatedResult")}</div>`
: nothing}
@@ -0,0 +1,194 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest";
import {
expectedEmbedUrl,
mount,
resetDiscussionPanelTestState,
type SessionDiscussionInfoLoader,
type SessionDiscussionOpener,
type SessionDiscussionStateListener,
} from "./session-discussion-panel.test-support.ts";
afterEach(resetDiscussionPanelTestState);
describe("session discussion panel", () => {
it("shows the opening affordance while auto-open is in flight", async () => {
const openDiscussion = vi
.fn<SessionDiscussionOpener>()
.mockImplementation(() => new Promise(() => {}));
const panel = mount({
loadInfo: vi.fn().mockResolvedValue({ state: "available" }),
openDiscussion,
});
await vi.waitFor(() => {
expect(openDiscussion).toHaveBeenCalledTimes(1);
expect(panel.textContent).toContain("Opening discussion");
});
expect(panel.querySelector("button")).toBeNull();
});
it("does not auto-open without operator write access", async () => {
const openDiscussion = vi.fn<SessionDiscussionOpener>();
const panel = mount({
loadInfo: vi.fn().mockResolvedValue({ state: "available" }),
openDiscussion,
canOpen: false,
});
await vi.waitFor(() => {
expect(panel.textContent).toContain("Operator write access is required");
});
expect(openDiscussion).not.toHaveBeenCalled();
expect(panel.querySelector("button")).toBeNull();
});
it("opens once write access is granted after the discussion resolved", async () => {
const openDiscussion = vi.fn<SessionDiscussionOpener>().mockResolvedValue({
state: "open",
embedUrl: "https://clack.example.com/embed/channel/T1/C1",
});
const panel = mount({
loadInfo: vi.fn().mockResolvedValue({ state: "available" }),
openDiscussion,
canOpen: false,
});
await vi.waitFor(() => {
expect(panel.textContent).toContain("Operator write access is required");
});
expect(openDiscussion).not.toHaveBeenCalled();
panel.canOpen = true;
await vi.waitFor(() => expect(openDiscussion).toHaveBeenCalledTimes(1));
});
it("refetches on session switch and reports a hidden discussion", async () => {
const loadInfo = vi
.fn<SessionDiscussionInfoLoader>()
.mockResolvedValueOnce({ state: "available" })
.mockResolvedValueOnce({ state: "none" });
const onStateChange = vi.fn<SessionDiscussionStateListener>();
const panel = mount({ loadInfo, openDiscussion: vi.fn(), onStateChange });
await vi.waitFor(() => expect(loadInfo).toHaveBeenCalledTimes(1));
panel.sessionKey = "agent:main:second";
await vi.waitFor(() => {
expect(loadInfo).toHaveBeenNthCalledWith(2, "agent:main:second");
expect(onStateChange).toHaveBeenLastCalledWith("agent:main:second", "none", null);
});
expect(panel.querySelector("button")).toBeNull();
expect(panel.querySelector("iframe")).toBeNull();
});
it("replaces source-owned content when the gateway generation changes", async () => {
const loadInfo = vi
.fn<SessionDiscussionInfoLoader>()
.mockResolvedValueOnce({
state: "open",
embedUrl: "https://old.example/embed/thread",
})
.mockResolvedValueOnce({
state: "open",
embedUrl: "https://new.example/embed/thread",
});
const panel = mount({ loadInfo, openDiscussion: vi.fn() });
await vi.waitFor(() => {
expect(panel.querySelector("iframe")?.getAttribute("src")).toBe(
expectedEmbedUrl("https://old.example/embed/thread"),
);
});
panel.sourceGeneration += 1;
await vi.waitFor(() => {
expect(panel.querySelector("iframe")?.getAttribute("src")).toBe(
expectedEmbedUrl("https://new.example/embed/thread"),
);
});
expect(loadInfo).toHaveBeenCalledTimes(2);
});
it("ignores an in-flight open result after the session changes", async () => {
let resolveFirstOpen: ((value: { state: "open"; embedUrl: string }) => void) | undefined;
const loadInfo = vi
.fn<SessionDiscussionInfoLoader>()
.mockResolvedValueOnce({ state: "available" })
.mockResolvedValueOnce({ state: "none" });
const openDiscussion = vi.fn<SessionDiscussionOpener>().mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirstOpen = resolve;
}),
);
const panel = mount({ loadInfo, openDiscussion });
await vi.waitFor(() => expect(openDiscussion).toHaveBeenCalledTimes(1));
panel.sessionKey = "agent:main:second";
await vi.waitFor(() => {
expect(loadInfo).toHaveBeenCalledTimes(2);
});
resolveFirstOpen?.({ state: "open", embedUrl: "https://discussion.example/stale" });
await panel.updateComplete;
expect(openDiscussion).toHaveBeenCalledTimes(1);
expect(panel.querySelector("iframe")).toBeNull();
expect(panel.textContent).not.toContain("Opening discussion");
});
it("does not auto-open a superseded available resolution", async () => {
let resolveFirstLoad: ((value: { state: "available" }) => void) | undefined;
const loadInfo = vi
.fn<SessionDiscussionInfoLoader>()
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirstLoad = resolve;
}),
)
.mockResolvedValueOnce({ state: "none" });
const openDiscussion = vi.fn<SessionDiscussionOpener>();
const panel = mount({ loadInfo, openDiscussion });
await vi.waitFor(() => expect(loadInfo).toHaveBeenCalledTimes(1));
panel.sessionKey = "agent:main:second";
await vi.waitFor(() => expect(loadInfo).toHaveBeenCalledTimes(2));
resolveFirstLoad?.({ state: "available" });
await panel.updateComplete;
expect(openDiscussion).not.toHaveBeenCalled();
});
it("does not open after the available-state callback revokes write access", async () => {
const openDiscussion = vi.fn<SessionDiscussionOpener>();
const panel = mount({
loadInfo: vi.fn().mockResolvedValue({ state: "available" }),
openDiscussion,
onStateChange: () => {
panel.canOpen = false;
},
});
await vi.waitFor(() => expect(panel.canOpen).toBe(false));
expect(openDiscussion).not.toHaveBeenCalled();
});
it("does not render non-HTTP discussion URLs", async () => {
const panel = mount({
loadInfo: vi.fn().mockResolvedValue({
state: "open",
embedUrl: "javascript:alert(1)",
openUrl: "data:text/html,unsafe",
}),
openDiscussion: vi.fn(),
});
await vi.waitFor(() => {
expect(panel.textContent).toContain("cannot be embedded");
});
expect(panel.querySelector("iframe")).toBeNull();
expect(panel.querySelector("a")).toBeNull();
});
});
@@ -0,0 +1,57 @@
import { vi } from "vitest";
import type { SessionDiscussionPanelConfig } from "./session-discussion-panel.ts";
import "./session-discussion-panel.ts";
export type SessionDiscussionInfoLoader = SessionDiscussionPanelConfig["loadInfo"];
export type SessionDiscussionOpener = SessionDiscussionPanelConfig["openDiscussion"];
export type SessionDiscussionStateListener = SessionDiscussionPanelConfig["onStateChange"];
type DiscussionPanelElement = HTMLElement & {
sessionKey: string;
canOpen: boolean;
sourceGeneration: number;
loadInfo: SessionDiscussionInfoLoader;
openDiscussion: SessionDiscussionOpener;
onStateChange: SessionDiscussionStateListener;
updateComplete: Promise<unknown>;
};
const panels: DiscussionPanelElement[] = [];
export function resetDiscussionPanelTestState(): void {
panels.splice(0).forEach((panel) => panel.remove());
document.documentElement.removeAttribute("data-theme");
document.documentElement.removeAttribute("data-theme-mode");
document.documentElement.removeAttribute("style");
vi.restoreAllMocks();
}
export function expectedEmbedUrl(url: string, mode: "light" | "dark" = "dark"): string {
const resolved = new URL(url);
if (
resolved.searchParams.get("openclawHostTheme") !== "1" ||
!/^\/embed\/(?:channel|thread)\/[^/]+\/[^/]+\/?$/u.test(resolved.pathname)
) {
return resolved.href;
}
resolved.searchParams.set("theme", mode);
resolved.searchParams.set("hostOrigin", window.location.origin);
return resolved.href;
}
export function mount(params: {
loadInfo: SessionDiscussionInfoLoader;
openDiscussion: SessionDiscussionOpener;
onStateChange?: SessionDiscussionStateListener;
canOpen?: boolean;
}): DiscussionPanelElement {
const panel = document.createElement("openclaw-session-discussion") as DiscussionPanelElement;
panel.sessionKey = "agent:main:first";
panel.loadInfo = params.loadInfo;
panel.openDiscussion = params.openDiscussion;
panel.onStateChange = params.onStateChange ?? vi.fn();
panel.canOpen = params.canOpen ?? true;
document.body.append(panel);
panels.push(panel);
return panel;
}
@@ -1,62 +1,16 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SessionDiscussionPanelConfig } from "./session-discussion-panel.ts";
import "./session-discussion-panel.ts";
import {
expectedEmbedUrl,
mount,
resetDiscussionPanelTestState,
type SessionDiscussionInfoLoader,
type SessionDiscussionOpener,
type SessionDiscussionStateListener,
} from "./session-discussion-panel.test-support.ts";
type SessionDiscussionInfoLoader = SessionDiscussionPanelConfig["loadInfo"];
type SessionDiscussionOpener = SessionDiscussionPanelConfig["openDiscussion"];
type SessionDiscussionStateListener = SessionDiscussionPanelConfig["onStateChange"];
type DiscussionPanelElement = HTMLElement & {
sessionKey: string;
canOpen: boolean;
sourceGeneration: number;
loadInfo: SessionDiscussionInfoLoader;
openDiscussion: SessionDiscussionOpener;
onStateChange: SessionDiscussionStateListener;
updateComplete: Promise<unknown>;
};
const panels: DiscussionPanelElement[] = [];
afterEach(() => {
panels.splice(0).forEach((panel) => panel.remove());
document.documentElement.removeAttribute("data-theme");
document.documentElement.removeAttribute("data-theme-mode");
document.documentElement.removeAttribute("style");
vi.restoreAllMocks();
});
function expectedEmbedUrl(url: string, mode: "light" | "dark" = "dark"): string {
const resolved = new URL(url);
if (
resolved.searchParams.get("openclawHostTheme") !== "1" ||
!/^\/embed\/(?:channel|thread)\/[^/]+\/[^/]+\/?$/u.test(resolved.pathname)
) {
return resolved.href;
}
resolved.searchParams.set("theme", mode);
resolved.searchParams.set("hostOrigin", window.location.origin);
return resolved.href;
}
function mount(params: {
loadInfo: SessionDiscussionInfoLoader;
openDiscussion: SessionDiscussionOpener;
onStateChange?: SessionDiscussionStateListener;
canOpen?: boolean;
}): DiscussionPanelElement {
const panel = document.createElement("openclaw-session-discussion") as DiscussionPanelElement;
panel.sessionKey = "agent:main:first";
panel.loadInfo = params.loadInfo;
panel.openDiscussion = params.openDiscussion;
panel.onStateChange = params.onStateChange ?? vi.fn();
panel.canOpen = params.canOpen ?? true;
document.body.append(panel);
panels.push(panel);
return panel;
}
afterEach(resetDiscussionPanelTestState);
describe("session discussion panel", () => {
it("automatically opens an available discussion without a redundant header", async () => {
@@ -285,169 +239,4 @@ describe("session discussion panel", () => {
expect(external?.target).toBe("_blank");
expect(external?.rel).toBe("noopener");
});
it("shows the opening affordance while auto-open is in flight", async () => {
const openDiscussion = vi
.fn<SessionDiscussionOpener>()
.mockImplementation(() => new Promise(() => {}));
const panel = mount({
loadInfo: vi.fn().mockResolvedValue({ state: "available" }),
openDiscussion,
});
await vi.waitFor(() => {
expect(openDiscussion).toHaveBeenCalledTimes(1);
expect(panel.textContent).toContain("Opening discussion");
});
expect(panel.querySelector("button")).toBeNull();
});
it("does not auto-open without operator write access", async () => {
const openDiscussion = vi.fn<SessionDiscussionOpener>();
const panel = mount({
loadInfo: vi.fn().mockResolvedValue({ state: "available" }),
openDiscussion,
canOpen: false,
});
await vi.waitFor(() => {
expect(panel.textContent).toContain("Operator write access is required");
});
expect(openDiscussion).not.toHaveBeenCalled();
expect(panel.querySelector("button")).toBeNull();
});
it("opens once write access is granted after the discussion resolved", async () => {
const openDiscussion = vi.fn<SessionDiscussionOpener>().mockResolvedValue({
state: "open",
embedUrl: "https://clack.example.com/embed/channel/T1/C1",
});
const panel = mount({
loadInfo: vi.fn().mockResolvedValue({ state: "available" }),
openDiscussion,
canOpen: false,
});
await vi.waitFor(() => {
expect(panel.textContent).toContain("Operator write access is required");
});
expect(openDiscussion).not.toHaveBeenCalled();
panel.canOpen = true;
await vi.waitFor(() => expect(openDiscussion).toHaveBeenCalledTimes(1));
});
it("refetches on session switch and reports a hidden discussion", async () => {
const loadInfo = vi
.fn<SessionDiscussionInfoLoader>()
.mockResolvedValueOnce({ state: "available" })
.mockResolvedValueOnce({ state: "none" });
const onStateChange = vi.fn<SessionDiscussionStateListener>();
const panel = mount({ loadInfo, openDiscussion: vi.fn(), onStateChange });
await vi.waitFor(() => expect(loadInfo).toHaveBeenCalledTimes(1));
panel.sessionKey = "agent:main:second";
await vi.waitFor(() => {
expect(loadInfo).toHaveBeenNthCalledWith(2, "agent:main:second");
expect(onStateChange).toHaveBeenLastCalledWith("agent:main:second", "none", null);
});
expect(panel.querySelector("button")).toBeNull();
expect(panel.querySelector("iframe")).toBeNull();
});
it("replaces source-owned content when the gateway generation changes", async () => {
const loadInfo = vi
.fn<SessionDiscussionInfoLoader>()
.mockResolvedValueOnce({
state: "open",
embedUrl: "https://old.example/embed/thread",
})
.mockResolvedValueOnce({
state: "open",
embedUrl: "https://new.example/embed/thread",
});
const panel = mount({ loadInfo, openDiscussion: vi.fn() });
await vi.waitFor(() => {
expect(panel.querySelector("iframe")?.getAttribute("src")).toBe(
expectedEmbedUrl("https://old.example/embed/thread"),
);
});
panel.sourceGeneration += 1;
await vi.waitFor(() => {
expect(panel.querySelector("iframe")?.getAttribute("src")).toBe(
expectedEmbedUrl("https://new.example/embed/thread"),
);
});
expect(loadInfo).toHaveBeenCalledTimes(2);
});
it("ignores an in-flight open result after the session changes", async () => {
let resolveFirstOpen: ((value: { state: "open"; embedUrl: string }) => void) | undefined;
const loadInfo = vi
.fn<SessionDiscussionInfoLoader>()
.mockResolvedValueOnce({ state: "available" })
.mockResolvedValueOnce({ state: "none" });
const openDiscussion = vi.fn<SessionDiscussionOpener>().mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirstOpen = resolve;
}),
);
const panel = mount({ loadInfo, openDiscussion });
await vi.waitFor(() => expect(openDiscussion).toHaveBeenCalledTimes(1));
panel.sessionKey = "agent:main:second";
await vi.waitFor(() => {
expect(loadInfo).toHaveBeenCalledTimes(2);
});
resolveFirstOpen?.({ state: "open", embedUrl: "https://discussion.example/stale" });
await panel.updateComplete;
expect(openDiscussion).toHaveBeenCalledTimes(1);
expect(panel.querySelector("iframe")).toBeNull();
expect(panel.textContent).not.toContain("Opening discussion");
});
it("does not auto-open a superseded available resolution", async () => {
let resolveFirstLoad: ((value: { state: "available" }) => void) | undefined;
const loadInfo = vi
.fn<SessionDiscussionInfoLoader>()
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirstLoad = resolve;
}),
)
.mockResolvedValueOnce({ state: "none" });
const openDiscussion = vi.fn<SessionDiscussionOpener>();
const panel = mount({ loadInfo, openDiscussion });
await vi.waitFor(() => expect(loadInfo).toHaveBeenCalledTimes(1));
panel.sessionKey = "agent:main:second";
await vi.waitFor(() => expect(loadInfo).toHaveBeenCalledTimes(2));
resolveFirstLoad?.({ state: "available" });
await panel.updateComplete;
expect(openDiscussion).not.toHaveBeenCalled();
});
it("does not render non-HTTP discussion URLs", async () => {
const panel = mount({
loadInfo: vi.fn().mockResolvedValue({
state: "open",
embedUrl: "javascript:alert(1)",
openUrl: "data:text/html,unsafe",
}),
openDiscussion: vi.fn(),
});
await vi.waitFor(() => {
expect(panel.textContent).toContain("cannot be embedded");
});
expect(panel.querySelector("iframe")).toBeNull();
expect(panel.querySelector("a")).toBeNull();
});
});
@@ -1,3 +1,4 @@
import { initialState, Task, TaskStatus } from "@lit/task";
import { html, nothing, type TemplateResult } from "lit";
import { property, state } from "lit/decorators.js";
import type {
@@ -16,6 +17,19 @@ type SessionDiscussionStateListener = (
openUrl: string | null,
) => void;
type SessionDiscussionTaskResult = {
sessionKey: string;
info: SessionDiscussionInfo;
};
type OpeningDiscussion = {
sessionKey: string;
loader: SessionDiscussionInfoLoader;
opener: SessionDiscussionOpener | null;
sourceGeneration: number;
canOpen: boolean;
};
export type SessionDiscussionPanelConfig = {
sessionKey: string;
canOpen: boolean;
@@ -80,14 +94,70 @@ class SessionDiscussionPanel extends OpenClawLightDomElement {
@property({ type: Boolean }) canOpen = true;
@property({ type: Number }) sourceGeneration = 0;
@state() private info: SessionDiscussionInfo | null = null;
@state() private loading = false;
@state() private opening = false;
@state() private error: string | null = null;
private requestVersion = 0;
@state() private openingDiscussion: OpeningDiscussion | null = null;
private themeObserver: MutationObserver | null = null;
private readonly discussionTask = new Task(this, {
args: () =>
[
this.sessionKey.trim(),
this.loadInfo,
this.openDiscussion,
this.sourceGeneration,
this.canOpen,
] as const,
task: async ([sessionKey, loader, opener, _sourceGeneration, canOpen], { signal }) => {
if (!loader || !sessionKey) {
return null;
}
const loaded = await loader(sessionKey);
signal.throwIfAborted();
const opening = {
sessionKey,
loader,
opener,
sourceGeneration: _sourceGeneration,
canOpen,
};
if (!this.isOpeningCurrent(opening)) {
return initialState;
}
let info = loaded;
if (loaded.state === "available" && canOpen && opener) {
this.openingDiscussion = opening;
this.publish(sessionKey, loaded);
if (!this.isOpeningCurrent(opening)) {
return initialState;
}
info = (await opener(sessionKey)) ?? loaded;
}
signal.throwIfAborted();
if (!this.isOpeningCurrent(opening)) {
return initialState;
}
return { sessionKey, info } satisfies SessionDiscussionTaskResult;
},
onComplete: (result) => {
this.openingDiscussion = null;
if (result) {
this.publish(result.sessionKey, result.info);
}
},
onError: () => {
this.openingDiscussion = null;
},
});
private isOpeningCurrent(opening: OpeningDiscussion): boolean {
return (
opening.sessionKey === this.sessionKey.trim() &&
opening.loader === this.loadInfo &&
opening.opener === this.openDiscussion &&
opening.sourceGeneration === this.sourceGeneration &&
opening.canOpen === this.canOpen
);
}
override connectedCallback(): void {
super.connectedCallback();
if (typeof MutationObserver === "undefined") {
@@ -122,89 +192,16 @@ class SessionDiscussionPanel extends OpenClawLightDomElement {
postWidgetTheme(frame, new URL(frame.src).origin);
}
private isCurrentRequest(sessionKey: string, version: number): boolean {
return version === this.requestVersion && sessionKey === this.sessionKey.trim();
}
protected override updated(changed: Map<string, unknown>) {
if (changed.has("sessionKey") || changed.has("loadInfo") || changed.has("sourceGeneration")) {
void this.refresh();
return;
}
// Gaining write access after an available discussion resolved must still
// open it: refresh() already ran, and without the removed manual button
// nothing else would ever call the opener.
if (changed.has("canOpen") && this.canOpen && this.info?.state === "available") {
void this.open(this.sessionKey.trim(), this.requestVersion);
}
}
// requestKey is the key the request was issued for; the sessionKey property
// may already name the next session while an old result resolves, and a
// stale result must not be attributed to (or close the panel of) the new one.
// may already name the next session while an old result resolves. Task only
// calls onComplete for the latest args, so stale results never publish.
private publish(requestKey: string, info: SessionDiscussionInfo): void {
if (requestKey !== this.sessionKey.trim()) {
return;
}
this.info = info;
this.onStateChange?.(requestKey, info.state, resolveDiscussionUrl(info.openUrl));
}
private async refresh(): Promise<void> {
const loader = this.loadInfo;
const sessionKey = this.sessionKey.trim();
const version = ++this.requestVersion;
this.info = null;
this.error = null;
this.opening = false;
if (!loader || !sessionKey) {
this.loading = false;
return;
}
this.loading = true;
try {
const info = await loader(sessionKey);
if (this.isCurrentRequest(sessionKey, version)) {
this.publish(sessionKey, info);
this.loading = false;
if (info.state === "available" && this.canOpen) {
await this.open(sessionKey, version);
}
}
} catch (error) {
if (this.isCurrentRequest(sessionKey, version)) {
this.error = error instanceof Error ? error.message : String(error);
}
} finally {
if (this.isCurrentRequest(sessionKey, version)) {
this.loading = false;
}
}
}
private async open(sessionKey: string, version: number): Promise<void> {
const opener = this.openDiscussion;
if (!opener || !sessionKey || this.opening || !this.isCurrentRequest(sessionKey, version)) {
return;
}
this.opening = true;
this.error = null;
try {
const info = await opener(sessionKey);
if (this.isCurrentRequest(sessionKey, version)) {
this.publish(sessionKey, info);
}
} catch (error) {
if (this.isCurrentRequest(sessionKey, version)) {
this.error = error instanceof Error ? error.message : String(error);
}
} finally {
if (this.isCurrentRequest(sessionKey, version)) {
this.opening = false;
}
}
}
// The iframe sandbox must include allow-same-origin: without it the frame
// gets an opaque origin, the discussion app's session cookie is never sent,
// and the embed is stuck on its sign-in card.
@@ -236,27 +233,39 @@ class SessionDiscussionPanel extends OpenClawLightDomElement {
}
override render() {
if (this.error) {
if (this.discussionTask.status === TaskStatus.ERROR) {
const error = this.discussionTask.error;
return html`<div class="session-discussion__empty">
<div class="callout danger">${this.error}</div>
<div class="callout danger">${error instanceof Error ? error.message : String(error)}</div>
</div>`;
}
if (this.loading || !this.info) {
const value = this.discussionTask.value;
if (
this.discussionTask.status === TaskStatus.PENDING &&
this.openingDiscussion &&
this.isOpeningCurrent(this.openingDiscussion)
) {
return html`<div class="session-discussion__empty">
${t("chat.sessionDiscussion.opening")}
</div>`;
}
if (this.discussionTask.status !== TaskStatus.COMPLETE || !value) {
return html`<div class="session-discussion__empty">
${t("chat.sessionDiscussion.loading")}
</div>`;
}
if (this.info.state === "none") {
const { info } = value;
if (info.state === "none") {
return nothing;
}
if (this.info.state === "available") {
if (info.state === "available") {
return html`<div class="session-discussion__empty">
${this.canOpen
? t("chat.sessionDiscussion.opening")
: t("chat.sessionDiscussion.requiresWriteAccess")}
</div>`;
}
return this.renderOpen(this.info);
return this.renderOpen(info);
}
}
@@ -237,6 +237,9 @@ describe("gateway source replacement across reconnect with a reused client", ()
document.body.append(page);
await page.updateComplete;
await waitForFast(() =>
expect(request).toHaveBeenCalledWith("sessions.usage", expect.any(Object)),
);
await waitForFast(() => expect(page.usageResult).toBe(freshResult));
expect(request).toHaveBeenCalledWith("sessions.usage", expect.any(Object));
@@ -288,15 +291,23 @@ describe("gateway source replacement across reconnect with a reused client", ()
it("retries a usage load interrupted by a same-client disconnect", async () => {
vi.spyOn(document, "hasFocus").mockReturnValue(true);
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
const request = vi.fn(async (method: string) =>
method === "sessions.usage" ? { sessions: [] } : {},
);
const interrupted = deferred<UsageRouteData["result"]>();
const freshResult = { sessions: [{ key: "fresh" }] } as unknown as UsageRouteData["result"];
let usageRequestCount = 0;
const request = vi.fn(async (method: string) => {
if (method !== "sessions.usage") {
return {};
}
usageRequestCount += 1;
return usageRequestCount === 1 ? interrupted.promise : freshResult;
});
const client = { request } as unknown as GatewayBrowserClient;
const context = contextWithClient(client, { connected: true });
const page = createPage("openclaw-usage-page", context) as TestPage & {
routeData: UsageRouteData;
usageLoading: boolean;
usageResult: UsageRouteData["result"];
refreshRuntime: {
request: (reason: UsageRefreshReason) => void;
applyGatewaySnapshot: (snapshot: ApplicationGatewaySnapshot) => void;
};
};
@@ -319,7 +330,8 @@ describe("gateway source replacement across reconnect with a reused client", ()
document.body.append(page);
await page.updateComplete;
page.usageLoading = true;
page.refreshRuntime.request("manual");
await waitForFast(() => expect(usageRequestCount).toBe(1));
page.refreshRuntime.applyGatewaySnapshot({
...context.gateway.snapshot,
phase: "stopped",
@@ -327,8 +339,13 @@ describe("gateway source replacement across reconnect with a reused client", ()
page.refreshRuntime.applyGatewaySnapshot(context.gateway.snapshot);
await waitForFast(() =>
expect(request).toHaveBeenCalledWith("sessions.usage", expect.any(Object)),
expect(request.mock.calls.filter(([method]) => method === "sessions.usage")).toHaveLength(2),
);
await waitForFast(() => expect(page.usageResult).toBe(freshResult));
interrupted.resolve({ sessions: [{ key: "stale" }] } as unknown as UsageRouteData["result"]);
await Promise.resolve();
await Promise.resolve();
expect(page.usageResult).toBe(freshResult);
});
it("gates same-client usage reconnects by payload age and page visibility", async () => {
@@ -348,7 +365,7 @@ describe("gateway source replacement across reconnect with a reused client", ()
const result = { sessions: [] } as unknown as UsageRouteData["result"];
const page = createPage("openclaw-usage-page", harness.context) as TestPage & {
routeData: UsageRouteData;
usageLoading: boolean;
readonly usageLoading: boolean;
refreshRuntime: {
request: (reason: UsageRefreshReason) => void;
setLastLoadedAtMs: (value: number | null) => void;
@@ -394,7 +411,6 @@ describe("gateway source replacement across reconnect with a reused client", ()
"usage.status",
]);
page.usageLoading = true;
page.refreshRuntime.request("manual");
await waitForFast(() => expect(page.usageLoading).toBe(false));
expect(request).toHaveBeenCalledTimes(6);
@@ -325,7 +325,6 @@ describe("MemoryImportPage", () => {
);
context.gateway.snapshot.phase = "stopped";
context.gateway.snapshot.client = null;
page.requestUpdate();
await page.updateComplete;
await page.updateComplete;
+95 -118
View File
@@ -1,11 +1,11 @@
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { html } from "lit";
import { state } from "lit/decorators.js";
import type {
MigrationsMemoryApplyResult,
MigrationsMemoryPlanResult,
} from "../../../../packages/gateway-protocol/src/schema/migrations.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { titleForRoute } from "../../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
@@ -45,9 +45,6 @@ export class MemoryImportPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@state() private plan: MigrationsMemoryPlanResult | null = null;
@state() private loading = false;
@state() private error: string | null = null;
@state() private replaceExisting = false;
@state() private selectedByProvider: Record<string, string[]> = {};
@state() private applyingProviderId: string | null = null;
@@ -55,13 +52,13 @@ export class MemoryImportPage extends OpenClawLightDomElement {
@state() private applyError: string | null = null;
@state() private lastResults: Record<string, MigrationsMemoryApplyResult> = {};
private loadedKey: string | null = null;
private requestedKey: string | null = null;
private loadedClient: GatewayBrowserClient | null = null;
private requestedClient: GatewayBrowserClient | null = null;
private refreshEpoch = 0;
private applyEpoch = 0;
private gatewayUnavailable = false;
private lastPlanValue: {
client: NonNullable<ApplicationContext["gateway"]["snapshot"]["client"]>;
agentId: string;
overwrite: boolean;
plan: MigrationsMemoryPlanResult;
} | null = null;
private readonly subscriptions = new SubscriptionsController(this)
.watch(
() => this.context?.gateway,
@@ -76,8 +73,49 @@ export class MemoryImportPage extends OpenClawLightDomElement {
(selection, notify) => selection.subscribe(notify),
);
private readonly planTask = new Task(this, {
args: () => {
const snapshot = this.context?.gateway.snapshot;
return [
this.isConnected && snapshot?.phase === "connected" ? (snapshot.client ?? null) : null,
this.currentAgentId(),
this.replaceExisting,
] as const;
},
task: async ([client, agentId, overwrite], { signal }) => {
if (!client || !agentId) {
return initialState;
}
const plan = await client.request<MigrationsMemoryPlanResult>(
"migrations.memory.plan",
{ agentId, overwrite },
{ signal },
);
return { client, agentId, overwrite, plan };
},
onComplete: (value) => {
const previous = this.lastPlanValue;
if (
previous &&
(previous.client !== value.client ||
previous.agentId !== value.agentId ||
previous.overwrite !== value.overwrite)
) {
this.resetMutationState({ preserveAttemptedImport: previous.client !== value.client });
}
this.lastPlanValue = value;
const { plan } = value;
this.selectedByProvider = Object.fromEntries(
plan.providers.map((provider) => [
provider.providerId,
provider.items.filter((item) => item.status === "planned").map((item) => item.id),
]),
);
},
});
override disconnectedCallback() {
this.refreshEpoch += 1;
void this.planTask.run([null, null, this.replaceExisting]);
this.applyEpoch += 1;
this.subscriptions.clear();
super.disconnectedCallback();
@@ -85,39 +123,16 @@ export class MemoryImportPage extends OpenClawLightDomElement {
override updated() {
const snapshot = this.context.gateway.snapshot;
if (snapshot.phase !== "connected" || !snapshot.client) {
if (!this.gatewayUnavailable) {
this.gatewayUnavailable = true;
this.resetPlanState({ preserveAttemptedImport: true });
}
return;
}
this.gatewayUnavailable = false;
if (!this.context.agents.state.agentsList) {
void this.context.agents.ensureList();
return;
}
const agentId = this.currentAgentId();
if (!agentId) {
return;
}
const key = this.planKey(agentId);
const activeClient = this.requestedClient ?? this.loadedClient;
const activeKey = this.requestedKey ?? this.loadedKey;
if (
(activeClient !== null && activeClient !== snapshot.client) ||
(activeKey !== null && activeKey !== key)
) {
this.resetPlanState({
preserveAttemptedImport: activeClient !== null && activeClient !== snapshot.client,
});
}
if (
!this.loading &&
(this.loadedClient !== snapshot.client || this.loadedKey !== key) &&
(this.requestedClient !== snapshot.client || this.requestedKey !== key)
this.pendingImport &&
(snapshot.phase !== "connected" ||
snapshot.client !== (this.planTask.value ?? this.lastPlanValue)?.client ||
this.currentAgentId() !== this.pendingImport.agentId)
) {
void this.refresh();
this.resetMutationState({ preserveAttemptedImport: true });
}
}
@@ -136,89 +151,52 @@ export class MemoryImportPage extends OpenClawLightDomElement {
: (agents[0]?.id ?? null);
}
private planKey(agentId: string): string {
return `${agentId}:${this.replaceExisting ? "replace" : "safe"}`;
private get plan(): MigrationsMemoryPlanResult | null {
const value = this.planTask.value ?? this.lastPlanValue;
const snapshot = this.context.gateway.snapshot;
const agentId = this.currentAgentId();
return value &&
snapshot.phase === "connected" &&
value.client === snapshot.client &&
value.agentId === agentId &&
value.overwrite === this.replaceExisting
? value.plan
: null;
}
private resetPlanState(options: { preserveAttemptedImport?: boolean } = {}) {
private get loading(): boolean {
return this.planTask.status === TaskStatus.PENDING;
}
private get error(): string | null {
return this.planTask.status === TaskStatus.ERROR ? toErrorMessage(this.planTask.error) : null;
}
private resetMutationState(options: { preserveAttemptedImport?: boolean } = {}) {
// A disconnected apply has an unknown outcome. Keep its key so reconnect retries can
// recover the cached server result instead of repeating side effects.
const pendingImport =
options.preserveAttemptedImport && this.pendingImport?.attempted ? this.pendingImport : null;
this.refreshEpoch += 1;
this.applyEpoch += 1;
this.plan = null;
this.loading = false;
this.error = null;
this.selectedByProvider = {};
this.applyingProviderId = null;
this.pendingImport = pendingImport;
this.applyError = null;
this.lastResults = {};
this.loadedKey = null;
this.requestedKey = null;
this.loadedClient = null;
this.requestedClient = null;
}
private async refresh(force = false) {
const snapshot = this.context.gateway.snapshot;
const agentId = this.currentAgentId();
if (snapshot.phase !== "connected" || !snapshot.client || !agentId || this.loading) {
return;
}
const client = snapshot.client;
const key = this.planKey(agentId);
if (!force && this.loadedClient === client && this.loadedKey === key) {
return;
}
const epoch = ++this.refreshEpoch;
this.requestedKey = key;
this.requestedClient = client;
this.loading = true;
this.error = null;
try {
const plan = await client.request<MigrationsMemoryPlanResult>("migrations.memory.plan", {
agentId,
overwrite: this.replaceExisting,
});
if (epoch !== this.refreshEpoch) {
return;
}
this.plan = plan;
this.loadedKey = key;
this.loadedClient = client;
this.selectedByProvider = Object.fromEntries(
plan.providers.map((provider) => [
provider.providerId,
provider.items.filter((item) => item.status === "planned").map((item) => item.id),
]),
);
} catch (error) {
if (epoch === this.refreshEpoch) {
this.error = toErrorMessage(error);
// Record the attempted key so reactive updates keep the stable error.
// The Refresh action explicitly retries with force=true.
this.loadedKey = key;
this.loadedClient = client;
}
} finally {
if (epoch === this.refreshEpoch) {
this.loading = false;
this.requestedKey = null;
this.requestedClient = null;
}
}
private refresh(): Promise<void> {
return this.planTask.run();
}
private selectAgent(agentId: string) {
this.context.agentSelection.set(agentId);
this.resetPlanState();
this.resetMutationState();
}
private setReplaceExisting(enabled: boolean) {
this.replaceExisting = enabled;
this.resetPlanState();
this.resetMutationState();
}
private toggleCollection(providerId: string, itemIds: readonly string[], selected: boolean) {
@@ -277,32 +255,31 @@ export class MemoryImportPage extends OpenClawLightDomElement {
return;
}
const attemptedImport = { ...pending, attempted: true };
const client = snapshot.client;
this.pendingImport = attemptedImport;
const applyEpoch = ++this.applyEpoch;
this.applyingProviderId = attemptedImport.providerId;
this.applyError = null;
try {
const result = await snapshot.client.request<MigrationsMemoryApplyResult>(
"migrations.memory.apply",
{
idempotencyKey: attemptedImport.idempotencyKey,
agentId: attemptedImport.agentId,
providerId: attemptedImport.providerId,
planFingerprint: attemptedImport.planFingerprint,
itemIds: attemptedImport.itemIds,
overwrite: attemptedImport.overwrite,
},
);
if (applyEpoch !== this.applyEpoch) {
const result = await client.request<MigrationsMemoryApplyResult>("migrations.memory.apply", {
idempotencyKey: attemptedImport.idempotencyKey,
agentId: attemptedImport.agentId,
providerId: attemptedImport.providerId,
planFingerprint: attemptedImport.planFingerprint,
itemIds: attemptedImport.itemIds,
overwrite: attemptedImport.overwrite,
});
if (
applyEpoch !== this.applyEpoch ||
this.context.gateway.snapshot.phase !== "connected" ||
this.context.gateway.snapshot.client !== client ||
this.currentAgentId() !== attemptedImport.agentId
) {
return;
}
this.lastResults = { ...this.lastResults, [attemptedImport.providerId]: result };
this.pendingImport = null;
this.loadedKey = null;
this.requestedKey = null;
this.loadedClient = null;
this.requestedClient = null;
await this.refresh(true);
await this.refresh();
} catch (error) {
if (applyEpoch === this.applyEpoch) {
this.applyError = toErrorMessage(error);
@@ -334,7 +311,7 @@ export class MemoryImportPage extends OpenClawLightDomElement {
lastResults: this.lastResults,
onSelectAgent: (nextAgentId) => this.selectAgent(nextAgentId),
onReplaceExisting: (enabled) => this.setReplaceExisting(enabled),
onRefresh: () => void this.refresh(true),
onRefresh: () => void this.refresh(),
onToggleCollection: (providerId, itemIds, selected) =>
this.toggleCollection(providerId, itemIds, selected),
onRequestImport: (providerId) => this.requestImport(providerId),
+116 -105
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { initialState, Task } from "@lit/task";
import { html, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
@@ -49,6 +50,21 @@ function errorMessage(error: unknown): string {
return typeof error === "string" && error.trim() ? error : t("modelSetup.errors.requestFailed");
}
type BoundModelResult<T> =
| { client: GatewayBrowserClient; value: T }
| { client: GatewayBrowserClient; error: unknown };
async function captureModelResult<T>(
client: GatewayBrowserClient,
load: () => Promise<T>,
): Promise<BoundModelResult<T>> {
try {
return { client, value: await load() };
} catch (error) {
return { client, error };
}
}
export class ModelSetupPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@@ -69,12 +85,6 @@ export class ModelSetupPage extends OpenClawLightDomElement {
private observedClient: GatewayBrowserClient | null = null;
private dataClient: GatewayBrowserClient | null = null;
private detectAbort: AbortController | null = null;
private activationAbort: AbortController | null = null;
private verifyAbort: AbortController | null = null;
private detectEpoch = 0;
private activationEpoch = 0;
private verifyEpoch = 0;
private readonly iconMisses = new Set<string>();
private readonly iconRequests = new Map<
string,
@@ -99,10 +109,96 @@ export class ModelSetupPage extends OpenClawLightDomElement {
sessionExpiredMessage: () => t("modelSetup.wizard.sessionExpired"),
});
private readonly detectTask = new Task<
readonly [GatewayBrowserClient | null, object | null],
BoundModelResult<SystemAgentSetupDetectResult> & { token: object }
>(this, {
autoRun: false,
args: () => {
const client = this.context?.gateway.snapshot.client ?? null;
return [this.canUseSetup(client) ? client : null, null] as const;
},
task: async ([client, token], { signal }) =>
client && token
? { ...(await captureModelResult(client, () => detectModelSetup(client, signal))), token }
: initialState,
onComplete: (outcome) => {
if (this.context.gateway.snapshot.client !== outcome.client) {
return;
}
if ("error" in outcome) {
this.pageState = { phase: "detect-error", message: errorMessage(outcome.error) };
return;
}
this.pageState = { phase: "ready", result: outcome.value };
this.dataClient = outcome.client;
this.syncManualProvider(this.pageState);
},
});
private readonly activationTask = new Task<
readonly [GatewayBrowserClient | null, SystemAgentSetupActivateParams | null],
BoundModelResult<SystemAgentSetupActivateResult>
>(this, {
autoRun: false,
args: () => [null, null],
task: ([client, params], { signal }) =>
client && params
? captureModelResult(client, () =>
client.request<SystemAgentSetupActivateResult>("openclaw.setup.activate", params, {
timeoutMs: activationTimeoutForKind(params.kind),
signal,
}),
)
: initialState,
onComplete: (outcome) => {
const current = this.activationState;
if (current.phase !== "testing" || this.context.gateway.snapshot.client !== outcome.client) {
return;
}
if ("error" in outcome) {
this.activationState = {
phase: "failure",
targetId: current.targetId,
status: "unknown",
error: errorMessage(outcome.error),
};
return;
}
this.activationState = mapActivationResult({
result: outcome.value,
targetId: current.targetId,
fallbackError: t("modelSetup.errors.activationFailed"),
});
if (this.activationState.phase === "success") {
this.manualApiKey = "";
}
},
});
private readonly verifyTask = new Task<
readonly [GatewayBrowserClient | null],
BoundModelResult<Awaited<ReturnType<typeof verifyModelSetup>>>
>(this, {
autoRun: false,
args: () => [null],
task: ([client], { signal }) =>
client ? captureModelResult(client, () => verifyModelSetup(client, signal)) : initialState,
onComplete: (outcome) => {
if (this.context.gateway.snapshot.client !== outcome.client) {
return;
}
this.verifyState =
"error" in outcome
? { phase: "failed", status: "unknown", error: errorMessage(outcome.error) }
: mapVerifyResult(outcome.value);
},
});
override disconnectedCallback() {
this.detectAbort?.abort();
this.activationAbort?.abort();
this.verifyAbort?.abort();
void this.detectTask.run([null, null]);
void this.activationTask.run([null, null]);
void this.verifyTask.run([null]);
this.resetIcons();
void this.wizard.cancel();
this.subscriptions.clear();
@@ -125,12 +221,12 @@ export class ModelSetupPage extends OpenClawLightDomElement {
return;
}
this.observedClient = snapshot.client;
this.detectAbort?.abort();
this.activationAbort?.abort();
this.verifyAbort?.abort();
this.resetIcons();
void this.detectTask.run([null, null]);
this.activationState = { phase: "idle" };
void this.activationTask.run([null, null]);
this.verifyState = { phase: "idle" };
void this.verifyTask.run([null]);
this.resetIcons();
void this.wizard.cancel();
if (!snapshot.client || snapshot.client === this.dataClient) {
return;
@@ -300,35 +396,12 @@ export class ModelSetupPage extends OpenClawLightDomElement {
if (!this.canUseSetup(client)) {
return null;
}
const epoch = ++this.detectEpoch;
this.resetVerify();
this.detectAbort?.abort();
const abortController = new AbortController();
this.detectAbort = abortController;
this.pageState = { phase: "loading" };
try {
const result = await detectModelSetup(client, abortController.signal);
if (epoch !== this.detectEpoch || this.context.gateway.snapshot.client !== client) {
return null;
}
this.pageState = { phase: "ready", result };
this.dataClient = client;
this.syncManualProvider(this.pageState);
return result;
} catch (error) {
if (
epoch === this.detectEpoch &&
this.context.gateway.snapshot.client === client &&
!abortController.signal.aborted
) {
this.pageState = { phase: "detect-error", message: errorMessage(error) };
}
return null;
} finally {
if (this.detectAbort === abortController) {
this.detectAbort = null;
}
}
const token = {};
await this.detectTask.run([client, token]);
const outcome = this.detectTask.value;
return outcome?.token === token && "value" in outcome ? outcome.value : null;
}
private canVerify(client: GatewayBrowserClient | null): client is GatewayBrowserClient {
@@ -340,10 +413,8 @@ export class ModelSetupPage extends OpenClawLightDomElement {
}
private resetVerify(): void {
this.verifyEpoch += 1;
this.verifyAbort?.abort();
this.verifyAbort = null;
this.verifyState = { phase: "idle" };
void this.verifyTask.run([null]);
}
private async verifyConnection(): Promise<void> {
@@ -351,30 +422,8 @@ export class ModelSetupPage extends OpenClawLightDomElement {
if (!this.canVerify(client) || this.actionsDisabled()) {
return;
}
const epoch = ++this.verifyEpoch;
this.verifyAbort?.abort();
const abortController = new AbortController();
this.verifyAbort = abortController;
this.verifyState = { phase: "checking" };
try {
const result = await verifyModelSetup(client, abortController.signal);
if (epoch !== this.verifyEpoch || this.context.gateway.snapshot.client !== client) {
return;
}
this.verifyState = mapVerifyResult(result);
} catch (error) {
if (
epoch === this.verifyEpoch &&
this.context.gateway.snapshot.client === client &&
!abortController.signal.aborted
) {
this.verifyState = { phase: "failed", status: "unknown", error: errorMessage(error) };
}
} finally {
if (this.verifyAbort === abortController) {
this.verifyAbort = null;
}
}
await this.verifyTask.run([client]);
}
private async activate(
@@ -386,47 +435,9 @@ export class ModelSetupPage extends OpenClawLightDomElement {
if (!this.canUseSetup(client) || this.actionsDisabled()) {
return;
}
const epoch = ++this.activationEpoch;
this.activationAbort?.abort();
const abortController = new AbortController();
this.activationAbort = abortController;
this.manualError = null;
this.activationState = { phase: "testing", targetId, modelRef };
try {
const result = await client.request<SystemAgentSetupActivateResult>(
"openclaw.setup.activate",
params,
{ timeoutMs: activationTimeoutForKind(params.kind), signal: abortController.signal },
);
if (epoch !== this.activationEpoch || this.context.gateway.snapshot.client !== client) {
return;
}
this.activationState = mapActivationResult({
result,
targetId,
fallbackError: t("modelSetup.errors.activationFailed"),
});
if (this.activationState.phase === "success") {
this.manualApiKey = "";
}
} catch (error) {
if (
epoch === this.activationEpoch &&
this.context.gateway.snapshot.client === client &&
!abortController.signal.aborted
) {
this.activationState = {
phase: "failure",
targetId,
status: "unknown",
error: errorMessage(error),
};
}
} finally {
if (this.activationAbort === abortController) {
this.activationAbort = null;
}
}
await this.activationTask.run([client, params]);
}
private activateCandidate(candidate: Candidate): void {
@@ -2,116 +2,20 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { requestCloudProfiles } from "./cloud-target.ts";
import type { DraftCloudProfile } from "./discovery.ts";
const RETRY_DELAYS_MS = [1_000, 3_000, 10_000, 30_000, 60_000] as const;
type CloudProfileDiscoverySnapshot = {
connected: boolean;
client: Pick<GatewayBrowserClient, "request"> | null;
admin: boolean;
pendingCloud: boolean;
selectedId: string;
};
export const CLOUD_PROFILE_RETRY_DELAYS_MS = [1_000, 3_000, 10_000, 30_000, 60_000] as const;
export function selectProfiles(
profiles: DraftCloudProfile[],
client: { recoveryScopeReady?: boolean } | null,
recoveryScope: string,
): { profiles: DraftCloudProfile[]; unsupported: boolean } {
) {
const unsupported = profiles.length > 0 && client?.recoveryScopeReady === true && !recoveryScope;
return { profiles: unsupported ? [] : profiles, unsupported };
}
export class CloudProfileDiscovery {
private requestToken = 0;
private retryAttempt = 0;
private retryTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
constructor(
private readonly host: {
snapshot: () => CloudProfileDiscoverySnapshot;
update: (params: {
profiles: DraftCloudProfile[];
hydrated: boolean;
clearSelection?: boolean;
selectionUnavailable?: boolean;
}) => void;
},
) {}
invalidate() {
this.requestToken += 1;
globalThis.clearTimeout(this.retryTimer);
this.retryTimer = undefined;
this.retryAttempt = 0;
this.host.update({ profiles: [], hydrated: false });
}
stop() {
globalThis.clearTimeout(this.retryTimer);
this.retryTimer = undefined;
}
async load() {
const requestId = ++this.requestToken;
this.host.update({ profiles: [], hydrated: false });
const snapshot = this.host.snapshot();
if (!snapshot.connected || !snapshot.client || !snapshot.admin) {
this.resetRetry();
this.host.update({
profiles: [],
hydrated: true,
clearSelection: !snapshot.pendingCloud,
});
return;
}
try {
const profiles = await requestCloudProfiles(snapshot.client);
if (requestId !== this.requestToken) {
return;
}
this.resetRetry();
this.host.update({
profiles,
hydrated: true,
selectionUnavailable:
!snapshot.pendingCloud &&
Boolean(snapshot.selectedId) &&
!profiles.some((profile) => profile.id === snapshot.selectedId),
});
} catch {
if (requestId === this.requestToken) {
this.host.update({ profiles: [], hydrated: false });
this.scheduleRetry();
}
}
}
private resetRetry() {
globalThis.clearTimeout(this.retryTimer);
this.retryTimer = undefined;
this.retryAttempt = 0;
}
private scheduleRetry() {
const snapshot = this.host.snapshot();
if (this.retryTimer || !snapshot.connected || !snapshot.client) {
return;
}
if (this.retryAttempt >= RETRY_DELAYS_MS.length) {
this.host.update({
profiles: [],
hydrated: true,
selectionUnavailable: !snapshot.pendingCloud && Boolean(snapshot.selectedId),
});
return;
}
const delayMs = RETRY_DELAYS_MS[this.retryAttempt];
this.retryAttempt += 1;
this.retryTimer = globalThis.setTimeout(() => {
this.retryTimer = undefined;
if (this.host.snapshot().connected) {
void this.load();
}
}, delayMs);
}
export function discoverCloudProfiles(
client: Pick<GatewayBrowserClient, "request">,
admin: boolean,
): Promise<DraftCloudProfile[]> {
return admin ? requestCloudProfiles(client) : Promise.resolve([]);
}
@@ -1,46 +1,23 @@
import type { SystemInfoResult } from "../../../../packages/gateway-protocol/src/index.js";
import type { ApplicationContext } from "../../app/context.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
function shortHostname(value: unknown): string {
return normalizeOptionalString(value)?.split(".", 1)[0] ?? "";
}
export class GatewayNameDiscovery {
private requestToken = 0;
constructor(
private readonly snapshot: () => ApplicationContext["gateway"]["snapshot"] | undefined,
private readonly update: (name: string) => void,
) {}
invalidate() {
this.requestToken += 1;
this.update("");
export async function discoverGatewayName(
client: GatewayBrowserClient | null,
methodAdvertised: boolean,
signal: AbortSignal,
): Promise<string> {
if (!client || !methodAdvertised) {
return "";
}
async load() {
const requestId = ++this.requestToken;
const snapshot = this.snapshot();
const client = snapshot?.client;
if (
snapshot?.phase !== "connected" ||
!client ||
isGatewayMethodAdvertised(snapshot, "system.info") !== true
) {
this.update("");
return;
}
try {
const result = await client.request<SystemInfoResult>("system.info", {});
if (requestId === this.requestToken) {
this.update(normalizeOptionalString(result.machineName) ?? shortHostname(result.hostname));
}
} catch {
if (requestId === this.requestToken) {
this.update("");
}
}
try {
const result = await client.request<SystemInfoResult>("system.info", {}, { signal });
return (
normalizeOptionalString(result.machineName) ??
normalizeOptionalString(result.hostname)?.split(".", 1)[0] ??
""
);
} catch {
return "";
}
}
+104 -38
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type {
@@ -14,6 +15,7 @@ import "../../components/tooltip.ts";
import "../../components/web-awesome-popover.ts";
import { t } from "../../i18n/index.ts";
import { listSelectableAgents } from "../../lib/agents/display.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts";
import { buildAgentMainSessionKey, normalizeAgentId } from "../../lib/sessions/session-key.ts";
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
@@ -26,7 +28,11 @@ import { renderWelcomeState } from "../chat/components/chat-welcome.ts";
import { prepareInitialUserMessageHandoff } from "../chat/initial-turn-handoff.ts";
import { NewSessionAttachmentDraft } from "./attachment-draft.ts";
import * as catalog from "./catalog-target.ts";
import { CloudProfileDiscovery, selectProfiles } from "./cloud-profile-discovery.ts";
import {
CLOUD_PROFILE_RETRY_DELAYS_MS,
discoverCloudProfiles,
selectProfiles,
} from "./cloud-profile-discovery.ts";
import { PendingCloudRecoveryState, resolveScope } from "./cloud-recovery-state.ts";
import { advanceCloudDraftSession } from "./cloud-submit.ts";
import {
@@ -48,7 +54,7 @@ import {
readDraftNodes,
} from "./discovery.ts";
import { isMissingRestoredFolderError } from "./folder-validation.ts";
import { GatewayNameDiscovery } from "./gateway-name-discovery.ts";
import { discoverGatewayName } from "./gateway-name-discovery.ts";
import type { NewSessionRouteData } from "./location.ts";
import { NewSessionModelControl } from "./model-control.ts";
import { isAbsolutePath } from "./path.ts";
@@ -80,7 +86,7 @@ class NewSessionPage extends OpenClawLightDomElement {
@state() private gatewayName = "";
@state() private execNode = "";
@state() private cloudProfiles: DraftCloudProfile[] = [];
@state() private cloudProfilesHydrated = false;
@state() private cloudProfilesReady = false;
@state() private cloudProfileId = "";
@state() private message = "";
@state() private submitting = false;
@@ -108,36 +114,7 @@ class NewSessionPage extends OpenClawLightDomElement {
private worktreeSelectedByUser = false;
private submitRequestToken = 0;
private nodesRequestToken = 0;
private readonly gatewayNameDiscovery = new GatewayNameDiscovery(
() => this.context?.gateway.snapshot,
(name) => (this.gatewayName = name),
);
private readonly pendingCloud = new PendingCloudRecoveryState();
private readonly cloudProfileDiscovery = new CloudProfileDiscovery({
snapshot: () => ({
connected: this.gatewayConnected,
client: this.gatewayClient,
admin: this.isAdmin(),
pendingCloud: Boolean(this.pendingCloud.sessionKey),
selectedId: this.cloudProfileId,
}),
update: ({ profiles, hydrated, clearSelection, selectionUnavailable }) => {
const recovery = selectProfiles(profiles, this.gatewayClient, this.gatewayRecoveryScope);
this.cloudProfiles = recovery.profiles;
this.cloudProfilesHydrated = hydrated;
if (clearSelection) {
this.cloudProfileId = "";
this.closeBrowser();
}
if (selectionUnavailable) {
this.error = t("newSession.catalogUnavailable");
} else if (recovery.unsupported) {
this.error = t("newSession.cloudSecureContextRequired");
} else if (this.error === t("newSession.cloudSecureContextRequired")) {
this.error = null;
}
},
});
private branchesRequestToken = 0;
private baseRefEditGeneration = 0;
private browserRequestToken = 0;
@@ -158,6 +135,8 @@ class NewSessionPage extends OpenClawLightDomElement {
private catalogRetryScope = "";
private catalogRetryAttempt = 0;
private catalogRetryTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
private cloudProfileRetryAttempt = 0;
private cloudProfileRetryTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
// Re-render when agents/sessions hydrate so the hero identity and the
// recent-chats list appear without a route change.
@@ -176,6 +155,90 @@ class NewSessionPage extends OpenClawLightDomElement {
(sessions, notify) => sessions.subscribe(notify),
);
private readonly gatewayNameTask = new Task(this, {
args: () =>
[
this.isConnected && this.gatewayConnected ? this.gatewayClient : null,
this.context
? isGatewayMethodAdvertised(this.context.gateway.snapshot, "system.info") === true
: false,
this.gatewayConnectionEpoch,
] as const,
task: ([client, advertised, _connectionEpoch], { signal }) =>
discoverGatewayName(client, advertised, signal),
onComplete: (name) => {
this.gatewayName = name;
},
});
private readonly cloudProfileTask = new Task(this, {
args: () =>
[
this.isConnected && this.gatewayConnected ? this.gatewayClient : null,
this.gatewayConnectionEpoch,
this.isAdmin(),
this.gatewayRecoveryScope,
] as const,
task: ([client, _connectionEpoch, admin]) =>
client ? discoverCloudProfiles(client, admin) : initialState,
onComplete: (profiles) => {
this.resetCloudProfileRetry();
this.applyCloudProfiles(profiles);
this.cloudProfilesReady = true;
},
onError: () => {
this.cloudProfiles = [];
this.cloudProfilesReady = false;
this.scheduleCloudProfileRetry();
},
});
private applyCloudProfiles(profiles: DraftCloudProfile[]) {
const recovery = selectProfiles(profiles, this.gatewayClient, this.gatewayRecoveryScope);
this.cloudProfiles = recovery.profiles;
const pendingCloud = Boolean(this.pendingCloud.sessionKey);
if ((!this.gatewayConnected || !this.isAdmin()) && !pendingCloud) {
this.cloudProfileId = "";
this.closeBrowser();
}
const selectionUnavailable =
!pendingCloud &&
Boolean(this.cloudProfileId) &&
!profiles.some((profile) => profile.id === this.cloudProfileId);
if (selectionUnavailable) {
this.error = t("newSession.catalogUnavailable");
} else if (recovery.unsupported) {
this.error = t("newSession.cloudSecureContextRequired");
} else if (this.error === t("newSession.cloudSecureContextRequired")) {
this.error = null;
}
}
private resetCloudProfileRetry() {
globalThis.clearTimeout(this.cloudProfileRetryTimer);
this.cloudProfileRetryTimer = undefined;
this.cloudProfileRetryAttempt = 0;
}
private scheduleCloudProfileRetry() {
if (this.cloudProfileRetryTimer || !this.gatewayConnected || !this.gatewayClient) {
return;
}
if (this.cloudProfileRetryAttempt >= CLOUD_PROFILE_RETRY_DELAYS_MS.length) {
this.applyCloudProfiles([]);
this.cloudProfilesReady = true;
return;
}
const delayMs = CLOUD_PROFILE_RETRY_DELAYS_MS[this.cloudProfileRetryAttempt];
this.cloudProfileRetryAttempt += 1;
this.cloudProfileRetryTimer = globalThis.setTimeout(() => {
this.cloudProfileRetryTimer = undefined;
if (this.gatewayConnected) {
void this.cloudProfileTask.run();
}
}, delayMs);
}
private synchronizeGateway(gateway: ApplicationContext["gateway"]) {
const snapshot = gateway.snapshot;
const connected = snapshot.phase === "connected";
@@ -227,17 +290,17 @@ class NewSessionPage extends OpenClawLightDomElement {
if (becameConnected) {
this.gatewayConnectionEpoch += 1;
this.retryPendingCatalogTarget();
void this.gatewayNameDiscovery.load();
}
void this.cloudProfileDiscovery.load();
}
}
private invalidateGatewayDiscovery(resetHostSelection: boolean) {
this.nodesRequestToken += 1;
this.nodesHydrated = false;
this.gatewayNameDiscovery.invalidate();
this.cloudProfileDiscovery.invalidate();
this.gatewayName = "";
this.cloudProfiles = [];
this.cloudProfilesReady = false;
this.resetCloudProfileRetry();
this.branchesRequestToken += 1;
this.repository = { kind: "idle" };
this.baseRef = ""; // Never carry a derived ref across a transport epoch.
@@ -370,7 +433,9 @@ class NewSessionPage extends OpenClawLightDomElement {
this.catalogRetryTimer = undefined;
this.attachmentDraft.reset({ release: true });
this.composerTextarea.disconnect();
this.cloudProfileDiscovery.stop();
void this.gatewayNameTask.run([null, false, -1]);
void this.cloudProfileTask.run([null, -1, false, ""]);
this.resetCloudProfileRetry();
super.disconnectedCallback();
}
@@ -923,7 +988,8 @@ class NewSessionPage extends OpenClawLightDomElement {
(!this.isAdmin() ||
!gateway.snapshot.client.recoveryScope ||
!gateway.snapshot.client.recoveryScopeReady ||
!this.cloudProfilesHydrated ||
!this.cloudProfilesReady ||
this.cloudProfileTask.status === TaskStatus.PENDING ||
!this.worktree ||
!this.cloudProfiles.some((profile) => profile.id === cloudProfileId) ||
Boolean(this.cloudRuntimeUnsupportedReason()))
@@ -0,0 +1,228 @@
import { vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type {
ApplicationContext,
ApplicationGateway,
ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { t } from "../../i18n/index.ts";
import type {
PluginCatalogItem,
PluginListResult,
PluginMutationResult,
PluginSearchResult,
} from "../../lib/plugins/index.ts";
import {
createApplicationContextProvider,
type ApplicationContextProvider,
} from "../../test-helpers/application-context.ts";
import type { PluginsRouteData } from "./plugins-page.ts";
import "./plugins-page.ts";
type RequestHandler = (method: string, params: unknown) => Promise<unknown>;
type GatewayHarness = {
gateway: ApplicationGateway;
emit: (client: GatewayBrowserClient | null, connected: boolean) => ApplicationGatewaySnapshot;
};
type TestPluginsPage = HTMLElement & {
routeData?: PluginsRouteData;
updateComplete: Promise<boolean>;
result: PluginListResult | null;
loading: boolean;
busy: Record<string, boolean>;
activeTab: "installed" | "discover";
searchResults: PluginSearchResult[] | null;
applyMutationResult: (result: PluginMutationResult) => void;
};
export type RuntimeConfigTestState = {
configFormDirty: boolean;
lastError: string | null;
configSnapshot?: { sourceConfig: Record<string, unknown>; hash: string } | null;
};
export function createPlugin(overrides: Partial<PluginCatalogItem> = {}): PluginCatalogItem {
return {
id: "workboard",
name: "Workboard",
description: t("subtitles.workboard"),
origin: "bundled",
installed: true,
enabled: false,
state: "disabled",
featured: true,
order: 10,
...overrides,
};
}
export function createResult(plugin = createPlugin()): PluginListResult {
return { plugins: [plugin], diagnostics: [], mutationAllowed: true };
}
export function createClient(handler: RequestHandler) {
const request = vi.fn(handler);
return {
client: { request } as unknown as GatewayBrowserClient,
request,
};
}
function createSnapshot(
client: GatewayBrowserClient | null,
connected: boolean,
): ApplicationGatewaySnapshot {
return {
client,
phase: connected ? "connected" : "reconnecting",
offlineStable: false,
canvasPluginSurfaceUrl: null,
hello: {
type: "hello-ok",
protocol: 1,
auth: { role: "operator", scopes: ["operator.read", "operator.admin"] },
},
assistantAgentId: "main",
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
}
export function createGateway(client: GatewayBrowserClient, connected = true): GatewayHarness {
let snapshot = createSnapshot(client, connected);
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const gateway = {
get snapshot() {
return snapshot;
},
connection: { gatewayUrl: "ws://localhost", token: "", password: "", bootstrapToken: "" },
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,
emit(nextClient, nextConnected) {
snapshot = createSnapshot(nextClient, nextConnected);
for (const listener of listeners) {
listener(snapshot);
}
return snapshot;
},
};
}
type RuntimeConfigTestHarness = {
runtimeConfig: {
state: RuntimeConfigTestState;
refresh: ApplicationContext["runtimeConfig"]["refresh"];
ensureLoaded: ReturnType<typeof vi.fn<() => Promise<undefined>>>;
patch: ReturnType<
typeof vi.fn<(options: { raw: Record<string, unknown>; note: string }) => Promise<boolean>>
>;
patchFromSnapshot: ApplicationContext["runtimeConfig"]["patchFromSnapshot"];
subscribe: (listener: (state: RuntimeConfigTestState) => void) => () => void;
};
notify: () => void;
};
export function createRuntimeConfigHarness(
refreshConfig: ApplicationContext["runtimeConfig"]["refresh"],
runtimeConfigState: RuntimeConfigTestState,
): RuntimeConfigTestHarness {
const listeners = new Set<(state: RuntimeConfigTestState) => void>();
const patch = vi.fn<
(options: { raw: Record<string, unknown>; note: string }) => Promise<boolean>
>(async () => true);
const runtimeConfig = {
state: runtimeConfigState,
refresh: refreshConfig,
ensureLoaded: vi.fn(async () => undefined),
patch,
patchFromSnapshot: vi.fn(async (build) => {
const config = runtimeConfigState.configSnapshot?.sourceConfig ?? {};
const built = build(config);
if ("error" in built) {
runtimeConfigState.lastError = built.error;
return false;
}
return patch(built.options);
}),
subscribe(listener: (state: RuntimeConfigTestState) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
return {
runtimeConfig,
notify: () => {
for (const listener of listeners) {
listener(runtimeConfigState);
}
},
};
}
export function createContext(
gateway: ApplicationGateway,
refreshConfig: ApplicationContext["runtimeConfig"]["refresh"],
runtimeConfigState: RuntimeConfigTestState = {
configFormDirty: false,
lastError: null,
},
harness = createRuntimeConfigHarness(refreshConfig, runtimeConfigState),
): ApplicationContext {
return {
gateway,
basePath: "",
runtimeConfig: harness.runtimeConfig,
navigate: vi.fn(),
} as unknown as ApplicationContext;
}
export async function mountPage(
context: ApplicationContext,
routeData?: PluginsRouteData,
): Promise<{ page: TestPluginsPage; provider: ApplicationContextProvider }> {
const provider = createApplicationContextProvider(context);
const page = document.createElement("openclaw-plugins-page") as unknown as TestPluginsPage;
page.routeData = routeData;
provider.append(page);
document.body.append(provider);
await page.updateComplete;
return { page, provider };
}
export function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
export async function clickRowAction(page: TestPluginsPage, pluginSelector: string, label: string) {
const button = [...page.querySelectorAll<HTMLButtonElement>(`${pluginSelector} button`)].find(
(element) => (element.getAttribute("aria-label") ?? element.textContent ?? "").includes(label),
);
button?.click();
await page.updateComplete;
}
export function resetPluginsPageTestState(): void {
document.body.replaceChildren();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
}
+112 -231
View File
@@ -2,237 +2,30 @@
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type {
ApplicationContext,
ApplicationGateway,
ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { i18n } from "../../i18n/index.ts";
import type {
PluginCatalogItem,
PluginListResult,
PluginMutationResult,
} from "../../lib/plugins/index.ts";
import {
createApplicationContextProvider,
type ApplicationContextProvider,
} from "../../test-helpers/application-context.ts";
import type { PluginListResult, PluginSearchResult } from "../../lib/plugins/index.ts";
import { waitForFast } from "../../test-helpers/wait-for.ts";
import {
clickRowAction,
createClient,
createContext,
createGateway,
createPlugin,
createResult,
createRuntimeConfigHarness,
deferred,
mountPage,
resetPluginsPageTestState,
type RuntimeConfigTestState,
} from "./plugins-page.test-support.ts";
import type { PluginsRouteData } from "./plugins-page.ts";
import "./plugins-page.ts";
type RequestHandler = (method: string, params: unknown) => Promise<unknown>;
type GatewayHarness = {
gateway: ApplicationGateway;
emit: (client: GatewayBrowserClient | null, connected: boolean) => ApplicationGatewaySnapshot;
};
type TestPluginsPage = HTMLElement & {
routeData?: PluginsRouteData;
updateComplete: Promise<boolean>;
result: PluginListResult | null;
loading: boolean;
busy: Record<string, boolean>;
activeTab: "installed" | "discover";
applyMutationResult: (result: PluginMutationResult) => void;
};
type RuntimeConfigTestState = {
configFormDirty: boolean;
lastError: string | null;
configSnapshot?: { sourceConfig: Record<string, unknown>; hash: string } | null;
};
function createPlugin(overrides: Partial<PluginCatalogItem> = {}): PluginCatalogItem {
return {
id: "workboard",
name: "Workboard",
description: "Agent work queue and thread handoff.",
origin: "bundled",
installed: true,
enabled: false,
state: "disabled",
featured: true,
order: 10,
...overrides,
};
}
function createResult(plugin = createPlugin()): PluginListResult {
return { plugins: [plugin], diagnostics: [], mutationAllowed: true };
}
function createClient(handler: RequestHandler) {
const request = vi.fn(handler);
return {
client: { request } as unknown as GatewayBrowserClient,
request,
};
}
function createSnapshot(
client: GatewayBrowserClient | null,
connected: boolean,
): ApplicationGatewaySnapshot {
return {
client,
phase: connected ? "connected" : "reconnecting",
offlineStable: false,
canvasPluginSurfaceUrl: null,
hello: {
type: "hello-ok",
protocol: 1,
auth: { role: "operator", scopes: ["operator.read", "operator.admin"] },
},
assistantAgentId: "main",
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
}
function createGateway(client: GatewayBrowserClient, connected = true): GatewayHarness {
let snapshot = createSnapshot(client, connected);
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const gateway = {
get snapshot() {
return snapshot;
},
connection: { gatewayUrl: "ws://localhost", token: "", password: "", bootstrapToken: "" },
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,
emit(nextClient, nextConnected) {
snapshot = createSnapshot(nextClient, nextConnected);
for (const listener of listeners) {
listener(snapshot);
}
return snapshot;
},
};
}
type RuntimeConfigTestHarness = {
runtimeConfig: {
state: RuntimeConfigTestState;
refresh: ApplicationContext["runtimeConfig"]["refresh"];
ensureLoaded: ReturnType<typeof vi.fn<() => Promise<undefined>>>;
patch: ReturnType<
typeof vi.fn<(options: { raw: Record<string, unknown>; note: string }) => Promise<boolean>>
>;
patchFromSnapshot: ApplicationContext["runtimeConfig"]["patchFromSnapshot"];
subscribe: (listener: (state: RuntimeConfigTestState) => void) => () => void;
};
notify: () => void;
};
function createRuntimeConfigHarness(
refreshConfig: ApplicationContext["runtimeConfig"]["refresh"],
runtimeConfigState: RuntimeConfigTestState,
): RuntimeConfigTestHarness {
const listeners = new Set<(state: RuntimeConfigTestState) => void>();
const patch = vi.fn<
(options: { raw: Record<string, unknown>; note: string }) => Promise<boolean>
>(async () => true);
const runtimeConfig = {
state: runtimeConfigState,
refresh: refreshConfig,
ensureLoaded: vi.fn(async () => undefined),
patch,
patchFromSnapshot: vi.fn(async (build) => {
const config = runtimeConfigState.configSnapshot?.sourceConfig ?? {};
const built = build(config);
if ("error" in built) {
runtimeConfigState.lastError = built.error;
return false;
}
return patch(built.options);
}),
subscribe(listener: (state: RuntimeConfigTestState) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
return {
runtimeConfig,
notify: () => {
for (const listener of listeners) {
listener(runtimeConfigState);
}
},
};
}
function createContext(
gateway: ApplicationGateway,
refreshConfig: ApplicationContext["runtimeConfig"]["refresh"],
runtimeConfigState: RuntimeConfigTestState = {
configFormDirty: false,
lastError: null,
},
harness = createRuntimeConfigHarness(refreshConfig, runtimeConfigState),
): ApplicationContext {
return {
gateway,
basePath: "",
runtimeConfig: harness.runtimeConfig,
navigate: vi.fn(),
} as unknown as ApplicationContext;
}
async function mountPage(
context: ApplicationContext,
routeData?: PluginsRouteData,
): Promise<{ page: TestPluginsPage; provider: ApplicationContextProvider }> {
const provider = createApplicationContextProvider(context);
const page = document.createElement("openclaw-plugins-page") as unknown as TestPluginsPage;
page.routeData = routeData;
provider.append(page);
document.body.append(provider);
await page.updateComplete;
return { page, provider };
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
async function clickRowAction(page: TestPluginsPage, pluginSelector: string, label: string) {
const button = [...page.querySelectorAll<HTMLButtonElement>(`${pluginSelector} button`)].find(
(element) => (element.getAttribute("aria-label") ?? element.textContent ?? "").includes(label),
);
button?.click();
await page.updateComplete;
}
describe("PluginsPage", () => {
beforeEach(async () => {
await i18n.setLocale("en");
});
afterEach(() => {
document.body.replaceChildren();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
afterEach(resetPluginsPageTestState);
it("accepts matching route data without issuing a duplicate list request", async () => {
const { client, request } = createClient(async () => createResult());
@@ -260,6 +53,28 @@ describe("PluginsPage", () => {
expect(page.querySelector("h1")?.textContent).toBe("Plugins");
});
it("surfaces an initial catalog load failure", async () => {
const { client } = createClient(async () => {
throw new Error("catalog unavailable");
});
const harness = createGateway(client);
const { page } = await mountPage(
createContext(
harness.gateway,
vi.fn(async () => undefined),
),
);
await waitForFast(() =>
expect(page.querySelector(".plugins-page-error")?.textContent).toContain(
"catalog unavailable",
),
);
expect(
page.querySelector(".plugins-page-error")?.textContent?.match(/catalog unavailable/gu),
).toHaveLength(1);
});
it("fetches proxied icons with auth fallback and revokes their blob URLs", async () => {
const createObjectURL = vi.fn(() => "blob:firecrawl-icon");
const revokeObjectURL = vi.fn();
@@ -474,7 +289,11 @@ describe("PluginsPage", () => {
harness.emit(client, true);
await waitForFast(() => expect(page.result?.plugins[0]?.enabled).toBe(true));
expect(request).toHaveBeenCalledWith("plugins.list", {});
expect(request).toHaveBeenCalledWith(
"plugins.list",
{},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it("debounces two-character ClawHub searches and cancels stale input", async () => {
@@ -511,10 +330,65 @@ describe("PluginsPage", () => {
await vi.advanceTimersByTimeAsync(300);
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith("plugins.search", {
query: "workboard",
limit: 20,
expect(request).toHaveBeenCalledWith(
"plugins.search",
{
query: "workboard",
limit: 20,
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it("commits only the latest ClawHub search result", async () => {
vi.useFakeTimers();
const first = deferred<{ results: PluginSearchResult[] }>();
const second = deferred<{ results: PluginSearchResult[] }>();
const { client, request } = createClient(async (method, params) => {
if (method !== "plugins.search") {
throw new Error(`Unexpected method ${method}`);
}
return (params as { query: string }).query === "first" ? first.promise : second.promise;
});
const harness = createGateway(client);
const { page } = await mountPage(
createContext(
harness.gateway,
vi.fn(async () => undefined),
),
{
gateway: harness.gateway,
gatewaySnapshot: harness.gateway.snapshot,
initialTab: "discover",
result: createResult(),
error: null,
},
);
const search = page.querySelector<HTMLInputElement>("#plugins-global-search")!;
search.value = "first";
search.dispatchEvent(new Event("input", { bubbles: true }));
await vi.advanceTimersByTimeAsync(300);
search.value = "second";
search.dispatchEvent(new Event("input", { bubbles: true }));
await vi.advanceTimersByTimeAsync(300);
expect(request).toHaveBeenCalledTimes(2);
const latest: PluginSearchResult = {
score: 1,
package: {
name: "latest-plugin",
displayName: "Latest Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
},
};
second.resolve({ results: [latest] });
await vi.waitFor(() => expect(page.searchResults).toEqual([latest]));
first.resolve({ results: [] });
await Promise.resolve();
expect(page.searchResults).toEqual([latest]);
});
it("refreshes plugins and runtime config without discarding a pending config draft", async () => {
@@ -638,10 +512,14 @@ describe("PluginsPage", () => {
harness.emit(client, true);
await vi.advanceTimersByTimeAsync(300);
expect(request).toHaveBeenCalledWith("plugins.search", {
query: "calendar",
limit: 20,
});
expect(request).toHaveBeenCalledWith(
"plugins.search",
{
query: "calendar",
limit: 20,
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it("clears visible catalog loading when a mutation supersedes a manual refresh", async () => {
@@ -706,7 +584,10 @@ describe("PluginsPage", () => {
let refreshCalls = 0;
const refreshConfig = vi.fn(async () => {
refreshCalls += 1;
runtimeConfigState.lastError = refreshCalls === 1 ? "config.get failed" : null;
if (refreshCalls === 1) {
throw new Error("config.get failed");
}
runtimeConfigState.lastError = null;
});
const { page } = await mountPage(
createContext(harness.gateway, refreshConfig, runtimeConfigState),
+112 -118
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { html, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
@@ -29,10 +30,8 @@ import {
} from "../../lib/config/mcp-servers.ts";
import {
installPlugin,
loadPluginCatalog,
pluginInstallNeedsRiskAcknowledgement,
readPluginInstallTrustError,
searchPluginCatalog,
setPluginEnabled,
uninstallPlugin,
type PluginCatalogItem,
@@ -105,16 +104,12 @@ class PluginsPage extends OpenClawLightDomElement {
@state() private client: GatewayBrowserClient | null = null;
@state() private connected = false;
@state() private loading = false;
@state() private result: PluginListResult | null = null;
@state() private error: string | null = null;
@state() private configRefreshError: string | null = null;
@state() private activeTab: PluginsTab = "installed";
@state() private query = "";
@state() private installedFilter: InstalledFilter = "all";
@state() private searchResults: PluginSearchResult[] | null = null;
@state() private searchLoading = false;
@state() private searchError: string | null = null;
@state() private debouncedSearchQuery = "";
@state() private busy: Record<string, boolean> = {};
@state() private messages: Record<string, PluginRowMessage> = {};
@state() private pendingRemoval: Record<string, boolean> = {};
@@ -128,9 +123,6 @@ class PluginsPage extends OpenClawLightDomElement {
private gatewaySource?: ApplicationContext["gateway"];
private sourceGeneration = 0;
private catalogRequestGeneration = 0;
private configRequestGeneration = 0;
private searchRequestGeneration = 0;
private routeDataConsumed = false;
private searchTimer: ReturnType<typeof setTimeout> | null = null;
private mutationToken = 0;
@@ -142,6 +134,56 @@ class PluginsPage extends OpenClawLightDomElement {
>();
private iconAuthCandidates: string[] = [];
private readonly catalogTask = new Task(this, {
autoRun: false,
args: () => [this.connected ? this.client : null] as const,
task: ([client], { signal }) =>
client ? client.request<PluginListResult>("plugins.list", {}, { signal }) : initialState,
onComplete: (result) => {
this.replaceResult(result);
},
onError: (error) => {
this.error = errorMessage(error);
},
});
private readonly configTask = new Task(this, {
autoRun: false,
args: () => [this.connected ? this.client : null, this.context?.runtimeConfig ?? null] as const,
task: async ([client, runtimeConfig]) => {
if (!client || !runtimeConfig) {
return initialState;
}
await runtimeConfig.refresh();
return runtimeConfig.state.lastError;
},
onComplete: () => {
this.syncMcpServers();
},
onError: () => {
this.syncMcpServers();
},
});
private readonly searchTask = new Task(this, {
args: () =>
[
this.connected && this.activeTab === "discover" ? this.client : null,
this.debouncedSearchQuery,
] as const,
task: async ([client, query], { signal }) => {
if (!client || query.length < 2) {
return initialState;
}
const response = await client.request<{ results: PluginSearchResult[] }>(
"plugins.search",
{ query, limit: 20 },
{ signal },
);
return response.results;
},
});
private readonly subscriptions = new SubscriptionsController(this)
.effect(
() => this.context?.gateway,
@@ -213,17 +255,13 @@ class PluginsPage extends OpenClawLightDomElement {
snapshot.phase === "connected" &&
this.routeDataConsumed;
if (sourceChanged || connectionChanged || clientChanged || iconAuthChanged) {
this.invalidateRequests();
this.invalidateRequests(snapshot.phase !== "connected" || !snapshot.client);
this.resetPluginIcons();
this.client = snapshot.client;
this.connected = snapshot.phase === "connected";
this.loading = false;
this.searchLoading = false;
this.busy = {};
this.mcpBusy = false;
this.configRefreshError = null;
this.searchResults = null;
this.searchError = null;
this.debouncedSearchQuery = "";
if (sourceChanged || clientChanged) {
this.result = null;
this.error = null;
@@ -272,18 +310,20 @@ class PluginsPage extends OpenClawLightDomElement {
}
this.client = snapshot.client;
this.connected = snapshot.phase === "connected";
this.loading = false;
this.replaceResult(data.result);
this.error = data.error;
this.ensureInitialData();
}
private invalidateRequests() {
private invalidateRequests(invalidateCatalog = true) {
this.sourceGeneration += 1;
this.catalogRequestGeneration += 1;
this.configRequestGeneration += 1;
this.searchRequestGeneration += 1;
this.clearSearchTimer();
this.debouncedSearchQuery = "";
if (invalidateCatalog) {
void this.catalogTask.run([null]);
}
void this.configTask.run([null, this.context.runtimeConfig]);
void this.searchTask.run([null, ""]);
this.mutationTokens.clear();
}
@@ -431,6 +471,42 @@ class PluginsPage extends OpenClawLightDomElement {
}
}
private get loading(): boolean {
return this.connected && this.catalogTask.status === TaskStatus.PENDING;
}
private get searchResults(): PluginSearchResult[] | null {
return this.searchTask.status === TaskStatus.COMPLETE &&
this.debouncedSearchQuery === this.query.trim()
? (this.searchTask.value ?? null)
: null;
}
private get searchLoading(): boolean {
return (
this.activeTab === "discover" &&
this.debouncedSearchQuery.length >= 2 &&
this.searchTask.status === TaskStatus.PENDING
);
}
private get searchError(): string | null {
return this.searchTask.status === TaskStatus.ERROR &&
this.debouncedSearchQuery === this.query.trim()
? errorMessage(this.searchTask.error)
: null;
}
private get configRefreshError(): string | null {
const failure =
this.configTask.status === TaskStatus.ERROR
? errorMessage(this.configTask.error)
: this.configTask.status === TaskStatus.COMPLETE
? this.configTask.value
: null;
return failure ? t("pluginsPage.configRefreshFailed", { error: failure }) : null;
}
private isCurrentSource(client: GatewayBrowserClient, sourceGeneration: number): boolean {
return (
this.isConnected &&
@@ -455,27 +531,8 @@ class PluginsPage extends OpenClawLightDomElement {
if (!client || !this.connected) {
return;
}
const sourceGeneration = this.sourceGeneration;
const requestGeneration = ++this.catalogRequestGeneration;
const isCurrent = () =>
this.isCurrentSource(client, sourceGeneration) &&
requestGeneration === this.catalogRequestGeneration;
this.loading = true;
this.error = null;
try {
const result = await loadPluginCatalog(client);
if (isCurrent()) {
this.replaceResult(result);
}
} catch (error) {
if (isCurrent()) {
this.error = errorMessage(error);
}
} finally {
if (isCurrent()) {
this.loading = false;
}
}
await this.catalogTask.run([client]);
}
private async refreshRuntimeConfig(): Promise<void> {
@@ -484,27 +541,7 @@ class PluginsPage extends OpenClawLightDomElement {
return;
}
const runtimeConfig = this.context.runtimeConfig;
const sourceGeneration = this.sourceGeneration;
const requestGeneration = ++this.configRequestGeneration;
const isCurrent = () =>
this.isCurrentSource(client, sourceGeneration) &&
requestGeneration === this.configRequestGeneration;
this.configRefreshError = null;
let refreshError: string | null = null;
try {
// Keep app-global pending config edits; the new snapshot/base hash still refreshes.
await runtimeConfig.refresh();
} catch (error) {
refreshError = errorMessage(error);
}
if (!isCurrent()) {
return;
}
this.syncMcpServers();
const failure = refreshError ?? runtimeConfig.state.lastError;
this.configRefreshError = failure
? t("pluginsPage.configRefreshFailed", { error: failure })
: null;
await this.configTask.run([client, runtimeConfig]);
}
private async refreshPage(): Promise<void> {
@@ -533,10 +570,8 @@ class PluginsPage extends OpenClawLightDomElement {
private changeTab(tab: PluginsTab) {
this.activeTab = tab;
this.clearSearchTimer();
this.searchRequestGeneration += 1;
this.searchLoading = false;
this.searchResults = null;
this.searchError = null;
this.debouncedSearchQuery = "";
void this.searchTask.run([null, ""]);
if (tab === "discover") {
this.scheduleSearch();
}
@@ -545,10 +580,8 @@ class PluginsPage extends OpenClawLightDomElement {
private changeQuery(query: string) {
this.query = query;
this.clearSearchTimer();
this.searchRequestGeneration += 1;
this.searchLoading = false;
this.searchResults = null;
this.searchError = null;
this.debouncedSearchQuery = "";
void this.searchTask.run([null, ""]);
if (this.activeTab === "discover") {
this.scheduleSearch();
}
@@ -575,30 +608,8 @@ class PluginsPage extends OpenClawLightDomElement {
if (!client || !this.connected || query.length < 2) {
return;
}
const sourceGeneration = this.sourceGeneration;
const requestGeneration = ++this.searchRequestGeneration;
const isCurrent = () =>
this.isCurrentSource(client, sourceGeneration) &&
requestGeneration === this.searchRequestGeneration &&
this.activeTab === "discover" &&
this.query.trim() === query;
this.searchLoading = true;
this.searchError = null;
this.searchResults = null;
try {
const response = await searchPluginCatalog(client, query);
if (isCurrent()) {
this.searchResults = response.results;
}
} catch (error) {
if (isCurrent()) {
this.searchError = errorMessage(error);
}
} finally {
if (isCurrent()) {
this.searchLoading = false;
}
}
this.debouncedSearchQuery = query;
await this.searchTask.run([client, query]);
}
private mutationBlockedReason(): string | null {
@@ -667,29 +678,12 @@ class PluginsPage extends OpenClawLightDomElement {
}
/** Plugin changes can affect both catalog state and route visibility (for example Workboard). */
private async refreshAfterMutation(
client: GatewayBrowserClient,
sourceGeneration: number,
): Promise<void> {
const requestGeneration = ++this.catalogRequestGeneration;
// This authoritative refresh supersedes a visible catalog refresh and owns its cleanup.
this.loading = false;
private async refreshAfterMutation(client: GatewayBrowserClient): Promise<void> {
this.error = null;
const [catalogResult] = await Promise.allSettled([
loadPluginCatalog(client),
this.refreshRuntimeConfig(),
await Promise.all([
this.catalogTask.run([client]),
this.configTask.run([client, this.context.runtimeConfig]),
]);
if (
!this.isCurrentSource(client, sourceGeneration) ||
requestGeneration !== this.catalogRequestGeneration
) {
return;
}
if (catalogResult.status === "fulfilled") {
this.replaceResult(catalogResult.value);
} else {
this.error = errorMessage(catalogResult.reason);
}
}
private pageError(): string | null {
@@ -722,7 +716,7 @@ class PluginsPage extends OpenClawLightDomElement {
kind: "success",
text: mutationSuccessMessage("installed", result),
});
await this.refreshAfterMutation(client, sourceGeneration);
await this.refreshAfterMutation(client);
} catch (error) {
if (!isCurrent()) {
return;
@@ -775,7 +769,7 @@ class PluginsPage extends OpenClawLightDomElement {
if (enabled) {
this.pinEnabledPluginRoute(pluginId);
}
await this.refreshAfterMutation(client, sourceGeneration);
await this.refreshAfterMutation(client);
if (isCurrent() && !result.restartRequired) {
// Plugin-provided tabs are projected in the connection hello. Re-handshake
// after the registry refresh so sidebar navigation reflects this mutation.
@@ -823,7 +817,7 @@ class PluginsPage extends OpenClawLightDomElement {
.filter(Boolean)
.join("\n"),
};
await this.refreshAfterMutation(client, sourceGeneration);
await this.refreshAfterMutation(client);
} catch (error) {
if (isCurrent()) {
this.setMessage(rowKey, { kind: "error", text: errorMessage(error) });
+96 -72
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { html, nothing, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
@@ -94,6 +95,7 @@ class SessionsPage extends OpenClawLightDomElement {
@state() private statusFilter: SessionArchivedFilter = "active";
@state() private searchQuery = "";
@state() private transcriptSearchQuery = "";
@state() private submittedTranscriptSearchQuery = "";
@state() private transcriptSearch: TranscriptSearchState = { status: "idle" };
@state() private sortColumn: "key" | "kind" | "updated" | "tokens" = "updated";
@state() private sortDir: "asc" | "desc" = "desc";
@@ -108,13 +110,11 @@ class SessionsPage extends OpenClawLightDomElement {
// narrows sessionListOptions so the linked session is guaranteed to load.
private deepLinkSessionKey: string | null = null;
@state() private checkpointItemsByKey: Record<string, SessionCompactionCheckpoint[]> = {};
@state() private checkpointLoadingKey: string | null = null;
@state() private checkpointTaskKey: string | null = null;
@state() private checkpointBusyKey: string | null = null;
@state() private checkpointErrorByKey: Record<string, string> = {};
private sessionRequestId = 0;
private transcriptSearchRequestId = 0;
private checkpointRequestId = 0;
// Async completions belong to one context/capability/connection epoch. Bump
// before releasing locks so stale finally blocks cannot clear newer work.
private pageEpoch = 0;
@@ -223,6 +223,73 @@ class SessionsPage extends OpenClawLightDomElement {
(workboard, notify) => workboard.subscribe(notify),
);
private transcriptSearchArgs() {
const context = this.context;
const snapshot = context?.gateway.snapshot;
return [
snapshot?.phase === "connected" ? (snapshot.client ?? null) : null,
this.submittedTranscriptSearchQuery,
context ?? null,
context?.agentSelection.state.scopeId ?? null,
snapshot ? isGatewayMethodAdvertised(snapshot, "sessions.search") === true : false,
] as const;
}
private readonly transcriptSearchTask = new Task(this, {
args: () => this.transcriptSearchArgs(),
task: async ([client, query, context, _agentScope, advertised]) => {
if (!client || !query || !context || !advertised) {
return null;
}
const result = await searchVisibleSessionTranscripts({
client,
query,
result: this.result,
listSessions: context.sessions.list,
listOptions: this.sessionListOptions(),
resolveAgentId: (sessionKey) =>
parseAgentSessionKey(sessionKey)?.agentId ?? this.sessionAgentId(sessionKey, context),
});
return {
results: result.results,
indexing: result.indexing === true,
truncated: result.truncated === true,
};
},
onComplete: (result) => {
this.transcriptSearch = result ? { status: "results", ...result } : { status: "idle" };
},
onError: (error) => {
this.transcriptSearch = { status: "error", message: String(error) };
},
});
private readonly checkpointTask = new Task(this, {
autoRun: false,
args: () => [null, ""] as const,
task: async ([scope, sessionKey]: readonly [SessionsPageRequestScope | null, string]) => {
if (!scope || !sessionKey) {
return initialState;
}
const checkpoints = await scope.sessions.listCheckpoints(sessionKey, {
agentId: this.sessionAgentId(sessionKey, scope.context),
});
return { sessionKey, checkpoints };
},
onComplete: ({ sessionKey, checkpoints }) => {
this.checkpointItemsByKey = { ...this.checkpointItemsByKey, [sessionKey]: checkpoints };
},
onError: (error) => {
const sessionKey = this.checkpointTaskKey;
if (sessionKey) {
this.checkpointErrorByKey = {
...this.checkpointErrorByKey,
[sessionKey]: String(error),
};
}
},
});
override willUpdate(changed: PropertyValues) {
if (changed.has("routeData") || changed.has("context")) {
this.applyRouteData();
@@ -267,14 +334,12 @@ class SessionsPage extends OpenClawLightDomElement {
private invalidatePageWork() {
this.pageEpoch += 1;
this.sessionRequestId += 1;
this.transcriptSearchRequestId += 1;
this.checkpointRequestId += 1;
this.submittedTranscriptSearchQuery = "";
this.transcriptSearch = { status: "idle" };
void this.transcriptSearchTask.run(this.transcriptSearchArgs());
this.resetCheckpointTask();
this.sessionReloadQueued = false;
this.loading = false;
if (this.transcriptSearch.status === "loading") {
this.transcriptSearch = { status: "idle" };
}
this.checkpointLoadingKey = null;
this.checkpointBusyKey = null;
this.sessionMutationPending = false;
this.closeSessionMenu();
@@ -289,7 +354,7 @@ class SessionsPage extends OpenClawLightDomElement {
this.expandedSessionKey = null;
this.deepLinkSessionKey = null;
this.checkpointItemsByKey = {};
this.checkpointLoadingKey = null;
this.checkpointTaskKey = null;
this.checkpointBusyKey = null;
this.checkpointErrorByKey = {};
}
@@ -491,9 +556,10 @@ class SessionsPage extends OpenClawLightDomElement {
}
private resetTranscriptSearchState(query: string) {
this.transcriptSearchRequestId += 1;
this.transcriptSearchQuery = query;
this.submittedTranscriptSearchQuery = "";
this.transcriptSearch = { status: "idle" };
void this.transcriptSearchTask.run(this.transcriptSearchArgs());
}
private updateTranscriptSearchQuery(query: string) {
@@ -519,34 +585,10 @@ class SessionsPage extends OpenClawLightDomElement {
if (!scope || isGatewayMethodAdvertised(scope.gateway.snapshot, "sessions.search") !== true) {
return;
}
this.resetTranscriptSearchState(query);
const requestId = this.transcriptSearchRequestId;
this.transcriptSearchQuery = query;
this.submittedTranscriptSearchQuery = query;
this.transcriptSearch = { status: "loading" };
try {
const result = await searchVisibleSessionTranscripts({
client: scope.client,
query,
result: this.result,
listSessions: scope.sessions.list,
listOptions: this.sessionListOptions(),
resolveAgentId: (sessionKey) =>
parseAgentSessionKey(sessionKey)?.agentId ??
this.sessionAgentId(sessionKey, scope.context),
});
if (requestId !== this.transcriptSearchRequestId || !this.isRequestScopeCurrent(scope)) {
return;
}
this.transcriptSearch = {
status: "results",
results: result.results,
indexing: result.indexing === true,
truncated: result.truncated === true,
};
} catch (error) {
if (requestId === this.transcriptSearchRequestId && this.isRequestScopeCurrent(scope)) {
this.transcriptSearch = { status: "error", message: String(error) };
}
}
await this.transcriptSearchTask.run(this.transcriptSearchArgs());
}
private ensureAgentIdentities(result: SessionsListResult | null) {
@@ -995,22 +1037,17 @@ class SessionsPage extends OpenClawLightDomElement {
if (!context) {
return;
}
// Any interactive toggle ends deep-link mode so reloads return the roster.
this.deepLinkSessionKey = null;
if (this.expandedSessionKey === sessionKey) {
this.checkpointRequestId += 1;
this.resetCheckpointTask();
this.expandedSessionKey = null;
return;
}
this.expandedSessionKey = sessionKey;
// Every row opens the details drawer; only fetch compaction history when
// the row reports checkpoints, so plain sessions skip the round-trip.
const row = this.result?.sessions.find((session) => session.key === sessionKey);
const hasCheckpoints =
(row?.compactionCheckpointCount ?? 0) > 0 || Boolean(row?.latestCompactionCheckpoint);
if (!hasCheckpoints) {
// Seed an empty cache entry so reconcileCheckpointCache sees this key
// and reloads the open drawer if the session compacts on a refresh.
if (!this.checkpointItemsByKey[sessionKey]) {
this.checkpointItemsByKey = { ...this.checkpointItemsByKey, [sessionKey]: [] };
}
@@ -1027,34 +1064,18 @@ class SessionsPage extends OpenClawLightDomElement {
if (!scope) {
return;
}
const requestId = ++this.checkpointRequestId;
this.checkpointLoadingKey = sessionKey;
this.checkpointTaskKey = sessionKey;
this.checkpointErrorByKey = { ...this.checkpointErrorByKey, [sessionKey]: "" };
try {
const checkpoints = await scope.sessions.listCheckpoints(sessionKey, {
agentId: this.sessionAgentId(sessionKey, scope.context),
});
if (requestId !== this.checkpointRequestId || !this.isRequestScopeCurrent(scope)) {
return;
}
this.checkpointItemsByKey = { ...this.checkpointItemsByKey, [sessionKey]: checkpoints };
} catch (error) {
if (requestId !== this.checkpointRequestId || !this.isRequestScopeCurrent(scope)) {
return;
}
this.checkpointErrorByKey = {
...this.checkpointErrorByKey,
[sessionKey]: String(error),
};
} finally {
if (
requestId === this.checkpointRequestId &&
this.isRequestScopeCurrent(scope) &&
this.checkpointLoadingKey === sessionKey
) {
this.checkpointLoadingKey = null;
}
}
await this.checkpointTask.run([scope, sessionKey]);
}
private resetCheckpointTask() {
this.checkpointTaskKey = null;
void this.checkpointTask.run([null, ""]);
}
private get checkpointLoadingKey(): string | null {
return this.checkpointTask.status === TaskStatus.PENDING ? this.checkpointTaskKey : null;
}
private async branchCheckpoint(sessionKey: string, checkpointId: string) {
@@ -1324,7 +1345,10 @@ class SessionsPage extends OpenClawLightDomElement {
transcriptSearchAvailable:
isGatewayMethodAdvertised(context.gateway.snapshot, "sessions.search") === true,
transcriptSearchQuery: this.transcriptSearchQuery,
transcriptSearch: this.transcriptSearch,
transcriptSearch:
this.transcriptSearchTask.status === TaskStatus.PENDING
? { status: "loading" }
: this.transcriptSearch,
agentIdentityById: sessionAgentIdentityById(
this.result,
(agentId) => context.agentIdentity.get(agentId) ?? undefined,
+22 -7
View File
@@ -12,10 +12,8 @@ type TestUsagePage = HTMLElement & {
context: ApplicationContext;
usageSelectedSessions: string[];
usageTimeSeries: SessionUsageTimeSeries | null;
usageTimeSeriesLoading: boolean;
usageTimeSeriesStatus: { error: string | null; hasLoaded: boolean; stale: boolean };
usageSessionLogs: SessionLogEntry[] | null;
usageSessionLogsLoading: boolean;
usageSessionLogsStatus: { error: string | null; hasLoaded: boolean; stale: boolean };
loadSessionTimeSeries: (sessionKey: string) => Promise<void>;
loadSessionLogs: (sessionKey: string) => Promise<void>;
@@ -80,6 +78,28 @@ afterEach(() => {
});
describe("UsagePage detail requests", () => {
it("commits only the latest time-series selection", async () => {
const first = deferred<SessionUsageTimeSeries>();
const second = deferred<SessionUsageTimeSeries>();
const request = vi.fn((_method: string, params: { key: string }) =>
params.key === "agent:main:a" ? first.promise : second.promise,
);
const page = await createPage({ request } as unknown as GatewayBrowserClient);
page.usageSelectedSessions = ["agent:main:a"];
const firstLoad = page.loadSessionTimeSeries("agent:main:a");
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
page.usageSelectedSessions = ["agent:main:b"];
const secondLoad = page.loadSessionTimeSeries("agent:main:b");
const latest = { points: [{ timestamp: 2 }] } as SessionUsageTimeSeries;
second.resolve(latest);
await secondLoad;
first.resolve({ points: [{ timestamp: 1 }] } as SessionUsageTimeSeries);
await firstLoad;
expect(page.usageTimeSeries).toBe(latest);
});
it("retains stale time-series data until a retry succeeds", async () => {
const retry = deferred<SessionUsageTimeSeries>();
const request = vi
@@ -98,19 +118,16 @@ describe("UsagePage detail requests", () => {
hasLoaded: true,
stale: true,
});
expect(page.usageTimeSeriesLoading).toBe(false);
expect(page.usageTimeSeries).toBe(previous);
const retryLoad = page.loadSessionTimeSeries("agent:main:detail");
expect(page.usageTimeSeriesStatus).toEqual({ error: null, hasLoaded: true, stale: true });
expect(page.usageTimeSeriesLoading).toBe(true);
const result = { points: [] } as unknown as SessionUsageTimeSeries;
retry.resolve(result);
await retryLoad;
expect(page.usageTimeSeries).toBe(result);
expect(page.usageTimeSeriesStatus).toEqual({ error: null, hasLoaded: true, stale: false });
expect(page.usageTimeSeriesLoading).toBe(false);
});
it("surfaces a session-log failure and clears it after a successful retry", async () => {
@@ -124,13 +141,11 @@ describe("UsagePage detail requests", () => {
await page.loadSessionLogs("agent:main:detail");
expect(page.usageSessionLogsStatus.error).toBe("logs unavailable");
expect(page.usageSessionLogsLoading).toBe(false);
expect(page.usageSessionLogs).toBeNull();
await page.loadSessionLogs("agent:main:detail");
expect(page.usageSessionLogs).toEqual([{ timestamp: 1, role: "user", content: "hello" }]);
expect(page.usageSessionLogsStatus).toEqual({ error: null, hasLoaded: true, stale: false });
expect(page.usageSessionLogsLoading).toBe(false);
});
it("does not retain detail data when the selected session changes", async () => {
+177 -172
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import type { PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
@@ -16,6 +17,7 @@ import {
beginPanelRefresh,
completePanelRefresh,
createPanelRefreshStatus,
type PanelRefreshStatus,
} from "../../components/panel-refresh-status.ts";
import {
formatMissingOperatorReadScopeMessage,
@@ -67,19 +69,31 @@ export type UsageRouteData = {
error: string | null;
};
type UsageTaskValue = {
result: SessionsUsageResult;
costSummary: CostUsageSummary;
providerUsageSummary: ProviderUsageSummary | null;
};
type UsageDetailTaskValue<T> = {
sessionKey: string;
data: T;
};
class UsagePage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@property({ attribute: false }) routeData?: UsageRouteData;
@state() private usageLoading = true;
@state() private usageResult: SessionsUsageResult | null = null;
@state() private usageCostSummary: CostUsageSummary | null = null;
@state() private providerUsageSummary: ProviderUsageSummary | null = null;
@state() private usageError: string | null = null;
@state() private usageStartDate = currentLocalDate();
@state() private usageEndDate = currentLocalDate();
@state() private usageLoadStartDate = this.usageStartDate;
@state() private usageLoadEndDate = this.usageEndDate;
@state() private usageScope: "instance" | "family" = "family";
@state() private usageAgentId: string | null = null;
@state() private usageSelectedSessions: string[] = [];
@@ -89,15 +103,11 @@ class UsagePage extends OpenClawLightDomElement {
@state() private usageDailyChartMode: "total" | "by-type" = "by-type";
@state() private usageTimeSeriesMode: "cumulative" | "per-turn" = "per-turn";
@state() private usageTimeSeriesBreakdownMode: "total" | "by-type" = "by-type";
@state() private usageTimeSeries: SessionUsageTimeSeries | null = null;
private usageTimeSeriesSessionKey: string | null = null;
@state() private usageTimeSeriesLoading = false;
private usageTimeSeriesValue: UsageDetailTaskValue<SessionUsageTimeSeries | null> | null = null;
@state() private usageTimeSeriesStatus = createPanelRefreshStatus();
@state() private usageTimeSeriesCursorStart: number | null = null;
@state() private usageTimeSeriesCursorEnd: number | null = null;
@state() private usageSessionLogs: SessionLogEntry[] | null = null;
private usageSessionLogsSessionKey: string | null = null;
@state() private usageSessionLogsLoading = false;
private usageSessionLogsValue: UsageDetailTaskValue<SessionLogEntry[] | null> | null = null;
@state() private usageSessionLogsStatus = createPanelRefreshStatus();
@state() private usageSessionLogsExpanded = false;
@state() private usageQuery = "";
@@ -116,11 +126,11 @@ class UsagePage extends OpenClawLightDomElement {
@state() private usageLogFilterHasTools = false;
@state() private usageLogFilterQuery = "";
private usageRequestId = 0;
private timeSeriesRequestId = 0;
private logsRequestId = 0;
private dateDebounceTimer: number | null = null;
private queryDebounceTimer: number | null = null;
// Invalidation runs the Task with a null client to supersede stale completions.
// Track real gateway work separately so that no-op runs cannot block reconnect retries.
private usageTaskActiveClient: GatewayBrowserClient | null = null;
private routeDataInitialized = false;
private routeDataEnabled = true;
private observedAgentScopeId: string | null | undefined;
@@ -129,10 +139,128 @@ class UsagePage extends OpenClawLightDomElement {
isLoading: () => this.usageLoading,
isRouteDataInitialized: () => this.routeDataInitialized,
ensureAgents: () => void this.context.agents.ensureList(),
invalidateRequests: () => this.invalidateRequests(),
invalidateRequests: () => {
this.usageTaskActiveClient = null;
void this.usageTask.run(this.usageTaskArgs(null));
void this.usageTimeSeriesTask.run([null, ""]);
void this.usageSessionLogsTask.run([null, ""]);
},
resetForClientChange: () => this.resetForClientChange(),
reload: () => this.performUsageReload(),
});
private usageTaskArgs(
client = this.refreshRuntime.connected ? this.refreshRuntime.client : null,
) {
return [
client,
this.usageLoadStartDate,
this.usageLoadEndDate,
this.usageScope,
this.usageTimeZone,
normalizeLowercaseStringOrEmpty(this.usageAgentId ?? "") || null,
] as const;
}
private readonly usageTask = new Task(this, {
autoRun: false,
args: () => this.usageTaskArgs(),
task: async ([client, startDate, endDate, scope, timeZone, normalizedAgentId], { signal }) => {
if (!client) {
return initialState;
}
if (this.routeDataEnabled) {
return initialState;
}
this.refreshRuntime.beginLoad();
const agentId = normalizedAgentId || undefined;
const agentScopeParams = agentId ? { agentId } : { agentScope: "all" as const };
const [result, costSummary, providerUsageSummary] = await Promise.all([
requestSessionUsage(client, { startDate, endDate, agentId, scope, timeZone }),
client.request<CostUsageSummary>(
"usage.cost",
{
startDate,
endDate,
...agentScopeParams,
...buildSessionUsageDateParams(timeZone),
},
{ signal },
),
client
.request<ProviderUsageSummary>("usage.status", undefined, { signal })
.catch(() => null),
]);
return { result, costSummary, providerUsageSummary } satisfies UsageTaskValue;
},
onComplete: (value) => {
this.usageTaskActiveClient = null;
this.usageResult = value.result;
this.usageCostSummary = value.costSummary;
this.providerUsageSummary = value.providerUsageSummary;
this.usageError = null;
this.refreshRuntime.markLoaded();
this.refreshRuntime.flushPending();
},
onError: (error) => {
this.usageTaskActiveClient = null;
if (isMissingOperatorReadScopeError(error)) {
this.usageResult = null;
this.usageCostSummary = null;
this.usageError = formatMissingOperatorReadScopeMessage("usage");
} else {
this.usageError = toUsageErrorMessage(error);
}
this.refreshRuntime.flushPending();
},
});
private createUsageDetailTask<T>(
load: (client: GatewayBrowserClient, sessionKey: string) => Promise<T>,
status: () => PanelRefreshStatus,
apply: (value: UsageDetailTaskValue<T> | null | undefined, status: PanelRefreshStatus) => void,
) {
return new Task(this, {
autoRun: false,
args: () =>
[
this.refreshRuntime.connected ? this.refreshRuntime.client : null,
this.usageSelectedSessions.length === 1 ? (this.usageSelectedSessions[0] ?? "") : "",
] as const,
task: async ([client, sessionKey]) =>
client && sessionKey ? { sessionKey, data: await load(client, sessionKey) } : initialState,
onComplete: (value) => apply(value, completePanelRefresh()),
onError: (error) => {
const failure = failUsageDetailRefresh(status(), error);
apply(failure.clearData ? null : undefined, failure.status);
},
});
}
private readonly usageTimeSeriesTask = this.createUsageDetailTask(
requestSessionUsageTimeSeries,
() => this.usageTimeSeriesStatus,
(value, status) => {
if (value !== undefined) {
this.usageTimeSeriesValue = value;
}
this.usageTimeSeriesStatus = status;
},
);
private readonly usageSessionLogsTask = this.createUsageDetailTask(
async (client, sessionKey) => {
const payload = await requestSessionUsageLogs(client, sessionKey);
return Array.isArray(payload.logs) ? (payload.logs as SessionLogEntry[]) : null;
},
() => this.usageSessionLogsStatus,
(value, status) => {
if (value !== undefined) {
this.usageSessionLogsValue = value;
}
this.usageSessionLogsStatus = status;
},
);
private readonly subscriptions = new SubscriptionsController(this)
.effect(
() => this.context?.agentSelection,
@@ -174,7 +302,10 @@ class UsagePage extends OpenClawLightDomElement {
this.subscriptions.clear();
this.clearDateDebounce();
this.clearQueryDebounce();
this.invalidateRequests();
this.usageTaskActiveClient = null;
void this.usageTask.run(this.usageTaskArgs(null));
void this.usageTimeSeriesTask.run([null, ""]);
void this.usageSessionLogsTask.run([null, ""]);
super.disconnectedCallback();
}
@@ -192,13 +323,10 @@ class UsagePage extends OpenClawLightDomElement {
this.refreshRuntime.adoptGatewaySnapshot(snapshot);
if (data.gateway !== gateway || data.gatewaySnapshot !== snapshot) {
this.routeDataEnabled = false;
this.usageLoading = false;
return;
}
const currentAgentId = this.context.agentSelection.state.scopeId;
if (data.query.agentId !== currentAgentId) {
// Route loaders may finish after the page scope changes. Ignore their
// stale result and restart from the current scope in one operation.
this.usageAgentId = currentAgentId;
this.clearSelectionsAndDetails();
this.refreshRuntime.reload();
@@ -207,6 +335,8 @@ class UsagePage extends OpenClawLightDomElement {
this.usageStartDate = data.query.startDate;
this.usageEndDate = data.query.endDate;
this.usageLoadStartDate = data.query.startDate;
this.usageLoadEndDate = data.query.endDate;
this.usageScope = data.query.scope;
this.usageTimeZone = data.query.timeZone;
this.usageAgentId = data.query.agentId;
@@ -215,7 +345,6 @@ class UsagePage extends OpenClawLightDomElement {
this.providerUsageSummary = data.providerUsageSummary;
this.refreshRuntime.setLastLoadedAtMs(data.loadedAtMs);
this.usageError = data.error;
this.usageLoading = false;
}
private ensureInitialData() {
@@ -233,7 +362,8 @@ class UsagePage extends OpenClawLightDomElement {
private resetForClientChange() {
this.clearDateDebounce();
this.invalidateRequests();
this.usageTaskActiveClient = null;
void this.usageTask.run(this.usageTaskArgs(null));
if (this.routeDataInitialized) {
this.routeDataEnabled = false;
}
@@ -246,182 +376,59 @@ class UsagePage extends OpenClawLightDomElement {
this.clearSelectionsAndDetails();
}
private invalidateRequests() {
this.usageRequestId += 1;
this.timeSeriesRequestId += 1;
this.logsRequestId += 1;
this.usageLoading = false;
this.usageTimeSeriesLoading = false;
this.usageSessionLogsLoading = false;
private get usageLoading(): boolean {
return !this.routeDataInitialized || this.usageTaskActiveClient !== null;
}
private invalidateUsageRequest() {
this.usageRequestId += 1;
this.routeDataEnabled = false;
this.usageLoading = false;
private get usageTimeSeries() {
return this.usageTimeSeriesValue?.data ?? null;
}
private invalidateDetailRequests() {
this.timeSeriesRequestId += 1;
this.logsRequestId += 1;
this.usageTimeSeriesLoading = false;
this.usageSessionLogsLoading = false;
private get usageSessionLogs() {
return this.usageSessionLogsValue?.data ?? null;
}
private isCurrentRequest(requestId: number, client: GatewayBrowserClient): boolean {
const gateway = this.context.gateway.snapshot;
return this.isConnected && requestId === this.usageRequestId && gateway.client === client;
}
private isCurrentDetailRequest(
requestId: number,
currentRequestId: number,
client: GatewayBrowserClient,
sessionKey: string,
): boolean {
const gateway = this.context.gateway.snapshot;
return (
this.isConnected &&
requestId === currentRequestId &&
gateway.client === client &&
this.usageSelectedSessions.length === 1 &&
this.usageSelectedSessions[0] === sessionKey
);
}
private async loadUsage() {
private loadUsage(): Promise<void> {
const client = this.refreshRuntime.client;
if (!client || !this.refreshRuntime.connected) {
this.refreshRuntime.markLoadDeferred();
return;
return Promise.resolve();
}
if (this.usageLoading) {
return;
return Promise.resolve();
}
this.refreshRuntime.beginLoad();
this.routeDataEnabled = false;
const requestId = ++this.usageRequestId;
const startDate = this.usageStartDate;
const endDate = this.usageEndDate;
const scope = this.usageScope;
const timeZone = this.usageTimeZone;
const agentId = normalizeLowercaseStringOrEmpty(this.usageAgentId ?? "") || undefined;
this.usageLoading = true;
this.usageLoadStartDate = this.usageStartDate;
this.usageLoadEndDate = this.usageEndDate;
this.usageError = null;
try {
const agentScopeParams = agentId ? { agentId } : { agentScope: "all" as const };
const [sessionsResult, costSummary, providerUsageSummary] = await Promise.all([
requestSessionUsage(client, { startDate, endDate, agentId, scope, timeZone }),
client.request<CostUsageSummary>("usage.cost", {
startDate,
endDate,
...agentScopeParams,
...buildSessionUsageDateParams(timeZone),
}),
client.request<ProviderUsageSummary>("usage.status").catch(() => null),
]);
if (!this.isCurrentRequest(requestId, client)) {
return;
}
this.usageResult = sessionsResult;
this.usageCostSummary = costSummary;
this.providerUsageSummary = providerUsageSummary;
this.refreshRuntime.markLoaded();
} catch (error) {
if (!this.isCurrentRequest(requestId, client)) {
return;
}
if (isMissingOperatorReadScopeError(error)) {
this.usageResult = null;
this.usageCostSummary = null;
this.usageError = formatMissingOperatorReadScopeMessage("usage");
} else {
this.usageError = toUsageErrorMessage(error);
}
} finally {
if (this.isCurrentRequest(requestId, client)) {
this.usageLoading = false;
this.refreshRuntime.flushPending();
}
}
this.usageTaskActiveClient = client;
return this.usageTask.run();
}
private async loadSessionTimeSeries(sessionKey: string) {
private loadSessionTimeSeries(sessionKey: string): Promise<void> {
const client = this.refreshRuntime.client;
if (!client || !this.refreshRuntime.connected) {
return;
return Promise.resolve();
}
// Never render another session's retained timeline as stale.
if (this.usageTimeSeriesSessionKey !== sessionKey) {
this.usageTimeSeries = null;
this.usageTimeSeriesSessionKey = null;
if (this.usageTimeSeriesValue?.sessionKey !== sessionKey) {
this.usageTimeSeriesValue = null;
this.usageTimeSeriesStatus = createPanelRefreshStatus();
}
const requestId = ++this.timeSeriesRequestId;
this.usageTimeSeriesLoading = true;
this.usageTimeSeriesStatus = beginPanelRefresh(this.usageTimeSeriesStatus);
try {
const result = await requestSessionUsageTimeSeries(client, sessionKey);
if (this.isCurrentDetailRequest(requestId, this.timeSeriesRequestId, client, sessionKey)) {
this.usageTimeSeries = result;
this.usageTimeSeriesSessionKey = sessionKey;
this.usageTimeSeriesStatus = completePanelRefresh();
}
} catch (error) {
if (this.isCurrentDetailRequest(requestId, this.timeSeriesRequestId, client, sessionKey)) {
const failure = failUsageDetailRefresh(this.usageTimeSeriesStatus, error);
this.usageTimeSeriesStatus = failure.status;
if (failure.clearData) {
this.usageTimeSeries = null;
this.usageTimeSeriesSessionKey = null;
}
}
} finally {
if (this.isCurrentDetailRequest(requestId, this.timeSeriesRequestId, client, sessionKey)) {
this.usageTimeSeriesLoading = false;
}
}
return this.usageTimeSeriesTask.run([client, sessionKey]);
}
private async loadSessionLogs(sessionKey: string) {
private loadSessionLogs(sessionKey: string): Promise<void> {
const client = this.refreshRuntime.client;
if (!client || !this.refreshRuntime.connected) {
return;
return Promise.resolve();
}
// Never render another session's retained conversation as stale.
if (this.usageSessionLogsSessionKey !== sessionKey) {
this.usageSessionLogs = null;
this.usageSessionLogsSessionKey = null;
if (this.usageSessionLogsValue?.sessionKey !== sessionKey) {
this.usageSessionLogsValue = null;
this.usageSessionLogsStatus = createPanelRefreshStatus();
}
const requestId = ++this.logsRequestId;
this.usageSessionLogsLoading = true;
this.usageSessionLogsStatus = beginPanelRefresh(this.usageSessionLogsStatus);
try {
const payload = await requestSessionUsageLogs(client, sessionKey);
if (!this.isCurrentDetailRequest(requestId, this.logsRequestId, client, sessionKey)) {
return;
}
this.usageSessionLogs = Array.isArray(payload.logs)
? (payload.logs as SessionLogEntry[])
: null;
this.usageSessionLogsSessionKey = sessionKey;
this.usageSessionLogsStatus = completePanelRefresh();
} catch (error) {
if (this.isCurrentDetailRequest(requestId, this.logsRequestId, client, sessionKey)) {
const failure = failUsageDetailRefresh(this.usageSessionLogsStatus, error);
this.usageSessionLogsStatus = failure.status;
if (failure.clearData) {
this.usageSessionLogs = null;
this.usageSessionLogsSessionKey = null;
}
}
} finally {
if (this.isCurrentDetailRequest(requestId, this.logsRequestId, client, sessionKey)) {
this.usageSessionLogsLoading = false;
}
}
return this.usageSessionLogsTask.run([client, sessionKey]);
}
private clearSelections() {
@@ -431,13 +438,12 @@ class UsagePage extends OpenClawLightDomElement {
}
private clearDetails() {
this.invalidateDetailRequests();
this.usageTimeSeries = null;
this.usageTimeSeriesSessionKey = null;
this.usageTimeSeriesValue = null;
this.usageSessionLogsValue = null;
this.usageTimeSeriesStatus = createPanelRefreshStatus();
this.usageSessionLogs = null;
this.usageSessionLogsSessionKey = null;
this.usageSessionLogsStatus = createPanelRefreshStatus();
void this.usageTimeSeriesTask.run([null, ""]);
void this.usageSessionLogsTask.run([null, ""]);
this.usageTimeSeriesCursorStart = null;
this.usageTimeSeriesCursorEnd = null;
}
@@ -456,7 +462,7 @@ class UsagePage extends OpenClawLightDomElement {
private scheduleUsageLoad() {
this.clearDateDebounce();
this.invalidateUsageRequest();
this.routeDataEnabled = false;
this.dateDebounceTimer = window.setTimeout(() => {
this.dateDebounceTimer = null;
void this.loadUsage();
@@ -465,7 +471,6 @@ class UsagePage extends OpenClawLightDomElement {
private performUsageReload() {
this.clearDateDebounce();
this.invalidateUsageRequest();
void this.loadUsage();
}
@@ -546,12 +551,12 @@ class UsagePage extends OpenClawLightDomElement {
timeSeriesMode: this.usageTimeSeriesMode,
timeSeriesBreakdownMode: this.usageTimeSeriesBreakdownMode,
timeSeries: this.usageTimeSeries,
timeSeriesLoading: this.usageTimeSeriesLoading,
timeSeriesLoading: this.usageTimeSeriesTask.status === TaskStatus.PENDING,
timeSeriesStatus: this.usageTimeSeriesStatus,
timeSeriesCursorStart: this.usageTimeSeriesCursorStart,
timeSeriesCursorEnd: this.usageTimeSeriesCursorEnd,
sessionLogs: this.usageSessionLogs,
sessionLogsLoading: this.usageSessionLogsLoading,
sessionLogsLoading: this.usageSessionLogsTask.status === TaskStatus.PENDING,
sessionLogsStatus: this.usageSessionLogsStatus,
sessionLogsExpanded: this.usageSessionLogsExpanded,
logFilters: {
+105 -7
View File
@@ -279,7 +279,13 @@ describe("WorktreesPage lifecycle", () => {
);
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
document.body.append(page);
await waitForFast(() => expect(firstRequest).toHaveBeenCalledWith("worktrees.list", {}));
await waitForFast(() =>
expect(firstRequest).toHaveBeenCalledWith(
"worktrees.list",
{},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
),
);
const removing = page.removeWorktree(worktree());
await waitForFast(() =>
@@ -318,7 +324,13 @@ describe("WorktreesPage lifecycle", () => {
);
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
document.body.append(page);
await waitForFast(() => expect(request).toHaveBeenCalledWith("worktrees.list", {}));
await waitForFast(() =>
expect(request).toHaveBeenCalledWith(
"worktrees.list",
{},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
),
);
await page.removeWorktree(worktree());
@@ -341,7 +353,13 @@ describe("WorktreesPage lifecycle", () => {
const page = document.createElement("openclaw-worktrees-page") as WorktreesPageTestElement;
page.context = contextWithGateway(source.gateway);
document.body.append(page);
await waitForFast(() => expect(request).toHaveBeenCalledWith("worktrees.list", {}));
await waitForFast(() =>
expect(request).toHaveBeenCalledWith(
"worktrees.list",
{},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
),
);
const restoring = page.restore(worktree());
await waitForFast(() =>
@@ -414,6 +432,32 @@ describe("WorktreesPage lifecycle", () => {
expect(page.busyId).toBeNull();
});
it("surfaces an operation failure after an earlier list failure", async () => {
let listRequests = 0;
const request = vi.fn((method: string) => {
if (method === "worktrees.list") {
listRequests += 1;
return listRequests === 1
? Promise.reject(new Error("stale list failure"))
: Promise.resolve({ worktrees: [] });
}
if (method === "worktrees.restore") {
return Promise.reject(new Error("restore failed"));
}
return Promise.resolve({});
});
const page = document.createElement("openclaw-worktrees-page") as WorktreesPageTestElement;
page.context = contextWithGateway(
gatewayWithClient({ request } as unknown as GatewayBrowserClient),
);
document.body.append(page);
await waitForFast(() => expect(page.error).toBe("Error: stale list failure"));
await page.restore(worktree());
expect(page.error).toBe("Error: restore failed");
});
it("clears pending create state across a same-client reconnect", async () => {
const pendingCreate = deferred<unknown>();
const request = vi.fn((method: string) => {
@@ -428,7 +472,13 @@ describe("WorktreesPage lifecycle", () => {
page.context = contextWithGateway(source.gateway);
page.createRepoRoot = "/tmp/repo";
document.body.append(page);
await waitForFast(() => expect(request).toHaveBeenCalledWith("worktrees.list", {}));
await waitForFast(() =>
expect(request).toHaveBeenCalledWith(
"worktrees.list",
{},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
),
);
const creating = page.createWorktree();
await waitForFast(() =>
@@ -446,6 +496,36 @@ describe("WorktreesPage lifecycle", () => {
expect(page.error).toBeNull();
});
it("clears GC loading across a same-client reconnect", async () => {
const pendingGc = deferred<unknown>();
let listRequests = 0;
const request = vi.fn((method: string) => {
if (method === "worktrees.gc") {
return pendingGc.promise;
}
listRequests += 1;
return Promise.resolve({ worktrees: [] });
});
const client = { request } as unknown as GatewayBrowserClient;
const source = mutableGateway(client);
const page = document.createElement("openclaw-worktrees-page") as WorktreesPageTestElement;
page.context = contextWithGateway(source.gateway);
document.body.append(page);
await waitForFast(() => expect(listRequests).toBe(1));
const collecting = page.gc();
await waitForFast(() => expect(request).toHaveBeenCalledWith("worktrees.gc", {}));
expect(page.loading).toBe(true);
source.emit(false);
source.emit(true);
await waitForFast(() => expect(listRequests).toBe(2));
await waitForFast(() => expect(page.loading).toBe(false));
pendingGc.resolve({});
await collecting;
expect(page.loading).toBe(false);
});
it("locks the create draft and its toggle until create settles", async () => {
const pendingCreate = deferred<unknown>();
const request = vi.fn((method: string) => {
@@ -463,7 +543,13 @@ describe("WorktreesPage lifecycle", () => {
page.createName = "submitted-name";
page.createBaseRef = "main";
document.body.append(page);
await waitForFast(() => expect(request).toHaveBeenCalledWith("worktrees.list", {}));
await waitForFast(() =>
expect(request).toHaveBeenCalledWith(
"worktrees.list",
{},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
),
);
await waitForFast(() => expect(page.loading).toBe(false));
const toggleButton = Array.from(page.querySelectorAll<HTMLButtonElement>("button")).find(
@@ -523,7 +609,13 @@ describe("WorktreesPage lifecycle", () => {
);
page.createRepoRoot = "/tmp/repo";
document.body.append(page);
await waitForFast(() => expect(request).toHaveBeenCalledWith("worktrees.list", {}));
await waitForFast(() =>
expect(request).toHaveBeenCalledWith(
"worktrees.list",
{},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
),
);
page.loadCreateBranches();
@@ -549,7 +641,13 @@ describe("WorktreesPage lifecycle", () => {
);
page.createRepoRoot = "/tmp/repo";
document.body.append(page);
await waitForFast(() => expect(request).toHaveBeenCalledWith("worktrees.list", {}));
await waitForFast(() =>
expect(request).toHaveBeenCalledWith(
"worktrees.list",
{},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
),
);
page.loadCreateBranches();
page.loadCreateBranches();
+63 -55
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { html, nothing } from "lit";
import { state } from "lit/decorators.js";
import type { WorktreeRecord } from "../../../../packages/gateway-protocol/src/index.js";
@@ -46,7 +47,6 @@ class WorktreesPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@state() private loading = false;
@state() private records: WorktreeRecord[] = [];
@state() private error: string | null = null;
@state() private busyId: string | null = null;
@@ -56,12 +56,12 @@ class WorktreesPage extends OpenClawLightDomElement {
@state() private createBaseRef = "";
@state() private createBranches: string[] = [];
@state() private creating = false;
@state() private gcLoading = false;
private client: GatewayBrowserClient | null = null;
private listClient: GatewayBrowserClient | null = null;
private gatewayConnected = false;
private gatewaySource?: ApplicationContext["gateway"];
private hasBoundGateway = false;
private loadGeneration = 0;
private branchesGeneration = 0;
private operationEpoch = 0;
private readonly subscriptions = new SubscriptionsController(this).effect(
() => this.context?.gateway,
@@ -78,9 +78,42 @@ class WorktreesPage extends OpenClawLightDomElement {
},
);
private readonly listTask = new Task(this, {
autoRun: false,
args: () => [this.gatewayConnected ? this.client : null] as const,
task: ([client], { signal }) =>
client ? client.request<WorktreesListResult>("worktrees.list", {}, { signal }) : initialState,
onComplete: (result) => {
this.records = result.worktrees.toSorted((a, b) => b.lastActiveAt - a.lastActiveAt);
},
onError: (error) => {
this.error = String(error);
},
});
private readonly branchesTask = new Task(this, {
autoRun: false,
args: () => [this.gatewayConnected ? this.client : null, this.createRepoRoot.trim()] as const,
task: ([client, repoRoot], { signal }) =>
client && repoRoot
? client.request<WorktreeBranchesResult>("worktrees.branches", { repoRoot }, { signal })
: initialState,
onComplete: (result) => {
this.createBranches = result.branches.map((branch) => branch.name);
if (!this.createBaseRef) {
this.createBaseRef = result.defaultBranch ?? result.headBranch ?? "";
}
},
onError: () => {
this.createBranches = [];
},
});
override disconnectedCallback() {
this.subscriptions.clear();
this.invalidateLoad();
this.listClient = null;
void this.listTask.run([null]);
void this.branchesTask.run([null, ""]);
this.invalidateOperations();
this.gatewaySource = undefined;
this.client = null;
@@ -98,7 +131,11 @@ class WorktreesPage extends OpenClawLightDomElement {
this.client = snapshot.client;
this.gatewayConnected = snapshot.phase === "connected";
if (identityChanged || connectionChanged) {
this.invalidateLoad();
if (snapshot.phase !== "connected" || !snapshot.client) {
this.listClient = null;
void this.listTask.run([null]);
}
void this.branchesTask.run([null, ""]);
this.invalidateOperations();
}
if (identityChanged) {
@@ -110,16 +147,11 @@ class WorktreesPage extends OpenClawLightDomElement {
}
}
private invalidateLoad() {
this.loadGeneration += 1;
this.loading = false;
}
private invalidateOperations() {
this.operationEpoch += 1;
// Stale operation promises skip their finalizers, so reset every epoch-owned flag here.
this.busyId = null;
this.creating = false;
this.gcLoading = false;
}
private captureOperationScope(): WorktreeOperationScope | null {
@@ -148,37 +180,31 @@ class WorktreesPage extends OpenClawLightDomElement {
);
}
// Reads and writes share one page-level lane. Otherwise a stale list can
// overwrite a completed mutation, while busyId can only represent one row.
private get operationPending(): boolean {
return this.loading || this.busyId !== null || this.creating;
}
private get loading(): boolean {
return this.gcLoading || this.listTask.status === TaskStatus.PENDING;
}
private async load(options: { preserveError?: boolean } = {}) {
const client = this.client;
if (!client || !this.gatewayConnected || this.operationPending) {
if (
!client ||
!this.gatewayConnected ||
this.busyId !== null ||
this.creating ||
this.gcLoading ||
(this.listTask.status === TaskStatus.PENDING && this.listClient === client)
) {
return;
}
const generation = ++this.loadGeneration;
this.loading = true;
this.listClient = client;
if (!options.preserveError) {
this.error = null;
}
try {
const result = await client.request<WorktreesListResult>("worktrees.list", {});
if (generation === this.loadGeneration && client === this.client) {
// Registry order is insertion order; recently used checkouts matter most.
this.records = result.worktrees.toSorted((a, b) => b.lastActiveAt - a.lastActiveAt);
}
} catch (error) {
if (generation === this.loadGeneration && client === this.client) {
this.error = String(error);
}
} finally {
if (generation === this.loadGeneration && client === this.client) {
this.loading = false;
}
}
await this.listTask.run([client]);
}
private async removeWorktree(record: WorktreeRecord) {
@@ -201,7 +227,6 @@ class WorktreesPage extends OpenClawLightDomElement {
if (!this.isOperationScopeCurrent(scope) || result.removed) {
return;
}
// Structured snapshot failure: the caller decides whether to force.
const reason = result.snapshotError ?? "";
const force = window.confirm(t("worktrees.confirmForceDelete", { error: reason }));
if (!force) {
@@ -256,7 +281,7 @@ class WorktreesPage extends OpenClawLightDomElement {
if (!scope || this.operationPending) {
return;
}
this.loading = true;
this.gcLoading = true;
this.error = null;
try {
await scope.client.request("worktrees.gc", {});
@@ -266,15 +291,13 @@ class WorktreesPage extends OpenClawLightDomElement {
}
} finally {
if (this.isOperationScopeCurrent(scope)) {
this.loading = false;
this.gcLoading = false;
await this.load({ preserveError: true });
}
}
}
private toggleCreate() {
// A successful create closes and resets this shared draft, so the submitted
// snapshot must stay atomic until its request settles.
if (this.creating) {
return;
}
@@ -288,29 +311,14 @@ class WorktreesPage extends OpenClawLightDomElement {
}
private loadCreateBranches() {
const generation = ++this.branchesGeneration;
const scope = this.captureOperationScope();
const client = this.gatewayConnected ? this.client : null;
const repoRoot = this.createRepoRoot.trim();
if (!scope || !repoRoot) {
if (!client || !repoRoot) {
this.createBranches = [];
void this.branchesTask.run([null, ""]);
return;
}
void scope.client
.request<WorktreeBranchesResult>("worktrees.branches", { repoRoot })
.then((result) => {
// Only the latest picker request owns branch state, including after same-path retries.
if (generation === this.branchesGeneration && this.isOperationScopeCurrent(scope)) {
this.createBranches = result.branches.map((branch) => branch.name);
if (!this.createBaseRef) {
this.createBaseRef = result.defaultBranch ?? result.headBranch ?? "";
}
}
})
.catch(() => {
if (generation === this.branchesGeneration && this.isOperationScopeCurrent(scope)) {
this.createBranches = [];
}
});
void this.branchesTask.run([client, repoRoot]);
}
private async createWorktree() {