diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index 2ecf842671..f1e27ac548 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -15,8 +15,10 @@ fixed-target labels and icons from that registration, filter by host, and never panel type for another. Tab moves reject unsupported destinations, and placement resolves only to a compatible pane. -Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible -tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes. +Files and Changes are the Explorer defaults. Their panel manifests mark them as singletons, +so a pane’s + menu omits each while that pane already contains it. Closing one makes its menu +item available again. Other compatible tabs, including agents, terminals, files, and diffs, +can move between Explorer and main panes. Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts. @@ -41,11 +43,13 @@ The persisted layout still contains the Explorer pane so tabs survive reloads. T that pane from the workspace split tree and docks it separately. Persisted identifiers retain the 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 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. +Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes +hide their close buttons through the panel manifest; close them from the tab context menu. +Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and +omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer. +Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged +between compatible panes, but the dock cannot be split or maximized. 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. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders. diff --git a/package-lock.json b/package-lock.json index 8a71d40f8c..6e8fb5385f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26270,17 +26270,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/lucide-react-native": { - "version": "0.546.0", - "resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz", - "integrity": "sha512-v/qe5JnOHKIhccqfE7Ln78wd0mn5YgmCBvkTX2ca8sWeoym3ZYZcY/mapFsxssarutO4bMI/bRtTqiusGbpOUw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-native": "*", - "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" - } - }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -36489,7 +36478,7 @@ "fast-deep-equal": "^3.1.3", "htmlparser2": "^12.0.0", "i18next": "^26.3.0", - "lucide-react-native": "^0.546.0", + "lucide-react-native": "^1.50.0", "markdown-it": "^10.0.0", "mermaid": "^11.16.0", "mnemonic-id": "^3.2.7", @@ -37344,6 +37333,23 @@ "node": ">=20.19.0" } }, + "packages/app/node_modules/lucide-react-native": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.50.0.tgz", + "integrity": "sha512-uMtIW3I6jfHbUp2FnUwxbDJc7XFJDz2g5YVcw6T/De8Sp9f21vTO7HlJNhxpBynDYlqLyWHaXeR0dBSiw6TqOw==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-native": "*", + "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "packages/app/node_modules/miniflare": { "version": "4.20260625.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz", diff --git a/packages/app/e2e/browser/changes-shortcut-placement.spec.ts b/packages/app/e2e/browser/changes-shortcut-placement.spec.ts index a27880a111..74e6971c8d 100644 --- a/packages/app/e2e/browser/changes-shortcut-placement.spec.ts +++ b/packages/app/e2e/browser/changes-shortcut-placement.spec.ts @@ -17,7 +17,7 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) = await page.keyboard.press(CHANGES_SHORTCUT); const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }); - await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({ + await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible({ timeout: 30_000, }); await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible(); diff --git a/packages/app/e2e/browser/empty-pane-keyboard.spec.ts b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts index 96aac4d0d7..46dc4cf47f 100644 --- a/packages/app/e2e/browser/empty-pane-keyboard.spec.ts +++ b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts @@ -30,7 +30,7 @@ test.describe("New tab keyboard launcher", () => { await waitForWorkspaceTabsVisible(page); const explorer = await openExplorerWithKeyboard(page); - await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible(); + await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible(); await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible(); } finally { await workspace.cleanup(); diff --git a/packages/app/e2e/browser/explorer-pane-placement.spec.ts b/packages/app/e2e/browser/explorer-pane-placement.spec.ts index de0eafe677..55f96cc0cf 100644 --- a/packages/app/e2e/browser/explorer-pane-placement.spec.ts +++ b/packages/app/e2e/browser/explorer-pane-placement.spec.ts @@ -25,13 +25,16 @@ function draftTabChip(page: Page): Locator { return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }); } -/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */ +/** Scope the shared tab row to the Explorer dock. */ function explorerTabRow(page: Page): Locator { - return visible(page, "explorer-sidebar-tab-rail"); + return visible(page, "workspace-explorer-sidebar").getByTestId("workspace-tabs-row"); } function mainTabRow(page: Page): Locator { - return visible(page, "workspace-tabs-row"); + return page + .locator('[data-testid^="workspace-pane-"]') + .getByTestId("workspace-tabs-row") + .filter({ visible: true }); } async function selectExplorerChanges(page: Page): Promise { @@ -173,7 +176,7 @@ test.describe("explorer pane tab placement", () => { contentType: "image/png", }); // The split must have taken: agent pane + New main. Explorer keeps its own rail. - await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 }); + await expect(mainTabRow(page)).toHaveCount(2, { timeout: 10_000 }); await expect(explorerTabRow(page)).toHaveCount(1); await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible(); }); @@ -233,14 +236,14 @@ async function closeOnlyDraft(page: Page): Promise { } async function moveOnlyDraftIntoRightSplit(page: Page): Promise { - await page.getByRole("button", { name: "More actions", exact: true }).click(); - await page.getByRole("menuitem", { name: "Split pane right", exact: true }).click(); + await mainTabRow(page).getByRole("button", { name: "More actions", exact: true }).click(); + await page.getByRole("menuitem", { name: /^Split pane right/ }).click(); const target = await emptyPaneBox(page); await dragChipTo(page, draftTabChip(page), { x: target.x + target.width / 2, y: target.y + target.height / 2, }); - await expect(visible(page, "workspace-tabs-row")).toHaveCount(1); + await expect(mainTabRow(page)).toHaveCount(1); } async function expectNewLauncher(page: Page): Promise { diff --git a/packages/app/e2e/browser/explorer-plugin-menu.spec.ts b/packages/app/e2e/browser/explorer-plugin-menu.spec.ts index 8dc515ef74..a871718b88 100644 --- a/packages/app/e2e/browser/explorer-plugin-menu.spec.ts +++ b/packages/app/e2e/browser/explorer-plugin-menu.spec.ts @@ -3,20 +3,20 @@ import { withExplorerPanelPlugins, expectWorkspacePanelsInExplorerLauncher, openWorkspacePanelFromExplorerMenu, - expectIndependentExplorerPanelToggles, + expectIndependentExplorerPanelTabs, expectExplorerPanelWithFocusedAgent, } from "../support/helpers/explorer-plugin-menu"; -test("Explorer toggles workspace panels independently without inheriting agent context", async ({ +test("Explorer opens and closes workspace panel tabs independently without inheriting agent context", async ({ page, }, testInfo) => { await withExplorerPanelPlugins(page, async (workspace) => { - await test.step("the New Tab launcher offers compatible workspace panels", () => + await test.step("the + menu offers compatible workspace panels", () => expectWorkspacePanelsInExplorerLauncher(page)); - await test.step("the rail menu offers the same workspace panels", () => + await test.step("the + menu opens a workspace panel in Explorer", () => openWorkspacePanelFromExplorerMenu(page, workspace, testInfo)); await test.step("different panels and plugins keep independent selection and closing", () => - expectIndependentExplorerPanelToggles(page, workspace)); + expectIndependentExplorerPanelTabs(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/browser/explorer-sidebar.spec.ts b/packages/app/e2e/browser/explorer-sidebar.spec.ts index 0d8db71435..c8536c884a 100644 --- a/packages/app/e2e/browser/explorer-sidebar.spec.ts +++ b/packages/app/e2e/browser/explorer-sidebar.spec.ts @@ -1,5 +1,9 @@ import { expect, test } from "../support/fixtures"; -import { gotoWorkspace } from "../support/helpers/launcher"; +import { + gotoWorkspace, + pressNewTabShortcut, + expectTabTitleFits, +} from "../support/helpers/launcher"; import { seedWorkspace } from "../support/helpers/seed-client"; import { ensureExplorerSidebar, @@ -11,6 +15,25 @@ function explorerSidebar(page: Parameters[0]) { return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }); } +async function expectExplorerActiveTabForeground( + page: Parameters[0], +) { + await openFilesPanel(page); + const main = page.getByTestId("workspace-pane-main"); + await main.hover(); + const activeMainLabel = main + .getByTestId("workspace-tabs-row") + .locator('[aria-selected="true"]') + .getByText("New tab", { exact: true }); + const foreground = await activeMainLabel.evaluate((element) => getComputedStyle(element).color); + const activeFiles = explorerSidebar(page).getByRole("button", { + name: "Browse workspace files", + exact: true, + }); + await expect(activeFiles).toHaveAttribute("aria-selected", "true"); + await expect(activeFiles.getByText("Files", { exact: true })).toHaveCSS("color", foreground); +} + test.describe("Explorer sidebar", () => { test("starts with Files and Changes, switches views, and toggles without changing main", async ({ page, @@ -26,14 +49,14 @@ test.describe("Explorer sidebar", () => { .count(); const explorer = await ensureExplorerSidebar(page); - await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible(); - await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible(); - await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0); + await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible(); + await expect(explorer.getByTestId("workspace-tab-changes_tree")).toBeVisible(); + await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(1); - await openFilesPanel(page); + await expectExplorerActiveTabForeground(page); await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible(); - await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click(); + await explorer.getByTestId("workspace-tab-changes_tree").click(); await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible(); await page.getByTestId("workspace-explorer-toggle").first().click(); @@ -46,3 +69,181 @@ test.describe("Explorer sidebar", () => { } }); }); + +async function launchExplorerPanel( + page: Parameters[0], + name: string, +) { + const explorer = explorerSidebar(page); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + await page.getByRole("menuitem", { name: new RegExp(`^${name}`) }).click(); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + await expect(page.getByRole("menuitem", { name: new RegExp(`^${name}`) })).toHaveCount(0); + await page.keyboard.press("Escape"); +} + +async function closeExplorerFilesFromContextMenu( + page: Parameters[0], +) { + const explorer = explorerSidebar(page); + const files = explorer.getByRole("button", { name: "Browse workspace files", exact: true }); + await files.hover(); + await expect(explorer.getByTestId("workspace-files-close")).toHaveCount(0); + await explorer.getByTestId("workspace-tab-changes_tree").hover(); + await expect(explorer.getByTestId("workspace-working-diff-close-changes_tree")).toHaveCount(0); + await files.click({ button: "right", position: { x: 12, y: 13 } }); + await page.getByRole("menuitem", { name: "Close", exact: true }).click(); + await expect(files).toHaveCount(0); +} + +async function expectWorkspaceCloseOnHover(page: Parameters[0]) { + const main = page.getByTestId("workspace-pane-main"); + const tab = main.locator('[data-testid^="workspace-tab-"][aria-selected="true"]'); + const close = main.getByRole("button", { name: "Close", exact: true }); + await explorerSidebar(page).hover(); + await expect(close.locator("..")).toHaveCSS("opacity", "0"); + await tab.hover(); + await expect(close.locator("..")).toHaveCSS("opacity", "1"); +} + +async function closeOtherExplorerTabs(page: Parameters[0]) { + const files = explorerSidebar(page).getByRole("button", { + name: "Browse workspace files", + exact: true, + }); + await files.click({ button: "right", position: { x: 12, y: 13 } }); + const confirmation = page.waitForEvent("dialog").then((dialog) => { + expect(dialog.message()).toContain("close 1 tab"); + return dialog.accept(); + }); + await page.getByRole("menuitem", { name: "Close other tabs", exact: true }).click(); + await confirmation; +} + +test("Explorer keeps Files and Changes close actions in the context menu", async ({ + page, +}, testInfo) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-shared-tabs-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + const main = page.getByTestId("workspace-pane-main"); + const mainTabsBefore = await main.getByTestId("workspace-tabs-row").getByRole("button").count(); + const explorer = await ensureExplorerSidebar(page); + await expectTabTitleFits(page, "Files", { min: 64, max: 90 }); + + await test.step("Explorer's + menu excludes Agent and terminal profiles", async () => { + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0); + await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible(); + const entries = await menu.getByRole("menuitem").allTextContents(); + expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan( + entries.findIndex((entry) => entry.startsWith("Diff")), + ); + await page.keyboard.press("Escape"); + await main.getByTestId("workspace-new-tab-button").click(); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toBeVisible(); + await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + }); + + await test.step("Files and Changes omit X, and + restores Files after context-menu close", async () => { + await expectWorkspaceCloseOnHover(page); + await closeExplorerFilesFromContextMenu(page); + await launchExplorerPanel(page, "Files"); + await expect( + explorer.getByRole("button", { name: "Browse workspace files", exact: true }), + ).toBeVisible(); + await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible(); + }); + + await test.step("standard bulk-close menus affect only Explorer tabs", async () => { + await closeOtherExplorerTabs(page); + await expect(explorer.getByTestId("workspace-tab-changes_tree")).toHaveCount(0); + await expect(explorer.getByTestId("workspace-tab-files")).toBeVisible(); + await expect(main.getByTestId("workspace-tabs-row").getByRole("button")).toHaveCount( + mainTabsBefore, + ); + await launchExplorerPanel(page, "Changes"); + await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible(); + }); + + await test.step("Cmd+T still belongs to the focused workspace pane", async () => { + await pressNewTabShortcut(page); + await expect( + main.getByTestId("workspace-new-tab-panel").filter({ visible: true }), + ).toBeVisible(); + await expect(explorer.getByTestId("workspace-new-tab-panel")).toHaveCount(0); + }); + + await testInfo.attach("shared-explorer-tabs", { + body: await page.screenshot(), + contentType: "image/png", + }); + } finally { + await workspace.cleanup(); + } +}); + +async function dragAgentIntoExplorer( + page: Parameters[0], + agentId: string, +) { + const source = page + .getByTestId("workspace-pane-main") + .getByTestId(`workspace-tab-agent_${agentId}`); + const destination = explorerSidebar(page).getByTestId("workspace-tab-files"); + const sourceBox = await source.boundingBox(); + const destinationBox = await destination.boundingBox(); + if (!sourceBox || !destinationBox) + throw new Error("Tab drag requires visible source and destination"); + const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 }; + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x + 12, start.y + 4); + await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, { + steps: 20, + }); + await page.mouse.up(); +} + +test("agents can still be dragged into Explorer when absent from its + menu", async ({ + page, +}, testInfo) => { + const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + const explorer = await ensureExplorerSidebar(page); + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Dragged agent", + modeId: "load-test", + model: "ten-second-stream", + }); + const main = page.getByTestId("workspace-pane-main"); + await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible(); + await dragAgentIntoExplorer(page, agent.id); + await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible(); + await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0); + await explorer.getByRole("button", { name: "New tab", exact: true }).click(); + const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0); + await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + await testInfo.attach("agent-in-explorer", { + body: await page.screenshot(), + contentType: "image/png", + }); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts index 9e2e6a2378..95a5a62bc3 100644 --- a/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts +++ b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts @@ -64,10 +64,8 @@ test.describe("explorer surface after upgrading from the docked sidebar", () => const toggle = visible(page, "workspace-explorer-toggle").first(); await toggle.click(); await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 }); - await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible(); - await expect( - explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"), - ).toBeVisible(); + await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible(); + await expect(explorerSidebar(page).getByTestId("workspace-tab-changes_tree")).toBeVisible(); await toggle.click(); await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 }); @@ -102,7 +100,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () => await test.step("open the explorer so both sides are showing", async () => { await toggle.click(); await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 }); - await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible(); + await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible(); await expect(agentList).toHaveCount(1); }); @@ -116,7 +114,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () => await page.keyboard.press(`${modifier}+.`); await expect(agentList).toHaveCount(1, { timeout: 15_000 }); await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 }); - await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible(); + await expect(explorerSidebar(page).getByTestId("workspace-tab-files")).toBeVisible(); }); } finally { await workspace.cleanup(); diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index d426a03fb4..3c7475627d 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -279,7 +279,12 @@ test.describe("CodeMirror workspace file editing", () => { await openAgentRoute(page, session); await openWorkspaceFile(page, "target.ts"); - await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2); + await expect( + page + .locator('[data-testid^="workspace-pane-"]') + .getByTestId("workspace-tabs-row") + .filter({ visible: true }), + ).toHaveCount(2); await page .getByTestId(`workspace-tab-agent_${session.agentId}`) diff --git a/packages/app/e2e/browser/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts index 9be6d79418..e9d43ced27 100644 --- a/packages/app/e2e/browser/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -337,7 +337,7 @@ test.describe("Tab transitions (no flash)", () => { expect(counts.every((count) => count === initialCount)).toBe(true); expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2); - await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 }); + await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 }); }); test("Terminal transition completes within visual budget", async ({ page }) => { diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index b588add807..700d1e200f 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -572,18 +572,21 @@ test.describe("Half-screen desktop layout", () => { await openFilesPanel(page); const explorerToggle = page.getByTestId("workspace-explorer-toggle").first(); - await expect( - page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }), - ).toBeVisible(); + await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible(); await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar"); await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); - await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible(); - await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1); + await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible(); + await expect( + page + .locator('[data-testid^="workspace-pane-"]') + .getByTestId("workspace-tabs-row") + .filter({ visible: true }), + ).toHaveCount(1); await explorerToggle.click(); - await expect( - page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }), - ).toHaveCount(0); + await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount( + 0, + ); await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar"); await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); } finally { diff --git a/packages/app/e2e/support/helpers/explorer-plugin-menu.ts b/packages/app/e2e/support/helpers/explorer-plugin-menu.ts index 2d13080274..ccccac8570 100644 --- a/packages/app/e2e/support/helpers/explorer-plugin-menu.ts +++ b/packages/app/e2e/support/helpers/explorer-plugin-menu.ts @@ -35,18 +35,14 @@ export default function contribute(client) { 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 explorer.getByRole("button", { name: "New tab", exact: true }).click(); + const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); await expect(menu).toBeVisible(); await expect(menu).toHaveCSS("opacity", "1"); return menu; } -async function toggleExplorerView(page: Page, name: string) { +async function launchExplorerView(page: Page, name: string | RegExp) { const menu = await openExplorerMenu(page); await menu.getByRole("menuitem", { name, exact: true }).click(); await expect(menu).not.toBeVisible(); @@ -84,13 +80,12 @@ export async function withExplorerPanelPlugins( 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( + await expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Review agent", exact: true })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: "Review main only", exact: true })).toHaveCount( 0, ); + await page.keyboard.press("Escape"); } export async function openWorkspacePanelFromExplorerMenu( @@ -103,67 +98,51 @@ export async function openWorkspacePanelFromExplorerMenu( 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 expect(menu.getByRole("menuitem", { name: "Review", exact: true })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Other review", exact: true })).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "Review summary", exact: true })).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Other review summary", exact: true }), + ).toBeVisible(); await menu.getByRole("menuitem", { name: "Review", exact: true }).click(); await expect( page.getByText(`Review workspace ${workspace.workspaceId}`, { exact: true }), ).toBeVisible(); } -export async function expectIndependentExplorerPanelToggles( +export async function expectIndependentExplorerPanelTabs( page: Page, workspace: ExplorerPanelWorkspace, ) { - await toggleExplorerView(page, "Other review"); + await launchExplorerView(page, "Other review"); await expect( page.getByText(`Other review workspace ${workspace.workspaceId}`, { exact: true }), ).toBeVisible(); - await toggleExplorerView(page, "Review summary"); + await launchExplorerView(page, "Review summary"); + const tabs = page.getByTestId("workspace-explorer-sidebar").getByTestId("workspace-tabs-row"); + await expect(tabs.getByRole("button", { name: "Review", exact: true })).toBeVisible(); + await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible(); + await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible(); + await tabs + .getByRole("button", { name: "Review", exact: true }) + .click({ button: "right", position: { x: 12, y: 13 } }); + await page.getByRole("menuitem", { name: "Close", exact: true }).click(); + await expect(tabs.getByRole("button", { name: "Review", exact: true })).toHaveCount(0); + await expect(tabs.getByRole("button", { name: "Other review", exact: true })).toBeVisible(); + await expect(tabs.getByRole("button", { name: "Review summary", exact: true })).toBeVisible(); + const files = tabs.getByRole("button", { name: "Browse workspace files", exact: true }); + await expect(files).toHaveCount(1); 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 expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0); + await page.keyboard.press("Escape"); + await files.click({ button: "right", position: { x: 12, y: 13 } }); + await page.getByRole("menuitem", { name: "Close", exact: true }).click(); + await expect(files).toHaveCount(0); + await launchExplorerView(page, /^Files/); + await expect(files).toHaveCount(1); + const restoredMenu = await openExplorerMenu(page); + await expect(restoredMenu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0); 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( diff --git a/packages/app/e2e/support/helpers/workspace-tabs.ts b/packages/app/e2e/support/helpers/workspace-tabs.ts index 9477556823..8ddd507563 100644 --- a/packages/app/e2e/support/helpers/workspace-tabs.ts +++ b/packages/app/e2e/support/helpers/workspace-tabs.ts @@ -89,7 +89,7 @@ async function openExplorerView( ): Promise { const explorer = await ensureExplorerSidebar(page); const tab = explorer.getByTestId(view.tabTestId); - await tab.click(); + await selectWorkspaceTab(tab); await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({ timeout: view.timeout ?? 30_000, }); @@ -97,7 +97,7 @@ async function openExplorerView( export async function openChangesTreePanel(page: Page): Promise { await openExplorerView(page, { - tabTestId: "explorer-sidebar-tab-changes_tree", + tabTestId: "workspace-tab-changes_tree", contentTestId: "changes-tree-panel", }); } @@ -117,7 +117,7 @@ export async function openChangesPanel(page: Page, timeout = 30_000): Promise { await openExplorerView(page, { - tabTestId: "explorer-sidebar-tab-files", + tabTestId: "workspace-tab-files", contentTestId: "file-explorer-tree-scroll", }); } diff --git a/packages/app/package.json b/packages/app/package.json index 2d6f308206..a44656bc18 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -102,7 +102,7 @@ "fast-deep-equal": "^3.1.3", "htmlparser2": "^12.0.0", "i18next": "^26.3.0", - "lucide-react-native": "^0.546.0", + "lucide-react-native": "^1.50.0", "markdown-it": "^10.0.0", "mermaid": "^11.16.0", "mnemonic-id": "^3.2.7", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 0c926c559c..2eb4e5185a 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,6 +1,7 @@ import "@/styles/unistyles"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { PortalProvider } from "@gorhom/portal"; +import { LucideProvider } from "lucide-react-native"; import * as Linking from "expo-linking"; import * as Notifications from "expo-notifications"; import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router"; @@ -113,7 +114,7 @@ import { import { getDaemonStartService } from "@/runtime/daemon-start-service"; import { usePanelStore } from "@/stores/panel-store"; import { flushDraftPersistStorage } from "@/stores/draft-store"; -import { getNextThemePreference } from "@/styles/theme"; +import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme"; import { useSessionStore } from "@/stores/session-store"; import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles"; import type { HostProfile } from "@/types/host-connection"; @@ -972,11 +973,13 @@ function RootAppTree() { return ( - - - - - + + + + + + + ); diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index 4dcd50b019..d874a73618 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -6,7 +6,6 @@ import { Folder, FolderOpen, FolderPlus, - Github, HardDrive, Plus, Search, @@ -34,6 +33,7 @@ import { type EditingTextInputHandle, } from "@/components/ui/text-input"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { GitHubIcon } from "@/components/icons/github-icon"; import { applyAvailableAddProjectHosts, backAddProjectPage, @@ -162,7 +162,7 @@ function FlowBackButton({ onPress }: { onPress: () => void }) { } function methodIcon(method: AddProjectMethodId): FlowRowOption["icon"] { - if (method === "github") return Github; + if (method === "github") return GitHubIcon; if (method === "browse") return FolderOpen; if (method === "new-directory") return FolderPlus; return Search; @@ -656,7 +656,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { ? `${repository.nameWithOwner} via ${repository.cloneProtocol.toUpperCase()}` : repository.nameWithOwner, subtitle: repository.description, - icon: Github, + icon: GitHubIcon, testID: `add-project-flow-repository-${repository.id}`, select: () => setState((current) => openGithubLocationPage(current, page.hostId, repository)), diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 5f467785d7..9705f3be15 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -397,18 +397,6 @@ export function SplitContainer({ }, [workspaceKey], ); - const handleCreateExplorerTab = useCallback( - () => onCreateNewTab({ paneId: explorerSidebarPaneId ?? undefined }), - [explorerSidebarPaneId, onCreateNewTab], - ); - const handleMoveExplorerTabToMain = useCallback( - (tabId: string) => { - if (layout.focusedPaneId) { - onMoveTabToPane(tabId, layout.focusedPaneId); - } - }, - [layout.focusedPaneId, onMoveTabToPane], - ); const splitRoot = useMemo( () => resolveSplitContainerRoot({ @@ -725,8 +713,19 @@ export function SplitContainer({ closingTabIds={closingTabIds} onSelectTab={onSelectTabInPane} onCloseTab={onCloseTab} - onCreateNewTab={handleCreateExplorerTab} - onMoveTabToMain={handleMoveExplorerTabToMain} + onCreateNewTab={onCreateNewTab} + hoveredCloseTabKey={hoveredCloseTabKey} + setHoveredCloseTabKey={setHoveredCloseTabKey} + onCopyResumeCommand={onCopyResumeCommand} + onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} + onCopyFilePath={onCopyFilePath} + onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} + onCloseTabsToLeft={onCloseTabsToLeft} + onCloseTabsToRight={onCloseTabsToRight} + onCloseOtherTabs={onCloseOtherTabs} + onExitFocusMode={onExitFocusMode} buildPaneContentModel={buildPaneContentModel} onReorderTabsInPane={onReorderTabsInPane} activeDragTabId={activeDragTabId} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 0300dd518c..e15a00018e 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1921,10 +1921,15 @@ const styles = StyleSheet.create((theme: Theme) => ({ borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, borderRadius: theme.borderRadius["2xl"], - paddingVertical: { + paddingTop: { xs: theme.spacing[2], md: theme.spacing[4], }, + // The button row bleeds 6px horizontally, so match its corner inset at the bottom. + paddingBottom: { + xs: theme.spacing[2], + md: theme.spacing[3], + }, paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[4], diff --git a/packages/app/src/panels/new-tab-panel.tsx b/packages/app/src/panels/new-tab-panel.tsx index cdafafabed..da1039a980 100644 --- a/packages/app/src/panels/new-tab-panel.tsx +++ b/packages/app/src/panels/new-tab-panel.tsx @@ -137,6 +137,7 @@ const NewTabPanel = memo(function NewTabPanel(): ReactElement { serverId, purpose: host === "explorer" ? "supporting" : "primary", host, + surface: "panel", }); const itemsById = useMemo( () => new Map(groups.flatMap((group) => group.items).map((item) => [item.id, item])), diff --git a/packages/app/src/panels/panel-manifest.test.ts b/packages/app/src/panels/panel-manifest.test.ts index 0a91aad995..c06a87864d 100644 --- a/packages/app/src/panels/panel-manifest.test.ts +++ b/packages/app/src/panels/panel-manifest.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { panelResourceKey, panelSupportsHost } from "@/panels/panel-manifest"; +import { + getPanelManifest, + panelCanLaunchInPane, + panelResourceKey, + panelSupportsHost, +} from "@/panels/panel-manifest"; describe("panel manifest", () => { it("answers host support without React panel registration", () => { @@ -13,6 +18,23 @@ describe("panel manifest", () => { expect(panelSupportsHost("setup", "explorer")).toBe(false); }); + it("lets navigation tabs hide their close button without losing their close action", () => { + expect(getPanelManifest("files").showCloseButton).toBe(false); + expect(getPanelManifest("changes_tree").showCloseButton).toBe(false); + expect(getPanelManifest("agent").showCloseButton).toBe(true); + expect(getPanelManifest("terminal").showCloseButton).toBe(true); + expect(getPanelManifest("file").showCloseButton).toBe(true); + expect(getPanelManifest("working_diff").showCloseButton).toBe(true); + }); + + it("hides a singleton launch only when it already exists in the destination pane", () => { + expect(panelCanLaunchInPane("files", ["files"])).toBe(false); + expect(panelCanLaunchInPane("changes_tree", ["changes_tree"])).toBe(false); + expect(panelCanLaunchInPane("files", ["changes_tree"])).toBe(true); + expect(panelCanLaunchInPane("files", [])).toBe(true); + expect(panelCanLaunchInPane("terminal", ["terminal"])).toBe(true); + }); + it("keeps durable resource identity separate from transient target input", () => { expect( panelResourceKey({ kind: "working_diff", focusPath: "src/a.ts", focusRequestId: 1 }), diff --git a/packages/app/src/panels/panel-manifest.ts b/packages/app/src/panels/panel-manifest.ts index c608340d09..4122da80f1 100644 --- a/packages/app/src/panels/panel-manifest.ts +++ b/packages/app/src/panels/panel-manifest.ts @@ -5,6 +5,8 @@ export type PaneHost = "main" | "explorer"; export interface PanelManifest { kind: K; supportedHosts: readonly PaneHost[]; + showCloseButton: boolean; + singleton: boolean; resourceKey(target: Extract): string; } @@ -16,62 +18,86 @@ const manifests = { new_tab: { kind: "new_tab", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: () => "new_tab", }, draft: { kind: "draft", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.draftId, }, agent: { kind: "agent", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.agentId, }, provider_subagent: { kind: "provider_subagent", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`, }, terminal: { kind: "terminal", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.terminalId, }, browser: { kind: "browser", supportedHosts: ["main"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.browserId, }, changes_tree: { kind: "changes_tree", supportedHosts: ["explorer"], + showCloseButton: false, + singleton: true, resourceKey: () => "changes_tree", }, files: { kind: "files", supportedHosts: ["explorer"], + showCloseButton: false, + singleton: true, resourceKey: () => "files", }, pull_request: { kind: "pull_request", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: () => "pull_request", }, file: { kind: "file", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.path, }, working_diff: { kind: "working_diff", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: () => "working_diff", }, plugin: { kind: "plugin", // Plugin targets are narrowed by the target-aware plugin panel capability resolver. supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.context === "agent" ? `${target.pluginId}:${target.panelId}:agent:${target.agentId}` @@ -80,11 +106,15 @@ const manifests = { setup: { kind: "setup", supportedHosts: ["main"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.workspaceId, }, commit_diff: { kind: "commit_diff", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.sha, }, } satisfies PanelManifestByKind; @@ -97,6 +127,13 @@ export function panelSupportsHost(kind: WorkspaceTabTarget["kind"], host: PaneHo return getPanelManifest(kind).supportedHosts.includes(host); } +export function panelCanLaunchInPane( + kind: WorkspaceTabTarget["kind"], + panePanelKinds: readonly WorkspaceTabTarget["kind"][], +): boolean { + return !getPanelManifest(kind).singleton || !panePanelKinds.includes(kind); +} + export function panelResourceKey(target: WorkspaceTabTarget): string { const manifest = getPanelManifest(target.kind); return `${target.kind}:${manifest.resourceKey(target as never)}`; diff --git a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx b/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx deleted file mode 100644 index a9b7656963..0000000000 --- a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx +++ /dev/null @@ -1,468 +0,0 @@ -import { useCallback, useMemo, useState, type ComponentType, type ReactNode } from "react"; -import { Text, View } from "react-native"; -import { ArrowLeftToLine, Plus, X } from "lucide-react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useTranslation } from "react-i18next"; -import Animated from "react-native-reanimated"; -import { SortableInlineList } from "@/components/sortable-inline-list"; -import { EXPLORER_TAB_RAIL_INSET } from "@/components/explorer-sidebar-layout"; -import type { - DraggableListDragHandleProps, - DraggableRenderItemInfo, -} from "@/components/draggable-list.types"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { titlebarDragSurfaceStyle } from "@/components/desktop/titlebar-drag-region"; -import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; -import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome"; -import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; -import { - WorkspaceTabIcon, - WorkspaceTabPresentationResolver, - type WorkspaceTabPresentation, -} from "@/screens/workspace/workspace-tab-presentation"; -import type { WorkspaceDesktopTabRowItem } from "@/screens/workspace/workspace-desktop-tabs-row"; -import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -import { - useWorkspaceTabLaunchCatalog, - type WorkspaceTabLaunchItem, -} from "@/workspace-tabs/launcher"; -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"; -import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; -import { - HorizontalScrollBoundaryShades, - useHorizontalScrollBoundary, -} from "@/components/ui/horizontal-scroll-boundary"; - -const TAB_GAP = 4; -const TAB_DROP_INDICATOR_WIDTH = 4; - -const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -interface ExplorerSidebarTabRailProps { - paneId: string; - tabs: WorkspaceDesktopTabRowItem[]; - normalizedServerId: string; - normalizedWorkspaceId: string; - activeDragTabId: string | null; - tabDropPreviewIndex: number | null; - onNavigateTab: (tabId: string) => void; - onCloseTab: (tabId: string) => Promise | void; - onCreateNewTab: () => void; - onMoveTabToMain: (tabId: string) => void; - onReorderTabs: (tabs: WorkspaceTabDescriptor[]) => void; - trailingAccessory?: ReactNode; -} - -function tabKey(item: WorkspaceDesktopTabRowItem): string { - return `${item.tab.key}:${item.tab.kind}`; -} - -function resolveExplorerSidebarTabBackdrop(): SurfaceBackdrop { - return "surfaceSidebar"; -} - -function ExplorerSidebarTab({ - item, - isDragging, - dragHandleProps, - onNavigateTab, - onCloseTab, - onMoveTabToMain, - normalizedServerId, - normalizedWorkspaceId, -}: { - item: WorkspaceDesktopTabRowItem; - isDragging: boolean; - dragHandleProps?: DraggableListDragHandleProps; - onNavigateTab: (tabId: string) => void; - onCloseTab: (tabId: string) => Promise | void; - onMoveTabToMain: (tabId: string) => void; - normalizedServerId: string; - normalizedWorkspaceId: string; -}) { - const { t } = useTranslation(); - const [hovered, setHovered] = useState(false); - const handlePress = useCallback( - () => onNavigateTab(item.tab.tabId), - [item.tab.tabId, onNavigateTab], - ); - const handleHoverIn = useCallback(() => setHovered(true), []); - const handleHoverOut = useCallback(() => setHovered(false), []); - const handleClose = useCallback(() => { - void onCloseTab(item.tab.tabId); - }, [item.tab.tabId, onCloseTab]); - const handleMoveToMain = useCallback( - () => onMoveTabToMain(item.tab.tabId), - [item.tab.tabId, onMoveTabToMain], - ); - const canMoveToMain = panelTargetSupportsHost(normalizedServerId, item.tab.target, "main"); - const moveToMainLeading = useMemo( - () => , - [], - ); - const closeLeading = useMemo(() => , []); - const accessibilityState = useMemo(() => ({ selected: item.isActive }), [item.isActive]); - const renderPresentation = useCallback( - (presentation: WorkspaceTabPresentation) => ( - - - - - - - {presentation.label} - - - - - {presentation.tooltip} - - - - {canMoveToMain ? ( - - {t("workspace.tabs.menu.moveToMain")} - - ) : null} - {canMoveToMain ? : null} - - {t("workspace.tabs.menu.close")} - - - - ), - [ - accessibilityState, - dragHandleProps, - handleHoverIn, - handleHoverOut, - handleClose, - handleMoveToMain, - handlePress, - hovered, - isDragging, - item, - canMoveToMain, - closeLeading, - moveToMainLeading, - t, - ], - ); - - return ( - - {renderPresentation} - - ); -} - -function CatalogIcon({ - Icon, - color = "", -}: { - Icon: ComponentType; - color?: string; -}) { - return ; -} - -const ThemedCatalogIcon = withUnistyles(CatalogIcon); -const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); -const ThemedPlus = withUnistyles(Plus); -const ThemedX = withUnistyles(X); - -function ExplorerSidebarConfigurationItem({ - item, - paneId, - tab, - onCloseTab, -}: { - item: WorkspaceTabLaunchItem; - paneId: string; - tab: WorkspaceTabDescriptor | null; - onCloseTab: (tabId: string) => Promise | void; -}) { - const leading = useMemo(() => { - return item.Icon ? : null; - }, [item.Icon]); - const handleSelect = useCallback(() => { - if (tab) { - void onCloseTab(tab.tabId); - return; - } - item.launch({ kind: "open", paneId }); - }, [item, onCloseTab, paneId, tab]); - - return ( - - {item.label} - - ); -} - -function catalogItemMatchesTab(item: WorkspaceTabLaunchItem, tab: WorkspaceTabDescriptor): boolean { - return item.toggleTarget !== null && workspaceTabTargetsEqual(item.toggleTarget, tab.target); -} - -export function ExplorerSidebarTabRail({ - paneId, - tabs, - normalizedServerId, - normalizedWorkspaceId, - activeDragTabId, - tabDropPreviewIndex, - onNavigateTab, - onCloseTab, - onCreateNewTab, - onMoveTabToMain, - onReorderTabs, - trailingAccessory, -}: ExplorerSidebarTabRailProps) { - const scrollBoundary = useHorizontalScrollBoundary(); - const { t } = useTranslation(); - const groups = useWorkspaceTabLaunchCatalog({ - serverId: normalizedServerId, - purpose: "supporting", - host: "explorer", - }); - const singletonConfigurationItems = useMemo( - () => groups.flatMap((group) => group.items).filter((item) => item.toggleTarget !== null), - [groups], - ); - const newTabLeading = useMemo(() => , []); - const handleDragEnd = useCallback( - (nextTabs: WorkspaceDesktopTabRowItem[]) => onReorderTabs(nextTabs.map((item) => item.tab)), - [onReorderTabs], - ); - const getTabDragData = useCallback( - (item: WorkspaceDesktopTabRowItem) => ({ - kind: "workspace-tab" as const, - paneId, - tabId: item.tab.tabId, - }), - [paneId], - ); - const renderTab = useCallback( - ({ - item, - index, - dragHandleProps, - isActive, - }: DraggableRenderItemInfo) => { - const showBefore = activeDragTabId !== null && tabDropPreviewIndex === index; - const showAfter = - activeDragTabId !== null && - tabDropPreviewIndex === tabs.length && - index === tabs.length - 1; - return ( - - {showBefore ? : null} - - {showAfter ? : null} - - ); - }, - [ - activeDragTabId, - normalizedServerId, - normalizedWorkspaceId, - onNavigateTab, - onCloseTab, - onMoveTabToMain, - tabDropPreviewIndex, - tabs.length, - ], - ); - - return ( - - - - - - - - - {trailingAccessory ? ( - {trailingAccessory} - ) : null} - - - - {t("workspace.tabs.actions.newTab")} - - - {singletonConfigurationItems.map((item) => ( - catalogItemMatchesTab(item, tab))?.tab ?? null} - onCloseTab={onCloseTab} - /> - ))} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - track: { - minWidth: 0, - height: WORKSPACE_SECONDARY_HEADER_HEIGHT, - backgroundColor: theme.colors.surfaceSidebar, - flexDirection: "row", - alignItems: "center", - }, - scrollContainer: { - flex: 1, - minWidth: 0, - alignSelf: "stretch", - }, - scrollContent: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: EXPLORER_TAB_RAIL_INSET, - }, - trailingAccessory: { - marginRight: 4, - }, - tabSlot: { - position: "relative", - marginHorizontal: TAB_GAP / 2, - }, - tab: { - height: HEADER_CONTROL_HEIGHT, - maxWidth: 180, - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - userSelect: "none", - }, - tabHovered: { - backgroundColor: theme.colors.interactionHighlight, - }, - tabActive: { - backgroundColor: theme.colors.interactionHighlight, - }, - tabLabel: { - minWidth: 0, - flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - userSelect: "none", - }, - tabLabelActive: { - color: theme.colors.foreground, - }, - tabDragging: { - opacity: 0.3, - }, - dropIndicator: { - position: "absolute", - top: theme.spacing[0.5], - bottom: theme.spacing[0.5], - width: TAB_DROP_INDICATOR_WIDTH, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.accent, - zIndex: 10, - pointerEvents: "none", - }, - dropIndicatorBefore: { - left: -TAB_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2, - }, - dropIndicatorAfter: { - right: -TAB_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2, - }, - tooltipText: { - color: theme.colors.popoverForeground, - fontSize: theme.fontSize.base, - }, -})); diff --git a/packages/app/src/screens/workspace/explorer-sidebar.tsx b/packages/app/src/screens/workspace/explorer-sidebar.tsx index 389d37c821..175be3695b 100644 --- a/packages/app/src/screens/workspace/explorer-sidebar.tsx +++ b/packages/app/src/screens/workspace/explorer-sidebar.tsx @@ -4,17 +4,35 @@ import { StyleSheet } from "react-native-unistyles"; import { RetainedPanel } from "@/components/retained-panel"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import type { TabDropPreview } from "@/components/split-container-tab-drop-preview"; -import { ExplorerSidebarTabRail } from "@/screens/workspace/explorer-sidebar-tab-rail"; import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host"; import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; -import type { WorkspaceDesktopTabRowItem } from "@/screens/workspace/workspace-desktop-tabs-row"; +import { + WorkspaceDesktopTabsRow, + type WorkspaceDesktopTabRowItem, + type WorkspaceDesktopTabsRowProps, +} from "@/screens/workspace/workspace-desktop-tabs-row"; import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { SplitPane } from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/workspace-tabs/model"; import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; -interface ExplorerSidebarDockProps { +interface ExplorerSidebarDockProps extends Pick< + WorkspaceDesktopTabsRowProps, + | "setHoveredCloseTabKey" + | "onCopyResumeCommand" + | "onCopyAgentId" + | "onCopyTerminalId" + | "onCopyFilePath" + | "onReloadAgent" + | "onRenameTab" + | "onCreateNewTab" + | "onExitFocusMode" +> { + hoveredCloseTabKey: string | null; + onCloseTabsToLeft: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise | void; + onCloseTabsToRight: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise | void; + onCloseOtherTabs: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise | void; pane: SplitPane; uiTabs: WorkspaceTab[]; normalizedServerId: string; @@ -25,8 +43,6 @@ interface ExplorerSidebarDockProps { tabDropPreview: TabDropPreview | null; onSelectTab: (paneId: string, tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; - onCreateNewTab: () => void; - onMoveTabToMain: (tabId: string) => void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; buildPaneContentModel: (input: { paneId: string; @@ -48,7 +64,18 @@ export function ExplorerSidebarDock({ onSelectTab, onCloseTab, onCreateNewTab, - onMoveTabToMain, + hoveredCloseTabKey, + setHoveredCloseTabKey, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTabsToLeft, + onCloseTabsToRight, + onCloseOtherTabs, + onExitFocusMode, onReorderTabsInPane, buildPaneContentModel, headerAction, @@ -61,10 +88,10 @@ export function ExplorerSidebarDock({ tabs.map((tab) => ({ tab, isActive: tab.tabId === activeTabId, - isCloseHovered: false, + isCloseHovered: hoveredCloseTabKey === tab.key, isClosingTab: closingTabIds.has(tab.tabId), })), - [activeTabId, closingTabIds, tabs], + [activeTabId, closingTabIds, hoveredCloseTabKey, tabs], ); const handleSelectTab = useCallback( (tabId: string) => onSelectTab(pane.id, tabId), @@ -80,28 +107,59 @@ export function ExplorerSidebarDock({ [onReorderTabsInPane, pane.id], ); + const handleCloseTabsToLeft = useCallback( + (tabId: string) => onCloseTabsToLeft(tabId, tabs), + [onCloseTabsToLeft, tabs], + ); + const handleCloseTabsToRight = useCallback( + (tabId: string) => onCloseTabsToRight(tabId, tabs), + [onCloseTabsToRight, tabs], + ); + const handleCloseOtherTabs = useCallback( + (tabId: string) => onCloseOtherTabs(tabId, tabs), + [onCloseOtherTabs, tabs], + ); + return ( - + + + + {headerAction ? {headerAction} : null} @@ -131,9 +189,18 @@ const styles = StyleSheet.create((theme) => ({ }, tabRail: { position: "relative", + flexDirection: "row", + alignItems: "center", flexShrink: 0, backgroundColor: theme.colors.surfaceSidebar, }, + tabRow: { + flex: 1, + minWidth: 0, + }, + headerAction: { + marginRight: theme.spacing[1], + }, tabRailDivider: { position: "absolute", right: 0, diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index bbb0561130..f1806ede34 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -63,6 +63,8 @@ import { type WorkspaceTabMenuLabels, } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import type { PaneHost } from "@/panels/panel-manifest"; +import type { WorkspaceTabLaunchPurpose } from "@/workspace-tabs/launcher"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import type { Theme } from "@/styles/theme"; import { RenderProfile } from "@/utils/render-profiler"; @@ -107,7 +109,7 @@ const TAB_ICON_WIDTH = 14; const TAB_CONTENT_GAP = 4; const TAB_DROP_INDICATOR_WIDTH = 4; const TAB_MODIFIED_DOT_SIZE = 8; -const TAB_MIN_WIDTH = 96; +const TAB_MIN_WIDTH = 64; const TAB_MAX_WIDTH = 160; const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0; const TAB_LABEL_LAYOUT_ALLOWANCE = 4; @@ -209,7 +211,12 @@ function TabLabelMeasurement({ ); } +type PanePanelKinds = readonly WorkspaceTabDescriptor["kind"][]; + interface WorkspaceNewTabButtonProps { + panePanelKinds: PanePanelKinds; + host: PaneHost; + launchPurpose: WorkspaceTabLaunchPurpose; serverId: string; paneId?: string; shortcutKeys: ShortcutKey[][] | null; @@ -217,6 +224,9 @@ interface WorkspaceNewTabButtonProps { } function WorkspaceNewTabButton({ + panePanelKinds, + host, + launchPurpose, serverId, paneId, shortcutKeys, @@ -237,8 +247,9 @@ function WorkspaceNewTabButton({ @@ -248,6 +259,9 @@ function WorkspaceNewTabButton({ } function WorkspacePaneToolbarActions({ + panePanelKinds, + host, + launchPurpose, showNewTabButton, showSplitActions, showMaximizeAction, @@ -259,6 +273,9 @@ function WorkspacePaneToolbarActions({ onSplitDown, onTogglePaneMaximized, }: { + panePanelKinds: PanePanelKinds; + host: PaneHost; + launchPurpose: WorkspaceTabLaunchPurpose; showNewTabButton: boolean; showSplitActions: boolean; showMaximizeAction: boolean; @@ -297,6 +314,9 @@ function WorkspacePaneToolbarActions({ {showNewTabButton ? ( width === right[index]); } -interface WorkspaceDesktopTabsRowProps { +export interface WorkspaceDesktopTabsRowProps { + host?: PaneHost; + launchPurpose?: WorkspaceTabLaunchPurpose; paneId?: string; isFocused?: boolean; + ownsKeyboardShortcuts?: boolean; tabs: WorkspaceDesktopTabRowItem[]; normalizedServerId: string; normalizedWorkspaceId: string; @@ -988,8 +1011,11 @@ export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) { } function ResolvedWorkspaceDesktopTabsRow({ + host = "main", + launchPurpose = "primary", paneId, isFocused = false, + ownsKeyboardShortcuts = isFocused, tabs, normalizedServerId, normalizedWorkspaceId, @@ -1059,6 +1085,8 @@ function ResolvedWorkspaceDesktopTabsRow({ [exitFocusModeWidth, focusModeEnabled, showPaneMaximizeAction, showPaneSplitActions], ); + const panePanelKinds = useMemo(() => tabs.map(({ tab }) => tab.kind), [tabs]); + const fallbackTabLabels = useMemo( () => ({ newTab: t("workspace.tabs.actions.newTab"), @@ -1203,14 +1231,14 @@ function ResolvedWorkspaceDesktopTabsRow({ const handleNewTabKeyboardAction = useCallback( (action: KeyboardActionDefinition): boolean => { - if (!isFocused) return false; + if (!ownsKeyboardShortcuts) return false; if (action.id === "workspace.tab.menu.open") { createNewTab(); return true; } return false; }, - [createNewTab, isFocused], + [createNewTab, ownsKeyboardShortcuts], ); useKeyboardActionHandler({ @@ -1221,7 +1249,7 @@ function ResolvedWorkspaceDesktopTabsRow({ paneId, }), actions: ["workspace.tab.menu.open"], - enabled: isFocused, + enabled: ownsKeyboardShortcuts, priority: 200, handle: handleNewTabKeyboardAction, }); @@ -1233,7 +1261,8 @@ function ResolvedWorkspaceDesktopTabsRow({ dragHandleProps, isActive, }: DraggableRenderItemInfo) => { - const shouldShowCloseButton = layout.closeButtonPolicy === "all"; + const shouldShowCloseButton = + layout.closeButtonPolicy === "all" && item.presentation.showCloseButton; const layoutItem = layout.items[index] ?? null; const resolvedTabWidth = layoutItem?.width ?? 150; const showLabel = layoutItem?.showLabel ?? true; @@ -1360,6 +1389,9 @@ function ResolvedWorkspaceDesktopTabsRow({ /> {!layout.requiresHorizontalScrollFallback ? ( { expect(result.requiresHorizontalScrollFallback).toBe(false); expect(result.items).toHaveLength(3); expect(result.items.every((item) => item.showLabel)).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([96, 104, 96]); + expect(result.items.map((item) => item.width)).toEqual([90, 104, 83]); + }); + + it("lets short labels use their content width below the old 96px floor", () => { + const result = computeWorkspaceTabLayout({ + viewportWidth: 300, + tabLabelWidths: [29, 52], + metrics: { ...metrics, actionsReservedWidth: 36 }, + }); + expect(result.items).toEqual([ + { width: 64, showLabel: true }, + { width: 86, showLabel: true }, + ]); + expect(result.requiresHorizontalScrollFallback).toBe(false); }); it("sizes a single tab between the minimum and maximum from its content", () => { @@ -52,7 +65,7 @@ describe("computeWorkspaceTabLayout", () => { expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([117, 103, 96]); + expect(result.items.map((item) => item.width)).toEqual([132, 102, 82]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); @@ -75,27 +88,27 @@ describe("computeWorkspaceTabLayout", () => { it("keeps every tab at the clickable minimum at the exact fit boundary", () => { const result = computeWorkspaceTabLayout({ - viewportWidth: 532, + viewportWidth: 404, tabLabelWidths: [98, 98, 98, 98], metrics, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]); + expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); it("uses horizontal scroll rather than shrinking below the clickable minimum", () => { const result = computeWorkspaceTabLayout({ - viewportWidth: 531, + viewportWidth: 403, tabLabelWidths: [98, 98, 98, 98], metrics, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]); + expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index 07cd56e4fb..8b499429f0 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -26,6 +26,7 @@ export interface WorkspaceTabPresentation { subtitle: string; tooltip: string; modified: boolean; + showCloseButton: boolean; titleState: "ready" | "loading"; icon: React.ComponentType; statusBucket: SidebarStateBucket | null; @@ -90,6 +91,7 @@ function WorkspaceTabPresentationResolverInner({ subtitle: descriptor.subtitle, tooltip: descriptor.tooltip, modified: attributes.modified, + showCloseButton: registration.showCloseButton, titleState: descriptor.titleState, icon: descriptor.icon, statusBucket: descriptor.statusBucket, @@ -104,6 +106,7 @@ function WorkspaceTabPresentationResolverInner({ tab.key, tab.kind, attributes.modified, + registration.showCloseButton, ], ); diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index f58075b3a1..04b1e8f6ae 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -597,6 +597,8 @@ export const ICON_SIZE = { lg: 20, } as const; +export const ICON_STROKE_WIDTH = 1.5; + export const FONT_WEIGHT = { normal: "normal" as const, medium: "500" as const, diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 953c13e0c6..601030562f 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -13,6 +13,7 @@ import { type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; import type { Theme } from "@/styles/theme"; +import { getPanelManifest } from "@/panels/panel-manifest"; import type { SubagentRow } from "./select"; import type { ArchiveFinishedStatus } from "./use-archive-finished"; import { @@ -52,6 +53,7 @@ function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPre ...data, tooltip: data.label, modified: false, + showCloseButton: getPanelManifest(data.kind).showCloseButton, icon, }; } diff --git a/packages/app/src/workspace-tabs/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx index 8f10527d75..ea983b4bd8 100644 --- a/packages/app/src/workspace-tabs/launcher/index.tsx +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -18,7 +18,7 @@ import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; import type { NewTabSelection } from "@/workspace-tabs/new-tab"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; -import { panelSupportsHost, type PaneHost } from "@/panels/panel-manifest"; +import { panelCanLaunchInPane, panelSupportsHost, type PaneHost } from "@/panels/panel-manifest"; import { getPanelRegistration, type PanelIconProps, @@ -53,8 +53,6 @@ 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; } @@ -65,6 +63,8 @@ export interface WorkspaceTabLaunchGroup { accessory?: { id: string; label: string; run: () => void }; } +const EMPTY_PANE_PANEL_KINDS: readonly WorkspaceTabTarget["kind"][] = []; + const NewTabLauncherContext = createContext(null); export function NewTabLauncherProvider({ @@ -97,8 +97,10 @@ export function useWorkspaceTabLaunchCatalog(input: { serverId: string; purpose: WorkspaceTabLaunchPurpose; host: PaneHost; + surface: "menu" | "panel"; + panePanelKinds?: readonly WorkspaceTabTarget["kind"][]; }): readonly WorkspaceTabLaunchGroup[] { - const { serverId, purpose, host } = input; + const { serverId, purpose, host, surface, panePanelKinds = EMPTY_PANE_PANEL_KINDS } = input; const { t } = useTranslation(); const router = useRouter(); const launcher = useContext(NewTabLauncherContext); @@ -118,6 +120,7 @@ export function useWorkspaceTabLaunchCatalog(input: { }, [router, serverId]); return useMemo(() => { + const isExplorerMenu = host === "explorer" && surface === "menu"; const changesPresentation = getLaunchPresentation("changes_tree"); const diffPresentation = getLaunchPresentation("working_diff"); const filesPresentation = getLaunchPresentation("files"); @@ -130,7 +133,7 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-agent", disabled: false, panelKind: "draft", - toggleTarget: null, + hidden: isExplorerMenu, launch: launchSelection(BUILT_IN_SELECTIONS.agent), }, terminal: { @@ -140,7 +143,6 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-terminal-new", disabled: launcher.terminalDisabled, panelKind: "terminal", - toggleTarget: null, launch: launchSelection(BUILT_IN_SELECTIONS.terminal), }, changes: { @@ -149,7 +151,6 @@ 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), }, @@ -160,7 +161,6 @@ 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), }, @@ -171,7 +171,6 @@ 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: { @@ -181,7 +180,6 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-browser", disabled: false, panelKind: "browser", - toggleTarget: null, hidden: !launcher.showBrowser, launch: launchSelection(BUILT_IN_SELECTIONS.browser), }, @@ -191,7 +189,6 @@ export function useWorkspaceTabLaunchCatalog(input: { Icon: pullRequestPresentation.icon, disabled: false, panelKind: "pull_request", - toggleTarget: null, hidden: !launcher.showPullRequest, launch: launchSelection(BUILT_IN_SELECTIONS.pullRequest), }, @@ -218,7 +215,6 @@ export function useWorkspaceTabLaunchCatalog(input: { Icon: resolvePluginIcon(panel.icon), disabled: false, panelKind: "plugin", - toggleTarget: selection.target, launch: launchSelection(selection), }); } @@ -229,7 +225,7 @@ export function useWorkspaceTabLaunchCatalog(input: { if (pluginItems.length > 0) { groups.push({ id: "plugin-panels", label: null, items: pluginItems }); } - if (profiles.length > 0) { + if (!isExplorerMenu && profiles.length > 0) { groups.push({ id: "terminal-profiles", label: t("workspace.tabs.actions.terminalProfilesMenu"), @@ -239,7 +235,6 @@ export function useWorkspaceTabLaunchCatalog(input: { terminalIconKey: getTerminalProfileIcon(profile), disabled: launcher.terminalDisabled, panelKind: "terminal", - toggleTarget: null, launch: launchSelection({ kind: "terminal", profile }), })), accessory: { @@ -249,7 +244,13 @@ export function useWorkspaceTabLaunchCatalog(input: { }, }); } - return groups; + if (surface !== "menu") return groups; + return groups.flatMap((group) => { + const items = group.items.filter((item) => + panelCanLaunchInPane(item.panelKind, panePanelKinds), + ); + return items.length > 0 ? [{ ...group, items }] : []; + }); }, [ config?.terminalProfiles, editTerminalProfiles, @@ -258,6 +259,8 @@ export function useWorkspaceTabLaunchCatalog(input: { plugins, purpose, host, + surface, + panePanelKinds, serverId, t, ]); diff --git a/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts b/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts index a2ede20ddf..864e0ec43f 100644 --- a/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts +++ b/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts @@ -17,9 +17,9 @@ describe("getBuiltInLaunchOrder", () => { it("leads with companion tools in a supporting pane", () => { expect(getBuiltInLaunchOrder("supporting")).toEqual([ "changes", + "terminal", "diff", "files", - "terminal", "agent", "browser", "pullRequest", diff --git a/packages/app/src/workspace-tabs/launcher/internal/catalog.ts b/packages/app/src/workspace-tabs/launcher/internal/catalog.ts index dfed374170..5c2c77cdfe 100644 --- a/packages/app/src/workspace-tabs/launcher/internal/catalog.ts +++ b/packages/app/src/workspace-tabs/launcher/internal/catalog.ts @@ -10,9 +10,9 @@ export const PRIMARY_LAUNCH_ORDER = [ export const SUPPORTING_LAUNCH_ORDER = [ "changes", + "terminal", "diff", "files", - "terminal", "agent", "browser", "pullRequest", diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index b5f505ea2d..e5cb02d46e 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -991,9 +991,9 @@ const x = 1; }); it("does not report local-only no-track worktree commits as ahead of origin", async () => { - setupRemoteTrackingMain(repoDir, tempDir); - commitFile(repoDir, "second.txt", "second\n", "second commit"); - execFileSync("git", ["push"], { cwd: repoDir }); + // This case needs a remote base ref, not a second clone or extra base history. + execFileSync("git", ["remote", "add", "origin", repoDir], { cwd: repoDir }); + execFileSync("git", ["fetch", "origin", "main:refs/remotes/origin/main"], { cwd: repoDir }); const worktree = await createLegacyWorktreeForTest({ branchName: "fresh-feature", @@ -1007,6 +1007,7 @@ const x = 1; const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(status).toMatchObject({ isGit: true, + hasRemote: true, isPaseoOwnedWorktree: true, baseRef: "main", aheadBehind: { ahead: 1, behind: 0 }, diff --git a/packages/website/src/components/mockup/titlebar.tsx b/packages/website/src/components/mockup/titlebar.tsx index 6f3b91032f..de99f4db76 100644 --- a/packages/website/src/components/mockup/titlebar.tsx +++ b/packages/website/src/components/mockup/titlebar.tsx @@ -64,7 +64,7 @@ export function TitleBar({ state }: { state: MockupStateId }) { {shipping ? ( <> - Merge PR (squash) + Merge PR ) : ( <>