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,