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
100 changes: 92 additions & 8 deletions apps/app/src/views/thread-detail/SplitThreadArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,14 @@ function stackingLayer(element: HTMLElement): number {
return 0;
}

function twoPaneLayout(focusedPaneId: "pane-1" | "pane-2"): SplitLayout {
function twoPaneLayout(
focusedPaneId: "pane-1" | "pane-2",
dir: "row" | "col" = "row",
): SplitLayout {
return {
root: {
type: "split",
dir: "row",
dir,
sizes: [0.5, 0.5],
children: [
{ type: "pane", paneId: "pane-1", content: threadContent("thr-a") },
Expand Down Expand Up @@ -822,7 +825,7 @@ describe("SplitThreadArea", () => {
expect(separator.classList).toContain("w-px");
expect(separator.classList).toContain("bg-border-seam");
expect(separator.classList).not.toContain("w-1.5");
expect(separator.firstElementChild?.classList).toContain("-inset-x-1.5");
expect(separator.firstElementChild?.classList).toContain("w-3");
});

it("keeps the divider above pane headers so stacked splits stay resizable", () => {
Expand All @@ -847,7 +850,8 @@ describe("SplitThreadArea", () => {
// the divider swallows its grab target and blocks vertical resizing.
const separator = screen.getByRole("separator");
expect(separator.classList).toContain("h-px");
expect(separator.firstElementChild?.classList).toContain("-inset-y-1.5");
expect(separator.firstElementChild?.classList).toContain("h-3");
expect(separator.firstElementChild?.classList).toContain("w-full");
const dividerLayer = stackingLayer(separator);
const lowerHeader = document
.querySelector<HTMLElement>('[data-split-pane-id="pane-2"]')
Expand Down Expand Up @@ -936,7 +940,13 @@ describe("SplitThreadArea", () => {
throw new Error("Expected adjacent split flex items");
}

Object.defineProperty(separator, "setPointerCapture", {
const hitTarget = separator.querySelector<HTMLElement>(
"[data-split-divider-hit-target]",
);
if (hitTarget === null) {
throw new Error("Expected split divider hit target");
}
Object.defineProperty(hitTarget, "setPointerCapture", {
configurable: true,
value: vi.fn(),
});
Expand Down Expand Up @@ -1004,23 +1014,97 @@ describe("SplitThreadArea", () => {
return root.sizes;
};

fireEvent.pointerDown(separator, { clientX: 403, pointerId: 1 });
fireEvent.pointerMove(separator, { clientX: 564.2, pointerId: 1 });
fireEvent.pointerDown(hitTarget, { clientX: 403, pointerId: 1 });
fireEvent.pointerMove(hitTarget, { clientX: 564.2, pointerId: 1 });

expect(splitSizes()).toEqual([0.5, 0.5]);
expect(offscreenRow.style.contentVisibility).toBe("hidden");
expect(offscreenRow.style.containIntrinsicBlockSize).toBe("50px");
expect(Number.parseFloat(previous.style.flexGrow)).toBeCloseTo(0.7, 5);
expect(Number.parseFloat(next.style.flexGrow)).toBeCloseTo(0.3, 5);

fireEvent.pointerUp(separator, { clientX: 564.2, pointerId: 1 });
fireEvent.pointerUp(hitTarget, { clientX: 564.2, pointerId: 1 });

expect(splitSizes()[0]).toBeCloseTo(0.7, 5);
expect(splitSizes()[1]).toBeCloseTo(0.3, 5);
expect(offscreenRow.style.contentVisibility).toBe("");
expect(offscreenRow.style.containIntrinsicBlockSize).toBe("");
});

it("gives a top-to-bottom split a full-width drag target without thickening its seam", () => {
const store = renderSplitArea({
path: threadPath("thr-a"),
layout: twoPaneLayout("pane-1", "col"),
});
const separator = screen.getByRole("separator");
expect(separator.getAttribute("aria-orientation")).toBe("horizontal");
expect(separator.classList).toContain("h-px");
expect(separator.classList).not.toContain("h-3");

const hitTarget = separator.querySelector<HTMLElement>(
"[data-split-divider-hit-target]",
);
const previous = separator.previousElementSibling;
const next = separator.nextElementSibling;
if (
hitTarget === null ||
!(previous instanceof HTMLElement) ||
!(next instanceof HTMLElement)
) {
throw new Error("Expected a divider hit target between split panes");
}
expect(hitTarget.classList).toContain("h-3");
expect(hitTarget.classList).toContain("w-full");
expect(hitTarget.classList).toContain("cursor-row-resize");

Object.defineProperty(hitTarget, "setPointerCapture", {
configurable: true,
value: vi.fn(),
});
vi.spyOn(previous, "getBoundingClientRect").mockReturnValue({
bottom: 300,
height: 300,
left: 0,
right: 800,
top: 0,
width: 800,
x: 0,
y: 0,
toJSON: () => ({}),
});
vi.spyOn(next, "getBoundingClientRect").mockReturnValue({
bottom: 606,
height: 300,
left: 0,
right: 800,
top: 306,
width: 800,
x: 0,
y: 306,
toJSON: () => ({}),
});

fireEvent.pointerDown(hitTarget, { clientY: 303, pointerId: 1 });
fireEvent.pointerMove(hitTarget, { clientY: 424.2, pointerId: 1 });

expect(Number.parseFloat(previous.style.flexGrow)).toBeCloseTo(0.7, 5);
expect(Number.parseFloat(next.style.flexGrow)).toBeCloseTo(0.3, 5);
expect(store.get(splitLayoutAtom)?.root).toMatchObject({
dir: "col",
sizes: [0.5, 0.5],
});

fireEvent.pointerUp(hitTarget, { clientY: 424.2, pointerId: 1 });

const resizedRoot = store.get(splitLayoutAtom)?.root;
expect(resizedRoot).toMatchObject({ dir: "col" });
if (resizedRoot?.type !== "split") {
throw new Error("Expected resized split layout");
}
expect(resizedRoot.sizes[0]).toBeCloseTo(0.7, 5);
expect(resizedRoot.sizes[1]).toBeCloseTo(0.3, 5);
});

it("keeps the merged toggle absolute and places a visible shortcut hint below pane actions", async () => {
commandPresentationState.isModifierHeld = true;
commandPresentationState.shortcut = {
Expand Down
30 changes: 19 additions & 11 deletions apps/app/src/views/thread-detail/SplitThreadArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1244,7 +1244,11 @@ function SplitDivider({ dir, hidden, onResize }: SplitDividerProps) {
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const divider = event.currentTarget;
const hitTarget = event.currentTarget;
const divider = hitTarget.parentElement;
if (!(divider instanceof HTMLDivElement)) {
return;
}
const previous = divider.previousElementSibling;
const next = divider.nextElementSibling;
if (
Expand All @@ -1264,7 +1268,7 @@ function SplitDivider({ dir, hidden, onResize }: SplitDividerProps) {
return;
}

divider.setPointerCapture(event.pointerId);
hitTarget.setPointerCapture(event.pointerId);
divider.dataset.dragging = "true";

const previousGrow = Number.parseFloat(
Expand Down Expand Up @@ -1300,9 +1304,9 @@ function SplitDivider({ dir, hidden, onResize }: SplitDividerProps) {
if (finished) return;
finished = true;
delete divider.dataset.dragging;
divider.removeEventListener("pointermove", onMove);
divider.removeEventListener("pointerup", onUp);
divider.removeEventListener("pointercancel", onCancel);
hitTarget.removeEventListener("pointermove", onMove);
hitTarget.removeEventListener("pointerup", onUp);
hitTarget.removeEventListener("pointercancel", onCancel);
restoreTimelineRows();
if (commit && pendingFraction !== null) {
// Commit once so the imperative flex values above become the
Expand All @@ -1315,9 +1319,9 @@ function SplitDivider({ dir, hidden, onResize }: SplitDividerProps) {
};
const onUp = () => finish(true);
const onCancel = () => finish(false);
divider.addEventListener("pointermove", onMove);
divider.addEventListener("pointerup", onUp);
divider.addEventListener("pointercancel", onCancel);
hitTarget.addEventListener("pointermove", onMove);
hitTarget.addEventListener("pointerup", onUp);
hitTarget.addEventListener("pointercancel", onCancel);
},
[horizontal, onResize],
);
Expand All @@ -1326,7 +1330,6 @@ function SplitDivider({ dir, hidden, onResize }: SplitDividerProps) {
<div
role="separator"
aria-orientation={horizontal ? "vertical" : "horizontal"}
onPointerDown={handlePointerDown}
className={cn(
// A one-pixel seam between flush tiles — squared ends, no rounding,
// only BETWEEN splits (outer edges stay flush). Hover/drag warms it as
Expand All @@ -1344,9 +1347,14 @@ function SplitDivider({ dir, hidden, onResize }: SplitDividerProps) {
)}
>
<div
aria-hidden
data-split-divider-hit-target=""
onPointerDown={handlePointerDown}
className={cn(
"absolute",
horizontal ? "-inset-x-1.5 inset-y-0" : "inset-x-0 -inset-y-1.5",
"absolute z-10 touch-none bg-transparent",
horizontal
? "-left-1.5 top-0 h-full w-3 cursor-col-resize"
: "left-0 -top-1.5 h-3 w-full cursor-row-resize",
)}
/>
</div>
Expand Down
Loading