diff --git a/packages/app/e2e/browser/plugin-sidebar-items.spec.ts b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts index 900fb42db9..760eae9f79 100644 --- a/packages/app/e2e/browser/plugin-sidebar-items.spec.ts +++ b/packages/app/e2e/browser/plugin-sidebar-items.spec.ts @@ -27,6 +27,7 @@ import { seedWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; const APP_SETTINGS_KEY = "@paseo:app-settings"; @@ -133,6 +134,30 @@ function sidebarFooter(page: Page): Locator { return visibleTestId(page, "sidebar-add-project").locator("xpath=.."); } +function isPluginCatalogResponse(message: string | Buffer): boolean { + if (typeof message !== "string") return false; + const frame = JSON.parse(message); + return frame.type === "session" && frame.message?.type === "plugin.catalog.get.response"; +} + +async function holdNextPluginCatalogResponse(page: Page) { + const response = Promise.withResolvers<() => void>(); + let holdCatalog = true; + await page.routeWebSocket(daemonWsRoutePattern(), (socket) => { + const server = socket.connectToServer(); + socket.onMessage((message) => server.send(message)); + server.onMessage((message) => { + if (holdCatalog && isPluginCatalogResponse(message)) { + holdCatalog = false; + response.resolve(() => socket.send(message)); + return; + } + socket.send(message); + }); + }); + return { response: response.promise }; +} + test.describe("Plugin sidebar items", () => { let workspaceId: string; let cleanup: () => Promise; @@ -151,6 +176,38 @@ test.describe("Plugin sidebar items", () => { await cleanup(); }); + test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => { + await page.setViewportSize(WIDE); + await gotoWorkspace(page, workspaceId); + const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys"); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.click(); + const body = page.getByText("Deploys screen body", { exact: true }); + await expect(body).toBeVisible(); + const screenUrl = page.url(); + const catalog = await holdNextPluginCatalogResponse(page); + + await page.reload(); + const releaseCatalog = await catalog.response; + try { + await expect( + page.getByRole("progressbar", { name: "Loading plugin", exact: true }), + ).toBeVisible(); + await expect( + page.getByText("This plugin surface is unavailable.", { exact: true }), + ).toHaveCount(0); + await expect(body).toHaveCount(0); + } finally { + releaseCatalog(); + } + + await expect(body).toBeVisible(); + await expect( + page.getByRole("progressbar", { name: "Loading plugin", exact: true }), + ).toHaveCount(0); + await expect(page).toHaveURL(screenUrl); + }); + test("a plugin's screen, header row, footer popover and command work together", async ({ page, }) => { diff --git a/packages/app/src/plugins/catalog-sync-model.test.ts b/packages/app/src/plugins/catalog-sync-model.test.ts index 49f3c9ed2a..55cae64e5d 100644 --- a/packages/app/src/plugins/catalog-sync-model.test.ts +++ b/packages/app/src/plugins/catalog-sync-model.test.ts @@ -199,6 +199,83 @@ async function connected() { return { ...h, stop }; } +it("keeps a restored plugin screen loading until its catalog arrives", async () => { + const h = harness(); + expect(h.registry.getCatalogStatus("host")).toBe("loading"); + const pending = Promise.withResolvers(); + class PendingClient extends CatalogClient { + override getPluginCatalog() { + this.fetches++; + return pending.promise; + } + } + const client = new PendingClient(); + h.sync({ client }); + + expect(h.registry.getCatalogStatus("host")).toBe("loading"); + expect(h.registry.getSnapshot()).toEqual([]); + pending.resolve(client.catalog); + + await expect.poll(() => h.registry.getCatalogStatus("host")).toBe("ready"); + expect(h.installed().id).toBe("reports"); + expect(client.fetches).toBe(1); +}); + +it("settles an empty catalog without leaving missing plugin screens loading", async () => { + const h = harness(); + h.client.catalog = []; + const snapshot = h.registry.getSnapshot(); + const statuses: string[] = []; + cleanups.push( + h.registry.subscribe(() => { + statuses.push(h.registry.getCatalogStatus("host")); + }), + ); + h.sync(); + + await expect.poll(() => h.registry.getCatalogStatus("host")).toBe("ready"); + expect(h.registry.getSnapshot()).toBe(snapshot); + expect(statuses).toEqual(["loading", "ready"]); +}); + +it("settles a failed catalog request instead of loading indefinitely", async () => { + const h = harness(); + class FailingClient extends CatalogClient { + override async getPluginCatalog(): Promise { + throw new Error("Catalog request failed"); + } + } + h.sync({ client: new FailingClient() }); + + await expect.poll(() => h.registry.getCatalogStatus("host")).toBe("error"); + expect(h.registry.getSnapshot()).toEqual([]); +}); + +it("ignores an old failed request after a new connection loads its catalog", async () => { + const h = harness(); + const pending = Promise.withResolvers(); + const started = Promise.withResolvers(); + class PendingClient extends CatalogClient { + override getPluginCatalog() { + started.resolve(); + return pending.promise; + } + } + const stop = h.sync({ client: new PendingClient() }); + await started.promise; + stop(); + h.sync(); + await expect.poll(() => h.registry.getCatalogStatus("host")).toBe("ready"); + const plugin = h.installed(); + + pending.reject(new Error("Old connection failed")); + await pending.promise.catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(h.registry.getCatalogStatus("host")).toBe("ready"); + expect(h.installed()).toBe(plugin); +}); + function settingsQuery(plugin: InstalledPlugin) { return { queryKey: pluginSettingsKey("preferences"), diff --git a/packages/app/src/plugins/catalog-sync-model.ts b/packages/app/src/plugins/catalog-sync-model.ts index 6571294a68..3c11aef63b 100644 --- a/packages/app/src/plugins/catalog-sync-model.ts +++ b/packages/app/src/plugins/catalog-sync-model.ts @@ -9,7 +9,10 @@ interface CatalogSyncInput { audio: Pick | null; connected: boolean; supported: boolean; - registry: Pick; + registry: Pick< + PluginRegistry, + "installCatalog" | "getSnapshot" | "removeHost" | "setCatalogStatus" + >; } export function syncPluginCatalog({ @@ -22,30 +25,36 @@ export function syncPluginCatalog({ }: CatalogSyncInput): () => void { if (!supported || !audio) { registry.removeHost(serverId); + registry.setCatalogStatus(serverId, "ready"); return () => {}; } // A dropped socket reconnects on the same client, so installations keep rendering offline. if (!connected) return () => {}; + registry.setCatalogStatus(serverId, "loading"); const installationOptions = { client, audio }; let cancelled = false; let refreshQueue = Promise.resolve(); function refresh(replacePluginId?: string) { - refreshQueue = refreshQueue.then(() => - client + refreshQueue = refreshQueue.then(() => { + if (cancelled) return undefined; + registry.setCatalogStatus(serverId, "loading"); + return client .getPluginCatalog() .then((catalog) => { if (!cancelled) { registry.installCatalog(serverId, catalog, { ...installationOptions, replacePluginId }); + registry.setCatalogStatus(serverId, "ready"); } return undefined; }) .catch((error) => { if (!cancelled) { + registry.setCatalogStatus(serverId, "error"); console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error); } return undefined; - }), - ); + }); + }); return refreshQueue; } const observation = client.observeEvents([ diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index 96a9013686..fe21dc785f 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -13,6 +13,7 @@ import { runPluginClientBundle } from "./evaluate"; import type { InstalledPlugin } from "./types"; type CatalogPlugin = Awaited>[number]; +export type PluginCatalogStatus = "loading" | "ready" | "error"; export class PluginRegistry { private readonly byHost = new Map(); @@ -20,6 +21,7 @@ export class PluginRegistry { private snapshot: InstalledPlugin[] = []; private readonly disposed = new WeakSet(); private readonly evaluationErrors = new Map(); + private readonly catalogStatuses = new Map(); /** The client each host's installations were bound to; their Paseo API and RPCs call it. */ private readonly clientByHost = new Map(); @@ -42,6 +44,16 @@ export class PluginRegistry { return this.evaluationErrors.get(`${serverId}/${pluginId}`); } + getCatalogStatus(serverId: string): PluginCatalogStatus { + return this.catalogStatuses.get(serverId) ?? "loading"; + } + + setCatalogStatus(serverId: string, status: PluginCatalogStatus): void { + if (this.catalogStatuses.get(serverId) === status) return; + this.catalogStatuses.set(serverId, status); + for (const listener of this.listeners) listener(); + } + installCatalog( serverId: string, catalog: CatalogPlugin[], @@ -177,8 +189,14 @@ export class PluginRegistry { } removeHost(serverId: string): void { + const hadCatalog = this.catalogStatuses.delete(serverId); const installed = this.byHost.get(serverId); - if (!installed) return; + if (!installed) { + if (hadCatalog) { + for (const listener of this.listeners) listener(); + } + return; + } for (const plugin of installed) this.dispose(plugin); for (const key of this.evaluationErrors.keys()) { if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key); @@ -253,6 +271,14 @@ export function useInstalledPlugin(serverId: string, pluginId: string): Installe ); } +export function usePluginCatalogStatus(serverId: string): PluginCatalogStatus { + return useSyncExternalStore( + pluginRegistry.subscribe, + () => pluginRegistry.getCatalogStatus(serverId), + () => pluginRegistry.getCatalogStatus(serverId), + ); +} + export function usePluginInstallations(pluginId: string): InstalledPlugin[] { const installed = useInstalledPlugins(); return useMemo(() => installed.filter((plugin) => plugin.id === pluginId), [installed, pluginId]); diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index 2500d71e81..4973b34f5b 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -8,8 +8,25 @@ import { parsePluginOpenScreenInput, resolvePluginScreenTitle, resolvePluginSurfaceContribution, + resolvePluginSurfacePlaceholder, } from "./surface-contribution"; +it("shows loading instead of unavailable while the host catalog is pending", () => { + expect( + resolvePluginSurfacePlaceholder({ connectionStatus: "online", catalogStatus: "loading" }), + ).toBe("loading"); +}); + +it.each([ + ["online", "ready", "unavailable"], + ["online", "error", "error"], + ["connecting", "ready", "loading"], + ["offline", "loading", "offline"], + ["error", "loading", "offline"], +] as const)("shows %s host with %s catalog as %s", (connectionStatus, catalogStatus, expected) => { + expect(resolvePluginSurfacePlaceholder({ connectionStatus, catalogStatus })).toBe(expected); +}); + function installation( serverId: string, surfaces: string[], diff --git a/packages/app/src/plugins/surface-contribution.ts b/packages/app/src/plugins/surface-contribution.ts index a226e1f55c..7d1d2c665c 100644 --- a/packages/app/src/plugins/surface-contribution.ts +++ b/packages/app/src/plugins/surface-contribution.ts @@ -5,6 +5,26 @@ import { pluginScreenParamsFromRoute, } from "./routes"; import type { InstalledPlugin } from "./types"; +import type { PluginCatalogStatus } from "./registry"; +import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime"; + +export type PluginSurfacePlaceholderStatus = "loading" | "offline" | "error" | "unavailable"; + +interface PluginSurfacePlaceholderInput { + connectionStatus: HostRuntimeConnectionStatus; + catalogStatus: PluginCatalogStatus; +} + +export function resolvePluginSurfacePlaceholder({ + connectionStatus, + catalogStatus, +}: PluginSurfacePlaceholderInput): PluginSurfacePlaceholderStatus { + if (connectionStatus === "offline" || connectionStatus === "error") return "offline"; + if (connectionStatus !== "online") return "loading"; + if (catalogStatus === "loading") return "loading"; + if (catalogStatus === "error") return "error"; + return "unavailable"; +} export type PluginSurfaceContributionIdentity = | { kind: "sidebar"; id: string } diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index 849775addd..b0d4a2368b 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -12,14 +12,19 @@ import { ScreenHeader } from "@/components/headers/screen-header"; import { ScreenTitle } from "@/components/headers/screen-title"; import { HostFilter } from "@/components/hosts/host-filter"; import type { HostPickerHost } from "@/components/hosts/host-picker"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; +import { + useHostRuntimeClient, + useHostRuntimeConnectionStatus, + useHosts, +} from "@/runtime/host-runtime"; import type { Theme } from "@/styles/theme"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { usePluginHostNavigation } from "./host-navigation"; import { PluginIconView } from "./icons"; import { toPluginTheme } from "./theme"; -import { useInstalledPlugin, usePluginInstallations } from "./registry"; +import { useInstalledPlugin, usePluginCatalogStatus, usePluginInstallations } from "./registry"; import { buildPluginSurfaceRoute, pluginScreenParamsFromRoute } from "./routes"; import { legacySidebarItemHostKey, @@ -32,7 +37,9 @@ import { getPluginSurfaceContributionServerIds, resolvePluginScreenTitle, resolvePluginSurfaceContribution, + resolvePluginSurfacePlaceholder, type PluginSurfaceContributionIdentity, + type PluginSurfacePlaceholderStatus, } from "./surface-contribution"; import { resolvePluginPlatform } from "./platform"; @@ -42,6 +49,28 @@ const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme), }); const ThemedX = withUnistyles(X); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const SURFACE_ERRORS = { + offline: "Plugin host is offline.", + error: "Could not load plugins. Reconnect to try again.", + unavailable: "This plugin surface is unavailable.", +} as const; + +function PluginSurfacePlaceholder({ status }: { status: PluginSurfacePlaceholderStatus }) { + if (status === "loading") { + return ( + + + + ); + } + return {SURFACE_ERRORS[status]}; +} function routeParam(value: string | string[] | undefined): string { return typeof value === "string" ? value : ""; @@ -137,6 +166,9 @@ export function PluginSurfaceScreen() { const installations = usePluginInstallations(pluginId); const hosts = useHosts(); const client = useHostRuntimeClient(serverId); + const connectionStatus = useHostRuntimeConnectionStatus(serverId); + const catalogStatus = usePluginCatalogStatus(serverId); + const placeholderStatus = resolvePluginSurfacePlaceholder({ connectionStatus, catalogStatus }); const compact = useIsCompactFormFactor(); const { sidebarItem, surface } = useMemo( () => resolvePluginSurfaceContribution(plugin, identity), @@ -239,9 +271,7 @@ export function PluginSurfaceScreen() { /> ) : ( - - {plugin && surface ? "Plugin host is offline." : "This plugin surface is unavailable."} - + )} @@ -256,6 +286,11 @@ const styles = StyleSheet.create((theme) => ({ body: { flex: 1, }, + loading: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, errorText: { color: theme.colors.statusDanger, padding: theme.spacing[4],