diff --git a/ui/src/lib/plugins/index.ts b/ui/src/lib/plugins/index.ts index bd70559dd4d8..4539a0f6c406 100644 --- a/ui/src/lib/plugins/index.ts +++ b/ui/src/lib/plugins/index.ts @@ -23,6 +23,26 @@ export type PluginInstallRequest = PluginsInstallParams; export type PluginMutationResult = PluginsInstallResult | PluginsSetEnabledResult; type PluginUninstallResult = PluginsUninstallResult; +export function resolvePluginInstallIdentity( + request: PluginInstallRequest, + plugins: readonly PluginCatalogItem[], + runtimeId?: string, +): string { + if (request.source === "official") { + return `plugin:${request.pluginId}`; + } + const catalogEntry = + plugins.find( + (plugin) => + plugin.packageName === request.packageName || + (plugin.install?.source === "clawhub" && + plugin.install.packageName === request.packageName), + ) ?? (runtimeId ? plugins.find((plugin) => plugin.id === runtimeId) : undefined); + return catalogEntry || runtimeId + ? `plugin:${catalogEntry?.id ?? runtimeId}` + : `clawhub:${request.packageName}`; +} + export const CLAWHUB_BROWSE_URL = "https://clawhub.ai/plugins"; export function loadPluginCatalog(client: GatewayBrowserClient): Promise { diff --git a/ui/src/pages/plugins/plugins-page.test.ts b/ui/src/pages/plugins/plugins-page.test.ts index c1912533bb35..2169a47d16c4 100644 --- a/ui/src/pages/plugins/plugins-page.test.ts +++ b/ui/src/pages/plugins/plugins-page.test.ts @@ -230,11 +230,19 @@ describe("PluginsPage", () => { throw new Error(`Unexpected method ${method}`); }); const harness = createGateway(client); + const lobsterCatalog = createResult( + createPlugin({ + id: "lobster", + name: "Lobster", + installed: false, + install: { source: "clawhub", packageName: "@openclaw/lobster" }, + }), + ); const { page } = await mountPage( createContext(harness.gateway), - createPluginsRouteData(harness.gateway), + createPluginsRouteData(harness.gateway, lobsterCatalog), ); - const rowKey = "plugin:@openclaw/lobster"; + const rowKey = "plugin:lobster"; const installRequest = { source: "clawhub", packageName: "@openclaw/lobster", @@ -321,11 +329,19 @@ describe("PluginsPage", () => { throw new Error(`Unexpected method ${method}`); }); const harness = createGateway(client); + const lobsterCatalog = createResult( + createPlugin({ + id: "lobster", + name: "Lobster", + installed: false, + install: { source: "clawhub", packageName: "@openclaw/lobster" }, + }), + ); const { page } = await mountPage( createContext(harness.gateway), - createPluginsRouteData(harness.gateway), + createPluginsRouteData(harness.gateway, lobsterCatalog), ); - const rowKey = "plugin:@openclaw/lobster"; + const rowKey = "plugin:lobster"; const installRequest = { source: "clawhub", packageName: "@openclaw/lobster", @@ -353,6 +369,50 @@ describe("PluginsPage", () => { expect(page.result?.plugins[0]?.installed).toBe(false); }); + it("deduplicates an active install across catalog and ClawHub search rows", async () => { + const pendingInstall = deferred(); + const { client, request } = createClient(async (method) => { + if (method === "plugins.install") { + return pendingInstall.promise; + } + if (method === "plugins.list") { + return createResult( + createPlugin({ id: "lobster", name: "Lobster", installed: true, enabled: true }), + ); + } + throw new Error(`Unexpected method ${method}`); + }); + const harness = createGateway(client); + const catalog = createResult( + createPlugin({ + id: "lobster", + name: "Lobster", + installed: false, + install: { source: "clawhub", packageName: "@openclaw/lobster" }, + }), + ); + const { page } = await mountPage( + createContext(harness.gateway), + createPluginsRouteData(harness.gateway, catalog), + ); + const installRequest = { + source: "clawhub", + packageName: "@openclaw/lobster", + } satisfies PluginInstallRequest; + + const catalogInstall = page.install("plugin:lobster", installRequest); + await waitForFast(() => expect(request).toHaveBeenCalledOnce()); + await page.install("clawhub:@openclaw/lobster", installRequest); + expect(request).toHaveBeenCalledOnce(); + + pendingInstall.resolve({ + ok: true, + plugin: createPlugin({ id: "lobster", name: "Lobster", installed: true, enabled: true }), + restartRequired: true, + }); + await catalogInstall; + }); + it("debounces two-character ClawHub searches and cancels stale input", async () => { vi.useFakeTimers(); const { client, request } = createClient(async (method) => { diff --git a/ui/src/pages/plugins/plugins-page.ts b/ui/src/pages/plugins/plugins-page.ts index 365b2e3abe94..2e2cd78f08b9 100644 --- a/ui/src/pages/plugins/plugins-page.ts +++ b/ui/src/pages/plugins/plugins-page.ts @@ -35,6 +35,7 @@ import { installPlugin, pluginInstallNeedsRiskAcknowledgement, readPluginInstallTrustError, + resolvePluginInstallIdentity, runPluginConfigMutation, setPluginEnabled, uninstallPlugin, @@ -146,6 +147,7 @@ class PluginsPage extends OpenClawLightDomElement { private searchTimer: ReturnType | null = null; private mutationToken = 0; private readonly mutationTokens = new Map(); + private readonly pendingInstallTargets = new Set(); private readonly iconMisses = new Set(); private readonly iconRequests = new Map< string, @@ -372,17 +374,17 @@ class PluginsPage extends OpenClawLightDomElement { } private captureUnknownInstallOutcomes() { - // Once an acknowledged request crosses a connection epoch, only a fresh catalog can + // Once an install crosses a connection epoch, only a fresh catalog can // prove whether it committed. Keep that safety gate separate from dismissible messages. const nextMessages = { ...this.messages }; const nextReconciliations = { ...this.installOutcomeReconciliations }; + for (const identity of this.pendingInstallTargets) { + nextReconciliations[identity] = "checking"; + } for (const [key, message] of Object.entries(this.messages)) { if (!message.installPolicyWarning) { continue; } - if (this.busy[key]) { - nextReconciliations[key] = "checking"; - } delete nextMessages[key]; } this.messages = nextMessages; @@ -783,17 +785,25 @@ class PluginsPage extends OpenClawLightDomElement { text: formatUiError(error), }); }, - options: { preserveMessageWhilePending?: boolean } = {}, + options: { + operationKey?: string; + pendingInstallTarget?: string; + preserveMessageWhilePending?: boolean; + } = {}, ): Promise { const scope = this.gateway.capture(); - if (!scope || !this.canMutate() || this.busy[rowKey]) { + const operationKey = options.operationKey ?? rowKey; + if (!scope || !this.canMutate() || this.busy[operationKey]) { return; } const mutationToken = ++this.mutationToken; - this.mutationTokens.set(rowKey, mutationToken); + this.mutationTokens.set(operationKey, mutationToken); + if (options.pendingInstallTarget) { + this.pendingInstallTargets.add(options.pendingInstallTarget); + } const isCurrent = () => - this.gateway.isCurrent(scope) && this.mutationTokens.get(rowKey) === mutationToken; - this.setBusy(rowKey, true); + this.gateway.isCurrent(scope) && this.mutationTokens.get(operationKey) === mutationToken; + this.setBusy(operationKey, true); if (!options.preserveMessageWhilePending) { this.setMessage(rowKey, null); } @@ -812,14 +822,24 @@ class PluginsPage extends OpenClawLightDomElement { onError(error); } } finally { - if (this.mutationTokens.get(rowKey) === mutationToken) { - this.mutationTokens.delete(rowKey); - this.setBusy(rowKey, false); + if (this.mutationTokens.get(operationKey) === mutationToken) { + this.mutationTokens.delete(operationKey); + this.setBusy(operationKey, false); + } + if (options.pendingInstallTarget) { + this.pendingInstallTargets.delete(options.pendingInstallTarget); + if (!this.gateway.isCurrent(scope)) { + this.installOutcomeReconciliations = { + ...this.installOutcomeReconciliations, + [options.pendingInstallTarget]: "checking", + }; + } } } } private async install(rowKey: string, request: PluginInstallRequest): Promise { + const operationKey = resolvePluginInstallIdentity(request, this.result?.plugins ?? []); await this.runPluginMutation( rowKey, (client) => installPlugin(client, request), @@ -860,6 +880,8 @@ class PluginsPage extends OpenClawLightDomElement { }); }, { + operationKey, + pendingInstallTarget: operationKey, preserveMessageWhilePending: request.installPolicyWarningAcknowledgement !== undefined, }, ); diff --git a/ui/src/pages/plugins/plugins.e2e.test.ts b/ui/src/pages/plugins/plugins.e2e.test.ts index 320d36ef0e54..06a2d79bab2a 100644 --- a/ui/src/pages/plugins/plugins.e2e.test.ts +++ b/ui/src/pages/plugins/plugins.e2e.test.ts @@ -148,6 +148,22 @@ const calendarSearchResponse = { ], } satisfies PluginsSearchResult; +const lobsterSearchResponse = { + results: [ + { + score: 1, + package: { + name: "@openclaw/lobster", + displayName: "Lobster", + family: "code-plugin", + channel: "official", + isOfficial: true, + runtimeId: "lobster", + }, + }, + ], +} satisfies PluginsSearchResult; + const uninstallResult = { ok: true, pluginId: "calendar-plus", @@ -860,6 +876,17 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => { ); expect((await gateway.getRequests("plugins.install")).length).toBe(2); + await gateway.setMethodResponse("plugins.search", lobsterSearchResponse); + await page.getByRole("searchbox", { name: "Search plugins" }).fill("lobster"); + await gateway.waitForRequest("plugins.search"); + const searchRow = page.locator('[data-package-name="@openclaw/lobster"]'); + await searchRow.waitFor({ state: "visible" }); + await searchRow.getByText("Checking plugin status…", { exact: false }).waitFor(); + expect( + await searchRow.getByRole("button", { name: "Install Lobster", exact: true }).isDisabled(), + ).toBe(true); + expect((await gateway.getRequests("plugins.install")).length).toBe(2); + await gateway.resolveDeferred("plugins.install", { ok: true, plugin: installedLobsterPlugin, diff --git a/ui/src/pages/plugins/view.test.ts b/ui/src/pages/plugins/view.test.ts index 3a7f67b47eb6..fa5e414fef3c 100644 --- a/ui/src/pages/plugins/view.test.ts +++ b/ui/src/pages/plugins/view.test.ts @@ -770,18 +770,33 @@ describe("renderPlugins", () => { const plugin = createPlugin({ id: "lobster", name: "Lobster", + packageName: "@openclaw/lobster", installed: false, enabled: false, state: "disabled", - install: { source: "clawhub", packageName: "@openclaw/lobster" }, + install: { source: "official", pluginId: "lobster" }, }); const key = pluginRowKey(plugin.id); const onRetryInstallOutcome = vi.fn(); const container = mount( createProps({ activeTab: "discover", + query: "lobster", result: createResult([plugin]), installOutcomeReconciliations: { [key]: "failed" }, + searchResults: [ + { + score: 1, + package: { + name: "@openclaw/lobster", + displayName: "Lobster", + family: "code-plugin", + channel: "official", + isOfficial: true, + runtimeId: "lobster", + }, + }, + ], onRetryInstallOutcome, }), ); @@ -793,6 +808,15 @@ describe("renderPlugins", () => { const install = actionButton(row ?? container, "Install Lobster"); expect(install?.disabled).toBe(true); expect(normalizedText(install)).toBe("Checking…"); + const searchRow = container.querySelector( + '[data-package-name="@openclaw/lobster"]', + ); + const searchInstall = actionButton(searchRow ?? container, "Install Lobster"); + expect(searchInstall?.disabled).toBe(true); + expect(normalizedText(searchInstall)).toBe("Checking…"); + expect(normalizedText(searchRow?.querySelector('[role="alert"]') ?? null)).toContain( + "couldn’t confirm whether this plugin was installed", + ); row?.querySelector('[role="alert"] button')?.click(); expect(onRetryInstallOutcome).toHaveBeenCalledOnce(); }); diff --git a/ui/src/pages/plugins/view.ts b/ui/src/pages/plugins/view.ts index 1887728c486c..a7eae049ac9a 100644 --- a/ui/src/pages/plugins/view.ts +++ b/ui/src/pages/plugins/view.ts @@ -23,6 +23,7 @@ import { EXTERNAL_LINK_TARGET, buildExternalLinkRel } from "../../lib/external-l import "../../styles/plugins.css"; import { CLAWHUB_BROWSE_URL, + resolvePluginInstallIdentity, type PluginCatalogItem, type PluginInstallRequest, type PluginListResult, @@ -159,6 +160,19 @@ function clawHubRowKey(packageName: string): string { return `clawhub:${packageName}`; } +function installOperationState( + props: PluginsViewProps, + request: PluginInstallRequest | undefined, + runtimeId?: string, +): { busy: boolean; outcome?: InstallOutcomeReconciliation } { + if (!request) { + return { busy: false }; + } + const identity = resolvePluginInstallIdentity(request, props.result?.plugins ?? [], runtimeId); + const outcome = props.installOutcomeReconciliations[identity]; + return { busy: Boolean(props.busy[identity] || outcome), ...(outcome ? { outcome } : {}) }; +} + export function connectorRowKey(connectorId: string): string { return `connector:${connectorId}`; } @@ -401,8 +415,8 @@ function renderRowMessage( message: PluginRowMessage | undefined, busy: boolean, props: PluginsViewProps, + installOutcome?: InstallOutcomeReconciliation, ) { - const installOutcome = props.installOutcomeReconciliations[key]; if (installOutcome) { return html`
${busy - ? props.installOutcomeReconciliations[key] + ? installOutcome ? t("pluginsPage.checkingInstallOutcome") : t("pluginsPage.installing") : t("pluginsPage.install")} @@ -764,7 +779,8 @@ function renderPluginRow( includePackageName = false, ): TemplateResult { const key = pluginRowKey(plugin.id); - const busy = Boolean(props.busy[key] || props.installOutcomeReconciliations[key]); + const installOperation = installOperationState(props, plugin.install); + const busy = Boolean(props.busy[key] || installOperation.busy); return html`
` : nothing} - ${renderRowMessage(key, props.messages[key], busy, props)} + ${renderRowMessage(key, props.messages[key], busy, props, installOperation.outcome)}
`; } @@ -943,7 +959,7 @@ function renderConnectorRow( props: PluginsViewProps, ): TemplateResult { const key = connectorRowKey(connector.id); - const busy = Boolean(props.busy[key] || props.installOutcomeReconciliations[key]); + const busy = Boolean(props.busy[key]); const isMcp = connector.action.kind === "mcp"; const installed = isMcp && @@ -1030,7 +1046,9 @@ function renderClawHubResult(item: PluginSearchResult, props: PluginsViewProps): const pkg = item.package; const installed = findInstalledSearchPlugin(item, props.result?.plugins ?? []); const key = clawHubRowKey(pkg.name); - const busy = Boolean(props.busy[key] || props.installOutcomeReconciliations[key]); + const installRequest = { source: "clawhub", packageName: pkg.name } as const; + const installOperation = installOperationState(props, installRequest, pkg.runtimeId); + const busy = Boolean(props.busy[key] || installOperation.busy); const artSlug = pkg.runtimeId ?? pkg.name; return html`
${installed ? html`${rowStateStatus(installed)}${renderCatalogActions(installed, props, busy, key)}` - : renderInstallButton(props, busy, key, pkg.displayName, { - source: "clawhub", - packageName: pkg.name, - })} + : renderInstallButton(props, busy, key, pkg.displayName, installRequest)}
- ${renderRowMessage(key, props.messages[key], busy, props)} + ${renderRowMessage(key, props.messages[key], busy, props, installOperation.outcome)} `; } @@ -1203,7 +1218,8 @@ function renderDetailOverlay(props: PluginsViewProps) { return nothing; } const key = pluginRowKey(plugin.id); - const busy = Boolean(props.busy[key] || props.installOutcomeReconciliations[key]); + const installOperation = installOperationState(props, plugin.install); + const busy = Boolean(props.busy[key] || installOperation.busy); return html` ` : nothing} - ${renderRowMessage(key, props.messages[key], busy, props)} + ${renderRowMessage(key, props.messages[key], busy, props, installOperation.outcome)}
${plugin.origin ? detailMetaRow(t("pluginsPage.detailOrigin"), originLabel(plugin.origin))