diff --git a/config/knip.config.ts b/config/knip.config.ts index 4a94979bf809..2a6c73ecc41d 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -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", diff --git a/package.json b/package.json index 4fa241f7c54b..0c2e9af28171 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba8df5a78ed3..e913b8740f95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/ui/package.json b/ui/package.json index c117b4a95bea..70434f42d746 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/components/mcp-app-view.ts b/ui/src/components/mcp-app-view.ts index 7269f6ff5c23..6a872c9ecdc8 100644 --- a/ui/src/components/mcp-app-view.ts +++ b/ui/src/components/mcp-app-view.ts @@ -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 } | null; + disposed: boolean; +}; +type McpAppBinding = { + client: NonNullable; + 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(); private resources: McpAppResources | null = null; private teardownPromise: Promise | 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): Promise { - 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, + signal?: AbortSignal, + ): Promise { 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 { + 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((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) => + 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`
- ${this.error - ? html`
${t("mcpApp.unavailable", { error: this.error })}
` - : nothing}`; + ${errorText ? html`
${errorText}
` : nothing}`; } } diff --git a/ui/src/components/onboarding-memory-import.test.ts b/ui/src/components/onboarding-memory-import.test.ts index 97389d52491a..9b730f479b3a 100644 --- a/ui/src/components/onboarding-memory-import.test.ts +++ b/ui/src/components/onboarding-memory-import.test.ts @@ -9,6 +9,7 @@ import "./onboarding-memory-import.ts"; type OnboardingMemoryImportElement = HTMLElement & { active: boolean; context: ApplicationContext; + requestUpdate: () => void; updateComplete: Promise; }; @@ -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(); }); diff --git a/ui/src/components/onboarding-memory-import.ts b/ui/src/components/onboarding-memory-import.ts index 4e4e3bc2d791..90a7dffc4304 100644 --- a/ui/src/components/onboarding-memory-import.ts +++ b/ui/src/components/onboarding-memory-import.ts @@ -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; @property({ type: Boolean }) active = false; - @state() private plan: MigrationsMemoryPlanResult | null = null; @state() private selectedByProvider: Record = {}; @state() private applyingProviderId: string | null = null; @state() private results: Record = {}; @state() private done = false; @state() private closed = false; + private agentsListRequest: ApplicationContext["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( + "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("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() { diff --git a/ui/src/lib/plugins/index.ts b/ui/src/lib/plugins/index.ts index 5b19a682f197..cab2051ad23d 100644 --- a/ui/src/lib/plugins/index.ts +++ b/ui/src/lib/plugins/index.ts @@ -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("plugins.list", {}); } -export function searchPluginCatalog( - client: GatewayBrowserClient, - query: string, -): Promise { - return client.request("plugins.search", { query, limit: 20 }); -} - export function installPlugin( client: GatewayBrowserClient, request: PluginInstallRequest, diff --git a/ui/src/pages/chat/components/session-diff-panel.test.ts b/ui/src/pages/chat/components/session-diff-panel.test.ts new file mode 100644 index 000000000000..12c9be8857e9 --- /dev/null +++ b/ui/src/pages/chat/components/session-diff-panel.test.ts @@ -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; +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((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(); + const second = deferred(); + 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"); + }); +}); diff --git a/ui/src/pages/chat/components/session-diff-panel.ts b/ui/src/pages/chat/components/session-diff-panel.ts index 5be830200837..f98a83c0ee4e 100644 --- a/ui/src/pages/chat/components/session-diff-panel.ts +++ b/ui/src/pages/chat/components/session-diff-panel.ts @@ -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(); - private requestVersion = 0; + private readonly diffTask = new Task(this, { + args: () => [this.loader] as const, + task: async ([loader]): Promise => { + 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(); + }, + }); - protected override updated(changed: Map) { - if (changed.has("loader")) { - void this.refresh(); - } + private get loading(): boolean { + return this.diffTask.status === TaskStatus.PENDING; } - private async refresh(): Promise { - 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(); - } 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 { + 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`
${this.error}
`; + if (this.diffTask.status === TaskStatus.ERROR) { + const error = this.diffTask.error; + return html`
+ ${error instanceof Error ? error.message : String(error)} +
`; } - const result = this.result; - if (!result) { + const value = this.diffTask.value; + if (!value) { return html`
${t("chat.sessionDiff.loading")}
`; } + const { result, views } = value; if (result.unavailableReason === "not_git") { return html`
${t("chat.sessionDiff.notGit")}
`; } @@ -191,7 +184,7 @@ class SessionDiffPanel extends OpenClawLightDomElement { ${this.renderSummary(result)} ${result.files.length === 0 ? html`
${t("chat.sessionDiff.empty")}
` - : this.views.map((view) => this.renderFile(view))} + : views.map((view) => this.renderFile(view))} ${result.truncated === true ? html`
${t("chat.sessionDiff.truncatedResult")}
` : nothing} diff --git a/ui/src/pages/chat/components/session-discussion-panel.open-flow.test.ts b/ui/src/pages/chat/components/session-discussion-panel.open-flow.test.ts new file mode 100644 index 000000000000..f7fad038439b --- /dev/null +++ b/ui/src/pages/chat/components/session-discussion-panel.open-flow.test.ts @@ -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() + .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(); + 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().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() + .mockResolvedValueOnce({ state: "available" }) + .mockResolvedValueOnce({ state: "none" }); + const onStateChange = vi.fn(); + 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() + .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() + .mockResolvedValueOnce({ state: "available" }) + .mockResolvedValueOnce({ state: "none" }); + const openDiscussion = vi.fn().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() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstLoad = resolve; + }), + ) + .mockResolvedValueOnce({ state: "none" }); + const openDiscussion = vi.fn(); + 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(); + 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(); + }); +}); diff --git a/ui/src/pages/chat/components/session-discussion-panel.test-support.ts b/ui/src/pages/chat/components/session-discussion-panel.test-support.ts new file mode 100644 index 000000000000..37d609d2c7ad --- /dev/null +++ b/ui/src/pages/chat/components/session-discussion-panel.test-support.ts @@ -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; +}; + +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; +} diff --git a/ui/src/pages/chat/components/session-discussion-panel.test.ts b/ui/src/pages/chat/components/session-discussion-panel.test.ts index e4eaf008a2b1..11c32e72c14a 100644 --- a/ui/src/pages/chat/components/session-discussion-panel.test.ts +++ b/ui/src/pages/chat/components/session-discussion-panel.test.ts @@ -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; -}; - -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() - .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(); - 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().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() - .mockResolvedValueOnce({ state: "available" }) - .mockResolvedValueOnce({ state: "none" }); - const onStateChange = vi.fn(); - 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() - .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() - .mockResolvedValueOnce({ state: "available" }) - .mockResolvedValueOnce({ state: "none" }); - const openDiscussion = vi.fn().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() - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirstLoad = resolve; - }), - ) - .mockResolvedValueOnce({ state: "none" }); - const openDiscussion = vi.fn(); - 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(); - }); }); diff --git a/ui/src/pages/chat/components/session-discussion-panel.ts b/ui/src/pages/chat/components/session-discussion-panel.ts index 37b3e2a146ca..fb6ee8365de1 100644 --- a/ui/src/pages/chat/components/session-discussion-panel.ts +++ b/ui/src/pages/chat/components/session-discussion-panel.ts @@ -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) { - 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 { - 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 { - 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`
-
${this.error}
+
${error instanceof Error ? error.message : String(error)}
`; } - if (this.loading || !this.info) { + const value = this.discussionTask.value; + if ( + this.discussionTask.status === TaskStatus.PENDING && + this.openingDiscussion && + this.isOpeningCurrent(this.openingDiscussion) + ) { + return html`
+ ${t("chat.sessionDiscussion.opening")} +
`; + } + if (this.discussionTask.status !== TaskStatus.COMPLETE || !value) { return html`
${t("chat.sessionDiscussion.loading")}
`; } - 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`
${this.canOpen ? t("chat.sessionDiscussion.opening") : t("chat.sessionDiscussion.requiresWriteAccess")}
`; } - return this.renderOpen(this.info); + return this.renderOpen(info); } } diff --git a/ui/src/pages/gateway-source-replacement.test.ts b/ui/src/pages/gateway-source-replacement.test.ts index e6e18b0cf6bc..9a4b81e1cbac 100644 --- a/ui/src/pages/gateway-source-replacement.test.ts +++ b/ui/src/pages/gateway-source-replacement.test.ts @@ -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(); + 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); diff --git a/ui/src/pages/memory-import/memory-import-page.test.ts b/ui/src/pages/memory-import/memory-import-page.test.ts index 903ae805a093..650544971718 100644 --- a/ui/src/pages/memory-import/memory-import-page.test.ts +++ b/ui/src/pages/memory-import/memory-import-page.test.ts @@ -325,7 +325,6 @@ describe("MemoryImportPage", () => { ); context.gateway.snapshot.phase = "stopped"; - context.gateway.snapshot.client = null; page.requestUpdate(); await page.updateComplete; await page.updateComplete; diff --git a/ui/src/pages/memory-import/memory-import-page.ts b/ui/src/pages/memory-import/memory-import-page.ts index debfcc49e896..477b4e618514 100644 --- a/ui/src/pages/memory-import/memory-import-page.ts +++ b/ui/src/pages/memory-import/memory-import-page.ts @@ -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 = {}; @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 = {}; - 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; + 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( + "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("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 { + 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( - "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("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), diff --git a/ui/src/pages/model-setup/model-setup-page.ts b/ui/src/pages/model-setup/model-setup-page.ts index a5bad77526c6..060c847fc1c9 100644 --- a/ui/src/pages/model-setup/model-setup-page.ts +++ b/ui/src/pages/model-setup/model-setup-page.ts @@ -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 = + | { client: GatewayBrowserClient; value: T } + | { client: GatewayBrowserClient; error: unknown }; + +async function captureModelResult( + client: GatewayBrowserClient, + load: () => Promise, +): Promise> { + 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(); 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 & { 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 + >(this, { + autoRun: false, + args: () => [null, null], + task: ([client, params], { signal }) => + client && params + ? captureModelResult(client, () => + client.request("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>> + >(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 { @@ -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( - "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 { diff --git a/ui/src/pages/new-session/cloud-profile-discovery.ts b/ui/src/pages/new-session/cloud-profile-discovery.ts index b69860f6f26f..db35175ee901 100644 --- a/ui/src/pages/new-session/cloud-profile-discovery.ts +++ b/ui/src/pages/new-session/cloud-profile-discovery.ts @@ -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 | 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 | 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, + admin: boolean, +): Promise { + return admin ? requestCloudProfiles(client) : Promise.resolve([]); } diff --git a/ui/src/pages/new-session/gateway-name-discovery.ts b/ui/src/pages/new-session/gateway-name-discovery.ts index 505bbdc309dc..79f8549b47d5 100644 --- a/ui/src/pages/new-session/gateway-name-discovery.ts +++ b/ui/src/pages/new-session/gateway-name-discovery.ts @@ -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 { + 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("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("system.info", {}, { signal }); + return ( + normalizeOptionalString(result.machineName) ?? + normalizeOptionalString(result.hostname)?.split(".", 1)[0] ?? + "" + ); + } catch { + return ""; } } diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index b950db996670..75a4b5172a40 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -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 | undefined; + private cloudProfileRetryAttempt = 0; + private cloudProfileRetryTimer: ReturnType | 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())) diff --git a/ui/src/pages/plugins/plugins-page.test-support.ts b/ui/src/pages/plugins/plugins-page.test-support.ts new file mode 100644 index 000000000000..1f8edd4e3976 --- /dev/null +++ b/ui/src/pages/plugins/plugins-page.test-support.ts @@ -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; + +type GatewayHarness = { + gateway: ApplicationGateway; + emit: (client: GatewayBrowserClient | null, connected: boolean) => ApplicationGatewaySnapshot; +}; + +type TestPluginsPage = HTMLElement & { + routeData?: PluginsRouteData; + updateComplete: Promise; + result: PluginListResult | null; + loading: boolean; + busy: Record; + activeTab: "installed" | "discover"; + searchResults: PluginSearchResult[] | null; + applyMutationResult: (result: PluginMutationResult) => void; +}; + +export type RuntimeConfigTestState = { + configFormDirty: boolean; + lastError: string | null; + configSnapshot?: { sourceConfig: Record; hash: string } | null; +}; + +export function createPlugin(overrides: Partial = {}): 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 Promise>>; + patch: ReturnType< + typeof vi.fn<(options: { raw: Record; note: string }) => Promise> + >; + 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; note: string }) => Promise + >(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() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +export async function clickRowAction(page: TestPluginsPage, pluginSelector: string, label: string) { + const button = [...page.querySelectorAll(`${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(); +} diff --git a/ui/src/pages/plugins/plugins-page.test.ts b/ui/src/pages/plugins/plugins-page.test.ts index 2d5f54eb0928..8d6e0badaa78 100644 --- a/ui/src/pages/plugins/plugins-page.test.ts +++ b/ui/src/pages/plugins/plugins-page.test.ts @@ -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; - -type GatewayHarness = { - gateway: ApplicationGateway; - emit: (client: GatewayBrowserClient | null, connected: boolean) => ApplicationGatewaySnapshot; -}; - -type TestPluginsPage = HTMLElement & { - routeData?: PluginsRouteData; - updateComplete: Promise; - result: PluginListResult | null; - loading: boolean; - busy: Record; - activeTab: "installed" | "discover"; - applyMutationResult: (result: PluginMutationResult) => void; -}; - -type RuntimeConfigTestState = { - configFormDirty: boolean; - lastError: string | null; - configSnapshot?: { sourceConfig: Record; hash: string } | null; -}; - -function createPlugin(overrides: Partial = {}): 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 Promise>>; - patch: ReturnType< - typeof vi.fn<(options: { raw: Record; note: string }) => Promise> - >; - 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; note: string }) => Promise - >(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() { - let resolve!: (value: T) => void; - const promise = new Promise((nextResolve) => { - resolve = nextResolve; - }); - return { promise, resolve }; -} - -async function clickRowAction(page: TestPluginsPage, pluginSelector: string, label: string) { - const button = [...page.querySelectorAll(`${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("#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), diff --git a/ui/src/pages/plugins/plugins-page.ts b/ui/src/pages/plugins/plugins-page.ts index 7908b61e0e20..a2d6ca7aec85 100644 --- a/ui/src/pages/plugins/plugins-page.ts +++ b/ui/src/pages/plugins/plugins-page.ts @@ -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 = {}; @state() private messages: Record = {}; @state() private pendingRemoval: Record = {}; @@ -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 | 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("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 { @@ -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 { @@ -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 { - 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 { 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) }); diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts index 4297d1351260..e18c054d729e 100644 --- a/ui/src/pages/sessions/sessions-page.ts +++ b/ui/src/pages/sessions/sessions-page.ts @@ -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 = {}; - @state() private checkpointLoadingKey: string | null = null; + @state() private checkpointTaskKey: string | null = null; @state() private checkpointBusyKey: string | null = null; @state() private checkpointErrorByKey: Record = {}; 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, diff --git a/ui/src/pages/usage/usage-page.test.ts b/ui/src/pages/usage/usage-page.test.ts index adbbaf261135..55a94095a9c8 100644 --- a/ui/src/pages/usage/usage-page.test.ts +++ b/ui/src/pages/usage/usage-page.test.ts @@ -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; loadSessionLogs: (sessionKey: string) => Promise; @@ -80,6 +78,28 @@ afterEach(() => { }); describe("UsagePage detail requests", () => { + it("commits only the latest time-series selection", async () => { + const first = deferred(); + const second = deferred(); + 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(); 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 () => { diff --git a/ui/src/pages/usage/usage-page.ts b/ui/src/pages/usage/usage-page.ts index 2537d82d723d..d5ca1342fc56 100644 --- a/ui/src/pages/usage/usage-page.ts +++ b/ui/src/pages/usage/usage-page.ts @@ -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 = { + 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 | 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 | 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( + "usage.cost", + { + startDate, + endDate, + ...agentScopeParams, + ...buildSessionUsageDateParams(timeZone), + }, + { signal }, + ), + client + .request("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( + load: (client: GatewayBrowserClient, sessionKey: string) => Promise, + status: () => PanelRefreshStatus, + apply: (value: UsageDetailTaskValue | 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 { 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("usage.cost", { - startDate, - endDate, - ...agentScopeParams, - ...buildSessionUsageDateParams(timeZone), - }), - client.request("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 { 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 { 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: { diff --git a/ui/src/pages/worktrees/worktrees-page.test.ts b/ui/src/pages/worktrees/worktrees-page.test.ts index 226733ecd823..bb018ae8261a 100644 --- a/ui/src/pages/worktrees/worktrees-page.test.ts +++ b/ui/src/pages/worktrees/worktrees-page.test.ts @@ -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(); 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(); + 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(); 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("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(); diff --git a/ui/src/pages/worktrees/worktrees-page.ts b/ui/src/pages/worktrees/worktrees-page.ts index c309fda7729f..131f174e656b 100644 --- a/ui/src/pages/worktrees/worktrees-page.ts +++ b/ui/src/pages/worktrees/worktrees-page.ts @@ -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("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("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("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("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() {