Files
paseo/packages/app/e2e/browser/plugin-workspace-panels.spec.ts
Mohamed Boudra 9bce7dbd25 refactor(plugin): unify the client UI entrypoint (#7474)
* fix(files): report document MIME types for binary files

File reads now carry the document's MIME type for binary files, so a file panel can
hand the bytes to a viewer that needs the real type (application/pdf).

* feat(plugins): file panels

Plugins contribute file panels with addFilePanel. A file tab hosts the panels that claim the
file: the built-in Markdown, HTML and image panels, plugin panels, and Source, Paseo's editor,
which claims every text file and is always the last candidate. The panel is the tab body;
the bar shows the document's status and a switcher between the file's panels (side by side
up to three, two on a phone, a menu beyond that).

Paseo asks which panel opens a file only when a plugin panel is among several candidates,
once per open, and remembers the answer per extension or exact filename when told to. Open
with in the tab menu and the switcher change the tab's panel; Always use remembers it.
Opening a file at a line switches the tab to Source.

Panels read and write the host's document, so dirty state, autosave and conflicts stay
Paseo's. DocumentFrame shows bytes documents such as PDFs in the browser's viewer.

Replaces the file tab's Preview/Source mode toggle.

* feat(file-panels): choose and forget file panels in Settings > Files

Settings > Files lists each extension or filename where a plugin panel competes with
another candidate, or that has a remembered panel. Each row chooses the panel that opens
it, or Ask every time; Ask again forgets the choice. Source appears as a candidate for
text files. Panels no connected host offers can be forgotten but not chosen.

* docs(plugins): file panel examples and author reference

Adds the pdf-panel and markdown-panel examples, the File panels section of the plugin
reference, and an e2e spec that installs both examples, opens a PDF in the browser
viewer, and edits Markdown through Paseo's document across the switcher, Source,
autosave and conflicts.

* feat(file-panels): panels read and write through a file handle

The file tab stats the file and mounts its panel without reading it. Every
panel, built-in or plugin, gets a PluginFile handle: readText, readBytes, url,
version-checked writeText/writeBytes (PluginFileConflictError unless force),
and watch. useFileText, useFileBytes and useFileUrl read through it, and
Loading joins @getpaseo/plugin/client/ui. Panels own their loading state.

The host document pipeline, the explorer preview cache and the preview
lifecycle are deleted. The daemon classifies a file version from a bounded
sniff and writes base64 content as bytes behind fileByteWrites.

A deleted file keeps its panel mounted; a read confirms the deletion first,
so a remove-and-rewrite is not reported as one. Native Source decodes with
builtins and highlights in idle-time slices, the lines around the opening
line first, so a large file no longer blocks the JS thread.

* fix(file-panels): Settings offers Source where the picker does, and review follow-ups

- Settings > Files offers Source on every key except those a built-in panel of
  another kind (Image) claims, so a plugin that alone claims a text extension
  shows the same choices as the picker.
- Delete the unused isSourceFilePanel.
- The Markdown example warns with the file name when its unmount write fails,
  rather than dropping the edit silently.
- Document that PluginFile stat fields are read at render time and that watch
  or the read hooks are how a panel learns they changed.

* refactor(plugin): consolidate the client UI entrypoint
2026-10-21 11:12:22 +07:00

502 lines
22 KiB
TypeScript

import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { submitMessage } from "../support/helpers/composer";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { buildAgentRoute } from "../support/helpers/mock-agent";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { expectMobileAgentSidebarHidden } from "../support/helpers/sidebar";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
const PLUGIN_ID = "workspace-panel-e2e";
const WIDE_VIEWPORT = { width: 1280, height: 900 };
const COMPACT_VIEWPORT = { width: 390, height: 844 };
function isSettledWorkspaceUrl(url: URL): boolean {
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
}
function pluginClientSource(input: {
workspaceId: string;
agentId: string;
remoteServerId: string;
remoteWorkspaceId: string;
remoteAgentId: string;
}): string {
return `import React, { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Icon } from "@getpaseo/plugin/client/ui";
import { useAgent, useWorkspace, openExternalUrl } from "@getpaseo/plugin/client";
import { ExternalLink } from "@getpaseo/plugin/client/ui";
import { recordComposerOpen } from "./shared/rpc";
function WorkspacePanel({ workspaceId, host, layout }) {
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
const renderCount = useRef(0);
renderCount.current += 1;
return <View><Text>Workspace bridge {workspace?.id}</Text><Text>Workspace renders {renderCount.current}</Text><Text>Host {host.id}</Text><Text>Layout {layout.compact ? "compact" : "wide"}</Text></View>;
}
function AgentPanel({ workspaceId, agentId, host, layout }) {
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
const agent = useAgent(agentId, (value) => ({ id: value.id }));
return <View><Text>Agent bridge {agent?.id}</Text><Text>Workspace {workspace?.id}</Text><Text>Host {host.id}</Text><Text>Layout {layout.compact ? "compact" : "wide"}</Text></View>;
}
function DirectCollisionSurface({ navigation }) {
return <View>
<Text>Direct collision surface</Text>
<Text>{navigation.openBrowser ? "Browser available" : "Browser unavailable"}</Text>
<ExternalLink href="https://example.com/">Plugin documentation</ExternalLink>
<Pressable accessibilityRole="button" onPress={() => openExternalUrl("https://example.com/")}><Text>Open external plugin URL</Text></Pressable>
{navigation ? <>
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ workspaceId: ${JSON.stringify(input.workspaceId)} })}><Text>Open workspace from plugin</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => navigation.openAgent({ agentId: ${JSON.stringify(input.agentId)} })}><Text>Open agent from plugin</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ serverId: ${JSON.stringify(input.remoteServerId)}, workspaceId: ${JSON.stringify(input.remoteWorkspaceId)} })}><Text>Open remote workspace from plugin</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => navigation.openAgent({ serverId: ${JSON.stringify(input.remoteServerId)}, agentId: ${JSON.stringify(input.remoteAgentId)} })}><Text>Open remote agent from plugin</Text></Pressable>
</> : null}
</View>;
}
function SidebarCollisionSurface() {
return <View><Text>Sidebar collision surface</Text></View>;
}
function contributeClient(client) {
const pills = new Map();
const remove = (agentId) => {
pills.get(agentId)?.();
pills.delete(agentId);
};
const apply = (update) => {
if (update.kind === "remove") {
remove(update.agentId);
return;
}
const agent = update.agent;
if (agent.title !== "Plugin panel context agent" || !agent.workspaceId) return;
remove(agent.id);
const pill = client.addComposerPill({
id: "review",
workspaceId: agent.workspaceId,
agentId: agent.id,
button: {
title: "Open composer review",
icon: "Scan",
label: "Review",
behavior: { kind: "action", async onPress() {
await client.rpc(recordComposerOpen, { workspaceId: agent.workspaceId });
pill.remove();
client.openPanel("agent", {
workspaceId: agent.workspaceId,
agentId: agent.id,
});
} },
},
});
pills.set(agent.id, () => pill.remove());
};
const lifetime = new AbortController();
void client.paseo.agents.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
subscription.subscribe({
snapshot({ entries }) {
for (const removePill of pills.values()) removePill();
pills.clear();
for (const { agent } of entries) apply({ kind: "upsert", agent });
},
update(message) { if (message.type === "agent_update") apply(message.payload); },
});
}).catch((error) => { if (!lifetime.signal.aborted) console.error(error); });
return () => {
lifetime.abort();
for (const removePill of pills.values()) removePill();
pills.clear();
};
}
export default function contribute(client) {
client.addSurface("collision", DirectCollisionSurface);
client.addSurface("sidebar-destination", SidebarCollisionSurface);
client.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
client.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel });
client.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel });
client.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} });
client.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
client.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
client.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
return contributeClient(client);
}`;
}
const pluginSharedSource = `import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export const recordComposerOpen = defineRpc({
name: "composer.open",
input: z.object({ workspaceId: z.string() }),
output: z.object({ opened: z.boolean() }),
});`;
const pluginServerSource = `import { recordComposerOpen } from "./shared/rpc";
export default function contribute(server) {
server.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill");
return { opened: true };
});
return () => {};
}`;
async function writePluginSources(
directory: string,
input: {
workspaceId: string;
agentId: string;
remoteServerId: string;
remoteWorkspaceId: string;
remoteAgentId: string;
},
): Promise<void> {
await mkdir(path.join(directory, "shared"), { recursive: true });
await Promise.all([
writeFile(path.join(directory, "index.client.tsx"), pluginClientSource(input)),
writeFile(path.join(directory, "index.server.ts"), pluginServerSource),
writeFile(path.join(directory, "shared", "rpc.ts"), pluginSharedSource),
]);
}
async function searchCommands(page: Page, query: string) {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(query);
return panel;
}
async function expectCommandAvailable(page: Page, title: string): Promise<void> {
const panel = await searchCommands(page, title);
await expect(panel.getByRole("button", { name: title, exact: true })).toBeVisible();
await page.keyboard.press("Escape");
}
async function expectCommandUnavailable(page: Page, title: string): Promise<void> {
const panel = await searchCommands(page, title);
await expect(panel.getByRole("button", { name: title, exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
}
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await searchCommands(page, title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function openPluginExternalTab(page: Page, role: "link" | "button", name: string) {
const opened = page.context().waitForEvent("page");
await page.getByRole(role, { name, exact: true }).click();
const tab = await opened;
await tab.waitForURL("https://example.com/");
await tab.close();
}
interface RemoteNavigationTarget {
serverId: string;
workspaceId: string;
agentId: string;
}
function remoteWorkspaceRoute(target: RemoteNavigationTarget): RegExp {
return new RegExp(
`/h/${encodeURIComponent(target.serverId)}/workspace/${encodeURIComponent(target.workspaceId)}(?:\\?.*)?$`,
);
}
async function openRemoteWorkspaceFromPlugin(
page: Page,
target: RemoteNavigationTarget,
): Promise<void> {
await page
.getByRole("button", { name: "Open remote workspace from plugin", exact: true })
.click();
await page.waitForURL(remoteWorkspaceRoute(target));
await expect(
page
.getByTestId(`workspace-deck-entry-${target.serverId}:${target.workspaceId}`)
.getByTestId("workspace-header-title"),
).toBeVisible();
}
async function openRemoteAgentFromPlugin(
page: Page,
target: RemoteNavigationTarget,
): Promise<void> {
await page.getByRole("button", { name: "Open remote agent from plugin", exact: true }).click();
await page.waitForURL(remoteWorkspaceRoute(target));
await expect(
page.getByTestId(`workspace-tab-agent_${target.agentId}`).filter({ visible: true }).first(),
).toBeVisible();
}
async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(page.getByTestId("sidebar-search")).toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const screenshot = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: screenshot });
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
}
test.describe("plugin workspace panels and Command Center", () => {
test.describe.configure({ timeout: 240_000 });
test("follows workspace, agent, host, compact, and unavailable state", async ({
page,
}, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-workspace-panel-e2e-"));
const primaryClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await primaryClient.getDaemonConfig();
const primary = await seedWorkspace({ repoPrefix: "plugin-panel-primary-" });
const secondaryDaemon = await startIsolatedHostDaemon("plugin-panel-secondary");
const secondary = await seedWorkspace({
repoPrefix: "plugin-panel-secondary-",
port: secondaryDaemon.port,
});
const secondaryAgent = await secondary.client.createAgent({
provider: "mock",
cwd: secondary.repoPath,
workspaceId: secondary.workspaceId,
title: "Remote plugin navigation agent",
model: "ten-second-stream",
modeId: "load-test",
});
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writePluginSources(directory, {
workspaceId: primary.workspaceId,
agentId: "missing-agent",
remoteServerId: secondaryDaemon.serverId,
remoteWorkspaceId: secondary.workspaceId,
remoteAgentId: secondaryAgent.id,
});
try {
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
await primaryClient.installDirectoryPlugin(directory);
await page.setViewportSize(WIDE_VIEWPORT);
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondaryDaemon.serverId,
label: "Secondary plugin host",
port: secondaryDaemon.port,
primaryLabel: "Primary plugin host",
});
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: primary.workspaceId,
});
await waitForWorkspaceInSidebar(page, {
serverId: secondaryDaemon.serverId,
workspaceId: secondary.workspaceId,
});
await test.step("workspace context opens the real wide panel bridge", async () => {
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: primary.workspaceId,
});
await expectCommandAvailable(page, "Plugin global action");
await expectCommandAvailable(page, "Open plugin workspace");
await expectCommandUnavailable(page, "Open plugin agent");
await runCommand(page, "Open plugin workspace");
await expect(page.getByText(`Workspace bridge ${primary.workspaceId}`)).toBeVisible();
const renderCount = Number(
(await page.getByText(/Workspace renders \d+/).textContent())?.split(" ").at(-1),
);
await primaryClient.setWorkspaceTitle(primary.workspaceId, "Unrelated title update");
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Unrelated title update",
);
expect(
Number((await page.getByText(/Workspace renders \d+/).textContent())?.split(" ").at(-1)),
).toBe(renderCount);
await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible();
await expect(page.getByText("Layout wide", { exact: true })).toBeVisible();
await capture(page, testInfo, "plugin-workspace-panel-wide");
});
await test.step("plugin links open browser tabs and in-app browsing is unavailable", async () => {
await runCommand(page, "Open direct collision surface");
await expect(page.getByText("Browser unavailable", { exact: true })).toBeVisible();
await openPluginExternalTab(page, "link", "Plugin documentation");
await openPluginExternalTab(page, "button", "Open external plugin URL");
await capture(page, testInfo, "plugin-external-links-web");
await page.getByTestId("plugin-surface-close").click();
});
await test.step("direct and sidebar routes preserve same-id contribution kind", async () => {
await runCommand(page, "Open direct collision surface");
await expect(page.getByText("Direct collision surface", { exact: true })).toBeVisible();
await expect(page.getByText("Sidebar collision surface", { exact: true })).toHaveCount(0);
await page.getByTestId("plugin-surface-close").click();
await page.getByTestId(`plugin-sidebar-${PLUGIN_ID}-collision`).click();
await expect(page.getByText("Sidebar collision surface", { exact: true })).toBeVisible();
await expect(page.getByText("Direct collision surface", { exact: true })).toHaveCount(0);
await capture(page, testInfo, "plugin-surface-kind-collision");
await page.getByTestId("plugin-surface-close").click();
});
await test.step("surface navigation opens host-owned workspace and agent routes", async () => {
await runCommand(page, "Open direct collision surface");
await page.getByRole("button", { name: "Open workspace from plugin", exact: true }).click();
await page.waitForURL(isSettledWorkspaceUrl);
await expect(page.getByTestId("workspace-header-title")).toBeVisible();
const agent = await primary.client.createAgent({
provider: "mock",
cwd: primary.repoPath,
workspaceId: primary.workspaceId,
title: "Plugin navigation agent",
model: "ten-second-stream",
modeId: "load-test",
});
const navigationAgentId = agent.id;
await writePluginSources(directory, {
workspaceId: primary.workspaceId,
agentId: navigationAgentId,
remoteServerId: secondaryDaemon.serverId,
remoteWorkspaceId: secondary.workspaceId,
remoteAgentId: secondaryAgent.id,
});
await primaryClient.reloadPlugin(PLUGIN_ID);
await runCommand(page, "Open direct collision surface");
await page.getByRole("button", { name: "Open agent from plugin", exact: true }).click();
await page.waitForURL(isSettledWorkspaceUrl);
await expect(
page
.getByTestId(`workspace-tab-agent_${navigationAgentId}`)
.filter({ visible: true })
.first(),
).toBeVisible();
});
await test.step("surface navigation opens a workspace and agent on another host", async () => {
const remoteTarget = {
serverId: secondaryDaemon.serverId,
workspaceId: secondary.workspaceId,
agentId: secondaryAgent.id,
};
await runCommand(page, "Open direct collision surface");
await openRemoteWorkspaceFromPlugin(page, remoteTarget);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: primary.workspaceId,
});
await runCommand(page, "Open direct collision surface");
await openRemoteAgentFromPlugin(page, remoteTarget);
});
await test.step("switching hosts removes commands from an uninstalled host", async () => {
await switchWorkspaceViaSidebar({
page,
serverId: secondaryDaemon.serverId,
workspaceId: secondary.workspaceId,
});
await expectCommandUnavailable(page, "Plugin global action");
await expectCommandUnavailable(page, "Open plugin workspace");
});
await test.step("legacy sidebar URLs survive a full browser navigation", async () => {
await page.goto(`/h/${encodeURIComponent(getServerId())}/plugin/${PLUGIN_ID}/collision`);
await page.waitForURL(new RegExp(`/plugin/${PLUGIN_ID}/sidebar/collision(?:\\?.*)?$`));
await expect(page.getByText("Sidebar collision surface", { exact: true })).toBeVisible();
});
await test.step("agent context opens the compact panel with synchronous snapshots", async () => {
const agent = await primary.client.createAgent({
provider: "mock",
cwd: primary.repoPath,
workspaceId: primary.workspaceId,
title: "Plugin panel context agent",
model: "ten-second-stream",
modeId: "load-test",
});
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
await submitMessage(page, "emit 1 agent stream updates");
await expect(page.getByRole("button", { name: "1/1 tasks" })).toBeVisible({
timeout: 30_000,
});
const composerPill = page.getByRole("button", { name: "Open composer review" });
await expect(composerPill).toContainText("Review");
await capture(page, testInfo, "plugin-composer-pill-wide");
await page.setViewportSize(COMPACT_VIEWPORT);
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
await capture(page, testInfo, "plugin-composer-pill-compact");
await page.getByRole("button", { name: "Open composer review" }).click();
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Opened from composer pill",
);
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
await capture(page, testInfo, "plugin-agent-panel-compact");
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
// Pressing removed the pill from that page only. The reloaded page evaluates
// the plugin again, and its agents snapshot contributes the pill afresh.
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
await openCompactSidebar(page);
// The sidebar's Search row dismisses the compact sidebar on its way to the
// command center, so nothing has to close it after the command runs.
await runCommand(page, "Open plugin agent");
await expectMobileAgentSidebarHidden(page);
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
await expect(
page.getByText(`Workspace ${primary.workspaceId}`, { exact: true }),
).toBeVisible();
await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible();
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
});
await test.step("removing the active plugin renders the panel unavailable", async () => {
await primaryClient.removePlugin(PLUGIN_ID);
await expect(
page.getByText("This plugin panel is unavailable.", { exact: true }),
).toBeVisible({
timeout: 30_000,
});
await capture(page, testInfo, "plugin-panel-unavailable");
await openCompactSidebar(page);
await expectCommandUnavailable(page, "Open plugin agent");
});
} finally {
await primaryClient.removePlugin(PLUGIN_ID).catch(() => undefined);
await primaryClient
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await primary.cleanup().catch(() => undefined);
await secondary.cleanup().catch(() => undefined);
await secondaryDaemon.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
});