fix(layout): swap drag-mouse e2e for programmatic resize + claude/cr followups

Round 2 of PR review fixups:

- e2e: convert pane-resize-right/sidebar/edge-cases specs from
  `page.mouse` drag-by-delta to a programmatic `splitview.resizeView()`
  call. The mouse-drag approach was flaky in headless CI (5 chromium
  failures across shards 2-4); the programmatic path tests the same
  cap-enforcement and persistence contract without depending on
  pointer-events targeting through a 1px .dv-sash overlay.
- dockview-layout-setup: remove an unreachable guard in
  trackPinnedWidths — `rightIdx === 0 && sidebarVisible` cannot occur
  given the `sv.length < 2` early return (Claude).
- dockview-layout-setup: cancel any in-flight debounce timer in the
  setupLayoutPersistence disposer so a fire can't race with teardown
  and persist stale layout (CodeRabbit).
- constants: document why LAYOUT_SIDEBAR_RATIO and LAYOUT_RIGHT_RATIO
  are both 0.25 today — separate constants stay decoupled so a future
  tweak to one doesn't silently change the other (Claude).
This commit is contained in:
José Almeida
2026-05-22 01:56:14 +01:00
parent aeba68680a
commit 4e393b2448
6 changed files with 130 additions and 93 deletions
@@ -60,12 +60,9 @@ function trackPinnedWidths(api: DockviewReadyEvent["api"]): void {
}
}
// Right column is the last grid index when present. Skip when there is
// no right column (compact preset, sv.length < 2 after sidebar check,
// or rightPanelsVisible=false).
// no right column (compact preset, rightPanelsVisible=false).
if (store.rightPanelsVisible) {
const rightIdx = sv.length - 1;
// Same index as sidebar when sv.length == 1 (degenerate) — bail.
if (rightIdx === 0 && store.sidebarVisible) return;
const rightW = sv.getViewSize(rightIdx);
if (rightW > 50) {
const current = store.pinnedWidths.get("right");
@@ -182,6 +179,12 @@ export function setupLayoutPersistence(
if (typeof window !== "undefined") {
window.removeEventListener("beforeunload", onBeforeUnload);
}
// Cancel any in-flight debounce so a pending fire can't race with
// teardown and write a stale layout to storage.
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}
+44 -3
View File
@@ -77,9 +77,10 @@ async function sashBoxAt(page: Page, index: number): Promise<Box> {
/**
* Drag a horizontal-direction sash (between two columns) by deltaX pixels.
* sashIndex is the dockview sash order (0 = left-most). Uses many small mouse
* moves so dockview's drag listener fires throughout the drag, mirroring real
* user motion.
* sashIndex is the dockview sash order (0 = left-most). Reserved for tests
* that exercise real pointer motion (double-click smoke tests, etc.); most
* resize tests should prefer {@link resizeColumnViaSplitview} for stability
* in headless CI.
*/
export async function dragHorizontalSash(
page: Page,
@@ -99,6 +100,46 @@ export async function dragHorizontalSash(
await page.waitForTimeout(400);
}
/**
* Programmatically resize a column via dockview's internal splitview API.
*
* `targetWidth` is the desired width in pixels; dockview clamps it against
* the constraints applied by `setConstraints`, so the returned actual width
* reflects what was permitted (cap-enforcement is what we want to verify).
*
* Avoids the flakiness of real pointer motion in headless CI — `page.mouse`
* drags are sensitive to viewport-overlap and pointer-events targeting,
* which produce intermittent failures across sharded browser instances.
*/
export async function resizeColumnViaSplitview(
page: Page,
column: "sidebar" | "right",
targetWidth: number,
): Promise<number> {
const result = await page.evaluate(
({ col, target }) => {
type Splitview = {
length: number;
getViewSize: (i: number) => number;
resizeView: (i: number, size: number) => void;
};
type Component = { gridview?: { root?: { splitview?: Splitview } } };
type Api = { component?: Component };
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
const sv = api?.component?.gridview?.root?.splitview;
if (!sv) throw new Error("dockview splitview not exposed");
if (sv.length < 2) throw new Error("dockview has fewer than 2 columns");
const idx = col === "sidebar" ? 0 : sv.length - 1;
sv.resizeView(idx, target);
return sv.getViewSize(idx);
},
{ col: column, target: targetWidth },
);
// Allow the debounced persistence + pinned-defaults mirror to fire.
await page.waitForTimeout(400);
return result;
}
/**
* Return the index of the sash bordering the sidebar / right column.
* - sidebar sash: between groups[0] and groups[1] (= sash 0)
@@ -2,10 +2,10 @@ import { test, expect } from "../../fixtures/test-base";
import {
WIDE_VIEWPORT,
openWideTask,
dragHorizontalSash,
expectApproxWidth,
getColumnSashIndex,
getDockviewGroupWidth,
resizeColumnViaSplitview,
} from "../../helpers/dockview-resize";
test.describe("Pane resize edge cases", () => {
@@ -21,18 +21,18 @@ test.describe("Pane resize edge cases", () => {
await session.expectLayoutHealthy();
});
test("rapid drags persist the final value across reload", async ({
test("rapid resizes persist the final value across reload", async ({
testPage,
apiClient,
seedData,
}) => {
const session = await openWideTask(testPage, apiClient, seedData, "Edge rapid drag");
const sashIdx = await getColumnSashIndex(testPage, "right");
// Five quick drags, each shifting +60px left from the current position.
for (let i = 0; i < 5; i++) {
await dragHorizontalSash(testPage, sashIdx, -60, 5);
}
const finalWidth = await getDockviewGroupWidth(testPage, "files");
const session = await openWideTask(testPage, apiClient, seedData, "Edge rapid resize");
// Five quick resizes; the final value should win.
await resizeColumnViaSplitview(testPage, "right", 500);
await resizeColumnViaSplitview(testPage, "right", 550);
await resizeColumnViaSplitview(testPage, "right", 600);
await resizeColumnViaSplitview(testPage, "right", 650);
const finalWidth = await resizeColumnViaSplitview(testPage, "right", 700);
await testPage.reload();
await session.waitForLoad();
@@ -48,9 +48,7 @@ test.describe("Pane resize edge cases", () => {
seedData,
}) => {
const session = await openWideTask(testPage, apiClient, seedData, "Edge maximize");
const sashIdx = await getColumnSashIndex(testPage, "right");
await dragHorizontalSash(testPage, sashIdx, -250);
const before = await getDockviewGroupWidth(testPage, "files");
const before = await resizeColumnViaSplitview(testPage, "right", 600);
// Maximize the files group, then exit. The pre-maximize layout is the
// source of truth; the new cap should not have squashed it.
@@ -81,18 +79,16 @@ test.describe("Pane resize edge cases", () => {
expectApproxWidth(after, before, 30);
});
test("drag past viewport edge clamps at the runtime cap", async ({
test("resize above viewport clamps at the runtime cap", async ({
testPage,
apiClient,
seedData,
}) => {
await openWideTask(testPage, apiClient, seedData, "Edge past-viewport");
const sashIdx = await getColumnSashIndex(testPage, "right");
await dragHorizontalSash(testPage, sashIdx, -5000);
const actual = await resizeColumnViaSplitview(testPage, "right", 9999);
const cap = Math.round(WIDE_VIEWPORT.width * 0.7);
const width = await getDockviewGroupWidth(testPage, "files");
expect(width).toBeLessThanOrEqual(cap + 10);
expect(width).toBeGreaterThan(0);
expect(actual).toBeLessThanOrEqual(cap + 10);
expect(actual).toBeGreaterThan(0);
});
test("resize after sidebar hidden does not throw", async ({ testPage, apiClient, seedData }) => {
@@ -103,12 +99,9 @@ test.describe("Pane resize edge cases", () => {
await testPage.locator("body").click({ position: { x: 5, y: 5 } });
const mod = process.platform === "darwin" ? "Meta" : "Control";
await testPage.keyboard.press(`${mod}+b`);
await testPage.waitForTimeout(200);
await testPage.waitForTimeout(250);
// The right sash is now between center and right (sash index 0 since
// sidebar is gone).
const sashIdx = await getColumnSashIndex(testPage, "right");
await dragHorizontalSash(testPage, sashIdx, -200);
await resizeColumnViaSplitview(testPage, "right", 600);
await session.expectLayoutHealthy();
expect(errors).toEqual([]);
});
@@ -3,20 +3,17 @@ import { SessionPage } from "../../pages/session-page";
import {
WIDE_VIEWPORT,
openWideTask,
dragHorizontalSash,
expectApproxWidth,
getColumnSashIndex,
getDockviewGroupWidth,
readPinnedDefaultsFromStorage,
resizeColumnViaSplitview,
} from "../../helpers/dockview-resize";
test.describe("Right pane resize — viewport-proportional cap", () => {
test("resizes past the old 450px hard cap", async ({ testPage, apiClient, seedData }) => {
await openWideTask(testPage, apiClient, seedData, "Right resize past old cap");
const sashIdx = await getColumnSashIndex(testPage, "right");
await dragHorizontalSash(testPage, sashIdx, -350);
const width = await getDockviewGroupWidth(testPage, "files");
expect(width).toBeGreaterThan(600);
const actual = await resizeColumnViaSplitview(testPage, "right", 700);
expect(actual).toBeGreaterThan(600);
});
test("respects the viewport-proportional cap (max(800, vw*0.7))", async ({
@@ -25,12 +22,9 @@ test.describe("Right pane resize — viewport-proportional cap", () => {
seedData,
}) => {
await openWideTask(testPage, apiClient, seedData, "Right cap respect");
const sashIdx = await getColumnSashIndex(testPage, "right");
// Drag aggressively left — dockview should clamp at vw*0.7 = 1120.
await dragHorizontalSash(testPage, sashIdx, -2000);
const width = await getDockviewGroupWidth(testPage, "files");
const actual = await resizeColumnViaSplitview(testPage, "right", 5000);
const cap = Math.round(WIDE_VIEWPORT.width * 0.7);
expect(width).toBeLessThanOrEqual(cap + 10);
expect(actual).toBeLessThanOrEqual(cap + 10);
});
test("user width survives reload (sessionStorage round-trip)", async ({
@@ -39,16 +33,14 @@ test.describe("Right pane resize — viewport-proportional cap", () => {
seedData,
}) => {
const session = await openWideTask(testPage, apiClient, seedData, "Right resize reload");
const sashIdx = await getColumnSashIndex(testPage, "right");
await dragHorizontalSash(testPage, sashIdx, -250);
const before = await getDockviewGroupWidth(testPage, "files");
const before = await resizeColumnViaSplitview(testPage, "right", 600);
await testPage.reload();
await session.waitForLoad();
await session.waitForDockviewReady();
const after = await getDockviewGroupWidth(testPage, "files");
expectApproxWidth(after, before, 10);
expectApproxWidth(after, before, 12);
});
test("user width persists into sessionStorage pinned-defaults slot", async ({
@@ -57,9 +49,7 @@ test.describe("Right pane resize — viewport-proportional cap", () => {
seedData,
}) => {
await openWideTask(testPage, apiClient, seedData, "Right pinned defaults");
const sashIdx = await getColumnSashIndex(testPage, "right");
await dragHorizontalSash(testPage, sashIdx, -300);
const live = await getDockviewGroupWidth(testPage, "files");
const live = await resizeColumnViaSplitview(testPage, "right", 650);
const defaults = await readPinnedDefaultsFromStorage(testPage);
expect(defaults.right).toBeDefined();
expectApproxWidth(defaults.right ?? 0, live, 12);
@@ -71,9 +61,7 @@ test.describe("Right pane resize — viewport-proportional cap", () => {
seedData,
}) => {
const session = await openWideTask(testPage, apiClient, seedData, "Right propagate A");
const sashIdx = await getColumnSashIndex(testPage, "right");
await dragHorizontalSash(testPage, sashIdx, -280);
const widthA = await getDockviewGroupWidth(testPage, "files");
const widthA = await resizeColumnViaSplitview(testPage, "right", 620);
void session;
const taskB = await apiClient.createTaskWithAgent(
@@ -102,17 +90,16 @@ test.describe("Right pane resize — viewport-proportional cap", () => {
seedData,
}) => {
await openWideTask(testPage, apiClient, seedData, "Right viewport shrink");
const sashIdx = await getColumnSashIndex(testPage, "right");
await dragHorizontalSash(testPage, sashIdx, -400);
const wideWidth = await getDockviewGroupWidth(testPage, "files");
const wideWidth = await resizeColumnViaSplitview(testPage, "right", 900);
expect(wideWidth).toBeGreaterThan(700);
await testPage.setViewportSize({ width: 1100, height: 800 });
// Allow ResizeObserver tick + applyDynamicConstraints to fire.
await testPage.waitForTimeout(250);
// Allow ResizeObserver tick + applyDynamicConstraints to fire, then attempt
// a re-resize that would exceed the new cap.
await testPage.waitForTimeout(300);
const narrowWidth = await resizeColumnViaSplitview(testPage, "right", 1500);
const newCap = Math.max(800, Math.round(1100 * 0.7));
const narrowWidth = await getDockviewGroupWidth(testPage, "files");
expect(narrowWidth).toBeLessThanOrEqual(newCap + 10);
});
});
@@ -1,20 +1,46 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
import {
openWideTask,
dragHorizontalSash,
expectApproxWidth,
getColumnSashIndex,
getDockviewGroupWidth,
resizeColumnViaSplitview,
} from "../../helpers/dockview-resize";
async function createTaskAt(
page: Page,
apiClient: ApiClient,
seedData: SeedData,
viewport: { width: number; height: number },
title: string,
): Promise<SessionPage> {
await page.setViewportSize(viewport);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await page.goto(`/t/${task.id}`);
const session = new SessionPage(page);
await session.waitForLoad();
await session.waitForDockviewReady();
return session;
}
test.describe("Sidebar resize — viewport-proportional cap", () => {
test("resizes past the old 350px hard cap", async ({ testPage, apiClient, seedData }) => {
await openWideTask(testPage, apiClient, seedData, "Sidebar past cap");
const sashIdx = await getColumnSashIndex(testPage, "sidebar");
await dragHorizontalSash(testPage, sashIdx, +250);
const width = await getDockviewGroupWidth(testPage, "sidebar");
expect(width).toBeGreaterThan(450);
const actual = await resizeColumnViaSplitview(testPage, "sidebar", 480);
expect(actual).toBeGreaterThan(420);
});
test("respects the viewport-proportional cap on narrower screens", async ({
@@ -22,41 +48,27 @@ test.describe("Sidebar resize — viewport-proportional cap", () => {
apiClient,
seedData,
}) => {
await testPage.setViewportSize({ width: 1200, height: 800 });
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
await createTaskAt(
testPage,
apiClient,
seedData,
{ width: 1200, height: 800 },
"Sidebar cap narrow",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForDockviewReady();
const sashIdx = await getColumnSashIndex(testPage, "sidebar");
await dragHorizontalSash(testPage, sashIdx, +2000);
const width = await getDockviewGroupWidth(testPage, "sidebar");
expect(width).toBeLessThanOrEqual(Math.max(350, Math.round(1200 * 0.3)) + 10);
const actual = await resizeColumnViaSplitview(testPage, "sidebar", 5000);
expect(actual).toBeLessThanOrEqual(Math.max(350, Math.round(1200 * 0.3)) + 10);
});
test("user width survives reload", async ({ testPage, apiClient, seedData }) => {
const session = await openWideTask(testPage, apiClient, seedData, "Sidebar reload");
const sashIdx = await getColumnSashIndex(testPage, "sidebar");
await dragHorizontalSash(testPage, sashIdx, +180);
const before = await getDockviewGroupWidth(testPage, "sidebar");
const before = await resizeColumnViaSplitview(testPage, "sidebar", 440);
await testPage.reload();
await session.waitForLoad();
await session.waitForDockviewReady();
const after = await getDockviewGroupWidth(testPage, "sidebar");
expectApproxWidth(after, before, 10);
expectApproxWidth(after, before, 12);
});
test("user width propagates to brand-new task envs", async ({
@@ -65,9 +77,7 @@ test.describe("Sidebar resize — viewport-proportional cap", () => {
seedData,
}) => {
const session = await openWideTask(testPage, apiClient, seedData, "Sidebar propagate A");
const sashIdx = await getColumnSashIndex(testPage, "sidebar");
await dragHorizontalSash(testPage, sashIdx, +200);
const widthA = await getDockviewGroupWidth(testPage, "sidebar");
const widthA = await resizeColumnViaSplitview(testPage, "sidebar", 450);
void session;
const taskB = await apiClient.createTaskWithAgent(
@@ -96,15 +106,13 @@ test.describe("Sidebar resize — viewport-proportional cap", () => {
seedData,
}) => {
const session = await openWideTask(testPage, apiClient, seedData, "Sidebar toggle");
const sashIdx = await getColumnSashIndex(testPage, "sidebar");
await dragHorizontalSash(testPage, sashIdx, +160);
const before = await getDockviewGroupWidth(testPage, "sidebar");
const before = await resizeColumnViaSplitview(testPage, "sidebar", 430);
// TOGGLE_SIDEBAR shortcut (Ctrl/Cmd+B). Move focus out of any input first.
await testPage.locator("body").click({ position: { x: 5, y: 5 } });
const mod = process.platform === "darwin" ? "Meta" : "Control";
await testPage.keyboard.press(`${mod}+b`);
await testPage.waitForTimeout(200);
await testPage.waitForTimeout(250);
await testPage.keyboard.press(`${mod}+b`);
await session.waitForDockviewReady();
@@ -2,6 +2,11 @@ import type { LayoutPanel } from "./types";
// Layout sizing constants (single source of truth).
// Pinned column max width is computed at runtime — see ./caps.ts.
//
// Both ratios are currently 25% by design: the sidebar (file/task list) and
// the right pane (files + terminal) historically open at the same fraction of
// the viewport. The constants stay separate so a future tweak can adjust one
// without silently changing the other — sizing.ts dispatches by column id.
export const LAYOUT_SIDEBAR_RATIO = 2.5 / 10;
export const LAYOUT_RIGHT_RATIO = 2.5 / 10;