From deedb5eff6ab04bd42f57585ca02ff88b5142256 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 22:41:51 -0700 Subject: [PATCH] fix(canvas): optimistic plugin install so the UI flips to "Installed" instantly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- canvas/src/components/tabs/SkillsTab.tsx | 30 ++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/canvas/src/components/tabs/SkillsTab.tsx b/canvas/src/components/tabs/SkillsTab.tsx index 8c5da29e..e6310352 100644 --- a/canvas/src/components/tabs/SkillsTab.tsx +++ b/canvas/src/components/tabs/SkillsTab.tsx @@ -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();