From b509d499f7b64521927b5c2ba5b8c5c8677d0cd9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 8 Nov 2026 14:06:36 +0700 Subject: [PATCH] fix(app): show one workspace header above split panes (#9088) #8740 moved the workspace header into every split pane so each pane could pick full or compact chrome by its own width. Every pane then repeated the title, project row, workspace menu and Explorer toggle, and narrow panes swapped their tab rail for the tab counter. The tab counter belongs to the single-pane layout. With two or more visible panes, the split container now renders the header once above them (full or compact by column width, without a counter) and every pane keeps its tab rail. A single visible pane, including focus mode, keeps the merged compact header with the counter when narrow. --- ...t-terminal-shortcut-pane-placement.spec.ts | 55 ++++---- .../browser/workspace-split-header.spec.ts | 131 ++++++++++++++++++ .../app/src/components/split-container.tsx | 72 ++++++++-- .../workspace/adaptive-workspace-chrome.tsx | 18 ++- .../screens/workspace/workspace-screen.tsx | 39 +++--- 5 files changed, 249 insertions(+), 66 deletions(-) create mode 100644 packages/app/e2e/browser/workspace-split-header.spec.ts 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;