mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 07:03:18 +00:00
Show progress while plugin screens load (#8123)
Plugin screens can report unavailable while a successful catalog request is still pending. Track the host catalog state so these screens show progress until their contributions register. Keep missing surfaces, failed requests, and offline hosts distinct from loading. Retain existing installed surfaces during catalog refreshes. Cover pending-to-ready rendering with a real browser regression and catalog lifecycle tests. Related to getpaseo/paseo#7088
This commit is contained in:
@@ -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<void>;
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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<Catalog>();
|
||||
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<Catalog> {
|
||||
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<Catalog>();
|
||||
const started = Promise.withResolvers<void>();
|
||||
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<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(h.registry.getCatalogStatus("host")).toBe("ready");
|
||||
expect(h.installed()).toBe(plugin);
|
||||
});
|
||||
|
||||
function settingsQuery(plugin: InstalledPlugin) {
|
||||
return {
|
||||
queryKey: pluginSettingsKey("preferences"),
|
||||
|
||||
@@ -9,7 +9,10 @@ interface CatalogSyncInput {
|
||||
audio: Pick<AudioEngine, "play"> | null;
|
||||
connected: boolean;
|
||||
supported: boolean;
|
||||
registry: Pick<PluginRegistry, "installCatalog" | "getSnapshot" | "removeHost">;
|
||||
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([
|
||||
|
||||
@@ -13,6 +13,7 @@ import { runPluginClientBundle } from "./evaluate";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
type CatalogPlugin = Awaited<ReturnType<DaemonClient["getPluginCatalog"]>>[number];
|
||||
export type PluginCatalogStatus = "loading" | "ready" | "error";
|
||||
|
||||
export class PluginRegistry {
|
||||
private readonly byHost = new Map<string, InstalledPlugin[]>();
|
||||
@@ -20,6 +21,7 @@ export class PluginRegistry {
|
||||
private snapshot: InstalledPlugin[] = [];
|
||||
private readonly disposed = new WeakSet<InstalledPlugin>();
|
||||
private readonly evaluationErrors = new Map<string, string>();
|
||||
private readonly catalogStatuses = new Map<string, PluginCatalogStatus>();
|
||||
/** The client each host's installations were bound to; their Paseo API and RPCs call it. */
|
||||
private readonly clientByHost = new Map<string, DaemonClient>();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
testID="plugin-surface-loading"
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel="Loading plugin"
|
||||
style={styles.loading}
|
||||
>
|
||||
<ThemedLoadingSpinner size="large" uniProps={mutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <Text style={styles.errorText}>{SURFACE_ERRORS[status]}</Text>;
|
||||
}
|
||||
|
||||
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() {
|
||||
/>
|
||||
</SurfaceErrorBoundary>
|
||||
) : (
|
||||
<Text style={styles.errorText}>
|
||||
{plugin && surface ? "Plugin host is offline." : "This plugin surface is unavailable."}
|
||||
</Text>
|
||||
<PluginSurfacePlaceholder status={placeholderStatus} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user