fix(canvas): optimistic plugin install so the UI flips to "Installed" instantly

After clicking Install, the button reverted from "Installing..." → "Install"
the moment the POST returned, then sat there for ~15s before the green
"Installed" tag appeared. The 15s gap is PLUGIN_RELOAD_DELAY_MS — we
delay the GET /workspaces/:id/plugins refetch to wait for the workspace
to restart (the listing handler returns [] while the container is
restarting because findRunningContainer comes up empty).

Uninstall already does optimistic local-state mutation (line 244 prior
to this commit) so the green tag → install button transition is
instant. Install was the inconsistent half — push the registry entry
into `installed` immediately after POST returns 200 and let the
delayed refetch reconcile.

The optimistic record uses the registry entry's metadata (name,
version, description, tags, runtimes, skills) and sets
supported_on_runtime=true. If reconciliation later disagrees (server
filter, install actually failed at the runtime layer), the refetch
overwrites the local record. Worst case is a brief 15s window where
we show "Installed" for a plugin that won't load — same window the
user previously experienced as "stuck on Install button" — but flipped
to the correct expected state.

Custom-source installs (github://, etc.) don't have a registry entry
to use, so they keep the old behavior of waiting for the refetch. Most
users install from the registry list in the UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-24 22:41:51 -07:00
parent 176b703dbc
commit deedb5eff6

View File

@ -213,12 +213,35 @@ export function SkillsTab({ workspaceId, data }: Props) {
// Install always goes through the source-based API. For registry
// plugins we build the local:// source on the fly; custom sources
// (github://, clawhub://, …) are typed into the input below.
const installFromSource = async (source: string, labelOverride?: string) => {
//
// Optional `optimistic` parameter mirrors the uninstall flow's local
// state mutation. Without it, the user sees the button revert from
// "Installing..." → "Install" the instant the POST returns, and the
// green "Installed" tag doesn't appear for ~15s while we wait out
// PLUGIN_RELOAD_DELAY_MS for the workspace restart before refetching.
// 15s of staring at the same button feels broken. Pushing the
// registry entry into `installed` immediately makes the UI reflect
// the install instantly; the delayed loadInstalled() reconciles
// anything we got wrong (or any server-side filtering we don't
// know about locally).
const installFromSource = async (
source: string,
labelOverride?: string,
optimistic?: PluginInfo,
) => {
const label = labelOverride ?? source;
setInstalling(label);
try {
await api.post(`/workspaces/${workspaceId}/plugins`, { source });
showToast(`Installed ${label} — restarting workspace`, "success");
if (optimistic && mountedRef.current) {
setInstalled((prev) =>
prev.some((p) => p.name === optimistic.name)
? prev
: [...prev, { ...optimistic, supported_on_runtime: true }],
);
setInstalledLoaded(true);
}
reloadTimerRef.current = setTimeout(() => loadInstalled(), PLUGIN_RELOAD_DELAY_MS);
} catch (e) {
showToast(e instanceof Error ? e.message : "Install failed", "error");
@ -227,7 +250,10 @@ export function SkillsTab({ workspaceId, data }: Props) {
}
};
const handleInstall = (pluginName: string) => installFromSource(`local://${pluginName}`, pluginName);
const handleInstall = (pluginName: string) => {
const entry = registry.find((p) => p.name === pluginName);
return installFromSource(`local://${pluginName}`, pluginName, entry);
};
const handleInstallCustom = async () => {
const source = customSource.trim();