fix(app): show workspace plugin panels in Explorer menu (#4446)

* fix(app): show workspace plugin panels in Explorer menu

Let the host-filtered launch catalog identify toggleable views and their targets. Explorer uses the same identity comparison for every view while plugin panels remain workspace-scoped.

* test(app): express Explorer panel regression as user steps
This commit is contained in:
Mohamed Boudra
2026-09-07 21:46:59 +02:00
committed by GitHub
parent 56fd50988c
commit 0859a67ab1
5 changed files with 244 additions and 11 deletions
+4 -3
View File
@@ -39,9 +39,10 @@ that pane from the workspace split tree and docks it separately. Persisted ident
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
toggles the singleton Files and Changes views. Individual tab menus close instances or move
compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting
an Explorer tab does not change workspace focus.
toggles Files, Changes, and Explorer-compatible workspace-scoped plugin panels from the shared
launch catalog. Individual tab menus close instances or move compatible tabs to main. Explorer tabs
can be reordered, but the dock cannot be split. Selecting an Explorer tab does not change workspace
focus.
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
@@ -0,0 +1,23 @@
import { test } from "../support/fixtures";
import {
withExplorerPanelPlugins,
expectWorkspacePanelsInExplorerLauncher,
openWorkspacePanelFromExplorerMenu,
expectIndependentExplorerPanelToggles,
expectExplorerPanelWithFocusedAgent,
} from "../support/helpers/explorer-plugin-menu";
test("Explorer toggles workspace panels independently without inheriting agent context", async ({
page,
}, testInfo) => {
await withExplorerPanelPlugins(page, async (workspace) => {
await test.step("the New Tab launcher offers compatible workspace panels", () =>
expectWorkspacePanelsInExplorerLauncher(page));
await test.step("the rail menu offers the same workspace panels", () =>
openWorkspacePanelFromExplorerMenu(page, workspace, testInfo));
await test.step("different panels and plugins keep independent selection and closing", () =>
expectIndependentExplorerPanelToggles(page, workspace));
await test.step("focusing an agent does not add agent panels to Explorer", () =>
expectExplorerPanelWithFocusedAgent(page, workspace, testInfo));
});
});
@@ -0,0 +1,201 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { gotoWorkspace } from "./launcher";
import { buildAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
import { ensureExplorerSidebar } from "./workspace-tabs";
type ExplorerPanelWorkspace = Awaited<ReturnType<typeof seedWorkspace>>;
async function writePanelPlugin(id: string, title: string) {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-explorer-menu-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id, requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`
import React from "react";
import { Text } from "react-native";
export default function contribute(client) {
client.addWorkspacePanel({ id: "review", title: ${JSON.stringify(title)}, icon: "Blocks", context: "workspace", locations: ["workspace", "explorer"], Component: ({ workspaceId }) => <Text>{${JSON.stringify(title)} + " workspace " + workspaceId}</Text> });
client.addWorkspacePanel({ id: "summary", title: ${JSON.stringify(`${title} summary`)}, icon: "Blocks", context: "workspace", locations: ["explorer"], Component: () => <Text>Summary content</Text> });
client.addWorkspacePanel({ id: "main", title: ${JSON.stringify(`${title} main only`)}, icon: "Blocks", context: "workspace", Component: () => null });
client.addWorkspacePanel({ id: "agent", title: ${JSON.stringify(`${title} agent`)}, icon: "Blocks", context: "agent", locations: ["explorer"], Component: () => null });
return () => {};
}`,
);
return directory;
}
async function openExplorerMenu(page: Page) {
const explorer = await ensureExplorerSidebar(page);
// The rail's empty background has no accessible role; tab menus are separate.
await explorer.getByTestId("explorer-sidebar-tab-rail").click({
button: "right",
position: { x: 20, y: 2 },
});
const menu = page.getByTestId("explorer-sidebar-tab-configuration");
await expect(menu).toBeVisible();
await expect(menu).toHaveCSS("opacity", "1");
return menu;
}
async function toggleExplorerView(page: Page, name: string) {
const menu = await openExplorerMenu(page);
await menu.getByRole("menuitem", { name, exact: true }).click();
await expect(menu).not.toBeVisible();
}
export async function withExplorerPanelPlugins(
page: Page,
run: (workspace: ExplorerPanelWorkspace) => Promise<void>,
) {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
const workspace = await seedWorkspace({ repoPrefix: "explorer-plugin-menu-" });
const first = await writePanelPlugin("explorer-review", "Review");
const second = await writePanelPlugin("explorer-other", "Other review");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(first);
await client.installDirectoryPlugin(second);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await run(workspace);
} finally {
await client.removePlugin("explorer-review");
await client.removePlugin("explorer-other");
await client.patchDaemonConfig({
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
});
await client.close();
await workspace.cleanup();
await rm(first, { recursive: true, force: true });
await rm(second, { recursive: true, force: true });
}
}
export async function expectWorkspacePanelsInExplorerLauncher(page: Page) {
const menu = await openExplorerMenu(page);
await menu.getByRole("menuitem", { name: "New tab", exact: true }).click();
const launcher = page.getByTestId("workspace-explorer-sidebar");
await expect(launcher.getByRole("button", { name: "Review", exact: true })).toBeVisible();
await expect(launcher.getByRole("button", { name: "Review agent", exact: true })).toHaveCount(0);
await expect(launcher.getByRole("button", { name: "Review main only", exact: true })).toHaveCount(
0,
);
}
export async function openWorkspacePanelFromExplorerMenu(
page: Page,
workspace: ExplorerPanelWorkspace,
testInfo: TestInfo,
) {
const menu = await openExplorerMenu(page);
await testInfo.attach("explorer-panel-menu", {
body: await page.screenshot({ path: testInfo.outputPath("explorer-panel-menu.png") }),
contentType: "image/png",
});
await expect(menu.getByRole("menuitem")).toHaveText([
"New tab",
"Changes",
"Files",
"Other review",
"Other review summary",
"Review",
"Review summary",
]);
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
"aria-checked",
"false",
);
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
await expect(
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
).toBeVisible();
}
export async function expectIndependentExplorerPanelToggles(
page: Page,
workspace: ExplorerPanelWorkspace,
) {
await toggleExplorerView(page, "Other review");
await expect(
page.getByText(`Other review workspace ${workspace.workspaceId}`, { exact: true }),
).toBeVisible();
await toggleExplorerView(page, "Review summary");
const menu = await openExplorerMenu(page);
await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
"aria-checked",
"true",
);
await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toHaveAttribute(
"aria-checked",
"true",
);
await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toHaveAttribute(
"aria-checked",
"true",
);
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
const updated = await openExplorerMenu(page);
await expect(updated.getByRole("menuitem", { name: "Review", exact: true })).toHaveAttribute(
"aria-checked",
"false",
);
await expect(
updated.getByRole("menuitem", { name: "Other review", exact: true }),
).toHaveAttribute("aria-checked", "true");
await expect(
updated.getByRole("menuitem", { name: "Review summary", exact: true }),
).toHaveAttribute("aria-checked", "true");
await page.keyboard.press("Escape");
await toggleExplorerView(page, "Files");
await toggleExplorerView(page, "Files");
await expect(
page
.getByTestId("explorer-sidebar-tab-rail")
.getByRole("button", { name: "Browse workspace files", exact: true }),
).toHaveCount(1);
}
export async function expectExplorerPanelWithFocusedAgent(
page: Page,
workspace: ExplorerPanelWorkspace,
testInfo: TestInfo,
) {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Focused agent",
model: "ten-second-stream",
modeId: "load-test",
});
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
).toHaveAttribute("aria-selected", "true");
const menu = await openExplorerMenu(page);
await expect(menu.getByRole("menuitem", { name: "Review agent", exact: true })).toHaveCount(0);
await expect(menu.getByRole("menuitem", { name: "Other review agent", exact: true })).toHaveCount(
0,
);
await menu.getByRole("menuitem", { name: "Review", exact: true }).click();
await expect(
page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }),
).toBeVisible();
await testInfo.attach("workspace-panel-with-focused-agent", {
body: await page.screenshot({
path: testInfo.outputPath("workspace-panel-with-focused-agent.png"),
}),
contentType: "image/png",
});
}
@@ -32,7 +32,7 @@ import {
useWorkspaceTabLaunchCatalog,
type WorkspaceTabLaunchItem,
} from "@/workspace-tabs/launcher";
import { panelSupportsHost } from "@/panels/panel-manifest";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import type { PanelIconProps } from "@/panels/panel-registry";
import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations";
import type { Theme } from "@/styles/theme";
@@ -248,7 +248,7 @@ function ExplorerSidebarConfigurationItem({
}
function catalogItemMatchesTab(item: WorkspaceTabLaunchItem, tab: WorkspaceTabDescriptor): boolean {
return item.panelKind === tab.target.kind;
return item.toggleTarget !== null && workspaceTabTargetsEqual(item.toggleTarget, tab.target);
}
export function ExplorerSidebarTabRail({
@@ -273,10 +273,7 @@ export function ExplorerSidebarTabRail({
host: "explorer",
});
const singletonConfigurationItems = useMemo(
() =>
(groups.find((group) => group.id === "tabs")?.items ?? []).filter(
(item) => !panelSupportsHost(item.panelKind, "main"),
),
() => groups.flatMap((group) => group.items).filter((item) => item.toggleTarget !== null),
[groups],
);
const newTabLeading = useMemo(() => <ThemedPlus size={14} uniProps={mutedColorMapping} />, []);
@@ -53,6 +53,8 @@ export interface WorkspaceTabLaunchItem {
shortcutActionId?: string;
disabled: boolean;
panelKind: WorkspaceTabTarget["kind"];
/** The fixed view this item can toggle in a configuration menu, or null for launch-only items. */
toggleTarget: WorkspaceTabTarget | null;
launch: (destination: WorkspaceTabLaunchDestination) => void;
}
@@ -75,7 +77,7 @@ export function NewTabLauncherProvider({
return <NewTabLauncherContext.Provider value={value}>{children}</NewTabLauncherContext.Provider>;
}
const BUILT_IN_SELECTIONS: Record<BuiltInLaunchItemId, NewTabSelection> = {
const BUILT_IN_SELECTIONS = {
agent: { kind: "agent" },
terminal: { kind: "terminal" },
changes: { kind: "target", target: { kind: "changes_tree" } },
@@ -83,7 +85,7 @@ const BUILT_IN_SELECTIONS: Record<BuiltInLaunchItemId, NewTabSelection> = {
files: { kind: "target", target: { kind: "files" } },
browser: { kind: "browser" },
pullRequest: { kind: "target", target: { kind: "pull_request" } },
};
} satisfies Record<BuiltInLaunchItemId, NewTabSelection>;
function getLaunchPresentation(kind: WorkspaceTabTarget["kind"]): PanelPresentation {
const registration = getPanelRegistration(kind);
@@ -128,6 +130,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
shortcutActionId: "workspace-tab-target-agent",
disabled: false,
panelKind: "draft",
toggleTarget: null,
launch: launchSelection(BUILT_IN_SELECTIONS.agent),
},
terminal: {
@@ -137,6 +140,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
shortcutActionId: "workspace-terminal-new",
disabled: launcher.terminalDisabled,
panelKind: "terminal",
toggleTarget: null,
launch: launchSelection(BUILT_IN_SELECTIONS.terminal),
},
changes: {
@@ -145,6 +149,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
Icon: changesPresentation.icon,
disabled: false,
panelKind: "changes_tree",
toggleTarget: BUILT_IN_SELECTIONS.changes.target,
hidden: !launcher.showChanges,
launch: launchSelection(BUILT_IN_SELECTIONS.changes),
},
@@ -155,6 +160,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
shortcutActionId: "workspace-tab-target-changes",
disabled: false,
panelKind: "working_diff",
toggleTarget: null,
hidden: !launcher.showChanges,
launch: launchSelection(BUILT_IN_SELECTIONS.diff),
},
@@ -165,6 +171,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
shortcutActionId: "workspace-tab-target-files",
disabled: false,
panelKind: "files",
toggleTarget: BUILT_IN_SELECTIONS.files.target,
launch: launchSelection(BUILT_IN_SELECTIONS.files),
},
browser: {
@@ -174,6 +181,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
shortcutActionId: "workspace-tab-target-browser",
disabled: false,
panelKind: "browser",
toggleTarget: null,
hidden: !launcher.showBrowser,
launch: launchSelection(BUILT_IN_SELECTIONS.browser),
},
@@ -183,6 +191,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
Icon: pullRequestPresentation.icon,
disabled: false,
panelKind: "pull_request",
toggleTarget: null,
hidden: !launcher.showPullRequest,
launch: launchSelection(BUILT_IN_SELECTIONS.pullRequest),
},
@@ -209,6 +218,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
Icon: resolvePluginIcon(panel.icon),
disabled: false,
panelKind: "plugin",
toggleTarget: selection.target,
launch: launchSelection(selection),
});
}
@@ -229,6 +239,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
terminalIconKey: getTerminalProfileIcon(profile),
disabled: launcher.terminalDisabled,
panelKind: "terminal",
toggleTarget: null,
launch: launchSelection({ kind: "terminal", profile }),
})),
accessory: {