fix(ui): share plugin install operation identity

This commit is contained in:
jesse-merhi
2026-08-13 01:42:31 +10:00
parent ca8385afc6
commit 058f435aa3
6 changed files with 199 additions and 30 deletions
+20
View File
@@ -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<PluginListResult> {
+64 -4
View File
@@ -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<PluginMutationResult>();
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) => {
+34 -12
View File
@@ -35,6 +35,7 @@ import {
installPlugin,
pluginInstallNeedsRiskAcknowledgement,
readPluginInstallTrustError,
resolvePluginInstallIdentity,
runPluginConfigMutation,
setPluginEnabled,
uninstallPlugin,
@@ -146,6 +147,7 @@ class PluginsPage extends OpenClawLightDomElement {
private searchTimer: ReturnType<typeof setTimeout> | null = null;
private mutationToken = 0;
private readonly mutationTokens = new Map<string, number>();
private readonly pendingInstallTargets = new Set<string>();
private readonly iconMisses = new Set<string>();
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<void> {
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<void> {
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,
},
);
+27
View File
@@ -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,
+25 -1
View File
@@ -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<HTMLElement>(
'[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(
"couldnt confirm whether this plugin was installed",
);
row?.querySelector<HTMLButtonElement>('[role="alert"] button')?.click();
expect(onRetryInstallOutcome).toHaveBeenCalledOnce();
});
+29 -13
View File
@@ -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`
<div
@@ -622,6 +636,7 @@ function renderInstallButton(
name: string,
request: PluginInstallRequest,
) {
const { outcome: installOutcome } = installOperationState(props, request);
return html`
<button
type="button"
@@ -635,7 +650,7 @@ function renderInstallButton(
}}
>
${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`
<article
class="settings-row plugins-item plugins-item--clickable"
@@ -813,7 +829,7 @@ function renderPluginRow(
${plugin.error}
</div>`
: nothing}
${renderRowMessage(key, props.messages[key], busy, props)}
${renderRowMessage(key, props.messages[key], busy, props, installOperation.outcome)}
</article>
`;
}
@@ -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`
<article
@@ -1075,12 +1093,9 @@ function renderClawHubResult(item: PluginSearchResult, props: PluginsViewProps):
<div class="settings-row__control">
${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)}
</div>
${renderRowMessage(key, props.messages[key], busy, props)}
${renderRowMessage(key, props.messages[key], busy, props, installOperation.outcome)}
</article>
`;
}
@@ -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`
<openclaw-modal-dialog
label=${plugin.name}
@@ -1281,7 +1297,7 @@ function renderDetailOverlay(props: PluginsViewProps) {
${plugin.error}
</div>`
: nothing}
${renderRowMessage(key, props.messages[key], busy, props)}
${renderRowMessage(key, props.messages[key], busy, props, installOperation.outcome)}
<div class="plugins-detail__meta">
${plugin.origin
? detailMetaRow(t("pluginsPage.detailOrigin"), originLabel(plugin.origin))