mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
74954fd931
commit
053384fa01
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+13
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user