fix(app): preserve compact pane actions and window clearance

Carry the owning pane into compact launch actions, keep split and restore controls in its menu, and distribute window corners through visible split children. Keep tab rails below header-owned window controls.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
This commit is contained in:
Mohamed Boudra
2026-11-04 07:33:05 +01:00
co-authored by Elsa Granger
parent 951caba89a
commit d1fd3e328f
8 changed files with 259 additions and 60 deletions
@@ -1,3 +1,4 @@
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import {
@@ -9,6 +10,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
test.describe("Direct terminal shortcut pane placement", () => {
test.use({ viewport: { width: 1600, height: 900 } });
test("opens a terminal in the focused pane", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "direct-terminal-shortcut-pane-" });
@@ -47,3 +49,58 @@ test.describe("Direct terminal shortcut pane placement", () => {
}
});
});
async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
await page.setViewportSize({ width: 1600, height: 900 });
await gotoWorkspace(page, workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
const draftPane = page
.locator('[data-testid^="workspace-pane-"]')
.filter({ has: page.getByTestId("message-input-root") });
const emptyPane = page
.locator('[data-testid^="workspace-pane-"]')
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
const draftPaneId = await draftPane.getAttribute("data-testid");
const emptyPaneId = await emptyPane.getAttribute("data-testid");
expect(draftPaneId).not.toBeNull();
expect(emptyPaneId).not.toBeNull();
await page.setViewportSize({ width: 760, height: 900 });
return {
originalPane: page.getByTestId(draftPaneId!),
otherPane: page.getByTestId(emptyPaneId!),
};
}
async function launchTerminalFromCompactPane(page: Page, pane: Locator) {
await pane.getByTestId("workspace-tab-switcher-trigger").click();
await page.getByTestId("workspace-tab-switcher-new-terminal").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();
}
test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" });
try {
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
page,
workspace.workspaceId,
);
await launchTerminalFromCompactPane(page, originalPane);
await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible();
await maximizeAndRestoreCompactPane(page, originalPane);
await expect(otherPane).toBeVisible();
} finally {
await workspace.cleanup();
}
});
+83 -48
View File
@@ -36,6 +36,7 @@ import {
WindowChromeSafeArea,
useWindowChromeCorners,
removeWindowChromeCorner,
resolveChildWindowChromeCorners,
type WindowChromeCorners,
} from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
@@ -187,7 +188,7 @@ export function SplitContainer({
renderCompactHeader,
}: SplitContainerProps) {
const inheritedWindowChromeCorners = useWindowChromeCorners();
const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none";
const windowChromeCorners = inheritedWindowChromeCorners;
const { activeDragTabId, dropPreview, tabDropPreview } = useWorkspaceTabDrag();
const [maximizedPane, setMaximizedPane] = useState<{
workspaceKey: string;
@@ -560,7 +561,18 @@ function SplitNodeView({
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={windowChromeCorners}
windowChromeCorners={resolveChildWindowChromeCorners({
corners: windowChromeCorners,
direction: node.group.direction,
isFirst:
node.group.children.findIndex(
(candidate) => !isSplitNodeHiddenForPresentation(candidate, maximizedPaneId),
) === index,
isLast:
node.group.children.findLastIndex(
(candidate) => !isSplitNodeHiddenForPresentation(candidate, maximizedPaneId),
) === index,
})}
maximizedPaneId={maximizedPaneId}
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
onTogglePaneMaximized={onTogglePaneMaximized}
@@ -723,11 +735,30 @@ const SplitPaneView = memo(function SplitPaneView({
);
const compactHeaderActions = useMemo(
() => ({
pane: {
id: paneId,
splitRight: focusModeEnabled ? undefined : handleSplitRight,
splitDown: focusModeEnabled ? undefined : handleSplitDown,
toggleMaximized:
workspaceHasMultiplePanes && !focusModeEnabled ? handleTogglePaneMaximized : undefined,
maximized: paneId === maximizedPaneId,
},
closeAbove: handleCloseTabsToLeft,
closeBelow: handleCloseTabsToRight,
closeOthers: handleCloseOtherTabs,
}),
[handleCloseTabsToLeft, handleCloseTabsToRight, handleCloseOtherTabs],
[
paneId,
focusModeEnabled,
handleSplitRight,
handleSplitDown,
workspaceHasMultiplePanes,
handleTogglePaneMaximized,
maximizedPaneId,
handleCloseTabsToLeft,
handleCloseTabsToRight,
handleCloseOtherTabs,
],
);
const createNewTab = useCallback(() => onCreateNewTab({ paneId }), [onCreateNewTab, paneId]);
useWorkspaceNewTabKeyboardAction({
@@ -738,6 +769,14 @@ const SplitPaneView = memo(function SplitPaneView({
paneId,
createNewTab,
});
const compactHeader = useMemo(
() => (
<WindowChromeSafeArea placement="inline">
{renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)}
</WindowChromeSafeArea>
),
[renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions],
);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
<View
@@ -746,52 +785,48 @@ const SplitPaneView = memo(function SplitPaneView({
style={styles.pane}
testID={`workspace-pane-${pane.id}`}
>
<AdaptiveWorkspaceChrome
compactHeader={renderCompactHeader(
paneTabs,
activeTabDescriptor?.key ?? "",
compactHeaderActions,
)}
>
<AdaptiveWorkspaceChrome compactHeader={compactHeader}>
{renderMainHeader?.()}
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused && isWorkspaceFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
ownsKeyboardShortcuts={false}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
showPaneSplitActions={!focusModeEnabled}
showPaneMaximizeAction={workspaceHasMultiplePanes && !focusModeEnabled}
paneMaximized={paneId === maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
focusModeEnabled={Boolean(focusModeEnabled)}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeSafeArea>
<WindowChromeRegion corners={focusModeEnabled ? "both" : "none"}>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused && isWorkspaceFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
ownsKeyboardShortcuts={false}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
showPaneSplitActions={!focusModeEnabled}
showPaneMaximizeAction={workspaceHasMultiplePanes && !focusModeEnabled}
paneMaximized={paneId === maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
focusModeEnabled={Boolean(focusModeEnabled)}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeSafeArea>
</WindowChromeRegion>
</AdaptiveWorkspaceChrome>
<View style={styles.paneContent}>
@@ -25,6 +25,13 @@ export function AdaptiveWorkspaceChrome({
}
export interface WorkspaceCompactHeaderActions {
pane?: {
id: string;
splitRight?: () => void;
splitDown?: () => void;
toggleMaximized?: () => void;
maximized: boolean;
};
closeAbove: (key: string) => Promise<void> | void;
closeBelow: (key: string) => Promise<void> | void;
closeOthers: (key: string) => Promise<void> | void;
@@ -124,14 +124,15 @@ function getFallbackTabOptionDescription(
}
interface MobileWorkspaceTabSwitcherProps {
paneId?: string;
tabs: WorkspaceTabDescriptor[];
activeTabKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
onSelectSwitcherTab: (key: string) => void;
createTerminalDisabled: boolean;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateDraftTab: (input?: { paneId?: string }) => void;
onCreateTerminal: (input?: { paneId?: string }) => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyTerminalId: (terminalId: string) => Promise<void> | void;
@@ -286,6 +287,7 @@ export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitch
createTerminalDisabled,
onCreateDraftTab,
onCreateTerminal,
paneId,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
@@ -343,13 +345,13 @@ export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitch
const handleNewAgent = useCallback(() => {
setIsOpen(false);
onCreateDraftTab();
}, [onCreateDraftTab]);
onCreateDraftTab({ paneId });
}, [onCreateDraftTab, paneId]);
const handleNewTerminal = useCallback(() => {
setIsOpen(false);
onCreateTerminal();
}, [onCreateTerminal]);
onCreateTerminal({ paneId });
}, [onCreateTerminal, paneId]);
const footer = useMemo(
() => (
@@ -1,3 +1,4 @@
import type { WorkspaceCompactHeaderActions } from "./adaptive-workspace-chrome";
import React, { useCallback, useMemo } from "react";
import { View } from "react-native";
import { useRouter, type Href } from "expo-router";
@@ -206,13 +207,14 @@ function HeaderMenuProfileItem({
}
export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspaceActions {
pane?: WorkspaceCompactHeaderActions["pane"];
normalizedServerId: string;
normalizedWorkspaceId: string;
scripts: UseWorkspaceScriptsMenuPagesInput;
showCreateBrowserTab: boolean;
createTerminalDisabled: boolean;
onCreateTerminalWithProfile: (profile: TerminalProfile) => void;
onCreateBrowser: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfile, paneId?: string) => void;
onCreateBrowser: (input?: { paneId?: string }) => void;
}
/**
@@ -231,8 +233,18 @@ export function WorkspaceHeaderMenuMobile({
createTerminalDisabled,
onCreateTerminalWithProfile,
onCreateBrowser,
pane,
...workspaceActions
}: WorkspaceHeaderMenuMobileProps) {
const paneId = pane?.id;
const handleCreateBrowser = useCallback(
() => onCreateBrowser({ paneId }),
[onCreateBrowser, paneId],
);
const handleCreateTerminalWithProfile = useCallback(
(profile: TerminalProfile) => onCreateTerminalWithProfile(profile, paneId),
[onCreateTerminalWithProfile, paneId],
);
const { t } = useTranslation();
const router = useRouter();
const triggerStyle = useIconButtonChromeStyle({ size: "large" });
@@ -278,6 +290,25 @@ export function WorkspaceHeaderMenuMobile({
pages={pages}
sheetTitle={t("workspace.header.actions.workspaceActions")}
>
{pane?.splitRight ? (
<DropdownMenuItem onSelect={pane.splitRight}>
{t("workspace.tabs.actions.splitRight")}
</DropdownMenuItem>
) : null}
{pane?.splitDown ? (
<DropdownMenuItem onSelect={pane.splitDown}>
{t("workspace.tabs.actions.splitDown")}
</DropdownMenuItem>
) : null}
{pane?.toggleMaximized ? (
<DropdownMenuItem onSelect={pane.toggleMaximized}>
{t(
pane.maximized
? "workspace.tabs.actions.restorePane"
: "workspace.tabs.actions.maximizePane",
)}
</DropdownMenuItem>
) : null}
{scriptsMenu.trigger}
{pluginMenu.rows}
{pluginMenu.rows.length > 0 ? <DropdownMenuSeparator /> : null}
@@ -285,7 +316,7 @@ export function WorkspaceHeaderMenuMobile({
<DropdownMenuItem
testID="workspace-header-new-browser"
leading={MENU_NEW_BROWSER_ICON}
onSelect={onCreateBrowser}
onSelect={handleCreateBrowser}
>
{t("workspace.header.actions.newBrowser")}
</DropdownMenuItem>
@@ -298,7 +329,7 @@ export function WorkspaceHeaderMenuMobile({
key={profile.id}
profile={profile}
disabled={createTerminalDisabled}
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
/>
))}
<DropdownMenuSeparator />
@@ -1799,8 +1799,8 @@ function WorkspaceScreenContent({
});
const handleCreateTerminalWithProfile = useCallback(
(profile: TerminalProfile) => {
createTerminal({ profile, destination: { kind: "open" } });
(profile: TerminalProfile, paneId?: string) => {
createTerminal({ profile, destination: { kind: "open", paneId } });
},
[createTerminal],
);
@@ -3384,6 +3384,7 @@ function WorkspaceScreenContent({
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
>
<MobileWorkspaceTabSwitcher
paneId={actions.pane?.id}
tabs={headerTabs}
activeTabKey={headerActiveTabKey}
normalizedServerId={normalizedServerId}
@@ -3404,6 +3405,7 @@ function WorkspaceScreenContent({
onCloseOtherTabs={actions.closeOthers}
/>
<WorkspaceHeaderMenuMobile
pane={actions.pane}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
scripts={compactHeaderScripts}
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
intersectWindowChromeCorners,
resolveChildWindowChromeCorners,
resolveHasOwnedWindowChromeObstruction,
resolveWindowChromeObstruction,
resolveWindowChromeSafeArea,
@@ -87,3 +88,54 @@ describe("window chrome", () => {
).toBe(false);
});
});
it("assigns window buttons only to exposed split edges", () => {
expect(
resolveChildWindowChromeCorners({
corners: "both",
direction: "horizontal",
isFirst: true,
isLast: false,
}),
).toBe("top-left");
expect(
resolveChildWindowChromeCorners({
corners: "both",
direction: "horizontal",
isFirst: false,
isLast: true,
}),
).toBe("top-right");
expect(
resolveChildWindowChromeCorners({
corners: "both",
direction: "horizontal",
isFirst: false,
isLast: false,
}),
).toBe("none");
expect(
resolveChildWindowChromeCorners({
corners: "both",
direction: "vertical",
isFirst: true,
isLast: false,
}),
).toBe("both");
expect(
resolveChildWindowChromeCorners({
corners: "both",
direction: "vertical",
isFirst: false,
isLast: true,
}),
).toBe("none");
expect(
resolveChildWindowChromeCorners({
corners: "top-right",
direction: "horizontal",
isFirst: true,
isLast: true,
}),
).toBe("top-right");
});
+13
View File
@@ -83,6 +83,19 @@ export function intersectWindowChromeCorners(
);
}
export function resolveChildWindowChromeCorners(input: {
corners: WindowChromeCorners;
direction: "horizontal" | "vertical";
isFirst: boolean;
isLast: boolean;
}): WindowChromeCorners {
if (input.direction === "vertical") return input.isFirst ? input.corners : "none";
return intersectWindowChromeCorners(
input.corners,
windowChromeCornersFromFlags(input.isFirst, input.isLast),
);
}
export function removeWindowChromeCorner(
corners: WindowChromeCorners,
corner: WindowChromeCorner,