From 27b6f806be5359dbc1d4b11d096216e05682ccde Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 26 Aug 2026 03:55:00 -0700 Subject: [PATCH 01/15] Add split resize snap targets --- .../SidebarSplitContainer.test.tsx | 111 +++++++++++ .../secondary-panel/SidebarSplitContainer.tsx | 16 +- apps/app/src/lib/split-resize-snap.test.ts | 109 +++++++++++ apps/app/src/lib/split-resize-snap.ts | 185 ++++++++++++++++++ .../thread-detail/SplitThreadArea.test.tsx | 108 ++++++++++ .../views/thread-detail/SplitThreadArea.tsx | 41 +++- 6 files changed, 562 insertions(+), 8 deletions(-) create mode 100644 apps/app/src/lib/split-resize-snap.test.ts create mode 100644 apps/app/src/lib/split-resize-snap.ts diff --git a/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx b/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx index 6aa6f4e507..4cd02df376 100644 --- a/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx +++ b/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx @@ -20,6 +20,7 @@ import { createSidebarSplitState, focusSidebarPane, moveSidebarTab, + parseSidebarSplitState, serializeSidebarSplitState, sidebarSplitStorageKey, type SidebarSplitState, @@ -782,6 +783,116 @@ describe("SidebarSplitContainer", () => { }); }); + it("snaps a right-panel divider to a workspace divider and persists the exact fraction", () => { + persistState(createTwoPaneState()); + renderContainer({ + renderPane: ({ paneId }) =>
{paneId}
, + }); + const separator = screen.getByRole("separator"); + const hitTarget = separator.firstElementChild; + const previous = separator.previousElementSibling; + const next = separator.nextElementSibling; + if ( + !(hitTarget instanceof HTMLElement) || + !(previous instanceof HTMLElement) || + !(next instanceof HTMLElement) + ) { + throw new Error("Expected adjacent right-panel split items"); + } + Object.defineProperties(hitTarget, { + releasePointerCapture: { configurable: true, value: vi.fn() }, + setPointerCapture: { configurable: true, value: vi.fn() }, + }); + vi.spyOn(previous, "getBoundingClientRect").mockReturnValue({ + bottom: 600, + height: 600, + left: 0, + right: 400, + top: 0, + width: 400, + x: 0, + y: 0, + toJSON: () => ({}), + }); + vi.spyOn(next, "getBoundingClientRect").mockReturnValue({ + bottom: 600, + height: 600, + left: 401, + right: 801, + top: 0, + width: 400, + x: 401, + y: 0, + toJSON: () => ({}), + }); + vi.spyOn(separator, "getBoundingClientRect").mockReturnValue({ + bottom: 600, + height: 600, + left: 400, + right: 401, + top: 0, + width: 1, + x: 400, + y: 0, + toJSON: () => ({}), + }); + const workspaceDivider = document.createElement("div"); + workspaceDivider.dataset.splitResizeAxis = "x"; + workspaceDivider.getBoundingClientRect = () => ({ + bottom: 600, + height: 600, + left: 560, + right: 561, + top: 0, + width: 1, + x: 560, + y: 0, + toJSON: () => ({}), + }); + const workspaceSplit = document.createElement("div"); + workspaceSplit.appendChild(workspaceDivider); + document.body.appendChild(workspaceSplit); + + fireEvent.pointerDown(hitTarget, { clientX: 400.5, pointerId: 32 }); + fireEvent.pointerMove(hitTarget, { clientX: 567, pointerId: 32 }); + + expect(Number.parseFloat(previous.style.flexGrow)).toBeCloseTo(0.7, 5); + expect(workspaceDivider.dataset.splitResizeSnapTarget).toBe("true"); + expect( + document.querySelector("[data-split-resize-snap-guide]") + ?.style.left, + ).toBe("560.5px"); + + fireEvent.pointerUp(hitTarget, { clientX: 567, pointerId: 32 }); + + const persisted = parseSidebarSplitState( + window.localStorage.getItem(sidebarSplitStorageKey(PANEL_STATE_ID)), + TABS.map((tab) => tab.id), + "tab-a", + ); + expect(persisted.layout.root.type).toBe("split"); + if (persisted.layout.root.type === "split") { + expect(persisted.layout.root.sizes[0]).toBeCloseTo(0.7, 5); + expect(persisted.layout.root.sizes[1]).toBeCloseTo(0.3, 5); + } + expect(workspaceDivider.dataset.splitResizeSnapTarget).toBeUndefined(); + expect(document.querySelector("[data-split-resize-snap-guide]")).toBeNull(); + workspaceSplit.remove(); + }); + + it("keeps right-panel separators out of the tab order", () => { + persistState(createTwoPaneState()); + renderContainer({ + renderPane: ({ paneId }) =>
{paneId}
, + }); + + const separator = screen.getByRole("separator"); + fireEvent.keyDown(separator, { key: "ArrowRight" }); + + expect(separator.tabIndex).toBe(-1); + expect(document.querySelector("[data-split-resize-snap-guide]")).toBeNull(); + }); + it("does not resize or persist when the divider is pressed and released in place", () => { persistState(createTwoPaneState()); const storageKey = sidebarSplitStorageKey(PANEL_STATE_ID); diff --git a/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx b/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx index c47f569237..3a6f60d5c8 100644 --- a/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx +++ b/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx @@ -12,7 +12,6 @@ import { useAtomValue } from "jotai"; import { cn } from "@bb/shared-ui/lib/utils"; import { beginSplitDrag, type SplitDropTarget } from "@/lib/split-drag"; import { - clampSplitPairFraction, computePaneRects, countPanes, listPanes, @@ -23,6 +22,7 @@ import { type SplitSide, } from "@/lib/split-layout"; import { dimInactiveSplitsAtom } from "@/lib/split-layout/atoms"; +import { createSplitResizeSnapSession } from "@/lib/split-resize-snap"; import { IframeDragGuardOverlay } from "@/lib/iframe-drag-guard"; import { MACOS_APP_REGION_NO_DRAG_CLASS } from "@/lib/bb-desktop"; import { @@ -758,6 +758,10 @@ function SidebarSplitDivider({ const pair = createSidebarSplitResizePair(previous, next); hitTarget.setPointerCapture(pointerId); divider.dataset.dragging = "true"; + const snapSession = createSplitResizeSnapSession( + divider, + horizontal ? "x" : "y", + ); let pendingFraction: number | null = null; let receivedPointerMove = false; let finished = false; @@ -765,7 +769,11 @@ function SidebarSplitDivider({ const pointer = horizontal ? pointerEvent.clientX : pointerEvent.clientY; - const fraction = clampSplitPairFraction((pointer - start) / span); + const { fraction } = snapSession.resolve({ + end, + pointer, + start, + }); pendingFraction = fraction; pair.previous.style.flex = `${pair.total * fraction} 1 0px`; pair.next.style.flex = `${pair.total * (1 - fraction)} 1 0px`; @@ -787,6 +795,7 @@ function SidebarSplitDivider({ if (hitTarget.hasPointerCapture?.(pointerId)) { hitTarget.releasePointerCapture(pointerId); } + snapSession.clear(); onResizeDragChange(null); onPreviewResize(null); if (commit && pendingFraction !== null) { @@ -823,6 +832,7 @@ function SidebarSplitDivider({ return (