From cab5eedfe3be11d358fb0ea97834e76f0b27f5e2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Nov 2026 18:23:41 +0700 Subject: [PATCH] Keep workspace layouts usable in narrow columns across platforms (#8740) * fix(app): retain Explorer resize width until acknowledged Co-authored-by: Elsa Granger * fix(app): choose workspace header by measured column width Co-authored-by: Elsa Granger * fix(app): separate iOS composer clipping from its shadow Keep explicit coverage of the existing keyboard rotation guard. Co-authored-by: Elsa Granger * fix(app): choose workspace chrome within each desktop pane The outer width wrapper added a compact header while the desktop split container kept its full header and pane rails. Each pane now owns the complete choice, replacing its header and rail together. Each split pane uses its own measured width: below 500 points it shows its own tab dropdown, even if the neighboring pane is wide. Pane width determines usable header space. Keep new-tab keyboard ownership outside the switched chrome and retain positive measurements while hidden. Co-authored-by: Elsa Granger * fix(app): reserve readable width for the compact tab popover Native scroll content does not contribute intrinsic width to the anchored frame. The count badge alone allowed the popover to compress its tab list and footer labels. Co-authored-by: Elsa Granger * 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 * fix(app): retain window dragging in compact panes Keep the shared titlebar drag region when pane width replaces full desktop chrome. Cover two compact panes with both sidebars closed through the desktop host adapter. Co-authored-by: Elsa Granger * test(app): align pane journeys with measured workspace chrome Give rail-specific split journeys enough column width, assert launched Diff content independently of header shape, and retain pane identity across compact transitions. Expect the compact selector in the half-screen Explorer workflow. Co-authored-by: Elsa Granger * refactor(app): share pane tab-switcher rendering after header rebase Keep compact and vertical-bar header actions on the same pane-aware switcher contract while preserving the merged control geometry and script ownership. Co-authored-by: Elsa Granger * fix(app): retain Explorer acknowledgements across consecutive drags Keep earlier released widths as expected allocations while a newer gesture owns the frame, and restore the latest release if that gesture is canceled. Co-authored-by: Elsa Granger * fix(app): keep focus mode exit in the compact workspace menu Carry the workspace focus state into its compact action menu so a narrow header retains an exit without keyboard shortcuts. Co-authored-by: Elsa Granger * fix(app): distinguish resize rollback from pending acknowledgements Restore the last released width when repeated widths precede a canceled gesture, and retain ownership when an unchanged old allocation matches a newer release before its predecessors arrive. Co-authored-by: Elsa Granger * test(app): allow software WebGL time to finish atlas growth coverage The 256-batch pixel regression crosses more than 500 render boundaries and exceeds its 30-second total budget under CPU contention. Keep the corpus, every pixel assertion, and the two-second per-frame deadline; allow 60 seconds for the complete workload. Reproduced the timeout with Chromium confined to one CPU and verified two successful constrained runs after the adjustment. Co-authored-by: Elsa Granger --------- Co-authored-by: Elsa Granger --- .../e2e/browser/composer-diff-stat.spec.ts | 3 +- ...t-terminal-shortcut-pane-placement.spec.ts | 88 ++++++ .../e2e/browser/empty-pane-keyboard.spec.ts | 6 +- .../browser/explorer-pane-placement.spec.ts | 3 + packages/app/e2e/browser/file-editing.spec.ts | 3 +- .../app/e2e/browser/sidebar-workspace.spec.ts | 3 + .../e2e/browser/subagent-origin-pane.spec.ts | 6 +- .../app/e2e/browser/terminal-find.spec.ts | 2 + .../browser/viewed-agent-timelines.spec.ts | 2 + .../e2e/browser/workspace-focus-mode.spec.ts | 17 ++ .../browser/workspace-pane-remount.spec.ts | 2 + .../explorer-sidebar-layout.test.ts | 197 +++++++++++++ .../src/components/explorer-sidebar-layout.ts | 79 +++++ .../app/src/components/split-container.tsx | 165 ++++++++--- packages/app/src/composer/input/input.tsx | 10 +- .../keyboard/shift/internal/policy.test.ts | 15 + .../workspace/adaptive-workspace-chrome.tsx | 38 +++ .../compact-workspace-header/tab-switcher.tsx | 16 +- .../workspace/explorer-sidebar/geometry.tsx | 87 ++++-- .../workspace/workspace-desktop-tabs-row.tsx | 89 ++++-- .../workspace/workspace-header-menu.tsx | 46 ++- .../screens/workspace/workspace-screen.tsx | 270 +++++++++++------- .../workspace/workspace-tab-layout.test.ts | 37 +++ .../screens/workspace/workspace-tab-layout.ts | 19 ++ .../terminal-glyph-rendering.browser.test.ts | 4 +- packages/app/src/utils/desktop-window.test.ts | 52 ++++ packages/app/src/utils/desktop-window.tsx | 13 + 27 files changed, 1063 insertions(+), 209 deletions(-) create mode 100644 packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx 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,