From d1fd3e328fd6f23f6e867fd416188edc684289af Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Nov 2026 07:33:05 +0100 Subject: [PATCH] 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 --- ...t-terminal-shortcut-pane-placement.spec.ts | 57 ++++++++ .../app/src/components/split-container.tsx | 131 +++++++++++------- .../workspace/adaptive-workspace-chrome.tsx | 7 + .../compact-workspace-header/tab-switcher.tsx | 14 +- .../workspace/workspace-header-menu.tsx | 39 +++++- .../screens/workspace/workspace-screen.tsx | 6 +- packages/app/src/utils/desktop-window.test.ts | 52 +++++++ packages/app/src/utils/desktop-window.tsx | 13 ++ 8 files changed, 259 insertions(+), 60 deletions(-) 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 0c3787f46d..fdcacbde2a 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 @@ -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(); + } +}); diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index fc70f91232..dec4dde880 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -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( + () => ( + + {renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)} + + ), + [renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions], + ); return ( - + {renderMainHeader?.()} - - - - + + + + + + diff --git a/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx b/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx index 820b3fb682..668f6345a8 100644 --- a/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx +++ b/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx @@ -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; closeBelow: (key: string) => Promise | void; closeOthers: (key: string) => Promise | void; diff --git a/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx index 115acde191..334a166aef 100644 --- a/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx +++ b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx @@ -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; onCopyAgentId: (agentId: string) => Promise | void; onCopyTerminalId: (terminalId: string) => Promise | 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( () => ( diff --git a/packages/app/src/screens/workspace/workspace-header-menu.tsx b/packages/app/src/screens/workspace/workspace-header-menu.tsx index b8f4739cfb..5ea23f9ef3 100644 --- a/packages/app/src/screens/workspace/workspace-header-menu.tsx +++ b/packages/app/src/screens/workspace/workspace-header-menu.tsx @@ -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 ? ( + + {t("workspace.tabs.actions.splitRight")} + + ) : null} + {pane?.splitDown ? ( + + {t("workspace.tabs.actions.splitDown")} + + ) : null} + {pane?.toggleMaximized ? ( + + {t( + pane.maximized + ? "workspace.tabs.actions.restorePane" + : "workspace.tabs.actions.maximizePane", + )} + + ) : null} {scriptsMenu.trigger} {pluginMenu.rows} {pluginMenu.rows.length > 0 ? : null} @@ -285,7 +316,7 @@ export function WorkspaceHeaderMenuMobile({ {t("workspace.header.actions.newBrowser")} @@ -298,7 +329,7 @@ export function WorkspaceHeaderMenuMobile({ key={profile.id} profile={profile} disabled={createTerminalDisabled} - onCreateTerminalWithProfile={onCreateTerminalWithProfile} + onCreateTerminalWithProfile={handleCreateTerminalWithProfile} /> ))} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 92d1cb5825..8da143923e 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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} > { ).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"); +}); diff --git a/packages/app/src/utils/desktop-window.tsx b/packages/app/src/utils/desktop-window.tsx index adc3f7c9a1..fa1f397e1c 100644 --- a/packages/app/src/utils/desktop-window.tsx +++ b/packages/app/src/utils/desktop-window.tsx @@ -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,