diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index 959567f58b..4eeada2f1f 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -78,7 +78,8 @@ test("composer diff stat reveals Changes, then opens the diff in the configured const workspace = await seedChangedAgent("composer-diff-stat-side-"); try { - await page.setViewportSize({ width: 1400, height: 900 }); + // Keep both workspace rails visible alongside app navigation and Explorer. + await page.setViewportSize({ width: 1900, height: 900 }); await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId, 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..373459ef4c 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,5 @@ +import { installLocalDesktopHost } from "../support/helpers/local-desktop-host"; +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 +11,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 +50,88 @@ 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(); + } +}); + +async function expectPaneHasWindowDragSurface(pane: Locator) { + await expect + .poll(() => + pane.evaluate((element) => + Array.from(element.querySelectorAll("div")).some( + (node) => node.style.getPropertyValue("-webkit-app-region") === "drag", + ), + ), + ) + .toBe(true); +} + +test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" }); + try { + await installLocalDesktopHost(page, workspace.repoPath); + const { originalPane, otherPane } = await prepareUnfocusedCompactPane( + 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 expectPaneHasWindowDragSurface(originalPane); + await expectPaneHasWindowDragSurface(otherPane); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/empty-pane-keyboard.spec.ts b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts index 46dc4cf47f..8430fe5974 100644 --- a/packages/app/e2e/browser/empty-pane-keyboard.spec.ts +++ b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts @@ -56,9 +56,9 @@ test.describe("New tab keyboard launcher", () => { await expectLauncherSelection(launcher, "Diff"); await page.keyboard.press("Enter"); - await expect( - page.getByTestId("workspace-tab-working_diff").filter({ visible: true }), - ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({ + timeout: 30_000, + }); } finally { await workspace.cleanup(); } diff --git a/packages/app/e2e/browser/explorer-pane-placement.spec.ts b/packages/app/e2e/browser/explorer-pane-placement.spec.ts index 55f96cc0cf..9a4a7127fe 100644 --- a/packages/app/e2e/browser/explorer-pane-placement.spec.ts +++ b/packages/app/e2e/browser/explorer-pane-placement.spec.ts @@ -105,6 +105,9 @@ function collectConsoleErrors(page: Page): string[] { return errors; } +// These drag and tab-menu journeys need full rails beside the docked Explorer. +test.use({ viewport: { width: 1900, height: 900 } }); + test.describe("explorer pane tab placement", () => { test("agents open in the main pane and New-tab splits keep the app usable", async ({ page, diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index e9fc0b15ba..a395a517c2 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -435,7 +435,8 @@ test.describe("CodeMirror workspace file editing", () => { }); try { - await page.setViewportSize({ width: 1280, height: 900 }); + // Keep both tab rails available alongside the docked Explorer. + await page.setViewportSize({ width: 1900, height: 900 }); await openAgentRoute(page, session); await openWorkspaceFile(page, "target.ts"); diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 0270235168..a7a7b4320a 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -652,6 +652,9 @@ test.describe("Half-screen desktop layout", () => { .locator('[data-testid^="workspace-pane-"]') .getByTestId("workspace-tabs-row") .filter({ visible: true }), + ).toHaveCount(0); + await expect( + page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }), ).toHaveCount(1); await explorerToggle.click(); diff --git a/packages/app/e2e/browser/subagent-origin-pane.spec.ts b/packages/app/e2e/browser/subagent-origin-pane.spec.ts index 545076ab06..8af88bd547 100644 --- a/packages/app/e2e/browser/subagent-origin-pane.spec.ts +++ b/packages/app/e2e/browser/subagent-origin-pane.spec.ts @@ -38,8 +38,12 @@ async function openParentInRightPane(page: Page, parentId: string, workspaceId: await expect(left).toBeVisible(); await expect(right).toBeVisible(); await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main"); + // A later split can replace the parent pane's rail with compact chrome. + // Keep the pane identity instead of finding it again through that rail. + const rightPaneId = await right.getAttribute("data-testid"); + expect(rightPaneId).not.toBeNull(); await left.locator('[data-testid^="workspace-tab-draft_"]').click(); - return { left, right }; + return { left, right: page.getByTestId(rightPaneId!) }; } test.beforeEach(async ({ page }) => { diff --git a/packages/app/e2e/browser/terminal-find.spec.ts b/packages/app/e2e/browser/terminal-find.spec.ts index 91e8e5826a..e4dde5cfaa 100644 --- a/packages/app/e2e/browser/terminal-find.spec.ts +++ b/packages/app/e2e/browser/terminal-find.spec.ts @@ -413,6 +413,8 @@ test("searches retained output without sending Find input to the shell", async ( test("keeps terminal Find owned by retained tabs and the focused split pane", async ({ page, }, testInfo) => { + // This journey selects tabs from the full rails in both split panes. + await page.setViewportSize({ width: 1600, height: 900 }); const first = await harness.createTerminal({ name: "First search", command: "bash", diff --git a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts index 5ef70cca61..934a48d264 100644 --- a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts +++ b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts @@ -357,6 +357,8 @@ test.describe("Viewed agent timelines", () => { }); test("two visible split chats both stay current", async ({ page }) => { + // Keep both split panes wide enough for the tab-rail interactions below. + await page.setViewportSize({ width: 1600, height: 900 }); const scenario = await seedViewedTimelineScenario(); try { await enableMoveTabShortcut(page); diff --git a/packages/app/e2e/browser/workspace-focus-mode.spec.ts b/packages/app/e2e/browser/workspace-focus-mode.spec.ts index e152b4defe..2929c2d093 100644 --- a/packages/app/e2e/browser/workspace-focus-mode.spec.ts +++ b/packages/app/e2e/browser/workspace-focus-mode.spec.ts @@ -101,3 +101,20 @@ test("focus mode only applies to the active workspace screen", async ({ page, wi await expect(exitFocusMode).toHaveCount(0); await expect(settingsButton).toBeVisible(); }); + +async function exitFocusModeFromCompactHeader(page: Page) { + await page.setViewportSize({ width: 400, height: 844 }); + await page.getByTestId("workspace-header-menu-trigger").click(); + await page.getByRole("menuitem", { name: "Exit focus mode", exact: true }).click(); + await page.setViewportSize({ width: 1280, height: 900 }); +} + +test("compact workspace menu keeps focus mode exit reachable", async ({ page, withWorkspace }) => { + const workspace = await withWorkspace({ prefix: "compact-focus-exit-" }); + await page.setViewportSize({ width: 1280, height: 900 }); + await workspace.navigateTo(); + await enterFocusMode(page); + await exitFocusModeFromCompactHeader(page); + await expect(exitFocusModeButton(page)).toHaveCount(0); + await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); +}); diff --git a/packages/app/e2e/browser/workspace-pane-remount.spec.ts b/packages/app/e2e/browser/workspace-pane-remount.spec.ts index 201b6e3ccf..c259dc8af2 100644 --- a/packages/app/e2e/browser/workspace-pane-remount.spec.ts +++ b/packages/app/e2e/browser/workspace-pane-remount.spec.ts @@ -52,6 +52,8 @@ async function waitForWorkspaceRoute(page: Page, route: string) { test.describe("Workspace pane mounting", () => { test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => { test.setTimeout(90_000); + // Keep both split panes wide enough for the tab-rail interactions below. + await page.setViewportSize({ width: 1600, height: 900 }); const serverId = getServerId(); const workspace = await seedWorkspace({ repoPrefix: "pane-remount-" }); diff --git a/packages/app/src/components/explorer-sidebar-layout.test.ts b/packages/app/src/components/explorer-sidebar-layout.test.ts index 3681ad4e16..adad890abe 100644 --- a/packages/app/src/components/explorer-sidebar-layout.test.ts +++ b/packages/app/src/components/explorer-sidebar-layout.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import { + beginExplorerSidebarResize, + finishExplorerSidebarResize, + reconcileExplorerSidebarResize, resolveExplorerSidebarDockSizes, + resolveExplorerSidebarFrameWidth, resolveExplorerSidebarWidth, + type ExplorerSidebarResize, } from "@/components/explorer-sidebar-layout"; describe("Explorer sidebar layout", () => { @@ -19,4 +24,196 @@ describe("Explorer sidebar layout", () => { expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(840); expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(390); }); + + describe("native resize ownership", () => { + const allocation = { + width: 320, + containerWidth: 1200, + overlay: false, + resizeEnabled: true, + }; + const dragging: ExplorerSidebarResize = { + sourceWidths: [allocation.width], + restoreWidth: allocation.width, + containerWidth: allocation.containerWidth, + pendingWidth: null, + }; + + it.each([false, true])( + "keeps a second drag through the previous release acknowledgement (released=%s)", + (releaseSecond) => { + const first = finishExplorerSidebarResize(dragging, 480, true); + const second = beginExplorerSidebarResize(first, allocation); + const current = releaseSecond ? finishExplorerSidebarResize(second, 600, true) : second; + const acknowledged = reconcileExplorerSidebarResize(current, { ...allocation, width: 480 }); + expect( + resolveExplorerSidebarFrameWidth({ + ...allocation, + width: 480, + resize: acknowledged, + resizeWidth: 600, + }), + ).toBe(600); + const released = finishExplorerSidebarResize(acknowledged, 600, true); + expect(released?.pendingWidth).toBe(600); + expect(reconcileExplorerSidebarResize(released, { ...allocation, width: 600 })).toBeNull(); + }, + ); + + it("keeps a return to the minimum while an earlier wider release is acknowledged", () => { + const initial = { ...allocation, width: 240 }; + const wider = finishExplorerSidebarResize( + beginExplorerSidebarResize(null, initial), + 540, + true, + ); + const returned = finishExplorerSidebarResize( + beginExplorerSidebarResize(wider, initial), + 240, + true, + ); + const awaiting = reconcileExplorerSidebarResize(returned, initial); + const intermediate = reconcileExplorerSidebarResize(awaiting, { ...initial, width: 540 }); + expect( + resolveExplorerSidebarFrameWidth({ + ...initial, + width: 540, + resize: intermediate, + resizeWidth: 240, + }), + ).toBe(240); + expect(reconcileExplorerSidebarResize(intermediate, initial)).toBeNull(); + }); + + it("cancels back to the latest release when earlier widths repeat", () => { + const first = finishExplorerSidebarResize(dragging, 480, true); + const second = finishExplorerSidebarResize( + beginExplorerSidebarResize(first, allocation), + 600, + true, + ); + const third = finishExplorerSidebarResize( + beginExplorerSidebarResize(second, allocation), + 480, + true, + ); + const fourth = beginExplorerSidebarResize(third, allocation); + const canceled = finishExplorerSidebarResize(fourth, 700, false); + expect( + resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 700 }), + ).toBe(480); + }); + + it("cancels a newer gesture back to its last released width", () => { + const first = finishExplorerSidebarResize(dragging, 480, true); + const second = beginExplorerSidebarResize(first, allocation); + const canceled = finishExplorerSidebarResize(second, 600, false); + expect( + resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 600 }), + ).toBe(480); + expect(reconcileExplorerSidebarResize(canceled, { ...allocation, width: 480 })).toBeNull(); + }); + + it("keeps the drag width after release while the JS commit still has the old prop", () => { + expect( + resolveExplorerSidebarFrameWidth({ ...allocation, resize: dragging, resizeWidth: 480 }), + ).toBe(480); + + const released = finishExplorerSidebarResize(dragging, 480, true); + expect(reconcileExplorerSidebarResize(released, allocation)).toBe(released); + expect( + resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 480 }), + ).toBe(480); + expect( + resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 320 }), + ).toBe(480); + }); + + it("acknowledges the committed width and immediately follows subsequent props", () => { + const released = finishExplorerSidebarResize(dragging, 480, true); + const acknowledgedAllocation = { ...allocation, width: 480 }; + expect( + resolveExplorerSidebarFrameWidth({ + ...acknowledgedAllocation, + resize: released, + resizeWidth: 480, + }), + ).toBe(480); + const acknowledged = reconcileExplorerSidebarResize(released, acknowledgedAllocation); + expect(acknowledged).toBeNull(); + expect( + resolveExplorerSidebarFrameWidth({ + ...allocation, + width: 360, + resize: acknowledged, + resizeWidth: 480, + }), + ).toBe(360); + }); + + it.each([ + { name: "a new width prop", next: { ...allocation, width: 360 }, expected: 360 }, + { + name: "a window allocation with an unchanged width prop", + next: { ...allocation, containerWidth: 900 }, + expected: 320, + }, + { + name: "a window allocation that clamps the width prop", + next: { ...allocation, width: 540, containerWidth: 900 }, + expected: 540, + }, + { name: "overlay presentation", next: { ...allocation, overlay: true }, expected: 1200 }, + { name: "an inactive dock", next: { ...allocation, resizeEnabled: false }, expected: 320 }, + ])( + "yields synchronously to $name and does not resurrect the old handoff", + ({ next, expected }) => { + const released = finishExplorerSidebarResize(dragging, 600, true); + expect( + resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 600 }), + ).toBe(expected); + const interrupted = reconcileExplorerSidebarResize(released, next); + expect(interrupted).toBeNull(); + expect( + resolveExplorerSidebarFrameWidth({ + ...allocation, + resize: interrupted, + resizeWidth: 600, + }), + ).toBe(320); + }, + ); + + it("restores canonical intent when a drag is canceled", () => { + const canceled = finishExplorerSidebarResize(dragging, 480, false); + expect(canceled).toBeNull(); + expect( + resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 480 }), + ).toBe(320); + }); + + it("cannot finish an interrupted drag over a newer allocation", () => { + const next = { ...allocation, width: 360, containerWidth: 1000 }; + const interrupted = reconcileExplorerSidebarResize(dragging, next); + const released = finishExplorerSidebarResize(interrupted, 480, true); + expect(released).toBeNull(); + expect( + resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 480 }), + ).toBe(360); + }); + + it("settles a release at the already-committed width without retaining ownership", () => { + const released = finishExplorerSidebarResize(dragging, allocation.width, true); + expect(reconcileExplorerSidebarResize(released, allocation)).toBeNull(); + }); + + it("keeps an earlier released handoff when a later touch fails before activation", () => { + const released = finishExplorerSidebarResize(dragging, 480, true); + const finalized = finishExplorerSidebarResize(released, 480, false); + expect(finalized).toBe(released); + expect( + resolveExplorerSidebarFrameWidth({ ...allocation, resize: finalized, resizeWidth: 480 }), + ).toBe(480); + }); + }); }); diff --git a/packages/app/src/components/explorer-sidebar-layout.ts b/packages/app/src/components/explorer-sidebar-layout.ts index 8a39e2e9f4..9bb09600eb 100644 --- a/packages/app/src/components/explorer-sidebar-layout.ts +++ b/packages/app/src/components/explorer-sidebar-layout.ts @@ -16,6 +16,85 @@ export function resolveExplorerSidebarWidth(input: { return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maximumVisibleWidth, requestedWidth)); } +// A release keeps its UI width until React acknowledges it or replaces its allocation. +// Null pendingWidth means the gesture still owns the live resizeWidth instead. +export interface ExplorerSidebarResize { + sourceWidths: number[]; + restoreWidth: number; + containerWidth: number; + pendingWidth: number | null; +} + +interface ExplorerSidebarAllocation { + width: number; + containerWidth: number; + overlay: boolean; + resizeEnabled: boolean; +} + +export function reconcileExplorerSidebarResize( + resize: ExplorerSidebarResize | null, + allocation: ExplorerSidebarAllocation, +): ExplorerSidebarResize | null { + "worklet"; + if ( + !resize || + allocation.overlay || + !allocation.resizeEnabled || + allocation.containerWidth !== resize.containerWidth || + !resize.sourceWidths.includes(allocation.width) || + (allocation.width === resize.pendingWidth && + resize.sourceWidths.indexOf(allocation.width) === resize.sourceWidths.length - 1) + ) { + return null; + } + const acknowledgedIndex = resize.sourceWidths.indexOf(allocation.width); + return acknowledgedIndex > 0 + ? { ...resize, sourceWidths: resize.sourceWidths.slice(acknowledgedIndex) } + : resize; +} + +export function beginExplorerSidebarResize( + previous: ExplorerSidebarResize | null, + allocation: ExplorerSidebarAllocation, +): ExplorerSidebarResize { + "worklet"; + const current = reconcileExplorerSidebarResize(previous, allocation); + // Released widths remain expected allocations while a newer gesture owns the frame. + const sourceWidths = current ? [...current.sourceWidths] : [allocation.width]; + if (current && current.pendingWidth !== null && !sourceWidths.includes(current.pendingWidth)) + sourceWidths.push(current.pendingWidth); + return { + sourceWidths, + restoreWidth: current ? (current.pendingWidth ?? current.restoreWidth) : allocation.width, + containerWidth: allocation.containerWidth, + pendingWidth: null, + }; +} + +export function finishExplorerSidebarResize( + resize: ExplorerSidebarResize | null, + width: number, + success: boolean, +): ExplorerSidebarResize | null { + "worklet"; + if (!resize || resize.pendingWidth !== null) return resize; + if (success) return { ...resize, pendingWidth: width }; + return resize.sourceWidths.length > 1 ? { ...resize, pendingWidth: resize.restoreWidth } : null; +} + +export function resolveExplorerSidebarFrameWidth( + allocation: ExplorerSidebarAllocation & { + resize: ExplorerSidebarResize | null; + resizeWidth: number; + }, +): number { + "worklet"; + if (allocation.overlay) return allocation.containerWidth; + const resize = reconcileExplorerSidebarResize(allocation.resize, allocation); + return resize ? (resize.pendingWidth ?? allocation.resizeWidth) : allocation.width; +} + export function resolveExplorerSidebarDockSizes(input: { requestedWidth?: number; containerWidth: number; diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 278317243a..63a3144dda 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -1,3 +1,7 @@ +import { + AdaptiveWorkspaceChrome, + type WorkspaceCompactHeaderActions, +} from "@/screens/workspace/adaptive-workspace-chrome"; import { useWorkspaceTabDrag } from "./workspace-tab-drag-context"; import { Fragment, @@ -32,6 +36,7 @@ import { WindowChromeSafeArea, useWindowChromeCorners, removeWindowChromeCorner, + resolveChildWindowChromeCorners, type WindowChromeCorners, } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; @@ -42,6 +47,7 @@ import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pa import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host"; import { WorkspaceDesktopTabsRow, + useWorkspaceNewTabKeyboardAction, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; @@ -61,6 +67,11 @@ import { isNative } from "@/constants/platform"; interface SplitContainerProps { layout: WorkspaceLayout; renderMainHeader?: () => ReactNode; + renderCompactHeader: ( + tabs: WorkspaceTabDescriptor[], + activeTabKey: string, + actions: WorkspaceCompactHeaderActions, + ) => ReactNode; workspaceKey: string; normalizedServerId: string; normalizedWorkspaceId: string; @@ -145,7 +156,6 @@ const EMPTY_SPLIT_SIZES: number[] = []; export function SplitContainer({ layout, - renderMainHeader, workspaceKey, normalizedServerId, normalizedWorkspaceId, @@ -174,9 +184,11 @@ export function SplitContainer({ onReorderTabsInPane, focusModeEnabled, onExitFocusMode, + renderMainHeader, + renderCompactHeader, }: SplitContainerProps) { const inheritedWindowChromeCorners = useWindowChromeCorners(); - const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none"; + const windowChromeCorners = inheritedWindowChromeCorners; const { activeDragTabId, dropPreview, tabDropPreview } = useWorkspaceTabDrag(); const [maximizedPane, setMaximizedPane] = useState<{ workspaceKey: string; @@ -237,7 +249,6 @@ export function SplitContainer({ - {renderMainHeader?.()} {splitRoot.usesFallbackStrip && } {renderRoot ? ( ) : null} @@ -410,6 +423,8 @@ function SplitNodeView({ onTogglePaneMaximized, focusModeEnabled, onExitFocusMode, + renderMainHeader, + renderCompactHeader, }: SplitNodeViewProps) { const [groupContainerSize, setGroupContainerSize] = useState(0); const groupId = node.kind === "group" ? node.group.id : null; @@ -496,6 +511,8 @@ function SplitNodeView({ onTogglePaneMaximized={onTogglePaneMaximized} focusModeEnabled={focusModeEnabled} onExitFocusMode={onExitFocusMode} + renderMainHeader={renderMainHeader} + renderCompactHeader={renderCompactHeader} /> @@ -544,12 +561,25 @@ 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} focusModeEnabled={focusModeEnabled} onExitFocusMode={onExitFocusMode} + renderMainHeader={renderMainHeader} + renderCompactHeader={renderCompactHeader} /> {index < node.group.children.length - 1 && @@ -608,6 +638,8 @@ const SplitPaneView = memo(function SplitPaneView({ onTogglePaneMaximized, focusModeEnabled, onExitFocusMode, + renderMainHeader, + renderCompactHeader, }: SplitPaneViewProps) { recordRenderProfileReasons(`SplitPaneView:${pane.id}`, ["render"]); const paneRef = useRef(null); @@ -701,6 +733,51 @@ const SplitPaneView = memo(function SplitPaneView({ () => onTogglePaneMaximized(paneId), [onTogglePaneMaximized, paneId], ); + 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, + }), + [ + paneId, + focusModeEnabled, + handleSplitRight, + handleSplitDown, + workspaceHasMultiplePanes, + handleTogglePaneMaximized, + maximizedPaneId, + handleCloseTabsToLeft, + handleCloseTabsToRight, + handleCloseOtherTabs, + ], + ); + const createNewTab = useCallback(() => onCreateNewTab({ paneId }), [onCreateNewTab, paneId]); + useWorkspaceNewTabKeyboardAction({ + handlerName: "workspace-chrome-new-tab", + ownsKeyboardShortcuts: isFocused && isWorkspaceFocused, + normalizedServerId, + normalizedWorkspaceId, + paneId, + createNewTab, + }); + const compactHeader = useMemo( + () => ( + + + {renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)} + + ), + [renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions], + ); return ( - - - - + + {renderMainHeader?.()} + + + + + + + ( const inputWrapperCombinedStyle = useMemo( () => [ styles.inputWrapper, - isCompact && !isFullscreen && styles.inputWrapperCompact, + Platform.OS !== "ios" && isCompact && !isFullscreen && styles.inputWrapperCompact, readOnly && styles.inputWrapperReadOnly, inputWrapperStyle, { opacity: surfacePresentation.input.opacity }, @@ -2230,7 +2231,11 @@ export const MessageInput = forwardRef( return ( @@ -2276,6 +2281,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ position: "relative", }, inputWrapper: { + ...(Platform.OS === "ios" ? { overflow: "hidden" as const } : {}), flexShrink: 1, flexDirection: "column", gap: theme.spacing[3], diff --git a/packages/app/src/keyboard/shift/internal/policy.test.ts b/packages/app/src/keyboard/shift/internal/policy.test.ts index 51387eba43..10192b1b88 100644 --- a/packages/app/src/keyboard/shift/internal/policy.test.ts +++ b/packages/app/src/keyboard/shift/internal/policy.test.ts @@ -29,6 +29,21 @@ describe("keyboard layout reservation", () => { }); describe("resolveKeyboardShift", () => { + it.each([0, -1])( + "ignores a rotation report with height %s and progress one", + (rawKeyboardHeight) => { + expect( + resolveKeyboardShift({ + rawKeyboardHeight, + keyboardProgress: 1, + bottomInset: 24, + isIos: true, + iosMinHeight: 120, + }), + ).toBe(0); + }, + ); + it("keeps the existing open-keyboard offset behavior", () => { expect( resolveKeyboardShift({ diff --git a/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx b/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx new file mode 100644 index 0000000000..668f6345a8 --- /dev/null +++ b/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx @@ -0,0 +1,38 @@ +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"; + +export function AdaptiveWorkspaceChrome({ + compactHeader, + children, +}: { + compactHeader: ReactNode; + children: ReactNode; +}) { + const [width, setWidth] = useState(null); + const measure = useCallback((event: LayoutChangeEvent) => { + const measured = event.nativeEvent.layout.width; + if (measured > 0) setWidth(measured); + }, []); + return ( + + + {selectWorkspaceChrome({ width, compact: compactHeader, full: children })} + + + ); +} + +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 737e92b4c2..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 @@ -1,3 +1,4 @@ +import { workspaceTabSwitcherPopoverLayout } from "../workspace-tab-layout"; import { memo, useCallback, useMemo, useRef, useState } from "react"; import { Keyboard, Pressable, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; @@ -123,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; @@ -285,6 +287,7 @@ export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitch createTerminalDisabled, onCreateDraftTab, onCreateTerminal, + paneId, onCopyResumeCommand, onCopyAgentId, onCopyTerminalId, @@ -342,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( () => ( @@ -456,6 +459,7 @@ export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitch (null); const [resizePressed, setResizePressed] = useState(false); - useEffect(() => { - resizeWidth.value = width; - }, [resizeWidth, width]); + useAnimatedReaction( + () => ({ resize: resize.value, width, containerWidth, overlay, resizeEnabled }), + ({ resize: currentResize, ...allocation }) => { + const nextResize = reconcileExplorerSidebarResize(currentResize, allocation); + if (nextResize !== currentResize) resize.value = nextResize; + }, + [containerWidth, overlay, resize, resizeEnabled, width], + ); const showResizeGrip = useCallback(() => setResizePressed(true), []); const hideResizeGrip = useCallback(() => setResizePressed(false), []); @@ -37,22 +55,50 @@ export function useExplorerGeometry({ .activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET]) .failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]) .onStart((event) => { - startWidth.value = width + event.translationX; - resizeWidth.value = width; + const currentWidth = resolveExplorerSidebarFrameWidth({ + width, + containerWidth, + overlay, + resizeEnabled, + resize: resize.value, + resizeWidth: resizeWidth.value, + }); + resize.value = beginExplorerSidebarResize(resize.value, { + width, + containerWidth, + overlay, + resizeEnabled, + }); + startWidth.value = currentWidth + event.translationX; + resizeWidth.value = currentWidth; }) .onUpdate((event) => { + if (!resize.value) return; resizeWidth.value = resolveExplorerSidebarWidth({ requestedWidth: startWidth.value - event.translationX, containerWidth, }); }) - .onEnd(() => runOnJS(onResize)(resizeWidth.value)) - .onFinalize(() => scheduleOnRN(hideResizeGrip)), + .onEnd((_event, success) => { + { + resize.value = finishExplorerSidebarResize(resize.value, resizeWidth.value, success); + if (!success || !resize.value) return; + } + runOnJS(onResize)(resizeWidth.value); + }) + .onFinalize((_event, success) => { + if (!success) { + resize.value = finishExplorerSidebarResize(resize.value, resizeWidth.value, false); + } + scheduleOnRN(hideResizeGrip); + }), [ resizeEnabled, onResize, containerWidth, hideResizeGrip, + overlay, + resize, resizeWidth, showResizeGrip, startWidth, @@ -61,12 +107,21 @@ export function useExplorerGeometry({ ); // Reanimated retains the last animated value when its style is removed. Keep this // style attached across folding and explicitly size overlays to the workspace viewport. - const animatedWidthStyle = useAnimatedStyle( - () => ({ - width: overlay ? containerWidth : resizeWidth.value, - }), - [containerWidth, overlay], - ); + const animatedWidthStyle = useAnimatedStyle(() => { + if (overlay) return { width: containerWidth }; + { + return { + width: resolveExplorerSidebarFrameWidth({ + width, + containerWidth, + overlay, + resizeEnabled, + resize: resize.value, + resizeWidth: resizeWidth.value, + }), + }; + } + }, [containerWidth, overlay, resizeEnabled, width]); return { frameStyle: animatedWidthStyle, panelStyle: { diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 01a5ccadaf..3c138087c0 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -1,6 +1,8 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { Fragment, + createContext, + useContext, useCallback, useEffect, useLayoutEffect, @@ -488,6 +490,13 @@ interface WorkspaceTabLabelMeasurement { width: number; } +const TabMeasurementCache = createContext | null>(null); + +export function WorkspaceTabMeasurementScope({ children }: { children: React.ReactNode }) { + const cache = useMemo(() => new Map(), []); + return {children}; +} + interface WorkspaceTabTrackSnapshot { signature: string; tabs: ResolvedWorkspaceDesktopTabRowItem[]; @@ -961,6 +970,47 @@ function TabChip({ ); } +export function useWorkspaceNewTabKeyboardAction({ + handlerName = "workspace-new-tab", + ownsKeyboardShortcuts, + normalizedServerId, + normalizedWorkspaceId, + paneId, + createNewTab, +}: { + handlerName?: string; + ownsKeyboardShortcuts: boolean; + normalizedServerId: string; + normalizedWorkspaceId: string; + paneId?: string; + createNewTab: () => void; +}) { + const handleNewTabKeyboardAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + if (!ownsKeyboardShortcuts) return false; + if (action.id === "workspace.tab.menu.open") { + createNewTab(); + return true; + } + return false; + }, + [createNewTab, ownsKeyboardShortcuts], + ); + + useKeyboardActionHandler({ + handlerId: buildWorkspaceKeyboardHandlerId({ + name: handlerName, + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + paneId, + }), + actions: ["workspace.tab.menu.open"], + enabled: ownsKeyboardShortcuts, + priority: 200, + handle: handleNewTabKeyboardAction, + }); +} + export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) { const [presentations, setPresentations] = useState( () => new Map(), @@ -1062,9 +1112,13 @@ function ResolvedWorkspaceDesktopTabsRow({ const tabsScrollRef = useRef(null); const [exitFocusModeWidth, setExitFocusModeWidth] = useState(0); const tabScrollBoundary = useHorizontalScrollBoundary(); - const [labelMeasurements, setLabelMeasurements] = useState( - () => new Map(), - ); + const measurementCache = useContext(TabMeasurementCache); + const [labelMeasurements, setLabelMeasurements] = useState(() => new Map(measurementCache ?? [])); + useLayoutEffect(() => { + if (!measurementCache) return; + measurementCache.clear(); + for (const [key, value] of labelMeasurements) measurementCache.set(key, value); + }, [measurementCache, labelMeasurements]); const [trackSnapshot, setTrackSnapshot] = useState(null); const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => { @@ -1249,29 +1303,12 @@ function ResolvedWorkspaceDesktopTabsRow({ const createNewTab = useCallback(() => onCreateNewTab({ paneId }), [onCreateNewTab, paneId]); - const handleNewTabKeyboardAction = useCallback( - (action: KeyboardActionDefinition): boolean => { - if (!ownsKeyboardShortcuts) return false; - if (action.id === "workspace.tab.menu.open") { - createNewTab(); - return true; - } - return false; - }, - [createNewTab, ownsKeyboardShortcuts], - ); - - useKeyboardActionHandler({ - handlerId: buildWorkspaceKeyboardHandlerId({ - name: "workspace-new-tab", - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - paneId, - }), - actions: ["workspace.tab.menu.open"], - enabled: ownsKeyboardShortcuts, - priority: 200, - handle: handleNewTabKeyboardAction, + useWorkspaceNewTabKeyboardAction({ + ownsKeyboardShortcuts, + normalizedServerId, + normalizedWorkspaceId, + paneId, + createNewTab, }); const rail: WorkspaceTabRailSurface = host === "explorer" ? "sidebar" : "surface"; diff --git a/packages/app/src/screens/workspace/workspace-header-menu.tsx b/packages/app/src/screens/workspace/workspace-header-menu.tsx index b8f4739cfb..cca5dd2e10 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,15 @@ function HeaderMenuProfileItem({ } export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspaceActions { + pane?: WorkspaceCompactHeaderActions["pane"]; + onExitFocusMode?: () => void; 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 +234,19 @@ export function WorkspaceHeaderMenuMobile({ createTerminalDisabled, onCreateTerminalWithProfile, onCreateBrowser, + pane, + onExitFocusMode, ...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 +292,30 @@ export function WorkspaceHeaderMenuMobile({ pages={pages} sheetTitle={t("workspace.header.actions.workspaceActions")} > + {onExitFocusMode ? ( + + {t("workspace.tabs.actions.exitFocusMode")} + + ) : null} + {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 +323,7 @@ export function WorkspaceHeaderMenuMobile({ {t("workspace.header.actions.newBrowser")} @@ -298,7 +336,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 68810013d7..2d6f2bfeac 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,3 +1,7 @@ +import { + AdaptiveWorkspaceChrome, + type WorkspaceCompactHeaderActions, +} from "./adaptive-workspace-chrome"; import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { useHostFeature } from "@/runtime/host-features"; import { @@ -148,6 +152,7 @@ import { } from "@/screens/workspace/use-workspace-tab-rename"; import { WorkspaceDesktopTabsRow, + useWorkspaceNewTabKeyboardAction, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests"; @@ -1808,8 +1813,8 @@ function WorkspaceScreenContent({ }); const handleCreateTerminalWithProfile = useCallback( - (profile: TerminalProfile) => { - createTerminal({ profile, destination: { kind: "open" } }); + (profile: TerminalProfile, paneId?: string) => { + createTerminal({ profile, destination: { kind: "open", paneId } }); }, [createTerminal], ); @@ -3372,85 +3377,100 @@ function WorkspaceScreenContent({ handleOpenUrlInBrowserTab, ], ); - const workspaceTabSwitcher = useMemo( - () => ( - - ), - [ - activeTabKey, - createTerminalDisabled, - handleCloseOtherTabs, - handleCloseTabById, - handleCloseTabsToLeft, - handleCloseTabsToRight, - handleCopyAgentId, - handleCopyFilePath, - handleCopyResumeCommand, - handleCopyTerminalId, - handleCreateDraftTab, - handleCreateTerminal, - handleReloadAgent, - handleRenameTab, - handleSelectSwitcherTab, - normalizedServerId, - normalizedWorkspaceId, - tabs, - ], - ); - const compactWorkspaceHeader = useMemo( - () => ( - - { + return ( + - - ), + ); + }, [ + normalizedServerId, + normalizedWorkspaceId, + handleSelectSwitcherTab, + createTerminalDisabled, + handleCreateDraftTab, + handleCreateTerminal, + handleCopyResumeCommand, + handleCopyAgentId, + handleCopyTerminalId, + handleCopyFilePath, + handleReloadAgent, + handleRenameTab, + handleCloseTabById, + ], + ); + const renderCompactWorkspaceHeader = useCallback( + ( + headerTabs: WorkspaceTabDescriptor[], + headerActiveTabKey: string, + actions: WorkspaceCompactHeaderActions, + actionsOnly = false, + ) => { + const tabSwitcher = renderWorkspaceTabSwitcher(headerTabs, headerActiveTabKey, actions); + return ( + + + + ); + }, + [ + renderWorkspaceTabSwitcher, + isFocusModeEnabled, + toggleFocusMode, canOpenImportSheet, compactHeaderScripts, createTerminalDisabled, @@ -3476,9 +3496,23 @@ function WorkspaceScreenContent({ workspaceHeaderSubtitle, workspaceHeaderTitle, workspacePanelActive, - workspaceTabSwitcher, ], ); + const compactWorkspaceHeader = renderCompactWorkspaceHeader(tabs, activeTabKey, { + closeAbove: handleCloseTabsToLeft, + closeBelow: handleCloseTabsToRight, + closeOthers: handleCloseOtherTabs, + }); + const fullWorkspaceActions = renderCompactWorkspaceHeader( + tabs, + activeTabKey, + { + closeAbove: handleCloseTabsToLeft, + closeBelow: handleCloseTabsToRight, + closeOthers: handleCloseOtherTabs, + }, + true, + ); const desktopSplitContent = useMemo(() => { if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) { return null; @@ -3487,6 +3521,7 @@ function WorkspaceScreenContent({ handleCreateNewTab({ paneId: focusedPaneIdOrUndefined }), + [handleCreateNewTab, focusedPaneIdOrUndefined], + ); + const ownsFallbackKeyboardShortcuts = useMemo( + () => shouldRenderDesktopPaneFallback && isRouteFocused, + [shouldRenderDesktopPaneFallback, isRouteFocused], + ); + useWorkspaceNewTabKeyboardAction({ + handlerName: "workspace-chrome-new-tab", + ownsKeyboardShortcuts: ownsFallbackKeyboardShortcuts, + normalizedServerId, + normalizedWorkspaceId, + paneId: focusedPaneIdOrUndefined, + createNewTab: createFallbackTab, + }); const compactHeaderLayout = resolveCompactHeaderLayout({ header: compactWorkspaceHeader, activeTab: activeTabDescriptor, @@ -3584,37 +3636,41 @@ function WorkspaceScreenContent({ ) : ( - {compactWorkspaceHeader} - {rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()} - - {shouldRenderDesktopPaneFallback ? ( - - - - ) : null} + {rendersDesktopSplitContent ? null : ( + + {fullWorkspaceActions} + {renderWorkspaceScreenHeader()} + {shouldRenderDesktopPaneFallback ? ( + + + + ) : null} + + )} {desktopContent} diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index 7c7b98cee0..9b428bc0bc 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -1,3 +1,4 @@ +import { buildDesktopFrameStyle } from "@/components/ui/combobox-frame-style"; import { WorkspaceHeaderExplorerToggle, WorkspaceExplorerSidebarToggle, @@ -7,6 +8,8 @@ import { SidebarCloseButton } from "@/components/headers/sidebar-close-button"; import { resolveWorkspaceTabChipBackdrop } from "@/screens/workspace/workspace-tab-chip-backdrop"; import { darkPureBlackTheme, darkTheme, lightTheme } from "@/styles/theme"; import { + workspaceTabSwitcherPopoverLayout, + selectWorkspaceChrome, computeWorkspaceTabLayout, retainWorkspaceTabMeasuredWidth, } from "@/screens/workspace/workspace-tab-layout"; @@ -256,3 +259,37 @@ describe("resolveWorkspaceTabChipBackdrop", () => { ).toBe("surface0"); }); }); + +import { shouldUseCompactWorkspaceHeader } from "./workspace-tab-layout"; +it.each([ + [349, true], + [499, true], + [500, false], + [800, false], +])("workspace width %s selects compact=%s", (width, compact) => { + expect(shouldUseCompactWorkspaceHeader(width)).toBe(compact); +}); + +describe("desktop pane chrome", () => { + it("replaces the pane header and rail together at the pane width boundary", () => { + const full = ["workspace header", "pane tab rail"]; + expect(selectWorkspaceChrome({ width: 440, compact: "pane dropdown", full })).toBe( + "pane dropdown", + ); + expect(selectWorkspaceChrome({ width: 500, compact: "pane dropdown", full })).toBe(full); + expect(selectWorkspaceChrome({ width: null, compact: "pane dropdown", full })).toBe(full); + }); +}); + +it("gives the tab switcher a readable popover width independent of its 16pt count badge", () => { + const frame = buildDesktopFrameStyle({ + desktopMinWidth: workspaceTabSwitcherPopoverLayout.desktopMinWidth, + desktopLockWidth: false, + referenceWidth: 16, + desktopFixedHeight: undefined, + desktopPositionStyle: {}, + shouldHideDesktopContent: false, + availableHeight: 600, + }) as Array<{ minWidth?: number }>; + expect(frame[0].minWidth).toBeGreaterThanOrEqual(320); +}); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index cb89d9c54c..2a9ec47837 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -1,3 +1,9 @@ +import { COMPACT_CONTENT_WIDTH } from "@/constants/layout"; + +export function shouldUseCompactWorkspaceHeader(width: number): boolean { + return width < COMPACT_CONTENT_WIDTH; +} + export type WorkspaceTabCloseButtonPolicy = "all" | "none"; export interface WorkspaceTabLayoutMetrics { @@ -120,3 +126,16 @@ export function computeWorkspaceTabLayout( requiresHorizontalScrollFallback, }; } + +export function selectWorkspaceChrome(input: { + width: number | null; + compact: Compact; + full: Full; +}): Compact | Full { + const compact = input.width !== null && shouldUseCompactWorkspaceHeader(input.width); + return compact ? input.compact : input.full; +} + +// A count badge is only 16pt wide. Native popovers need a content width floor +// because their scrolling tab list does not contribute an intrinsic width. +export const workspaceTabSwitcherPopoverLayout = { desktopMinWidth: 320 } as const; diff --git a/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts index 62a65c6878..5b1e875b44 100644 --- a/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts @@ -160,6 +160,8 @@ it("preserves another terminal's glyph pixels when a shared atlas is cleared", a expectSamePixels({ expected: before, actual: after, label: "sibling after atlas clear" }); }); +// Software WebGL can take over 30 seconds for this corpus on a contended runner. +// Each capture still has its own two-second render deadline. it("preserves both headers while ordinary output grows the shared glyph atlas", async () => { const { active, sibling } = await mountPair(); const cells = active.terminal.cols * (active.terminal.rows - 2); @@ -199,4 +201,4 @@ it("preserves both headers while ordinary output grows the shared glyph atlas", label: `sibling frame ${batch}`, }); } -}, 30000); +}, 60_000); diff --git a/packages/app/src/utils/desktop-window.test.ts b/packages/app/src/utils/desktop-window.test.ts index aa95a90dbf..21f35537dc 100644 --- a/packages/app/src/utils/desktop-window.test.ts +++ b/packages/app/src/utils/desktop-window.test.ts @@ -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"); +}); 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,