diff --git a/packages/app/e2e/browser/direct-terminal-shortcut-pane-placement.spec.ts b/packages/app/e2e/browser/direct-terminal-shortcut-pane-placement.spec.ts index 373459ef4c..b49e89cde7 100644 --- a/packages/app/e2e/browser/direct-terminal-shortcut-pane-placement.spec.ts +++ b/packages/app/e2e/browser/direct-terminal-shortcut-pane-placement.spec.ts @@ -51,7 +51,7 @@ test.describe("Direct terminal shortcut pane placement", () => { }); }); -async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) { +async function prepareNarrowSplit(page: Page, workspaceId: string) { await page.setViewportSize({ width: 1600, height: 900 }); await gotoWorkspace(page, workspaceId); await waitForWorkspaceTabsVisible(page); @@ -74,33 +74,38 @@ async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) { }; } -async function launchTerminalFromCompactPane(page: Page, pane: Locator) { - await pane.getByTestId("workspace-tab-switcher-trigger").click(); - await page.getByTestId("workspace-tab-switcher-new-terminal").click(); +async function expectPanesKeepTabRails(page: Page, panes: Locator[]) { + for (const pane of panes) { + await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible(); + } + await expect( + page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }), + ).toHaveCount(0); +} + +async function launchTerminalFromPaneRail(page: Page, pane: Locator) { + await pane.getByTestId("workspace-new-tab-button").click(); + await page.getByTestId("workspace-new-tab-menu-terminal").filter({ visible: true }).click(); await expect(pane.getByTestId("terminal-surface")).toBeVisible({ timeout: 30_000 }); } -async function maximizeAndRestoreCompactPane(page: Page, pane: Locator) { - await pane.getByTestId("workspace-header-menu-trigger").click(); - await expect(page.getByRole("menuitem", { name: "Split pane right", exact: true })).toBeVisible(); - await page.getByRole("menuitem", { name: "Maximize pane", exact: true }).click(); - await pane.getByTestId("workspace-header-menu-trigger").click(); - await page.getByRole("menuitem", { name: "Restore pane", exact: true }).click(); +async function maximizeAndRestorePane(pane: Locator) { + await pane.getByTestId("workspace-maximize-pane").click(); + await pane.getByTestId("workspace-restore-pane").click(); } -test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({ +test("narrow split panes keep their tab rails and launches stay in the owning pane", async ({ page, }) => { - const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" }); + const workspace = await seedWorkspace({ repoPrefix: "narrow-split-launch-" }); try { - const { originalPane, otherPane } = await prepareUnfocusedCompactPane( - page, - workspace.workspaceId, - ); - await launchTerminalFromCompactPane(page, originalPane); + const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId); + await expectPanesKeepTabRails(page, [originalPane, otherPane]); + await launchTerminalFromPaneRail(page, originalPane); await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible(); - await maximizeAndRestoreCompactPane(page, originalPane); + await maximizeAndRestorePane(originalPane); await expect(otherPane).toBeVisible(); + await expectPanesKeepTabRails(page, [originalPane, otherPane]); } finally { await workspace.cleanup(); } @@ -118,17 +123,15 @@ async function expectPaneHasWindowDragSurface(pane: Locator) { .toBe(true); } -test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => { - const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" }); +test("narrow desktop split panes retain window dragging with both sidebars closed", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "narrow-split-window-drag-" }); try { await installLocalDesktopHost(page, workspace.repoPath); - const { originalPane, otherPane } = await prepareUnfocusedCompactPane( - page, - workspace.workspaceId, - ); + const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId); await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar"); - await expect(originalPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible(); - await expect(otherPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible(); + await expectPanesKeepTabRails(page, [originalPane, otherPane]); await expectPaneHasWindowDragSurface(originalPane); await expectPaneHasWindowDragSurface(otherPane); } finally { diff --git a/packages/app/e2e/browser/workspace-split-header.spec.ts b/packages/app/e2e/browser/workspace-split-header.spec.ts new file mode 100644 index 0000000000..27bb92584d --- /dev/null +++ b/packages/app/e2e/browser/workspace-split-header.spec.ts @@ -0,0 +1,131 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { test } from "../support/fixtures"; +import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; + +function visible(page: Page, testId: string): Locator { + return page.getByTestId(testId).filter({ visible: true }); +} + +function panes(page: Page): Locator { + return page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true }); +} + +function splitDividers(page: Page): Locator { + return visible(page, "workspace-split-resize-handle"); +} + +async function openSplitWorkspace( + page: Page, + repoPrefix: string, + split: "Split pane right" | "Split pane down", +): Promise { + await page.setViewportSize({ width: 1600, height: 900 }); + const workspace = await seedWorkspace({ repoPrefix }); + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + await clickNewChat(page); + await expect(visible(page, "workspace-header-title")).toHaveCount(1); + await runWorkspaceActionFromCommandCenter(page, split); + await expect(splitDividers(page)).toHaveCount(1); + await expect(panes(page)).toHaveCount(2); + return workspace; +} + +async function expectOneSharedWorkspaceHeader(page: Page): Promise { + await expect(visible(page, "workspace-header-title")).toHaveCount(1); + await expect(visible(page, "workspace-header-menu-trigger")).toHaveCount(1); + await expect(panes(page).getByTestId("workspace-header-title")).toHaveCount(0); + await expect(panes(page).getByTestId("workspace-header-menu-trigger")).toHaveCount(0); +} + +/** The tab counter belongs to the single-pane layout: split panes always show their rails. */ +async function expectPanesKeepTabRails(page: Page): Promise { + for (const pane of await panes(page).all()) { + await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible(); + } + await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(0); +} + +async function dragDivider(divider: Locator, deltaX: number): Promise { + const page = divider.page(); + const box = await divider.boundingBox(); + if (!box) throw new Error("Divider has no bounding box"); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + deltaX, y, { steps: 8 }); + await page.mouse.up(); +} + +test.describe("Workspace header with split panes", () => { + test("wide side-by-side panes share one header and keep their tab rails", async ({ page }) => { + const workspace = await openSplitWorkspace(page, "split-header-wide-", "Split pane right"); + try { + await expectOneSharedWorkspaceHeader(page); + await expectPanesKeepTabRails(page); + } finally { + await workspace.cleanup(); + } + }); + + test("stacked panes share one header", async ({ page }) => { + const workspace = await openSplitWorkspace(page, "split-header-stacked-", "Split pane down"); + try { + await expectOneSharedWorkspaceHeader(page); + await expectPanesKeepTabRails(page); + } finally { + await workspace.cleanup(); + } + }); + + test("a narrow workspace shows one compact header and every pane keeps its tab rail", async ({ + page, + }) => { + const workspace = await openSplitWorkspace(page, "split-header-narrow-", "Split pane right"); + try { + await page.setViewportSize({ width: 760, height: 900 }); + await expectPanesKeepTabRails(page); + await expectOneSharedWorkspaceHeader(page); + } finally { + await workspace.cleanup(); + } + }); + + test("a narrow pane beside a wide one keeps its tab rail", async ({ page }) => { + const workspace = await openSplitWorkspace(page, "split-header-mixed-", "Split pane right"); + try { + await dragDivider(splitDividers(page), -300); + const [left, right] = await panes(page).all(); + const leftBox = await left.boundingBox(); + const rightBox = await right.boundingBox(); + expect(leftBox!.width).toBeLessThan(500); + expect(rightBox!.width).toBeGreaterThan(500); + await expectPanesKeepTabRails(page); + await expectOneSharedWorkspaceHeader(page); + } finally { + await workspace.cleanup(); + } + }); + + test("closing back to one pane returns the merged compact header", async ({ page }) => { + const workspace = await openSplitWorkspace(page, "split-header-close-", "Split pane right"); + try { + const [, emptyPane] = await panes(page).all(); + const emptyPaneRail = emptyPane.getByTestId("workspace-tabs-row"); + await emptyPaneRail.locator('[data-testid^="workspace-tab-"]').first().hover(); + await emptyPaneRail.locator('[data-testid*="-close-"]').first().click(); + await expect(splitDividers(page)).toHaveCount(0); + await expect(visible(page, "workspace-header-title")).toHaveCount(1); + await page.setViewportSize({ width: 760, height: 900 }); + await expect(visible(page, "workspace-tabs-row")).toHaveCount(0); + await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(1); + await expect(visible(page, "workspace-header-title")).toHaveCount(1); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 63a3144dda..36f3af0b28 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -1,6 +1,6 @@ import { AdaptiveWorkspaceChrome, - type WorkspaceCompactHeaderActions, + type WorkspaceCompactHeaderTabs, } from "@/screens/workspace/adaptive-workspace-chrome"; import { useWorkspaceTabDrag } from "./workspace-tab-drag-context"; import { @@ -66,12 +66,13 @@ import { isNative } from "@/constants/platform"; interface SplitContainerProps { layout: WorkspaceLayout; + /** The full workspace header: title, project row and workspace actions. */ renderMainHeader?: () => ReactNode; - renderCompactHeader: ( - tabs: WorkspaceTabDescriptor[], - activeTabKey: string, - actions: WorkspaceCompactHeaderActions, - ) => ReactNode; + /** + * The compact workspace header. With one presented pane it carries that pane's tab switcher in + * place of its tab rail; above a split it carries workspace controls only. + */ + renderCompactHeader: (paneTabs: WorkspaceCompactHeaderTabs | null) => ReactNode; workspaceKey: string; normalizedServerId: string; normalizedWorkspaceId: string; @@ -128,6 +129,11 @@ interface SplitNodeViewProps extends Omit< windowChromeCorners: WindowChromeCorners; maximizedPaneId: string | null; workspaceHasMultiplePanes: boolean; + /** + * One presented pane draws the workspace header itself and may merge its tabs into the compact + * header. Split panes share one header above them and always show their tab rails. + */ + ownsWorkspaceHeader: boolean; onTogglePaneMaximized: (paneId: string) => void; } @@ -244,11 +250,27 @@ export function SplitContainer({ : inheritedWindowChromeCorners; const mainColumnStyle = styles.mainColumn; const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]); + const panesShareWorkspaceHeader = hasMultipleVisiblePanes(splitRoot.root); + const sharedCompactHeader = useMemo( + () => + panesShareWorkspaceHeader ? ( + + + {renderCompactHeader(null)} + + ) : null, + [panesShareWorkspaceHeader, renderCompactHeader], + ); return ( + {panesShareWorkspaceHeader ? ( + + {renderMainHeader?.()} + + ) : null} {splitRoot.usesFallbackStrip && } {renderRoot ? ( @@ -578,6 +607,7 @@ function SplitNodeView({ onTogglePaneMaximized={onTogglePaneMaximized} focusModeEnabled={focusModeEnabled} onExitFocusMode={onExitFocusMode} + ownsWorkspaceHeader={ownsWorkspaceHeader} renderMainHeader={renderMainHeader} renderCompactHeader={renderCompactHeader} /> @@ -638,6 +668,7 @@ const SplitPaneView = memo(function SplitPaneView({ onTogglePaneMaximized, focusModeEnabled, onExitFocusMode, + ownsWorkspaceHeader, renderMainHeader, renderCompactHeader, }: SplitPaneViewProps) { @@ -770,13 +801,24 @@ const SplitPaneView = memo(function SplitPaneView({ createNewTab, }); const compactHeader = useMemo( - () => ( - - - {renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)} - - ), - [renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions], + () => + ownsWorkspaceHeader ? ( + + + {renderCompactHeader({ + tabs: paneTabs, + activeTabKey: activeTabDescriptor?.key ?? "", + actions: compactHeaderActions, + })} + + ) : null, + [ + ownsWorkspaceHeader, + renderCompactHeader, + paneTabs, + activeTabDescriptor?.key, + compactHeaderActions, + ], ); return ( @@ -787,7 +829,7 @@ const SplitPaneView = memo(function SplitPaneView({ testID={`workspace-pane-${pane.id}`} > - {renderMainHeader?.()} + {ownsWorkspaceHeader ? renderMainHeader?.() : null} diff --git a/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx b/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx index 668f6345a8..220e8dff2e 100644 --- a/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx +++ b/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx @@ -2,12 +2,17 @@ import { useCallback, useState, type ReactNode } from "react"; import { View, type LayoutChangeEvent } from "react-native"; import { selectWorkspaceChrome } from "./workspace-tab-layout"; import { WorkspaceTabMeasurementScope } from "./workspace-desktop-tabs-row"; +import type { WorkspaceTabDescriptor } from "./workspace-tabs-types"; +/** + * Shows `children` (full header and tab rail) or `compactHeader` below the compact width. + * Without a compact header it always shows `children`, keeping the same tree so they never remount. + */ export function AdaptiveWorkspaceChrome({ compactHeader, children, }: { - compactHeader: ReactNode; + compactHeader: ReactNode | null; children: ReactNode; }) { const [width, setWidth] = useState(null); @@ -18,12 +23,21 @@ export function AdaptiveWorkspaceChrome({ return ( - {selectWorkspaceChrome({ width, compact: compactHeader, full: children })} + {compactHeader === null + ? children + : selectWorkspaceChrome({ width, compact: compactHeader, full: children })} ); } +/** One pane's tabs, as its compact tab switcher and pane menu need them. */ +export interface WorkspaceCompactHeaderTabs { + tabs: WorkspaceTabDescriptor[]; + activeTabKey: string; + actions: WorkspaceCompactHeaderActions; +} + export interface WorkspaceCompactHeaderActions { pane?: { id: string; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 6ba87ac884..c1c375fbae 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,6 +1,6 @@ import { AdaptiveWorkspaceChrome, - type WorkspaceCompactHeaderActions, + type WorkspaceCompactHeaderTabs, } from "./adaptive-workspace-chrome"; import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { useHostFeature } from "@/runtime/host-features"; @@ -3389,11 +3389,11 @@ function WorkspaceScreenContent({ ], ); const renderWorkspaceTabSwitcher = useCallback( - ( - headerTabs: WorkspaceTabDescriptor[], - headerActiveTabKey: string, - actions: WorkspaceCompactHeaderActions, - ) => { + ({ + tabs: headerTabs, + activeTabKey: headerActiveTabKey, + actions, + }: WorkspaceCompactHeaderTabs) => { return ( { - const tabSwitcher = renderWorkspaceTabSwitcher(headerTabs, headerActiveTabKey, actions); + (paneTabs: WorkspaceCompactHeaderTabs | null, actionsOnly = false) => { + const tabSwitcher = paneTabs ? renderWorkspaceTabSwitcher(paneTabs) : null; return ( { if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) { return null;