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.
This commit is contained in:
Mohamed Boudra
2026-11-08 14:06:36 +07:00
committed by GitHub
parent f539ccf1c0
commit b509d499f7
5 changed files with 249 additions and 66 deletions
@@ -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 {
@@ -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<SeededWorkspace> {
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<void> {
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<void> {
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<void> {
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();
}
});
});
+57 -15
View File
@@ -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 ? (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader(null)}
</WindowChromeSafeArea>
) : null,
[panesShareWorkspaceHeader, renderCompactHeader],
);
return (
<RenderProfile id="SplitContainer">
<View style={styles.workspaceShell}>
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
<View style={mainColumnStyle}>
{panesShareWorkspaceHeader ? (
<AdaptiveWorkspaceChrome compactHeader={sharedCompactHeader}>
{renderMainHeader?.()}
</AdaptiveWorkspaceChrome>
) : null}
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
{renderRoot ? (
<SplitNodeView
@@ -284,7 +306,12 @@ export function SplitContainer({
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
windowChromeCorners={
splitRoot.usesFallbackStrip || panesShareWorkspaceHeader
? "none"
: windowChromeCorners
}
ownsWorkspaceHeader={!panesShareWorkspaceHeader}
maximizedPaneId={maximizedPaneId}
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
onTogglePaneMaximized={handleTogglePaneMaximized}
@@ -423,6 +450,7 @@ function SplitNodeView({
onTogglePaneMaximized,
focusModeEnabled,
onExitFocusMode,
ownsWorkspaceHeader,
renderMainHeader,
renderCompactHeader,
}: SplitNodeViewProps) {
@@ -511,6 +539,7 @@ function SplitNodeView({
onTogglePaneMaximized={onTogglePaneMaximized}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
ownsWorkspaceHeader={ownsWorkspaceHeader}
renderMainHeader={renderMainHeader}
renderCompactHeader={renderCompactHeader}
/>
@@ -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(
() => (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)}
</WindowChromeSafeArea>
),
[renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions],
() =>
ownsWorkspaceHeader ? (
<WindowChromeSafeArea placement="inline">
<TitlebarDragRegion />
{renderCompactHeader({
tabs: paneTabs,
activeTabKey: activeTabDescriptor?.key ?? "",
actions: compactHeaderActions,
})}
</WindowChromeSafeArea>
) : null,
[
ownsWorkspaceHeader,
renderCompactHeader,
paneTabs,
activeTabDescriptor?.key,
compactHeaderActions,
],
);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
@@ -787,7 +829,7 @@ const SplitPaneView = memo(function SplitPaneView({
testID={`workspace-pane-${pane.id}`}
>
<AdaptiveWorkspaceChrome compactHeader={compactHeader}>
{renderMainHeader?.()}
{ownsWorkspaceHeader ? renderMainHeader?.() : null}
<WindowChromeRegion corners={focusModeEnabled ? "both" : "none"}>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
@@ -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<number | null>(null);
@@ -18,12 +23,21 @@ export function AdaptiveWorkspaceChrome({
return (
<WorkspaceTabMeasurementScope>
<View onLayout={measure}>
{selectWorkspaceChrome({ width, compact: compactHeader, full: children })}
{compactHeader === null
? children
: selectWorkspaceChrome({ width, compact: compactHeader, full: children })}
</View>
</WorkspaceTabMeasurementScope>
);
}
/** 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;
@@ -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 (
<MobileWorkspaceTabSwitcher
paneId={actions.pane?.id}
@@ -3434,14 +3434,11 @@ function WorkspaceScreenContent({
handleCloseTabById,
],
);
// Without a pane's tabs (above a split) the header carries workspace controls only: split panes
// keep their tab rails.
const renderCompactWorkspaceHeader = useCallback(
(
headerTabs: WorkspaceTabDescriptor[],
headerActiveTabKey: string,
actions: WorkspaceCompactHeaderActions,
actionsOnly = false,
) => {
const tabSwitcher = renderWorkspaceTabSwitcher(headerTabs, headerActiveTabKey, actions);
(paneTabs: WorkspaceCompactHeaderTabs | null, actionsOnly = false) => {
const tabSwitcher = paneTabs ? renderWorkspaceTabSwitcher(paneTabs) : null;
return (
<CompactWorkspaceHeader
headerVariant={actionsOnly ? "full" : "compact"}
@@ -3457,7 +3454,7 @@ function WorkspaceScreenContent({
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
>
<WorkspaceHeaderMenuMobile
pane={actions.pane}
pane={paneTabs?.actions.pane}
onExitFocusMode={isFocusModeEnabled ? toggleFocusMode : undefined}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
@@ -3509,21 +3506,17 @@ function WorkspaceScreenContent({
workspacePanelActive,
],
);
const compactWorkspaceHeader = renderCompactWorkspaceHeader(tabs, activeTabKey, {
closeAbove: handleCloseTabsToLeft,
closeBelow: handleCloseTabsToRight,
closeOthers: handleCloseOtherTabs,
});
const fullWorkspaceActions = renderCompactWorkspaceHeader(
const workspaceCompactHeaderTabs = {
tabs,
activeTabKey,
{
actions: {
closeAbove: handleCloseTabsToLeft,
closeBelow: handleCloseTabsToRight,
closeOthers: handleCloseOtherTabs,
},
true,
);
};
const compactWorkspaceHeader = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs);
const fullWorkspaceActions = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs, true);
const desktopSplitContent = useMemo(() => {
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
return null;