From c4b4516890cc8d5c78911a314cfb2c1257a26db8 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:10:25 +0000 Subject: [PATCH] Keep split dividers under the pointer across mouse and gesture input (#6011) * fix(app): keep split dividers under the mouse before Explorer is first opened The main row only left the Explorer pane out once the Explorer had been shown, because the split container read the registered pane id directly. Before that, the hidden Explorer stayed in the row with its share of the sizes, and every divider drag moved about 1.28 times the mouse distance. Resolve the pane the same way the workspace screen does, so the default Explorer pane is left out from the start. Closes #5998 * 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. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../browser/split-pane-drag-tracking.spec.ts | 134 ++++++++++++++++++ packages/app/src/components/resize-handle.tsx | 35 +++-- .../app/src/components/split-container.tsx | 5 +- 3 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 packages/app/e2e/browser/split-pane-drag-tracking.spec.ts diff --git a/packages/app/e2e/browser/split-pane-drag-tracking.spec.ts b/packages/app/e2e/browser/split-pane-drag-tracking.spec.ts new file mode 100644 index 0000000000..90420c6fd9 --- /dev/null +++ b/packages/app/e2e/browser/split-pane-drag-tracking.spec.ts @@ -0,0 +1,134 @@ +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"; + +const DRAG_DISTANCE = -40; +const TOLERANCE_PX = 2; + +function splitDividers(page: Page): Locator { + return page.getByTestId("workspace-split-resize-handle").filter({ visible: true }); +} + +async function dividerX(divider: Locator): Promise { + const box = await divider.boundingBox(); + if (!box) throw new Error("Divider has no bounding box"); + return box.x; +} + +async function dragDivider(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; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + deltaX, y, { steps: 8 }); + await page.mouse.up(); +} + +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); }); diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 660b5f4e6b..278317243a 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -48,6 +48,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs- import { collectAllPanes, createDefaultLayout, + selectExplorerSidebarPaneId, useWorkspaceLayoutStore, type SplitNode, type SplitPane, @@ -183,8 +184,8 @@ export function SplitContainer({ } | null>(null); const maximizedPaneId = maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null; - const explorerSidebarPaneId = useWorkspaceLayoutStore( - (state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null, + const explorerSidebarPaneId = useWorkspaceLayoutStore((state) => + selectExplorerSidebarPaneId(state, workspaceKey), ); const mainRoot = useMemo(