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,