mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
Keep workspace layouts usable in narrow columns across platforms (#8740)
* fix(app): retain Explorer resize width until acknowledged Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): choose workspace header by measured column width Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): separate iOS composer clipping from its shadow Keep explicit coverage of the existing keyboard rotation guard. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * 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 <zeyugao@outlook.com> * 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 <zeyugao@outlook.com> * 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 <zeyugao@outlook.com> * 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 <zeyugao@outlook.com> * 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 <zeyugao@outlook.com> * 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 <zeyugao@outlook.com> * 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 <zeyugao@outlook.com> * 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 <zeyugao@outlook.com> * 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 <zeyugao@outlook.com> * 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 <zeyugao@outlook.com> --------- Co-authored-by: Elsa Granger <zeyugao@outlook.com>
This commit is contained in:
co-authored by
Elsa Granger
parent
4407283a13
commit
cab5eedfe3
@@ -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,
|
||||
|
||||
@@ -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<HTMLElement>("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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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-" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
<View style={styles.workspaceShell}>
|
||||
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
|
||||
<View style={mainColumnStyle}>
|
||||
{renderMainHeader?.()}
|
||||
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
|
||||
{renderRoot ? (
|
||||
<SplitNodeView
|
||||
@@ -279,6 +290,8 @@ export function SplitContainer({
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
renderMainHeader={renderMainHeader}
|
||||
renderCompactHeader={renderCompactHeader}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -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}
|
||||
/>
|
||||
</WindowChromeRegion>
|
||||
</RetainedPanel>
|
||||
@@ -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}
|
||||
/>
|
||||
</SplitGroupChild>
|
||||
{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<View | null>(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(
|
||||
() => (
|
||||
<WindowChromeSafeArea placement="inline">
|
||||
<TitlebarDragRegion />
|
||||
{renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)}
|
||||
</WindowChromeSafeArea>
|
||||
),
|
||||
[renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions],
|
||||
);
|
||||
return (
|
||||
<RenderProfile id={`SplitPaneView:${pane.id}`}>
|
||||
<View
|
||||
@@ -709,43 +786,49 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
style={styles.pane}
|
||||
testID={`workspace-pane-${pane.id}`}
|
||||
>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
|
||||
<TitlebarDragRegion />
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
isFocused={isFocused && isWorkspaceFocused}
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
externalDndContext
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
showPaneSplitActions={!focusModeEnabled}
|
||||
showPaneMaximizeAction={workspaceHasMultiplePanes && !focusModeEnabled}
|
||||
paneMaximized={paneId === maximizedPaneId}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
onSplitRight={handleSplitRight}
|
||||
onSplitDown={handleSplitDown}
|
||||
focusModeEnabled={Boolean(focusModeEnabled)}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
</WindowChromeSafeArea>
|
||||
<AdaptiveWorkspaceChrome compactHeader={compactHeader}>
|
||||
{renderMainHeader?.()}
|
||||
<WindowChromeRegion corners={focusModeEnabled ? "both" : "none"}>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
|
||||
<TitlebarDragRegion />
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
isFocused={isFocused && isWorkspaceFocused}
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateNewTab={onCreateNewTab}
|
||||
onReorderTabs={handleReorderTabs}
|
||||
ownsKeyboardShortcuts={false}
|
||||
externalDndContext
|
||||
activeDragTabId={activeDragTabId}
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
showPaneSplitActions={!focusModeEnabled}
|
||||
showPaneMaximizeAction={workspaceHasMultiplePanes && !focusModeEnabled}
|
||||
paneMaximized={paneId === maximizedPaneId}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
onSplitRight={handleSplitRight}
|
||||
onSplitDown={handleSplitDown}
|
||||
focusModeEnabled={Boolean(focusModeEnabled)}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
</WindowChromeSafeArea>
|
||||
</WindowChromeRegion>
|
||||
</AdaptiveWorkspaceChrome>
|
||||
|
||||
<View style={styles.paneContent}>
|
||||
<WorkspacePanelHost
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
Platform,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
NativeSyntheticEvent,
|
||||
@@ -1936,7 +1937,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
return (
|
||||
<View
|
||||
ref={rootRef}
|
||||
style={isFullscreen ? styles.fullscreenContainer : styles.container}
|
||||
style={
|
||||
isFullscreen
|
||||
? styles.fullscreenContainer
|
||||
: [styles.container, Platform.OS === "ios" && isCompact && styles.inputWrapperCompact]
|
||||
}
|
||||
testID="message-input-root"
|
||||
onLayout={handleComposerLayout}
|
||||
>
|
||||
@@ -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],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const measure = useCallback((event: LayoutChangeEvent) => {
|
||||
const measured = event.nativeEvent.layout.width;
|
||||
if (measured > 0) setWidth(measured);
|
||||
}, []);
|
||||
return (
|
||||
<WorkspaceTabMeasurementScope>
|
||||
<View onLayout={measure}>
|
||||
{selectWorkspaceChrome({ width, compact: compactHeader, full: children })}
|
||||
</View>
|
||||
</WorkspaceTabMeasurementScope>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkspaceCompactHeaderActions {
|
||||
pane?: {
|
||||
id: string;
|
||||
splitRight?: () => void;
|
||||
splitDown?: () => void;
|
||||
toggleMaximized?: () => void;
|
||||
maximized: boolean;
|
||||
};
|
||||
closeAbove: (key: string) => Promise<void> | void;
|
||||
closeBelow: (key: string) => Promise<void> | void;
|
||||
closeOthers: (key: string) => Promise<void> | void;
|
||||
}
|
||||
@@ -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> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCopyTerminalId: (terminalId: string) => Promise<void> | 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
|
||||
</Pressable>
|
||||
|
||||
<Combobox
|
||||
{...workspaceTabSwitcherPopoverLayout}
|
||||
options={tabSwitcherOptions}
|
||||
value={activeTabKey}
|
||||
onSelect={onSelectSwitcherTab}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import {
|
||||
runOnJS,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
|
||||
@@ -9,7 +14,14 @@ import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout";
|
||||
import {
|
||||
beginExplorerSidebarResize,
|
||||
finishExplorerSidebarResize,
|
||||
reconcileExplorerSidebarResize,
|
||||
resolveExplorerSidebarFrameWidth,
|
||||
resolveExplorerSidebarWidth,
|
||||
type ExplorerSidebarResize,
|
||||
} from "@/components/explorer-sidebar-layout";
|
||||
import type { ExplorerGeometryInput, ExplorerGeometry } from "./geometry-types";
|
||||
export function useExplorerGeometry({
|
||||
width,
|
||||
@@ -21,10 +33,16 @@ export function useExplorerGeometry({
|
||||
const insets = useSafeAreaInsets();
|
||||
const resizeWidth = useSharedValue(width);
|
||||
const startWidth = useSharedValue(width);
|
||||
const resize = useSharedValue<ExplorerSidebarResize | null>(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: {
|
||||
|
||||
@@ -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<Map<string, WorkspaceTabLabelMeasurement> | null>(null);
|
||||
|
||||
export function WorkspaceTabMeasurementScope({ children }: { children: React.ReactNode }) {
|
||||
const cache = useMemo(() => new Map<string, WorkspaceTabLabelMeasurement>(), []);
|
||||
return <TabMeasurementCache.Provider value={cache}>{children}</TabMeasurementCache.Provider>;
|
||||
}
|
||||
|
||||
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<string, WorkspaceTabPresentation>(),
|
||||
@@ -1062,9 +1112,13 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
const tabsScrollRef = useRef<ScrollView>(null);
|
||||
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
|
||||
const tabScrollBoundary = useHorizontalScrollBoundary();
|
||||
const [labelMeasurements, setLabelMeasurements] = useState(
|
||||
() => new Map<string, WorkspaceTabLabelMeasurement>(),
|
||||
);
|
||||
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<WorkspaceTabTrackSnapshot | null>(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";
|
||||
|
||||
@@ -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 ? (
|
||||
<DropdownMenuItem onSelect={onExitFocusMode}>
|
||||
{t("workspace.tabs.actions.exitFocusMode")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{pane?.splitRight ? (
|
||||
<DropdownMenuItem onSelect={pane.splitRight}>
|
||||
{t("workspace.tabs.actions.splitRight")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{pane?.splitDown ? (
|
||||
<DropdownMenuItem onSelect={pane.splitDown}>
|
||||
{t("workspace.tabs.actions.splitDown")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{pane?.toggleMaximized ? (
|
||||
<DropdownMenuItem onSelect={pane.toggleMaximized}>
|
||||
{t(
|
||||
pane.maximized
|
||||
? "workspace.tabs.actions.restorePane"
|
||||
: "workspace.tabs.actions.maximizePane",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{scriptsMenu.trigger}
|
||||
{pluginMenu.rows}
|
||||
{pluginMenu.rows.length > 0 ? <DropdownMenuSeparator /> : null}
|
||||
@@ -285,7 +323,7 @@ export function WorkspaceHeaderMenuMobile({
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-new-browser"
|
||||
leading={MENU_NEW_BROWSER_ICON}
|
||||
onSelect={onCreateBrowser}
|
||||
onSelect={handleCreateBrowser}
|
||||
>
|
||||
{t("workspace.header.actions.newBrowser")}
|
||||
</DropdownMenuItem>
|
||||
@@ -298,7 +336,7 @@ export function WorkspaceHeaderMenuMobile({
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
disabled={createTerminalDisabled}
|
||||
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
|
||||
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -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(
|
||||
() => (
|
||||
<MobileWorkspaceTabSwitcher
|
||||
tabs={tabs}
|
||||
activeTabKey={activeTabKey}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
onSelectSwitcherTab={handleSelectSwitcherTab}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onCopyTerminalId={handleCopyTerminalId}
|
||||
onCopyFilePath={handleCopyFilePath}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||
onCloseTabsBelow={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
/>
|
||||
),
|
||||
[
|
||||
activeTabKey,
|
||||
createTerminalDisabled,
|
||||
handleCloseOtherTabs,
|
||||
handleCloseTabById,
|
||||
handleCloseTabsToLeft,
|
||||
handleCloseTabsToRight,
|
||||
handleCopyAgentId,
|
||||
handleCopyFilePath,
|
||||
handleCopyResumeCommand,
|
||||
handleCopyTerminalId,
|
||||
handleCreateDraftTab,
|
||||
handleCreateTerminal,
|
||||
handleReloadAgent,
|
||||
handleRenameTab,
|
||||
handleSelectSwitcherTab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
tabs,
|
||||
],
|
||||
);
|
||||
const compactWorkspaceHeader = useMemo(
|
||||
() => (
|
||||
<CompactWorkspaceHeader
|
||||
headerVariant={isMobile ? "compact" : "full"}
|
||||
actionsActive={isRouteFocused && (!isMobile || workspacePanelActive)}
|
||||
isLoading={isWorkspaceHeaderLoading}
|
||||
title={workspaceHeaderTitle}
|
||||
subtitle={workspaceHeaderSubtitle}
|
||||
isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct}
|
||||
serverId={normalizedServerId}
|
||||
onToggleExplorer={handleToggleExplorerSidebar}
|
||||
explorerToggleLabel={explorerSidebarToggleLabel}
|
||||
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
|
||||
tabSwitcher={workspaceTabSwitcher}
|
||||
>
|
||||
<WorkspaceHeaderMenuMobile
|
||||
const renderWorkspaceTabSwitcher = useCallback(
|
||||
(
|
||||
headerTabs: WorkspaceTabDescriptor[],
|
||||
headerActiveTabKey: string,
|
||||
actions: WorkspaceCompactHeaderActions,
|
||||
) => {
|
||||
return (
|
||||
<MobileWorkspaceTabSwitcher
|
||||
paneId={actions.pane?.id}
|
||||
tabs={headerTabs}
|
||||
activeTabKey={headerActiveTabKey}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
scripts={compactHeaderScripts}
|
||||
currentBranchName={currentBranchName}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
onSelectSwitcherTab={handleSelectSwitcherTab}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
importAgentDisabled={!canOpenImportSheet}
|
||||
copyPathDisabled={!workspaceDirectory}
|
||||
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
|
||||
onCreateBrowser={handleCreateBrowserTab}
|
||||
onOpenImportSheet={openImportSheet}
|
||||
onCopyWorkspacePath={handleCopyWorkspacePath}
|
||||
onCopyBranchName={handleCopyBranchName}
|
||||
onOpenSetupTab={handleOpenSetupTab}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onCopyTerminalId={handleCopyTerminalId}
|
||||
onCopyFilePath={handleCopyFilePath}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCloseTabsAbove={actions.closeAbove}
|
||||
onCloseTabsBelow={actions.closeBelow}
|
||||
onCloseOtherTabs={actions.closeOthers}
|
||||
/>
|
||||
</CompactWorkspaceHeader>
|
||||
),
|
||||
);
|
||||
},
|
||||
[
|
||||
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 (
|
||||
<CompactWorkspaceHeader
|
||||
headerVariant={actionsOnly ? "full" : "compact"}
|
||||
actionsActive={isRouteFocused && (!isMobile || workspacePanelActive)}
|
||||
tabSwitcher={tabSwitcher}
|
||||
isLoading={isWorkspaceHeaderLoading}
|
||||
title={workspaceHeaderTitle}
|
||||
subtitle={workspaceHeaderSubtitle}
|
||||
isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct}
|
||||
serverId={normalizedServerId}
|
||||
onToggleExplorer={handleToggleExplorerSidebar}
|
||||
explorerToggleLabel={explorerSidebarToggleLabel}
|
||||
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
|
||||
>
|
||||
<WorkspaceHeaderMenuMobile
|
||||
pane={actions.pane}
|
||||
onExitFocusMode={isFocusModeEnabled ? toggleFocusMode : undefined}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
scripts={compactHeaderScripts}
|
||||
currentBranchName={currentBranchName}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
importAgentDisabled={!canOpenImportSheet}
|
||||
copyPathDisabled={!workspaceDirectory}
|
||||
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
|
||||
onCreateBrowser={handleCreateBrowserTab}
|
||||
onOpenImportSheet={openImportSheet}
|
||||
onCopyWorkspacePath={handleCopyWorkspacePath}
|
||||
onCopyBranchName={handleCopyBranchName}
|
||||
onOpenSetupTab={handleOpenSetupTab}
|
||||
/>
|
||||
</CompactWorkspaceHeader>
|
||||
);
|
||||
},
|
||||
[
|
||||
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({
|
||||
<SplitContainer
|
||||
layout={workspaceLayout}
|
||||
renderMainHeader={renderWorkspaceScreenHeader}
|
||||
renderCompactHeader={renderCompactWorkspaceHeader}
|
||||
focusModeEnabled={desktopFocusModeEnabled}
|
||||
onExitFocusMode={toggleFocusMode}
|
||||
workspaceKey={persistenceKey}
|
||||
@@ -3523,6 +3558,7 @@ function WorkspaceScreenContent({
|
||||
canRenderDesktopPaneSplits,
|
||||
workspaceLayout,
|
||||
renderWorkspaceScreenHeader,
|
||||
renderCompactWorkspaceHeader,
|
||||
persistenceKey,
|
||||
desktopFocusModeEnabled,
|
||||
toggleFocusMode,
|
||||
@@ -3561,6 +3597,22 @@ function WorkspaceScreenContent({
|
||||
[uiTabs, sheets],
|
||||
);
|
||||
const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`;
|
||||
const createFallbackTab = useCallback(
|
||||
() => 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({
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.centerColumn}>
|
||||
{compactWorkspaceHeader}
|
||||
{rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()}
|
||||
|
||||
{shouldRenderDesktopPaneFallback ? (
|
||||
<NewTabLauncherProvider value={newTabLauncher}>
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={focusedPaneIdOrUndefined}
|
||||
isFocused={isRouteFocused}
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={navigateToTabId}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onCopyTerminalId={handleCopyTerminalId}
|
||||
onCopyFilePath={handleCopyFilePath}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateNewTab={handleCreateNewTab}
|
||||
onReorderTabs={handleReorderTabsInFocusedPane}
|
||||
focusModeEnabled={desktopFocusModeEnabled}
|
||||
onExitFocusMode={toggleFocusMode}
|
||||
/>
|
||||
</NewTabLauncherProvider>
|
||||
) : null}
|
||||
{rendersDesktopSplitContent ? null : (
|
||||
<AdaptiveWorkspaceChrome compactHeader={compactWorkspaceHeader}>
|
||||
{fullWorkspaceActions}
|
||||
{renderWorkspaceScreenHeader()}
|
||||
|
||||
{shouldRenderDesktopPaneFallback ? (
|
||||
<NewTabLauncherProvider value={newTabLauncher}>
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={focusedPaneIdOrUndefined}
|
||||
ownsKeyboardShortcuts={false}
|
||||
isFocused={isRouteFocused}
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={navigateToTabId}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onCopyTerminalId={handleCopyTerminalId}
|
||||
onCopyFilePath={handleCopyFilePath}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onCreateNewTab={handleCreateNewTab}
|
||||
onReorderTabs={handleReorderTabsInFocusedPane}
|
||||
focusModeEnabled={desktopFocusModeEnabled}
|
||||
onExitFocusMode={toggleFocusMode}
|
||||
/>
|
||||
</NewTabLauncherProvider>
|
||||
) : null}
|
||||
</AdaptiveWorkspaceChrome>
|
||||
)}
|
||||
<View style={styles.centerContent}>
|
||||
<View style={styles.content}>{desktopContent}</View>
|
||||
</View>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<Compact, Full>(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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user