Unify Explorer tabs and refine launch controls (#5942)

* feat(app): unify Explorer tabs and refine interface styling

Give Explorer the workspace tab controls while keeping launcher choices separate from drag-and-drop support.

Upgrade Lucide and set a shared icon stroke width for consistent styling. Reduce the desktop composer bottom inset to align the action row with its corners.

* fix(app): show active Explorer tabs in foreground

The shared row coupled foreground styling to keyboard shortcut ownership. Separate those concerns so Explorer can highlight its active tab while Cmd+T remains with the workspace pane.

* fix(app): refine Explorer close controls and launch options

* test(app): respect singleton Files in Explorer plugin workflow

* test(server): trim unused setup from no-track checkout regression

* test(app): count visible main tab rows after pane moves
This commit is contained in:
Mohamed Boudra
2026-10-03 01:20:53 +01:00
committed by GitHub
parent d831c7bf33
commit d3c76be9c1
34 changed files with 605 additions and 672 deletions
+11 -7
View File
@@ -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.
+18 -12
View File
@@ -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",
@@ -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();
@@ -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();
@@ -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<void> {
@@ -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<void> {
}
async function moveOnlyDraftIntoRightSplit(page: Page): Promise<void> {
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<void> {
@@ -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));
});
@@ -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<typeof ensureExplorerSidebar>[0]) {
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
}
async function expectExplorerActiveTabForeground(
page: Parameters<typeof ensureExplorerSidebar>[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<typeof ensureExplorerSidebar>[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<typeof ensureExplorerSidebar>[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<typeof ensureExplorerSidebar>[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<typeof ensureExplorerSidebar>[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<typeof ensureExplorerSidebar>[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();
}
});
@@ -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();
@@ -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}`)
@@ -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 }) => {
@@ -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 {
@@ -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(
@@ -89,7 +89,7 @@ async function openExplorerView(
): Promise<void> {
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<void> {
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<vo
export async function openFilesPanel(page: Page): Promise<void> {
await openExplorerView(page, {
tabTestId: "explorer-sidebar-tab-files",
tabTestId: "workspace-tab-files",
contentTestId: "file-explorer-tree-scroll",
});
}
+1 -1
View File
@@ -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",
+9 -6
View File
@@ -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 (
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</LucideProvider>
</View>
</GestureHandlerRootView>
);
@@ -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)),
+13 -14
View File
@@ -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}
+6 -1
View File
@@ -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],
@@ -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])),
+23 -1
View File
@@ -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 }),
+37
View File
@@ -5,6 +5,8 @@ export type PaneHost = "main" | "explorer";
export interface PanelManifest<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
kind: K;
supportedHosts: readonly PaneHost[];
showCloseButton: boolean;
singleton: boolean;
resourceKey(target: Extract<WorkspaceTabTarget, { kind: K }>): 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)}`;
@@ -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> | 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> | 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(
() => <ThemedArrowLeftToLine size={14} uniProps={mutedColorMapping} />,
[],
);
const closeLeading = useMemo(() => <ThemedX size={14} uniProps={mutedColorMapping} />, []);
const accessibilityState = useMemo(() => ({ selected: item.isActive }), [item.isActive]);
const renderPresentation = useCallback(
(presentation: WorkspaceTabPresentation) => (
<ContextMenu>
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef">
<ContextMenuTrigger
{...(dragHandleProps?.attributes as object | undefined)}
{...(dragHandleProps?.listeners as object | undefined)}
triggerRef={dragHandleProps?.setActivatorNodeRef as never}
testID={`explorer-sidebar-tab-${item.tab.tabId}`}
accessibilityRole="button"
accessibilityLabel={presentation.tooltip}
accessibilityState={accessibilityState}
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={[
styles.tab,
hovered ? styles.tabHovered : null,
item.isActive ? styles.tabActive : null,
isDragging ? styles.tabDragging : null,
]}
>
<WorkspaceTabIcon
presentation={presentation}
active={item.isActive}
size={iconButtonChromeGlyphSize("small")}
strokeWidth={1.5}
backdrop={resolveExplorerSidebarTabBackdrop()}
/>
<Text
selectable={false}
numberOfLines={1}
ellipsizeMode="tail"
style={[styles.tabLabel, item.isActive ? styles.tabLabelActive : null]}
>
{presentation.label}
</Text>
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<Text style={styles.tooltipText}>{presentation.tooltip}</Text>
</TooltipContent>
</Tooltip>
<ContextMenuContent align="start" minWidth={180}>
{canMoveToMain ? (
<ContextMenuItem leading={moveToMainLeading} onSelect={handleMoveToMain}>
{t("workspace.tabs.menu.moveToMain")}
</ContextMenuItem>
) : null}
{canMoveToMain ? <ContextMenuSeparator /> : null}
<ContextMenuItem leading={closeLeading} onSelect={handleClose}>
{t("workspace.tabs.menu.close")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
),
[
accessibilityState,
dragHandleProps,
handleHoverIn,
handleHoverOut,
handleClose,
handleMoveToMain,
handlePress,
hovered,
isDragging,
item,
canMoveToMain,
closeLeading,
moveToMainLeading,
t,
],
);
return (
<WorkspaceTabPresentationResolver
tab={item.tab}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{renderPresentation}
</WorkspaceTabPresentationResolver>
);
}
function CatalogIcon({
Icon,
color = "",
}: {
Icon: ComponentType<PanelIconProps>;
color?: string;
}) {
return <Icon size={14} color={color} />;
}
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> | void;
}) {
const leading = useMemo(() => {
return item.Icon ? <ThemedCatalogIcon Icon={item.Icon} uniProps={mutedColorMapping} /> : null;
}, [item.Icon]);
const handleSelect = useCallback(() => {
if (tab) {
void onCloseTab(tab.tabId);
return;
}
item.launch({ kind: "open", paneId });
}, [item, onCloseTab, paneId, tab]);
return (
<ContextMenuItem
leading={leading}
selected={Boolean(tab)}
showSelectedCheck
disabled={!tab && item.disabled}
onSelect={handleSelect}
>
{item.label}
</ContextMenuItem>
);
}
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(() => <ThemedPlus size={14} uniProps={mutedColorMapping} />, []);
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<WorkspaceDesktopTabRowItem>) => {
const showBefore = activeDragTabId !== null && tabDropPreviewIndex === index;
const showAfter =
activeDragTabId !== null &&
tabDropPreviewIndex === tabs.length &&
index === tabs.length - 1;
return (
<View style={styles.tabSlot}>
{showBefore ? <View style={[styles.dropIndicator, styles.dropIndicatorBefore]} /> : null}
<ExplorerSidebarTab
item={item}
isDragging={isActive}
dragHandleProps={dragHandleProps}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onMoveTabToMain={onMoveTabToMain}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
{showAfter ? <View style={[styles.dropIndicator, styles.dropIndicatorAfter]} /> : null}
</View>
);
},
[
activeDragTabId,
normalizedServerId,
normalizedWorkspaceId,
onNavigateTab,
onCloseTab,
onMoveTabToMain,
tabDropPreviewIndex,
tabs.length,
],
);
return (
<ContextMenu>
<ContextMenuTrigger
contextOnly
style={[styles.track, titlebarDragSurfaceStyle as never]}
testID="explorer-sidebar-tab-rail"
>
<View style={styles.scrollContainer}>
<Animated.ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
onLayout={scrollBoundary.onLayout}
onContentSizeChange={scrollBoundary.onContentSizeChange}
onScroll={scrollBoundary.onScroll}
scrollEventThrottle={16}
>
<SortableInlineList
data={tabs}
keyExtractor={tabKey}
renderItem={renderTab}
onDragEnd={handleDragEnd}
useDragHandle
externalDndContext
activeId={activeDragTabId}
getItemData={getTabDragData}
/>
</Animated.ScrollView>
<HorizontalScrollBoundaryShades
visible
backdrop="sidebar"
testIDPrefix="explorer-sidebar-tabs-scroll-shade"
leftStyle={scrollBoundary.leftShadeStyle}
rightStyle={scrollBoundary.rightShadeStyle}
/>
</View>
{trailingAccessory ? (
<View style={styles.trailingAccessory}>{trailingAccessory}</View>
) : null}
</ContextMenuTrigger>
<ContextMenuContent align="start" minWidth={200} testID="explorer-sidebar-tab-configuration">
<ContextMenuItem leading={newTabLeading} onSelect={onCreateNewTab}>
{t("workspace.tabs.actions.newTab")}
</ContextMenuItem>
<ContextMenuSeparator />
{singletonConfigurationItems.map((item) => (
<ExplorerSidebarConfigurationItem
key={item.id}
item={item}
paneId={paneId}
tab={tabs.find(({ tab }) => catalogItemMatchesTab(item, tab))?.tab ?? null}
onCloseTab={onCloseTab}
/>
))}
</ContextMenuContent>
</ContextMenu>
);
}
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,
},
}));
@@ -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> | void;
onCloseTabsToRight: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise<void> | 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> | 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 (
<RetainedPanel active>
<WindowChromeRegion corners="top-right">
<View style={styles.dock} testID="workspace-explorer-sidebar">
<WindowChromeSafeArea placement="inline" style={styles.tabRail}>
<TitlebarDragRegion />
<ExplorerSidebarTabRail
paneId={pane.id}
tabs={tabItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
onNavigateTab={handleSelectTab}
onCloseTab={onCloseTab}
onCreateNewTab={onCreateNewTab}
onMoveTabToMain={onMoveTabToMain}
onReorderTabs={handleReorderTabs}
trailingAccessory={headerAction}
/>
<View style={styles.tabRow}>
<WorkspaceDesktopTabsRow
host="explorer"
launchPurpose="supporting"
isFocused={isWorkspaceFocused}
ownsKeyboardShortcuts={false}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
focusModeEnabled={false}
onExitFocusMode={onExitFocusMode}
externalDndContext
paneId={pane.id}
tabs={tabItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
onNavigateTab={handleSelectTab}
onCloseTab={onCloseTab}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
/>
</View>
{headerAction ? <View style={styles.headerAction}>{headerAction}</View> : null}
<View pointerEvents="none" style={styles.tabRailDivider} />
</WindowChromeSafeArea>
<View style={styles.content}>
@@ -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,
@@ -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({
</ToolbarButton>
<WorkspaceNewTabMenuContent
serverId={serverId}
purpose="primary"
host="main"
purpose={launchPurpose}
host={host}
panePanelKinds={panePanelKinds}
paneId={paneId}
/>
</DropdownMenu>
@@ -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({
<ToolbarControls style={styles.paneSplitActions}>
{showNewTabButton ? (
<WorkspaceNewTabButton
panePanelKinds={panePanelKinds}
host={host}
launchPurpose={launchPurpose}
placement="toolbar"
serverId={serverId}
paneId={paneId}
@@ -492,9 +512,12 @@ function sameWidths(left: number[], right: number[]): boolean {
return left.length === right.length && left.every((width, index) => 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<ResolvedWorkspaceDesktopTabRowItem>) => {
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 ? (
<WorkspaceNewTabButton
panePanelKinds={panePanelKinds}
host={host}
launchPurpose={launchPurpose}
placement="inline"
serverId={normalizedServerId}
paneId={paneId}
@@ -1376,6 +1408,9 @@ function ResolvedWorkspaceDesktopTabsRow({
/>
</View>
<WorkspacePaneToolbarActions
panePanelKinds={panePanelKinds}
host={host}
launchPurpose={launchPurpose}
showNewTabButton={layout.requiresHorizontalScrollFallback}
showSplitActions={showPaneSplitActions}
showMaximizeAction={showPaneMaximizeAction}
@@ -16,6 +16,7 @@ import {
type WorkspaceTabLaunchItem,
type WorkspaceTabLaunchPurpose,
} from "@/workspace-tabs/launcher";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { PaneHost } from "@/panels/panel-manifest";
import type { PanelIconProps } from "@/panels/panel-registry";
@@ -84,14 +85,22 @@ export function WorkspaceNewTabMenuContent({
serverId,
purpose,
host,
panePanelKinds,
paneId,
}: {
serverId: string;
purpose: WorkspaceTabLaunchPurpose;
host: PaneHost;
panePanelKinds: readonly WorkspaceTabTarget["kind"][];
paneId?: string;
}) {
const groups = useWorkspaceTabLaunchCatalog({ serverId, purpose, host });
const groups = useWorkspaceTabLaunchCatalog({
serverId,
purpose,
host,
surface: "menu",
panePanelKinds,
});
return (
<DropdownMenuContent
@@ -9,7 +9,7 @@ const metrics = {
actionsReservedWidth: 120,
rowPaddingHorizontal: 8,
tabGap: 4,
minTabWidth: 96,
minTabWidth: 64,
maxTabWidth: 160,
tabIconWidth: 14,
tabContentGap: 4,
@@ -29,7 +29,20 @@ describe("computeWorkspaceTabLayout", () => {
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);
});
@@ -26,6 +26,7 @@ export interface WorkspaceTabPresentation {
subtitle: string;
tooltip: string;
modified: boolean;
showCloseButton: boolean;
titleState: "ready" | "loading";
icon: React.ComponentType<PanelIconProps>;
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,
],
);
+2
View File
@@ -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,
+2
View File
@@ -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,
};
}
@@ -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<NewTabLauncher | null>(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,
]);
@@ -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",
@@ -10,9 +10,9 @@ export const PRIMARY_LAUNCH_ORDER = [
export const SUPPORTING_LAUNCH_ORDER = [
"changes",
"terminal",
"diff",
"files",
"terminal",
"agent",
"browser",
"pullRequest",
@@ -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 },
@@ -64,7 +64,7 @@ export function TitleBar({ state }: { state: MockupStateId }) {
{shipping ? (
<>
<GithubGlyph size={13} />
Merge PR (squash)
Merge PR
</>
) : (
<>