From 0859a67ab1a71b0e0c187b4b8db8e4a585b48e42 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 7 Sep 2026 21:46:59 +0200 Subject: [PATCH] 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 --- docs/explorer-sidebar.md | 7 +- .../e2e/browser/explorer-plugin-menu.spec.ts | 23 ++ .../support/helpers/explorer-plugin-menu.ts | 201 ++++++++++++++++++ .../workspace/explorer-sidebar-tab-rail.tsx | 9 +- .../app/src/workspace-tabs/launcher/index.tsx | 15 +- 5 files changed, 244 insertions(+), 11 deletions(-) create mode 100644 packages/app/e2e/browser/explorer-plugin-menu.spec.ts create mode 100644 packages/app/e2e/support/helpers/explorer-plugin-menu.ts diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index 30d074a151..379db5a31f 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -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. diff --git a/packages/app/e2e/browser/explorer-plugin-menu.spec.ts b/packages/app/e2e/browser/explorer-plugin-menu.spec.ts new file mode 100644 index 0000000000..8dc515ef74 --- /dev/null +++ b/packages/app/e2e/browser/explorer-plugin-menu.spec.ts @@ -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)); + }); +}); diff --git a/packages/app/e2e/support/helpers/explorer-plugin-menu.ts b/packages/app/e2e/support/helpers/explorer-plugin-menu.ts new file mode 100644 index 0000000000..2d13080274 --- /dev/null +++ b/packages/app/e2e/support/helpers/explorer-plugin-menu.ts @@ -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>; + +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 }) => {${JSON.stringify(title)} + " workspace " + workspaceId} }); + client.addWorkspacePanel({ id: "summary", title: ${JSON.stringify(`${title} summary`)}, icon: "Blocks", context: "workspace", locations: ["explorer"], Component: () => Summary content }); + 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, +) { + 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", + }); +} diff --git a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx b/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx index bda9f8af5c..7b772b1d4c 100644 --- a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx +++ b/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx @@ -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(() => , []); diff --git a/packages/app/src/workspace-tabs/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx index ba4998fda9..8f10527d75 100644 --- a/packages/app/src/workspace-tabs/launcher/index.tsx +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -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 {children}; } -const BUILT_IN_SELECTIONS: Record = { +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 = { files: { kind: "target", target: { kind: "files" } }, browser: { kind: "browser" }, pullRequest: { kind: "target", target: { kind: "pull_request" } }, -}; +} satisfies Record; 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: {