authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 20:12:00-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 20:12:00-07:00
log4e969f3731aaa590889d74274b3b6dced6d86291
tree16c7e578405b586c1dfb07e6aaafb90c266006ca
parentecf3716fd190005b1552957b3c7880ab849a0a65
signature Signed by PGP key B5690EEEBB952194

fix(web): keep sidebar drag dividers clear and gestures smooth (#10453)


6 files changed, 704 insertions(+), 142 deletions(-)

apps/web/src/components/Sidebar.drag.test.ts+174-69
...@@ -1,8 +1,13 @@...@@ -1,8 +1,13 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
2import { closestCenter, type CollisionDetection } from "@dnd-kit/core";2import { closestCenter, type CollisionDetection } from "@dnd-kit/core";
3import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";3import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";
4import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag";
5import {4import {
5 createSidebarCollisionDetection,
6 createSidebarSortingStrategy,
7 restrictBelowSidebarLabel,
8} from "./Sidebar.drag";
9import {
10 resolveSidebarDropTarget,
6 sidebarListItemId,11 sidebarListItemId,
7 sidebarMarkerId,12 sidebarMarkerId,
8 type SidebarListItem,13 type SidebarListItem,
...@@ -35,7 +40,7 @@ function layout(...@@ -35,7 +40,7 @@ function layout(
35 ? (item.section === "pinned" || item.section === "active" ? cardHeight : 36) * scale40 ? (item.section === "pinned" || item.section === "active" ? cardHeight : 36) * scale
36 : item.marker === "pinned-header" || item.marker === "pinned-divider"41 : item.marker === "pinned-header" || item.marker === "pinned-divider"
37 ? 042 ? 0
38 : (item.marker.endsWith("placeholder") ? 36 : 32) * scale;43 : (item.marker.endsWith("placeholder") ? 0 : 32) * scale;
39 const rect = { top, height, bottom: top + height, left: 0, right: 260, width: 260 };44 const rect = { top, height, bottom: top + height, left: 0, right: 260, width: 260 };
40 top += height + 1;45 top += height + 1;
41 return rect;46 return rect;
...@@ -119,69 +124,92 @@ describe("sidebar collision detection", () => {...@@ -119,69 +124,92 @@ describe("sidebar collision detection", () => {
119 expect(detector(collisionArgs())[0]?.id).toBe("blocked");124 expect(detector(collisionArgs())[0]?.id).toBe("blocked");
120 });125 });
121126
122 function clampedArgs() {
123 const args = collisionArgs();
124 const pinned = args.droppableRects.get(sidebarMarkerId("pinned-header"))!;
125 const source = args.droppableRects.get("source")!;
126 const collisionRect = {
127 ...source,
128 top: pinned.top - 8,
129 bottom: pinned.top - 8 + source.height,
130 };
131 return {
132 ...args,
133 active: {
134 ...args.active,
135 rect: { current: { initial: source, translated: collisionRect } },
136 },
137 collisionRect,
138 pointerCoordinates: { x: pinned.left + pinned.width / 2, y: pinned.top + 8 },
139 };
140 }
141
142 it("reaches empty Pins with an upward pointer while the card is clamped at the top", () => {
143 const args = clampedArgs();
144 const detector = createSidebarCollisionDetection(() => true, {
145 emptyPins: true,
146 activationY: args.pointerCoordinates.y + 6,
147 });
148 expect(args.droppableRects.get(sidebarMarkerId("pinned-header"))?.height).toBe(0);
149 expect(closestCenter(args)[0]?.id).toBe("source");
150 expect(detector(args)[0]?.id).toBe(sidebarMarkerId("pinned-header"));
151 });
152
153 it.each([127 it.each([
154 { reason: "below the boundary cue", x: 130, y: 109, activationY: 140, emptyPins: true },128 { sourceSection: "active", pins: 0 },
155 { reason: "left of the list", x: -1, y: 108, activationY: 140, emptyPins: true },129 { sourceSection: "active", pins: 1 },
156 { reason: "right of the list", x: 261, y: 108, activationY: 140, emptyPins: true },130 { sourceSection: "pinned", pins: 1 },
157 { reason: "less than 6px upward", x: 130, y: 108, activationY: 113, emptyPins: true },131 { sourceSection: "settled", pins: 1 },
158 { reason: "without an activation point", x: 130, y: 108, activationY: null, emptyPins: true },132 ] as const)(
159 { reason: "with populated Pins", x: 130, y: 108, activationY: 140, emptyPins: false },133 "switches on crossing the divider row from $sourceSection with $pins pins",
160 ])("keeps ordinary collision behavior $reason", ({ x, y, activationY, emptyPins }) => {134 ({ sourceSection, pins }) => {
161 const detector = createSidebarCollisionDetection(() => true, { emptyPins, activationY });135 const items = [
162 const args = { ...clampedArgs(), pointerCoordinates: { x, y } };136 pinnedHeader,
163 expect(detector(args)[0]?.id).toBe("source");137 ...(pins ? [thread("p", "pinned")] : []),
164 });138 ...(sourceSection === "pinned" ? [thread("source", "pinned")] : []),
165139 divider,
166 it("keeps ordinary collision behavior without pointer coordinates", () => {140 thread("a", "active"),
167 const detector = createSidebarCollisionDetection(() => true, {141 ...(sourceSection === "active" ? [thread("source", "active")] : []),
168 emptyPins: true,142 settledHeader,
169 activationY: 140,143 ...(sourceSection === "settled" ? [thread("source", "settled")] : []),
170 });144 ];
171 expect(detector({ ...clampedArgs(), pointerCoordinates: null })[0]?.id).toBe("source");145 const { rects, activeIndex } = layout(items, "source", "a");
172 });146 const sourceRect = rects[activeIndex]!;
173147 let boundaryTop = 300;
174 it("validates the empty Pins override and caches an unsupported result", () => {148 const boundaryNode = {
175 const isValid = vi.fn(() => false);149 querySelector: () => ({
176 const detector = createSidebarCollisionDetection(isValid, {150 getBoundingClientRect: () => ({
177 emptyPins: true,151 top: boundaryTop,
178 activationY: 140,152 bottom: boundaryTop + 16,
179 });153 left: 0,
180 const args = clampedArgs();154 right: 260,
181 expect(detector(args).map((collision) => collision.id)).toEqual(["source"]);155 }),
182 expect(detector(args).map((collision) => collision.id)).toEqual(["source"]);156 }),
183 expect(isValid.mock.calls).toEqual([[sidebarMarkerId("pinned-header")]]);157 } as unknown as HTMLElement;
184 });158 const detector = createSidebarCollisionDetection(() => true, {
159 items,
160 activationY: sourceSection === "pinned" ? 200 : 600,
161 });
162 const at = (center: number) => {
163 const collisionRect = {
164 ...sourceRect,
165 top: center - sourceRect.height / 2,
166 bottom: center + sourceRect.height / 2,
167 };
168 const args = {
169 ...collisionArgs(),
170 active: {
171 id: "source",
172 data: { current: {} },
173 rect: { current: { initial: sourceRect, translated: collisionRect } },
174 },
175 collisionRect,
176 pointerCoordinates: { x: 130, y: center },
177 droppableRects: new Map(
178 items.map((item, index) => [sidebarListItemId(item), rects[index]!]),
179 ),
180 droppableContainers: items.map((item, index) => ({
181 id: sidebarListItemId(item),
182 key: sidebarListItemId(item),
183 disabled: false,
184 data: { current: {} },
185 node: {
186 current:
187 item === divider
188 ? boundaryNode
189 : item === settledHeader
190 ? ({ getBoundingClientRect: () => ({ top: 600 }) } as unknown as HTMLElement)
191 : null,
192 },
193 rect: { current: rects[index]! },
194 })),
195 };
196 const over = detector(args)[0];
197 return over ? resolveSidebarDropTarget(items, "source", String(over.id))?.section : null;
198 };
199 expect(at(330)).toBe("active");
200 expect(at(317)).toBe("active");
201 expect(at(316)).toBe("pinned");
202 // The preview moves the divider; a stationary pointer must not undo the drop target.
203 boundaryTop = 400;
204 expect(at(316)).toBe("pinned");
205 expect(at(399)).toBe("pinned");
206 expect(at(400)).toBe("active");
207 boundaryTop = 300;
208 expect(at(400)).toBe("active");
209 expect(at(317)).toBe("active");
210 expect(at(316)).toBe("pinned");
211 },
212 );
185213
186 it("returns no collision if an unsupported target has no source fallback", () => {214 it("returns no collision if an unsupported target has no source fallback", () => {
187 const args = collisionArgs();215 const args = collisionArgs();
...@@ -289,7 +317,7 @@ describe("sidebar drag projection", () => {...@@ -289,7 +317,7 @@ describe("sidebar drag projection", () => {
289 }317 }
290 });318 });
291319
292 it("leaves canonically sorted settled peers in place", () => {320 it("preserves settled order while opening the zero-height Active target", () => {
293 const items = [321 const items = [
294 pinnedHeader,322 pinnedHeader,
295 divider,323 divider,
...@@ -303,7 +331,10 @@ describe("sidebar drag projection", () => {...@@ -303,7 +331,10 @@ describe("sidebar drag projection", () => {
303 "second",331 "second",
304 "first",332 "first",
305 );333 );
306 expect([...result.values()]).toEqual(items.map(() => stationary));334 expect(result.get(sidebarMarkerId("active-placeholder"))).toEqual(stationary);
335 expect(result.get(sidebarMarkerId("settled-header"))).toEqual({ ...stationary, y: 36 });
336 expect(result.get("first")).toEqual({ ...stationary, y: 36 });
337 expect(result.get("second")).toEqual(stationary);
307 });338 });
308339
309 it.each([340 it.each([
...@@ -357,6 +388,36 @@ describe("sidebar drag projection", () => {...@@ -357,6 +388,36 @@ describe("sidebar drag projection", () => {
357 expect(result.get("s")?.y).toBe(32);388 expect(result.get("s")?.y).toBe(32);
358 });389 });
359390
391 it.each(["s1", "missing-target"])(
392 "keeps label clearance when a settled drag is over %s",
393 (over) => {
394 const items = [
395 pinnedHeader,
396 thread("p", "pinned"),
397 divider,
398 thread("a", "active"),
399 settledHeader,
400 thread("s1", "settled"),
401 thread("s2", "settled"),
402 ];
403 const result = preview(
404 {
405 items,
406 settledOrder: ["s1", "s2"],
407 settledExpanded: true,
408 boundaryLabelHeight: 24,
409 },
410 "s2",
411 over,
412 );
413 expect(result.get("p")?.y).toBe(24);
414 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(24);
415 expect(result.get("a")?.y).toBe(48);
416 expect(result.get("s1")?.y).toBe(48);
417 expect(result.get("s2")).toEqual(stationary);
418 },
419 );
420
360 it("stacks the labels with their gaps when the pinned section is empty", () => {421 it("stacks the labels with their gaps when the pinned section is empty", () => {
361 const items = [422 const items = [
362 pinnedHeader,423 pinnedHeader,
...@@ -409,8 +470,8 @@ describe("sidebar drag projection", () => {...@@ -409,8 +470,8 @@ describe("sidebar drag projection", () => {
409 });470 });
410471
411 it.each([472 it.each([
412 ["p", -83, -37],473 ["p", -83, -1],
413 ["s", 0, 46],474 ["s", 0, 82],
414 ] as const)(475 ] as const)(
415 "replaces the empty Active target when %s enters",476 "replaces the empty Active target when %s enters",
416 (active, dividerOffset, settledOffset) => {477 (active, dividerOffset, settledOffset) => {
...@@ -561,7 +622,7 @@ describe("sidebar drag projection", () => {...@@ -561,7 +622,7 @@ describe("sidebar drag projection", () => {
561 expect(strategy({ ...smaller, index: 2 })?.y).toBe(-62.5);622 expect(strategy({ ...smaller, index: 2 })?.y).toBe(-62.5);
562 });623 });
563624
564 it("uses measured placeholder sizing when card height differs from its default", () => {625 it("uses shelf height for empty target sizing when card height differs from its default", () => {
565 const items = [626 const items = [
566 pinnedHeader,627 pinnedHeader,
567 thread("p", "pinned"),628 thread("p", "pinned"),
...@@ -674,3 +735,47 @@ describe("sidebar drag projection", () => {...@@ -674,3 +735,47 @@ describe("sidebar drag projection", () => {
674 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(46);735 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(46);
675 });736 });
676});737});
738
739describe("lifted card clearance", () => {
740 const rect = (top: number, height: number) => ({
741 top,
742 bottom: top + height,
743 height,
744 left: 0,
745 right: 260,
746 width: 260,
747 });
748 const apply = (cardTop: number, cardHeight: number, y: number, listTop = 136, offset = 32) =>
749 restrictBelowSidebarLabel(
750 {
751 transform: { ...stationary, y },
752 containerNodeRect: rect(listTop, 500),
753 draggingNodeRect: rect(cardTop, cardHeight),
754 activatorEvent: null,
755 active: null,
756 activeNodeRect: null,
757 over: null,
758 overlayNodeRect: null,
759 scrollableAncestors: [],
760 scrollableAncestorRects: [],
761 windowRect: null,
762 },
763 offset,
764 );
765
766 it.each([36, 82])("keeps a %ipx row below empty Pins even past the top edge", (height) => {
767 for (const pointerY of [150, 136, 100, 0]) {
768 const transform = apply(511, height, pointerY - 529);
769 expect(511 + transform.y).toBe(168);
770 }
771 });
772
773 it("preserves pointer movement below the label", () => {
774 expect(apply(511, 36, -200).y).toBe(-200);
775 });
776
777 it("follows the list when it scrolls and includes content preceding Pins", () => {
778 expect(511 + apply(511, 36, -500, 96).y).toBe(128);
779 expect(511 + apply(511, 36, -500, 136, 114).y).toBe(250);
780 });
781});
apps/web/src/components/Sidebar.drag.ts+73-29
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { closestCenter, type CollisionDetection } from "@dnd-kit/core";1import { closestCenter, type CollisionDetection, type Modifier } from "@dnd-kit/core";
2import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";2import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";
3import {3import {
4 resolveSidebarDropTarget,4 resolveSidebarDropTarget,
...@@ -14,36 +14,77 @@ const hidden = { ...stationary, scaleY: 0 };...@@ -14,36 +14,77 @@ const hidden = { ...stationary, scaleY: 0 };
14type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>;14type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>;
15type Layout = Parameters<SortingStrategy>[0];15type Layout = Parameters<SortingStrategy>[0];
1616
17/** Keep the lifted card below the Pins label, including when Pins is empty.
18 * The container rect follows scrolling; the offset is measured once at pickup. */
19export function restrictBelowSidebarLabel(
20 { transform, containerNodeRect, draggingNodeRect }: Parameters<Modifier>[0],
21 offset: number,
22) {
23 if (!containerNodeRect || !draggingNodeRect) return transform;
24 const minimumY = containerNodeRect.top + offset - draggingNodeRect.top;
25 return transform.y < minimumY ? { ...transform, y: minimumY } : transform;
26}
27
17/** Reject the nearest unsupported target without selecting another section.28/** Reject the nearest unsupported target without selecting another section.
18 * Recreate this detector when drop eligibility changes. */29 * Recreate this detector when drop eligibility changes. */
19export function createSidebarCollisionDetection(30export function createSidebarCollisionDetection(
20 isValidTarget: (id: string) => boolean,31 isValidTarget: (id: string) => boolean,
21 options: { emptyPins?: boolean; activationY?: number | null } = {},32 options: {
33 items?: readonly SidebarListItem[];
34 activationY?: number | null;
35 } = {},
22): CollisionDetection {36): CollisionDetection {
23 const validity = new Map<string, boolean>();37 const validity = new Map<string, boolean>();
24 const pinnedHeaderId = sidebarMarkerId("pinned-header");38 const sections = new Map<string, SidebarSection | null>();
39 let previousPointerY = options.activationY;
40 let boundarySection: "pinned" | "active" | undefined;
25 return (args) => {41 return (args) => {
26 let collisions = closestCenter(args);42 let collisions = closestCenter(args);
27 const pinnedRect = options.emptyPins ? args.droppableRects.get(pinnedHeaderId) : undefined;
28 const pointer = args.pointerCoordinates;43 const pointer = args.pointerCoordinates;
29 // The card itself is clamped by the scroll container. An upward pointer44 const items = options.items;
30 // gesture can still reach the empty pinned boundary without reserving a row.45 const source = items?.find((item) => item.kind === "thread" && item.key === args.active.id);
31 if (46 const boundary = args.droppableContainers
32 pinnedRect &&47 .find((container) => container.id === sidebarMarkerId("pinned-divider"))
33 pointer &&48 ?.node.current?.querySelector(".sidebar-drag-boundary-label")
34 options.activationY != null &&49 ?.getBoundingClientRect();
35 pointer.y <= options.activationY - 6 &&50 if (items && boundary && source?.kind === "thread" && pointer) {
36 pointer.y <= pinnedRect.top + 8 &&51 boundarySection ??= source.section === "pinned" ? "pinned" : "active";
37 pointer.x >= pinnedRect.left &&52 // Use the visible divider row, including its sortable translation.
38 pointer.x <= pinnedRect.right53 // Only pointer movement can change sections: opening the destination
39 ) {54 // moves this row, but must not toggle a stationary gesture back.
40 const pinned = collisions.find((collision) => collision.id === pinnedHeaderId);55 const previousY = previousPointerY ?? pointer.y;
41 if (pinned) {56 previousPointerY = pointer.y;
42 collisions = [pinned, ...collisions.filter((collision) => collision !== pinned)];57 if (pointer.x >= boundary.left && pointer.x <= boundary.right) {
58 if (pointer.y < previousY && pointer.y <= boundary.bottom) boundarySection = "pinned";
59 else if (pointer.y > previousY && pointer.y >= boundary.top) boundarySection = "active";
60 const nextHeader =
61 args.droppableContainers.find(
62 (container) => container.id === sidebarMarkerId("snoozed-header"),
63 ) ??
64 args.droppableContainers.find(
65 (container) => container.id === sidebarMarkerId("settled-header"),
66 );
67 const activeBottom = nextHeader?.node.current?.getBoundingClientRect().top;
68 if (boundarySection === "pinned" || (activeBottom != null && pointer.y < activeBottom)) {
69 const target = collisions.find((collision) => {
70 const id = String(collision.id);
71 if (!sections.has(id)) {
72 sections.set(
73 id,
74 resolveSidebarDropTarget(items, String(args.active.id), id)?.section ?? null,
75 );
76 }
77 return sections.get(id) === boundarySection;
78 });
79 if (target)
80 collisions = [target, ...collisions.filter((collision) => collision !== target)];
81 }
43 }82 }
44 }83 }
45 const nearest = collisions[0];84 const nearest = collisions[0];
46 if (!nearest || nearest.id === args.active.id) return collisions;85 if (!nearest || nearest.id === args.active.id) {
86 return collisions;
87 }
47 const id = String(nearest.id);88 const id = String(nearest.id);
48 const valid = validity.get(id) ?? isValidTarget(id);89 const valid = validity.get(id) ?? isValidTarget(id);
49 validity.set(id, valid);90 validity.set(id, valid);
...@@ -73,14 +114,10 @@ export function createSidebarSortingStrategy(input: {...@@ -73,14 +114,10 @@ export function createSidebarSortingStrategy(input: {
73114
74 function project({ rects, activeIndex, overIndex }: Layout) {115 function project({ rects, activeIndex, overIndex }: Layout) {
75 const active = items[activeIndex];116 const active = items[activeIndex];
76 const over = items[overIndex];117 const over = items[overIndex] ?? active;
77 if (active?.kind !== "thread" || !over || !rects[0]) return [];118 if (active?.kind !== "thread" || !over || !rects[0]) return [];
78 const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over));119 const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over));
79 if (!target) return [];120 if (!target) return [];
80 // Settled keeps time order, so a reorder inside it previews nothing.
81 // Every other drag projects so the boundary labels get their space.
82 if (target.section === active.section && over.kind === "thread" && target.section === "settled")
83 return [];
84 const groups: Record<SidebarSection, ThreadItem[]> = {121 const groups: Record<SidebarSection, ThreadItem[]> = {
85 pinned: [],122 pinned: [],
86 active: [],123 active: [],
...@@ -89,9 +126,13 @@ export function createSidebarSortingStrategy(input: {...@@ -89,9 +126,13 @@ export function createSidebarSortingStrategy(input: {
89 };126 };
90 let cardHeight = input.cardHeight;127 let cardHeight = input.cardHeight;
91 let slimHeight = input.slimHeight;128 let slimHeight = input.slimHeight;
129 let headerScale: number | undefined;
92 for (const [index, item] of items.entries()) {130 for (const [index, item] of items.entries()) {
93 if (item.kind === "marker") {131 if (item.kind === "marker") {
94 if (item.marker.endsWith("placeholder")) slimHeight ??= rects[index]?.height;132 if (item.marker === "settled-header" || item.marker === "snoozed-header") {
133 const height = rects[index]?.height;
134 if (height) headerScale ??= height / 32;
135 }
95 continue;136 continue;
96 }137 }
97 if (item.section === "pinned" || item.section === "active")138 if (item.section === "pinned" || item.section === "active")
...@@ -100,7 +141,8 @@ export function createSidebarSortingStrategy(input: {...@@ -100,7 +141,8 @@ export function createSidebarSortingStrategy(input: {
100 if (item.key !== active.key) groups[item.section].push(item);141 if (item.key !== active.key) groups[item.section].push(item);
101 }142 }
102 // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9.143 // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9.
103 const scale = slimHeight !== undefined ? slimHeight / 36 : (cardHeight ?? 82) / 82;144 const scale =
145 slimHeight !== undefined ? slimHeight / 36 : (headerScale ?? (cardHeight ?? 82) / 82);
104 cardHeight ??= 82 * scale;146 cardHeight ??= 82 * scale;
105 slimHeight ??= 36 * scale;147 slimHeight ??= 36 * scale;
106 const labelHeight = (input.boundaryLabelHeight ?? 0) * scale;148 const labelHeight = (input.boundaryLabelHeight ?? 0) * scale;
...@@ -163,9 +205,11 @@ export function createSidebarSortingStrategy(input: {...@@ -163,9 +205,11 @@ export function createSidebarSortingStrategy(input: {
163 item.kind === "marker" &&205 item.kind === "marker" &&
164 (item.marker === "pinned-header" || item.marker === "pinned-divider")206 (item.marker === "pinned-header" || item.marker === "pinned-divider")
165 ? labelHeight207 ? labelHeight
166 : moved208 : item.kind === "marker" && item.marker.endsWith("placeholder")
167 ? fallback209 ? slimHeight
168 : (rect?.height ?? fallback);210 : moved
211 ? fallback
212 : (rect?.height ?? fallback);
169 top += height + 1;213 top += height + 1;
170 }214 }
171 result[activeIndex] = stationary;215 result[activeIndex] = stationary;
apps/web/src/components/Sidebar.pointer.test.ts created+200
...@@ -0,0 +1,200 @@
1import type { SensorProps } from "@dnd-kit/core";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import { act, createElement, StrictMode } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer";
6
7class TestDocument extends EventTarget {
8 hidden = false;
9 getSelection = () => ({ removeAllRanges() {} });
10}
11
12let document: TestDocument;
13let window: EventTarget;
14const sensors: SidebarPointerSensor[] = [];
15
16function pointer(type: string, values: Partial<PointerEvent> = {}) {
17 return Object.assign(new Event(type, { cancelable: true }), {
18 pointerId: 1,
19 clientX: 10,
20 clientY: 10,
21 buttons: 1,
22 ...values,
23 });
24}
25
26function gesture() {
27 const callbacks = {
28 onStart: vi.fn(),
29 onMove: vi.fn(),
30 onEnd: vi.fn(),
31 onCancel: vi.fn(),
32 onAbort: vi.fn(),
33 onPending: vi.fn(),
34 };
35 const onFinish = vi.fn();
36 // The sensor never reads dnd-kit's layout context or active node.
37 const props = {
38 active: "thread",
39 event: pointer("pointerdown"),
40 options: { distance: 6, onAttach: vi.fn(), onFinish },
41 ...callbacks,
42 } as unknown as SensorProps<ConstructorParameters<typeof SidebarPointerSensor>[0]["options"]>;
43 const sensor = new SidebarPointerSensor(props);
44 sensors.push(sensor);
45 return { sensor, onFinish, ...callbacks };
46}
47
48beforeEach(() => {
49 vi.useFakeTimers();
50 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
51 document = new TestDocument();
52 window = Object.assign(new EventTarget(), { setTimeout });
53 vi.stubGlobal("document", document);
54 vi.stubGlobal("window", window);
55});
56afterEach(() => {
57 for (const sensor of sensors.splice(0)) sensor.cancel();
58 vi.runAllTimers();
59 vi.useRealTimers();
60 vi.unstubAllGlobals();
61});
62
63describe("sidebar pointer lifecycle", () => {
64 it("keeps a click idle and starts only after the drag threshold", () => {
65 const click = gesture();
66 document.dispatchEvent(pointer("pointermove", { clientY: 16 }));
67 expect(click.onStart).not.toHaveBeenCalled();
68 document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
69 expect(click.onAbort).toHaveBeenCalledOnce();
70 expect(click.onFinish).toHaveBeenCalledOnce();
71
72 const drag = gesture();
73 document.dispatchEvent(pointer("pointermove", { clientY: 17 }));
74 expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 });
75 document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
76 expect(drag.onEnd).toHaveBeenCalledOnce();
77 expect(drag.onAbort).not.toHaveBeenCalled();
78 expect(drag.onFinish).toHaveBeenCalledOnce();
79 });
80
81 const interruptions = {
82 blur: () => window.dispatchEvent(new Event("blur")),
83 hidden: () => {
84 document.hidden = true;
85 document.dispatchEvent(new Event("visibilitychange"));
86 },
87 pagehide: () => window.dispatchEvent(new Event("pagehide")),
88 resize: () => window.dispatchEvent(new Event("resize")),
89 escape: () => document.dispatchEvent(Object.assign(new Event("keydown"), { code: "Escape" })),
90 pointercancel: () => document.dispatchEvent(pointer("pointercancel")),
91 "missed release": () =>
92 document.dispatchEvent(pointer("pointermove", { buttons: 0, clientY: 100 })),
93 };
94 for (const [name, interrupt] of Object.entries(interruptions)) {
95 it.each([false, true])(`cancels on ${name}, started=%s, and ignores late events`, (started) => {
96 const drag = gesture();
97 if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
98 interrupt();
99 document.dispatchEvent(pointer("pointermove", { clientY: 100 }));
100 document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
101 drag.sensor.cancel();
102 expect(drag.onCancel).toHaveBeenCalledOnce();
103 expect(drag.onEnd).not.toHaveBeenCalled();
104 expect(drag.onFinish).toHaveBeenCalledOnce();
105 expect(drag.onStart).toHaveBeenCalledTimes(started ? 1 : 0);
106 expect(drag.onAbort).toHaveBeenCalledTimes(started ? 0 : 1);
107
108 document.hidden = false;
109 const next = gesture();
110 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
111 document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
112 expect(next.onStart).toHaveBeenCalledOnce();
113 expect(next.onEnd).toHaveBeenCalledOnce();
114 });
115 }
116
117 it("suppresses a delayed release click after cancellation, then accepts the next click", () => {
118 const drag = gesture();
119 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
120 drag.sensor.cancel();
121 vi.advanceTimersByTime(1000);
122 const releaseClick = new Event("click");
123 const releasePropagation = vi.spyOn(releaseClick, "stopPropagation");
124 document.dispatchEvent(releaseClick);
125 expect(releasePropagation).toHaveBeenCalledOnce();
126 document.dispatchEvent(pointer("pointerdown"));
127 const nextClick = new Event("click");
128 const nextPropagation = vi.spyOn(nextClick, "stopPropagation");
129 document.dispatchEvent(nextClick);
130 expect(nextPropagation).not.toHaveBeenCalled();
131 });
132
133 it("allows the next click when the cancelled release happened outside the document", () => {
134 const drag = gesture();
135 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
136 drag.sensor.cancel();
137 document.dispatchEvent(pointer("pointerdown"));
138 const click = new Event("click");
139 const propagation = vi.spyOn(click, "stopPropagation");
140 document.dispatchEvent(click);
141 expect(propagation).not.toHaveBeenCalled();
142 });
143
144 it("does not move after cancellation during activation", () => {
145 const drag = gesture();
146 drag.onStart.mockImplementation(() => drag.sensor.cancel());
147 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
148 expect(drag.onCancel).toHaveBeenCalledOnce();
149 expect(drag.onMove).not.toHaveBeenCalled();
150 expect(drag.onFinish).toHaveBeenCalledOnce();
151 });
152
153 it("ignores events from other pointers", () => {
154 const drag = gesture();
155 document.dispatchEvent(pointer("pointermove", { pointerId: 2, buttons: 0, clientY: 100 }));
156 document.dispatchEvent(pointer("pointercancel", { pointerId: 2 }));
157 document.dispatchEvent(pointer("pointerup", { pointerId: 2 }));
158 expect(drag.onStart).not.toHaveBeenCalled();
159 expect(drag.onFinish).not.toHaveBeenCalled();
160 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
161 expect(drag.onStart).toHaveBeenCalledOnce();
162 });
163
164 it.each([false, true])("cancels when search unmounts the list, started=%s", (started) => {
165 let renderer: ReactTestRenderer;
166 let drag: ReturnType<typeof gesture> | undefined;
167 act(() => {
168 renderer = create(
169 createElement(
170 StrictMode,
171 null,
172 createElement(SidebarDragLifecycle, {
173 onUnmount: () => drag?.sensor.cancel(),
174 }),
175 ),
176 );
177 });
178 drag = gesture();
179 if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
180 act(() => renderer.unmount());
181 document.dispatchEvent(pointer("pointermove", { clientY: 100 }));
182 document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
183 expect(drag.onStart).toHaveBeenCalledTimes(started ? 1 : 0);
184 expect(drag.onCancel).toHaveBeenCalledOnce();
185 expect(drag.onEnd).not.toHaveBeenCalled();
186 expect(drag.onFinish).toHaveBeenCalledOnce();
187 });
188
189 it("detaches a cancelled sensor before a replacement gesture starts", () => {
190 const previous = gesture();
191 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
192 previous.sensor.cancel();
193 const next = gesture();
194 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
195 document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
196 expect(previous.onCancel).toHaveBeenCalledOnce();
197 expect(previous.onEnd).not.toHaveBeenCalled();
198 expect(next.onEnd).toHaveBeenCalledOnce();
199 });
200});
apps/web/src/components/Sidebar.pointer.ts created+141
...@@ -0,0 +1,141 @@
1import { useLayoutEffect, type PointerEvent as ReactPointerEvent } from "react";
2import { type SensorProps } from "@dnd-kit/core";
3import { getOwnerDocument, getWindow } from "@dnd-kit/utilities";
4
5// Search unmounts the drag context while its owning Sidebar remains mounted.
6export function SidebarDragLifecycle({ onUnmount }: { onUnmount: () => void }) {
7 useLayoutEffect(() => onUnmount, [onUnmount]);
8 return null;
9}
10
11type Options = {
12 distance: number;
13 onAttach: (sensor: SidebarPointerSensor) => void;
14 onFinish: (started: boolean) => void;
15};
16
17/** A sidebar gesture ends on release, cancellation, or loss of its window.
18 * Own the listeners so unmounting the list can cancel the sensor too. */
19export class SidebarPointerSensor {
20 static activators = [
21 {
22 eventName: "onPointerDown" as const,
23 handler: ({ nativeEvent }: ReactPointerEvent) =>
24 nativeEvent.isPrimary && nativeEvent.button === 0,
25 },
26 ];
27 autoScrollEnabled = true;
28 private phase: "pending" | "dragging" | "finished" = "pending";
29 private readonly pointer: PointerEvent;
30 private readonly document: Document;
31 private readonly window: Window;
32
33 constructor(private readonly props: SensorProps<Options>) {
34 this.pointer = props.event as PointerEvent;
35 this.document = getOwnerDocument(this.pointer.target);
36 this.window = getWindow(this.pointer.target);
37 this.document.addEventListener("pointermove", this.move, { passive: false, capture: true });
38 this.document.addEventListener("pointerup", this.end, { capture: true });
39 this.document.addEventListener("pointercancel", this.pointerCancel, { capture: true });
40 this.document.addEventListener("keydown", this.keydown, { capture: true });
41 this.document.addEventListener("visibilitychange", this.visibilityChange);
42 this.window.addEventListener("blur", this.cancel);
43 this.window.addEventListener("pagehide", this.cancel);
44 this.window.addEventListener("resize", this.cancel);
45 this.document.addEventListener("dragstart", this.preventDefault);
46 this.document.addEventListener("contextmenu", this.preventDefault);
47 props.options.onAttach(this);
48 props.onPending(props.active, { distance: props.options.distance }, this.coordinates());
49 }
50
51 private coordinates = () => ({ x: this.pointer.clientX, y: this.pointer.clientY });
52 private preventDefault = (event: Event) => event.preventDefault();
53 private clearClickSuppression = () => {
54 this.document.removeEventListener("click", this.suppressClick, { capture: true });
55 this.document.removeEventListener("pointerdown", this.clearClickSuppression, { capture: true });
56 };
57 private suppressClick = (event: Event) => {
58 event.stopPropagation();
59 this.clearClickSuppression();
60 };
61 private clearSelection = () => this.document.getSelection()?.removeAllRanges();
62
63 private move = (event: PointerEvent) => {
64 if (this.phase === "finished" || event.pointerId !== this.pointer.pointerId) return;
65 // A release outside the window can be missed. Never activate or continue
66 // a drag when the initiating button is no longer held.
67 if ((event.buttons & 1) === 0) return this.cancel();
68 const coordinates = { x: event.clientX, y: event.clientY };
69 if (this.phase === "pending") {
70 const offset = {
71 x: event.clientX - this.pointer.clientX,
72 y: event.clientY - this.pointer.clientY,
73 };
74 if (Math.hypot(offset.x, offset.y) <= this.props.options.distance) {
75 this.props.onPending(
76 this.props.active,
77 { distance: this.props.options.distance },
78 this.coordinates(),
79 offset,
80 );
81 return;
82 }
83 this.phase = "dragging";
84 this.document.addEventListener("click", this.suppressClick, { capture: true });
85 this.document.addEventListener("selectionchange", this.clearSelection);
86 this.clearSelection();
87 this.props.onStart(this.coordinates());
88 return;
89 }
90 if (this.phase === "dragging") {
91 if (event.cancelable) event.preventDefault();
92 this.props.onMove(coordinates);
93 }
94 };
95
96 private end = (event: PointerEvent) => {
97 if (event.pointerId === this.pointer.pointerId) this.finish(false);
98 };
99 private pointerCancel = (event: PointerEvent) => {
100 if (event.pointerId === this.pointer.pointerId) this.cancel();
101 };
102 private keydown = (event: KeyboardEvent) => {
103 if (event.code === "Escape") this.cancel();
104 };
105 private visibilityChange = () => {
106 if (this.document.hidden) this.cancel();
107 };
108 cancel = () => this.finish(true);
109
110 private finish(cancelled: boolean) {
111 if (this.phase === "finished") return;
112 const aborted = this.phase === "pending";
113 this.phase = "finished";
114 this.document.removeEventListener("pointermove", this.move, { capture: true });
115 this.document.removeEventListener("pointerup", this.end, { capture: true });
116 this.document.removeEventListener("pointercancel", this.pointerCancel, { capture: true });
117 this.document.removeEventListener("keydown", this.keydown, { capture: true });
118 this.document.removeEventListener("visibilitychange", this.visibilityChange);
119 this.window.removeEventListener("blur", this.cancel);
120 this.window.removeEventListener("pagehide", this.cancel);
121 this.window.removeEventListener("resize", this.cancel);
122 this.document.removeEventListener("dragstart", this.preventDefault);
123 this.document.removeEventListener("contextmenu", this.preventDefault);
124 this.document.removeEventListener("selectionchange", this.clearSelection);
125 // Cancellation can precede release by an arbitrary amount of time. Consume
126 // that release click, or let a fresh pointerdown end suppression if release
127 // happened outside the document. Ordinary clicks never install this guard.
128 if (!aborted) {
129 this.document.addEventListener("pointerdown", this.clearClickSuppression, { capture: true });
130 }
131 try {
132 // Release the sidebar preview before dnd-kit clears its transforms.
133 // Its public end/cancel event can be omitted before its first layout.
134 this.props.options.onFinish(!aborted);
135 } finally {
136 if (aborted) this.props.onAbort(this.props.active);
137 if (cancelled) this.props.onCancel();
138 else this.props.onEnd();
139 }
140 }
141}
apps/web/src/components/Sidebar.tsx+101-44
...@@ -2,12 +2,12 @@ import { useAtomValue } from "@effect/atom-react";...@@ -2,12 +2,12 @@ import { useAtomValue } from "@effect/atom-react";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
3import {3import {
4 DndContext,4 DndContext,
5 PointerSensor,
6 useSensor,5 useSensor,
7 useSensors,6 useSensors,
8 type DragEndEvent,7 type DragEndEvent,
9 type DragOverEvent,8 type DragOverEvent,
10 type DragStartEvent,9 type DragStartEvent,
10 type Modifier,
11} from "@dnd-kit/core";11} from "@dnd-kit/core";
12import { SortableContext, useSortable } from "@dnd-kit/sortable";12import { SortableContext, useSortable } from "@dnd-kit/sortable";
13import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";13import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
...@@ -172,7 +172,12 @@ import {...@@ -172,7 +172,12 @@ import {
172 type SidebarSection,172 type SidebarSection,
173} from "./Sidebar.logic";173} from "./Sidebar.logic";
174import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";174import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
175import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag";175import {
176 createSidebarCollisionDetection,
177 createSidebarSortingStrategy,
178 restrictBelowSidebarLabel,
179} from "./Sidebar.drag";
180import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer";
176import { createSidebarListMotion } from "./Sidebar.motion";181import { createSidebarListMotion } from "./Sidebar.motion";
177import {182import {
178 ThreadWorktreeIndicator,183 ThreadWorktreeIndicator,
...@@ -549,8 +554,8 @@ function SortableSidebarMarker(props: {...@@ -549,8 +554,8 @@ function SortableSidebarMarker(props: {
549 );554 );
550}555}
551556
552// Empty targets stay mounted before pickup so starting a drag never changes557// Empty targets stay measurable without reserving space at rest. The sorting
553// the list's measured positions.558// strategy opens their hint space during a drag.
554function SidebarSectionPlaceholder(props: {559function SidebarSectionPlaceholder(props: {
555 marker: "active-placeholder" | "settled-placeholder";560 marker: "active-placeholder" | "settled-placeholder";
556 label: string;561 label: string;
...@@ -561,26 +566,25 @@ function SidebarSectionPlaceholder(props: {...@@ -561,26 +566,25 @@ function SidebarSectionPlaceholder(props: {
561 <SortableSidebarMarker566 <SortableSidebarMarker
562 marker={props.marker}567 marker={props.marker}
563 data-testid={`sidebar-${props.marker}`}568 data-testid={`sidebar-${props.marker}`}
564 className={cn(569 className="relative mx-0.5 h-0"
565 "mx-0.5 flex h-9 items-center justify-center rounded-md border border-dashed border-transparent text-xs text-sidebar-muted-foreground/60",
566 props.showHint && "border-sidebar-foreground/25 text-sidebar-foreground/80",
567 props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
568 )}
569 >570 >
570 {props.showHint ? props.label : null}571 {props.showHint ? (
572 <div
573 className={cn(
574 "absolute inset-x-0 top-0 flex h-9 items-center justify-center rounded-md border border-dashed border-sidebar-foreground/25 text-xs text-sidebar-foreground/80",
575 props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
576 )}
577 >
578 {props.label}
579 </div>
580 ) : null}
571 </SortableSidebarMarker>581 </SortableSidebarMarker>
572 );582 );
573}583}
574584
575// Boundary labels appear during a drag in space the sorting strategy opens585// Zero-height markers reserve no label space at rest. During a drag the
576// below each marker (SIDEBAR_DRAG_LABEL_HEIGHT), so they never sit on a row.586// sorting strategy opens 24px for a 16px label with 4px clearance on each side.
577// The marker itself stays zero height, so nothing is reserved at rest and587const SIDEBAR_DRAG_LABEL_HEIGHT = 24;
578// pickup measurements are unchanged. They read at full strength so the
579// sections are easy to find, and the section under the lifted row takes the
580// accent. They paint above the lifted row so a card dragged across a
581// boundary never hides its label.
582// Matches the label's h-4 below.
583const SIDEBAR_DRAG_LABEL_HEIGHT = 16;
584588
585function SidebarDragBoundary(props: {589function SidebarDragBoundary(props: {
586 marker: "pinned-header" | "pinned-divider";590 marker: "pinned-header" | "pinned-divider";
...@@ -592,10 +596,10 @@ function SidebarDragBoundary(props: {...@@ -592,10 +596,10 @@ function SidebarDragBoundary(props: {
592 <SortableSidebarMarker596 <SortableSidebarMarker
593 marker={props.marker}597 marker={props.marker}
594 data-testid={`sidebar-${props.marker}`}598 data-testid={`sidebar-${props.marker}`}
595 className="pointer-events-none relative z-30 mx-0.5 h-0"599 className="pointer-events-none relative mx-0.5 h-0"
596 >600 >
597 {props.visible ? (601 {props.visible ? (
598 <div className="absolute inset-x-2 top-0 flex h-4 items-center gap-1.5">602 <div className="sidebar-drag-boundary-label absolute inset-x-2 top-1 flex h-4 items-center gap-1.5">
599 <span603 <span
600 className={cn(604 className={cn(
601 "inline-flex h-4 shrink-0 items-center rounded-sm border bg-sidebar px-1.5 text-[10px] leading-none font-medium",605 "inline-flex h-4 shrink-0 items-center rounded-sm border bg-sidebar px-1.5 text-[10px] leading-none font-medium",
...@@ -3021,8 +3025,15 @@ export default function Sidebar() {...@@ -3021,8 +3025,15 @@ export default function Sidebar() {
3021 },3025 },
3022 [unsnoozeThread],3026 [unsnoozeThread],
3023 );3027 );
3028 const threadListRef = useRef<HTMLUListElement | null>(null);
3029 const dragLabelOffsetRef = useRef(0);
3030 const restrictBelowPins = useCallback<Modifier>(
3031 (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current),
3032 [],
3033 );
3024 const listMotionRef = useRef<ReturnType<typeof createSidebarListMotion> | null>(null);3034 const listMotionRef = useRef<ReturnType<typeof createSidebarListMotion> | null>(null);
3025 const attachListMotionRef = useCallback((node: HTMLUListElement | null) => {3035 const attachListMotionRef = useCallback((node: HTMLUListElement | null) => {
3036 threadListRef.current = node;
3026 listMotionRef.current?.dispose();3037 listMotionRef.current?.dispose();
3027 listMotionRef.current = node === null ? null : createSidebarListMotion(node);3038 listMotionRef.current = node === null ? null : createSidebarListMotion(node);
3028 listMotionRef.current?.update(false);3039 listMotionRef.current?.update(false);
...@@ -3031,16 +3042,35 @@ export default function Sidebar() {...@@ -3031,16 +3042,35 @@ export default function Sidebar() {
3031 // Hold the chosen section and order until every key write arrives. This3042 // Hold the chosen section and order until every key write arrives. This
3032 // also covers first-time ordering, which assigns keys to keyless neighbors.3043 // also covers first-time ordering, which assigns keys to keyless neighbors.
3033 // A failed write, concurrent reorder, or membership change releases the hold.3044 // A failed write, concurrent reorder, or membership change releases the hold.
3034 const dndSensors = useSensors(
3035 useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
3036 );
3037 const [dragState, setDragState] = useState<{3045 const [dragState, setDragState] = useState<{
3038 readonly activeKey: string;3046 readonly activeKey: string;
3039 readonly activeSection: SidebarSection;3047 readonly activeSection: SidebarSection;
3040 readonly occurredAt: string;3048 readonly occurredAt: string;
3041 readonly activationY: number | null;3049 readonly activationY: number | null;
3050 readonly targetSection: SidebarSection | null;
3042 } | null>(null);3051 } | null>(null);
3043 const [dragTargetSection, setDragTargetSection] = useState<SidebarSection | null>(null);3052 const dragTargetSection = dragState?.targetSection ?? null;
3053 const dragSensorRef = useRef<SidebarPointerSensor | null>(null);
3054 const finishThreadDrag = useCallback((started: boolean) => {
3055 dragSensorRef.current = null;
3056 if (started) {
3057 listMotionRef.current?.release();
3058 setDragState(null);
3059 }
3060 }, []);
3061 const attachDragSensor = useCallback((sensor: SidebarPointerSensor) => {
3062 dragSensorRef.current = sensor;
3063 }, []);
3064 const cancelThreadDrag = useCallback(() => {
3065 dragSensorRef.current?.cancel();
3066 }, []);
3067 const dndSensors = useSensors(
3068 useSensor(SidebarPointerSensor, {
3069 distance: 6,
3070 onAttach: attachDragSensor,
3071 onFinish: finishThreadDrag,
3072 }),
3073 );
3044 const sectionByThreadKey = useMemo(() => {3074 const sectionByThreadKey = useMemo(() => {
3045 const map = new Map<string, SidebarSection>();3075 const map = new Map<string, SidebarSection>();
3046 const add = (list: readonly EnvironmentThreadShell[], section: SidebarSection) => {3076 const add = (list: readonly EnvironmentThreadShell[], section: SidebarSection) => {
...@@ -3185,22 +3215,27 @@ export default function Sidebar() {...@@ -3185,22 +3215,27 @@ export default function Sidebar() {
3185 if (activeSection === undefined) return;3215 if (activeSection === undefined) return;
3186 // Stop normal section motion before dnd-kit measures the picked-up row.3216 // Stop normal section motion before dnd-kit measures the picked-up row.
3187 listMotionRef.current?.suspend();3217 listMotionRef.current?.suspend();
3218 const list = threadListRef.current;
3219 const header = list?.querySelector<HTMLElement>('[data-testid="sidebar-pinned-header"]');
3220 if (list && header) {
3221 const listRect = list.getBoundingClientRect();
3222 const scale = list.offsetWidth > 0 ? listRect.width / list.offsetWidth : 1;
3223 dragLabelOffsetRef.current =
3224 header.getBoundingClientRect().top - listRect.top + SIDEBAR_DRAG_LABEL_HEIGHT * scale;
3225 } else {
3226 dragLabelOffsetRef.current = 0;
3227 }
3188 setDragState({3228 setDragState({
3189 activeKey,3229 activeKey,
3190 activeSection,3230 activeSection,
3231 targetSection: activeSection,
3191 occurredAt: new Date().toISOString(),3232 occurredAt: new Date().toISOString(),
3192 activationY:3233 activationY:
3193 event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null,3234 event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null,
3194 });3235 });
3195 setDragTargetSection(activeSection);
3196 },3236 },
3197 [sectionByThreadKey],3237 [sectionByThreadKey],
3198 );3238 );
3199 const handleThreadDragCancel = useCallback(() => {
3200 listMotionRef.current?.release();
3201 setDragState(null);
3202 setDragTargetSection(null);
3203 }, []);
3204 // Include every visible row in the measured order. Older servers disable3239 // Include every visible row in the measured order. Older servers disable
3205 // pickup on their rows without changing where those rows render.3240 // pickup on their rows without changing where those rows render.
3206 const sidebarListItems = useMemo((): readonly SidebarListItem[] => {3241 const sidebarListItems = useMemo((): readonly SidebarListItem[] => {
...@@ -3249,6 +3284,14 @@ export default function Sidebar() {...@@ -3249,6 +3284,14 @@ export default function Sidebar() {
3249 snoozedThreads.length,3284 snoozedThreads.length,
3250 visibleSnoozedThreads,3285 visibleSnoozedThreads,
3251 ]);3286 ]);
3287 useEffect(() => {
3288 if (
3289 dragState !== null &&
3290 !sidebarListItems.some((item) => item.kind === "thread" && item.key === dragState.activeKey)
3291 ) {
3292 cancelThreadDrag();
3293 }
3294 }, [cancelThreadDrag, dragState, sidebarListItems]);
3252 const listMotionPaused = dragState !== null;3295 const listMotionPaused = dragState !== null;
3253 useLayoutEffect(() => {3296 useLayoutEffect(() => {
3254 // Drag release clears the baseline, so its commit cannot replay the3297 // Drag release clears the baseline, so its commit cannot replay the
...@@ -3264,7 +3307,11 @@ export default function Sidebar() {...@@ -3264,7 +3307,11 @@ export default function Sidebar() {
3264 const target = event.over3307 const target = event.over
3265 ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id))3308 ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id))
3266 : null;3309 : null;
3267 setDragTargetSection(target?.section ?? null);3310 setDragState((current) =>
3311 current === null || current.activeKey !== String(event.active.id)
3312 ? current
3313 : { ...current, targetSection: target?.section ?? null },
3314 );
3268 },3315 },
3269 [sidebarListItems],3316 [sidebarListItems],
3270 );3317 );
...@@ -3318,18 +3365,22 @@ export default function Sidebar() {...@@ -3318,18 +3365,22 @@ export default function Sidebar() {
3318 }),3365 }),
3319 [threads],3366 [threads],
3320 );3367 );
3368 const draggedThreadKey = dragState?.activeKey;
3369 const draggedFromSection = dragState?.activeSection;
3370 const dragActivationY = dragState?.activationY;
3321 const dndCollisionDetection = useMemo(() => {3371 const dndCollisionDetection = useMemo(() => {
3322 if (dragState === null) return createSidebarCollisionDetection(() => true);3372 if (draggedThreadKey === undefined || draggedFromSection === undefined)
3323 const source = threadByKey.get(dragState.activeKey);3373 return createSidebarCollisionDetection(() => true);
3374 const source = threadByKey.get(draggedThreadKey);
3324 if (source === undefined) return createSidebarCollisionDetection(() => false);3375 if (source === undefined) return createSidebarCollisionDetection(() => false);
3325 return createSidebarCollisionDetection(3376 return createSidebarCollisionDetection(
3326 (id) => {3377 (id) => {
3327 const target = resolveSidebarDropTarget(sidebarListItems, dragState.activeKey, id);3378 const target = resolveSidebarDropTarget(sidebarListItems, draggedThreadKey, id);
3328 if (target === null) return false;3379 if (target === null) return false;
3329 return (3380 return (
3330 planSidebarThreadDrop({3381 planSidebarThreadDrop({
3331 activeKey: dragState.activeKey,3382 activeKey: draggedThreadKey,
3332 activeSection: dragState.activeSection,3383 activeSection: draggedFromSection,
3333 activePinned: source.pinnedAt != null,3384 activePinned: source.pinnedAt != null,
3334 activeSettled: source.settledOverride === "settled",3385 activeSettled: source.settledOverride === "settled",
3335 supportsSettlement:3386 supportsSettlement:
...@@ -3345,7 +3396,10 @@ export default function Sidebar() {...@@ -3345,7 +3396,10 @@ export default function Sidebar() {
3345 }).kind !== "none"3396 }).kind !== "none"
3346 );3397 );
3347 },3398 },
3348 { emptyPins: pinnedKeys.length === 0, activationY: dragState.activationY },3399 {
3400 items: sidebarListItems,
3401 activationY: dragActivationY ?? null,
3402 },
3349 );3403 );
3350 }, [3404 }, [
3351 activeKeysById,3405 activeKeysById,
...@@ -3353,7 +3407,9 @@ export default function Sidebar() {...@@ -3353,7 +3407,9 @@ export default function Sidebar() {
3353 serverConfigs,3407 serverConfigs,
3354 activeKeys,3408 activeKeys,
3355 activeReorderableThreadKeys,3409 activeReorderableThreadKeys,
3356 dragState,3410 draggedThreadKey,
3411 draggedFromSection,
3412 dragActivationY,
3357 draggableThreadKeys,3413 draggableThreadKeys,
3358 pinnedKeys,3414 pinnedKeys,
3359 sidebarListItems,3415 sidebarListItems,
...@@ -3361,9 +3417,6 @@ export default function Sidebar() {...@@ -3361,9 +3417,6 @@ export default function Sidebar() {
3361 ]);3417 ]);
3362 const handleThreadDragEnd = useCallback(3418 const handleThreadDragEnd = useCallback(
3363 (event: DragEndEvent) => {3419 (event: DragEndEvent) => {
3364 listMotionRef.current?.release();
3365 setDragState(null);
3366 setDragTargetSection(null);
3367 const activeKey = String(event.active.id);3420 const activeKey = String(event.active.id);
3368 const activeSection = sectionByThreadKey.get(activeKey);3421 const activeSection = sectionByThreadKey.get(activeKey);
3369 const target =3422 const target =
...@@ -4547,12 +4600,16 @@ export default function Sidebar() {...@@ -4547,12 +4600,16 @@ export default function Sidebar() {
4547 <DndContext4600 <DndContext
4548 sensors={dndSensors}4601 sensors={dndSensors}
4549 collisionDetection={dndCollisionDetection}4602 collisionDetection={dndCollisionDetection}
4550 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}4603 modifiers={[
4604 restrictToVerticalAxis,
4605 restrictBelowPins,
4606 restrictToFirstScrollableAncestor,
4607 ]}
4551 onDragStart={handleThreadDragStart}4608 onDragStart={handleThreadDragStart}
4552 onDragOver={handleThreadDragOver}4609 onDragOver={handleThreadDragOver}
4553 onDragCancel={handleThreadDragCancel}
4554 onDragEnd={handleThreadDragEnd}4610 onDragEnd={handleThreadDragEnd}
4555 >4611 >
4612 <SidebarDragLifecycle onUnmount={cancelThreadDrag} />
4556 <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>4613 <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>
4557 <ul4614 <ul
4558 ref={attachListMotionRef}4615 ref={attachListMotionRef}
apps/web/src/index.css+15
...@@ -2056,3 +2056,18 @@ code {...@@ -2056,3 +2056,18 @@ code {
2056 transform: scaleX(0.9);2056 transform: scaleX(0.9);
2057 }2057 }
2058}2058}
2059
2060/* Let the sortable rows open label clearance before painting the dividers.
2061 This runs once per pickup; rows retain dnd-kit's normal 200ms transitions. */
2062.sidebar-drag-boundary-label {
2063 animation: sidebar-drag-label-reveal 200ms step-end;
2064}
2065
2066@keyframes sidebar-drag-label-reveal {
2067 from {
2068 visibility: hidden;
2069 }
2070 to {
2071 visibility: visible;
2072 }
2073}