diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx index 1c52abe5ce..fb59f734d8 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx +++ b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx @@ -305,6 +305,19 @@ beforeEach(() => { }); describe("SecondaryPanelLayout", () => { + it("registers the thread and right panel as one two-pane resize grid", () => { + renderLayout({ + isCompactViewport: false, + open: true, + renderPanel: createPanelRenderer(), + resetKey: "thread-grid", + }); + + expect( + screen.getByTestId("panel-group").dataset.splitResizeGridRoot, + ).toBe(""); + }); + it("preserves routed main content when the panel state identity changes", () => { const frames = installAnimationFrameQueue(); const view = renderLayout({ diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx index 3e0edb14f0..95fbe782ff 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx +++ b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx @@ -334,6 +334,7 @@ export function SecondaryPanelLayout({ { }); }); + it("snaps a right-panel divider to its equal two-pane boundary and persists it", () => { + 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"); + } + const grid = separator.parentElement; + if (grid === null) throw new Error("Expected a right-panel split grid"); + expect(separator.dataset.splitResizeGridBoundary).toBe("1"); + expect(separator.dataset.splitResizeGridCount).toBe("2"); + 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: 300, + right: 500, + top: 0, + width: 200, + x: 300, + y: 0, + toJSON: () => ({}), + }); + vi.spyOn(next, "getBoundingClientRect").mockReturnValue({ + bottom: 600, + height: 600, + left: 501, + right: 900, + top: 0, + width: 399, + x: 501, + y: 0, + toJSON: () => ({}), + }); + vi.spyOn(separator, "getBoundingClientRect").mockReturnValue({ + bottom: 600, + height: 600, + left: 500, + right: 501, + top: 0, + width: 1, + x: 500, + y: 0, + toJSON: () => ({}), + }); + vi.spyOn(grid, "getBoundingClientRect").mockReturnValue({ + bottom: 600, + height: 600, + left: 100, + right: 900, + top: 0, + width: 800, + x: 100, + y: 0, + toJSON: () => ({}), + }); + fireEvent.pointerDown(hitTarget, { clientX: 470, pointerId: 32 }); + fireEvent.pointerMove(hitTarget, { clientX: 518, pointerId: 32 }); + + expect(Number.parseFloat(previous.style.flexGrow)).toBeCloseTo( + 199.5 / 599, + 5, + ); + expect( + document.querySelector("[data-split-resize-snap-guide]") + ?.style.left, + ).toBe("500px"); + + fireEvent.pointerUp(hitTarget, { clientX: 518, 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(199.5 / 599, 5); + expect(persisted.layout.root.sizes[1]).toBeCloseTo(399.5 / 599, 5); + } + expect(document.querySelector("[data-split-resize-snap-guide]")).toBeNull(); + }); + + it("clears the resize overlay when the divider loses pointer capture", () => { + 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: () => ({}), + }); + + fireEvent.pointerDown(hitTarget, { clientX: 400.5, pointerId: 34 }); + expect(screen.getByTestId("iframe-drag-guard-overlay")).not.toBeNull(); + + fireEvent.lostPointerCapture(hitTarget, { pointerId: 34 }); + + expect(screen.queryByTestId("iframe-drag-guard-overlay")).toBeNull(); + }); + + 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..998c00093e 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 { @@ -558,6 +558,7 @@ function SidebarSplitTrackTree(props: SidebarSplitTrackTreeProps) { const node = props.node; return (
{index > 0 ? (