From 34f9e18bf61bb337ab37334ca7331fd121e73aae Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:36:29 +0000 Subject: [PATCH] fix(app): keep split dividers under gesture input Measure the gesture from its initial press so activation does not discard the movement before its threshold. Cover coarse-pointer mouse and touch input alongside app-created workspaces, Explorer changes and reload. --- .../browser/split-pane-drag-tracking.spec.ts | 126 ++++++++++++++---- packages/app/src/components/resize-handle.tsx | 35 +++-- 2 files changed, 121 insertions(+), 40 deletions(-) diff --git a/packages/app/e2e/browser/split-pane-drag-tracking.spec.ts b/packages/app/e2e/browser/split-pane-drag-tracking.spec.ts index 02ad911765..90420c6fd9 100644 --- a/packages/app/e2e/browser/split-pane-drag-tracking.spec.ts +++ b/packages/app/e2e/browser/split-pane-drag-tracking.spec.ts @@ -1,6 +1,7 @@ import { expect, type Locator, type Page } from "@playwright/test"; import { test } from "../support/fixtures"; import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; +import { createCreationScenario } from "../support/helpers/creation"; import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; import { seedWorkspace } from "../support/helpers/seed-client"; import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; @@ -30,31 +31,104 @@ async function dragDivider(divider: Locator, deltaX: number): Promise { await page.mouse.up(); } -test.describe("Split pane drag tracking", () => { - test("a divider moves as far as the mouse in the main row", async ({ page }) => { - await page.setViewportSize({ width: 1600, height: 900 }); - const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" }); - try { - await gotoWorkspace(page, workspace.workspaceId); - await waitForWorkspaceTabsVisible(page); - await clickNewChat(page); - for (const expectedDividers of [1, 2, 3]) { - await runWorkspaceActionFromCommandCenter(page, "Split pane right"); - await expect(splitDividers(page)).toHaveCount(expectedDividers); - } - - for (const index of [0, 1, 2]) { - const divider = splitDividers(page).nth(index); - const before = await dividerX(divider); - await dragDivider(divider, DRAG_DISTANCE); - await expect - .poll(async () => Math.abs((await dividerX(divider)) - before - DRAG_DISTANCE), { - message: `divider ${index + 1} should stay under the mouse`, - }) - .toBeLessThanOrEqual(TOLERANCE_PX); - } - } finally { - await workspace.cleanup(); +async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise { + const page = divider.page(); + const box = await divider.boundingBox(); + if (!box) throw new Error("Divider has no bounding box"); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + const input = await page.context().newCDPSession(page); + try { + await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] }); + for (let step = 1; step <= 8; step++) { + await input.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: x + (deltaX * step) / 8, y }], + }); } + await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + } finally { + await input.detach(); + } +} + +async function splitIntoFourPanes(page: Page): Promise { + for (const expectedDividers of [1, 2, 3]) { + await runWorkspaceActionFromCommandCenter(page, "Split pane right"); + await expect(splitDividers(page)).toHaveCount(expectedDividers); + } +} + +async function expectDividersTrackPointer( + page: Page, + distance = DRAG_DISTANCE, + drag = dragDivider, +): Promise { + for (const index of [0, 1, 2]) { + const divider = splitDividers(page).nth(index); + const before = await dividerX(divider); + await drag(divider, distance); + await expect + .poll(async () => Math.abs((await dividerX(divider)) - before - distance), { + message: `divider ${index + 1} should stay under the pointer`, + }) + .toBeLessThanOrEqual(TOLERANCE_PX); + } +} + +for (const hasTouch of [false, true]) { + test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => { + test.use({ hasTouch }); + + test("a divider moves as far as the pointer in the main row", async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 900 }); + const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + await clickNewChat(page); + await splitIntoFourPanes(page); + await expectDividersTrackPointer( + page, + DRAG_DISTANCE, + hasTouch ? dragDividerWithTouch : dragDivider, + ); + } finally { + await workspace.cleanup(); + } + }); + + test("dividers track the pointer after app creation, Explorer changes and reload", async ({ + page, + }) => { + await page.setViewportSize({ width: 1600, height: 900 }); + const creation = await createCreationScenario(page); + try { + await creation.openWorkspaceForm("local"); + await creation.submitPrompt("Reply briefly.", "Create"); + await creation.expectAssistantReply(); + await waitForWorkspaceTabsVisible(page); + await splitIntoFourPanes(page); + await expectDividersTrackPointer(page); + await page + .getByTestId("workspace-explorer-toggle") + .filter({ visible: true }) + .first() + .click(); + await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible(); + await expectDividersTrackPointer(page, -DRAG_DISTANCE); + await page + .getByTestId("workspace-explorer-toggle") + .filter({ visible: true }) + .first() + .click(); + await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible(); + await page.reload(); + await expect(splitDividers(page)).toHaveCount(3); + await expectDividersTrackPointer(page); + } finally { + await creation.cleanup(); + } + }); }); -}); +} diff --git a/packages/app/src/components/resize-handle.tsx b/packages/app/src/components/resize-handle.tsx index 6576728384..c3930688c2 100644 --- a/packages/app/src/components/resize-handle.tsx +++ b/packages/app/src/components/resize-handle.tsx @@ -49,7 +49,7 @@ export function ResizeHandle({ const { theme } = useUnistyles(); const finePointer = useHasFinePointer(); const pointerStatesRef = useRef(new Map()); - const touchDragRef = useRef(null); + const touchStateRef = useRef(null); const cursorBeforeDragRef = useRef(null); const hoverTimerRef = useRef | null>(null); const [active, setActive] = useState(false); @@ -151,23 +151,30 @@ export function ResizeHandle({ const touchGesture = useMemo(() => { const gesture = Gesture.Pan() .runOnJS(true) - .onBegin(() => setDragging(true)) - .onStart(() => { - touchDragRef.current = startResizeHandleDrag({ - sizes, - index, - preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes), - commit: (nextSizes) => onResizeSplit(groupId, nextSizes), - }); + .onBegin((event) => { + setDragging(true); + touchStateRef.current = { + containerSize, + pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY, + drag: startResizeHandleDrag({ + sizes, + index, + preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes), + commit: (nextSizes) => onResizeSplit(groupId, nextSizes), + }), + }; }) .onUpdate((event) => { - if (containerSize <= 0) return; - const translation = direction === "horizontal" ? event.translationX : event.translationY; - touchDragRef.current?.move(translation / containerSize); + const state = touchStateRef.current; + if (!state || state.containerSize <= 0) return; + // Gesture Handler resets web translation when the pan activates. Measure from + // the initial press so the activation distance is included on every platform. + const pointerCurrent = direction === "horizontal" ? event.absoluteX : event.absoluteY; + state.drag.move((pointerCurrent - state.pointerStart) / state.containerSize); }) - .onEnd(() => touchDragRef.current?.finish()) + .onEnd(() => touchStateRef.current?.drag.finish()) .onFinalize(() => { - touchDragRef.current = null; + touchStateRef.current = null; setDragging(false); });