Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export function SecondaryPanelLayout({
<PanelGroup
key={panelGroupKey ?? resetKey}
ref={horizontalPanelGroupRef}
data-split-resize-grid-root=""
direction="horizontal"
className="@container h-full min-w-0 flex-1"
// A clipped group cannot be programmatically scrolled by an iframe's
Expand Down
160 changes: 160 additions & 0 deletions apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
createSidebarSplitState,
focusSidebarPane,
moveSidebarTab,
parseSidebarSplitState,
serializeSidebarSplitState,
sidebarSplitStorageKey,
type SidebarSplitState,
Expand Down Expand Up @@ -782,6 +783,165 @@ describe("SidebarSplitContainer", () => {
});
});

it("snaps a right-panel divider to its equal two-pane boundary and persists it", () => {
persistState(createTwoPaneState());
renderContainer({
renderPane: ({ paneId }) => <div>{paneId}</div>,
});
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<HTMLElement>("[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 }) => <div>{paneId}</div>,
});
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 }) => <div>{paneId}</div>,
});

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);
Expand Down
39 changes: 36 additions & 3 deletions apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -558,6 +558,7 @@ function SidebarSplitTrackTree(props: SidebarSplitTrackTreeProps) {
const node = props.node;
return (
<div
data-split-resize-grid-root=""
className={cn(
"pointer-events-none flex min-h-0 min-w-0 flex-1",
node.dir === "row" ? "flex-row" : "flex-col",
Expand All @@ -567,6 +568,8 @@ function SidebarSplitTrackTree(props: SidebarSplitTrackTreeProps) {
<Fragment key={sidebarSplitSubtreeKey(child)}>
{index > 0 ? (
<SidebarSplitDivider
boundaryIndex={index}
childCount={node.children.length}
dir={node.dir}
hidden={props.maximizedPaneId !== null}
onResize={(fraction) =>
Expand Down Expand Up @@ -712,12 +715,16 @@ function SidebarSplitLeaf(props: SidebarSplitLeafProps) {
}

function SidebarSplitDivider({
boundaryIndex,
childCount,
dir,
hidden,
onResize,
onResizeDragChange,
onPreviewResize,
}: {
boundaryIndex: number;
childCount: number;
dir: "row" | "col";
hidden: boolean;
onResize: (fraction: number) => void;
Expand Down Expand Up @@ -758,14 +765,24 @@ function SidebarSplitDivider({
const pair = createSidebarSplitResizePair(previous, next);
hitTarget.setPointerCapture(pointerId);
divider.dataset.dragging = "true";
const snapSession = createSplitResizeSnapSession(
divider,
horizontal ? "x" : "y",
{ boundaryIndex, childCount },
);
snapSession.resolve({ end, pointer: pointerDownPosition, start });
let pendingFraction: number | null = null;
let receivedPointerMove = false;
let finished = false;
const applyPointerPosition = (pointerEvent: PointerEvent) => {
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`;
Expand All @@ -784,9 +801,11 @@ function SidebarSplitDivider({
hitTarget.removeEventListener("pointermove", move);
hitTarget.removeEventListener("pointerup", onUp);
hitTarget.removeEventListener("pointercancel", cancel);
hitTarget.removeEventListener("lostpointercapture", lostCapture);
if (hitTarget.hasPointerCapture?.(pointerId)) {
hitTarget.releasePointerCapture(pointerId);
}
snapSession.clear();
onResizeDragChange(null);
onPreviewResize(null);
if (commit && pendingFraction !== null) {
Expand All @@ -812,17 +831,31 @@ function SidebarSplitDivider({
if (cancelEvent.pointerId !== pointerId) return;
finish(false);
};
const lostCapture = (lostEvent: PointerEvent) => {
if (lostEvent.pointerId !== pointerId) return;
finish(false);
};
hitTarget.addEventListener("pointermove", move);
hitTarget.addEventListener("pointerup", onUp);
hitTarget.addEventListener("pointercancel", cancel);
hitTarget.addEventListener("lostpointercapture", lostCapture);
finishResizeRef.current = () => finish(false);
onResizeDragChange(horizontal ? "col-resize" : "row-resize");
},
[horizontal, onPreviewResize, onResize, onResizeDragChange],
[
boundaryIndex,
childCount,
horizontal,
onPreviewResize,
onResize,
onResizeDragChange,
],
);
return (
<div
role="separator"
data-split-resize-grid-boundary={boundaryIndex}
data-split-resize-grid-count={childCount}
aria-hidden={hidden || undefined}
aria-label={
horizontal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ export function ThreadSecondaryPanel({
const {
handleSecondaryPanelDragging: handleResizeDragging,
handleSecondaryPanelResize,
handleSecondaryPanelResizePointerDownCapture,
persistedWidthPercent,
secondaryPanelRef: panelRef,
secondaryResizablePanelRef: resizablePanelRef,
Expand Down Expand Up @@ -1185,6 +1186,7 @@ export function ThreadSecondaryPanel({
isConversationCollapsed={isConversationCollapsed}
matchesSplitDividers={hostLayout !== null}
onDragging={handleSecondaryPanelDragging}
onPointerDown={handleSecondaryPanelResizePointerDownCapture}
/>
<Panel
ref={resizablePanelRef}
Expand Down Expand Up @@ -1325,13 +1327,15 @@ interface SecondaryPanelResizeHandleProps {
*/
matchesSplitDividers: boolean;
onDragging: SecondaryPanelDraggingHandler;
onPointerDown: (event: PointerEvent) => void;
}

function SecondaryPanelResizeHandle({
isOpen,
isConversationCollapsed,
matchesSplitDividers,
onDragging,
onPointerDown,
}: SecondaryPanelResizeHandleProps) {
const isResizing = useAtomValue(threadSecondaryPanelResizingAtom);
return (
Expand All @@ -1342,6 +1346,8 @@ function SecondaryPanelResizeHandle({
// that state.
disabled={!isOpen || isConversationCollapsed}
onDragging={onDragging}
onPointerDownCapture={(event) => onPointerDown(event.nativeEvent)}
data-panel-resize-snap-handle=""
hitAreaMargins={PANEL_RESIZE_HIT_AREA_MARGINS}
className={cn(
"group relative shrink-0 overflow-visible transition-[width,opacity,background-color]",
Expand Down
Loading
Loading