authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-06 20:03:55-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 17:03:55-07:00
log5b0c923ea3413c038f32ac4c523ae96863443f9f
tree2b2f6d24315030ee73435b6063bc4f0183ef5374
parent9ab0635db6abb1abf15e664141a0f8b6f0c33eae
signature Signed by PGP key B5690EEEBB952194

feat(web): name the drop action while dragging sidebar threads (#10378)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

8 files changed, 326 insertions(+), 88 deletions(-)

apps/web/src/components/Sidebar.drag.test.ts+60
......@@ -245,7 +245,9 @@ describe("sidebar drag projection", () => {
245245 settledExpanded: true,
246246 });
247247 const args = layout(pinned, active, over);
248 // dnd-kit moves the lifted row by the pointer delta, so only peers matter.
248249 for (let index = 0; index < pinned.length; index += 1) {
250 if (index === args.activeIndex) continue;
249251 expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));
250252 }
251253 });
......@@ -282,6 +284,7 @@ describe("sidebar drag projection", () => {
282284 });
283285 const args = layout(items, active, over);
284286 for (let index = 0; index < items.length; index += 1) {
287 if (index === args.activeIndex) continue;
285288 expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));
286289 }
287290 });
......@@ -328,6 +331,63 @@ describe("sidebar drag projection", () => {
328331 },
329332 );
330333
334 it("opens label space below each pinned boundary while dragging", () => {
335 const items = [
336 pinnedHeader,
337 thread("p", "pinned"),
338 divider,
339 thread("a1", "active"),
340 thread("a2", "active"),
341 settledHeader,
342 thread("s", "settled"),
343 ];
344 // Reorder inside active: the header gap shifts every row, the divider
345 // gap shifts the active rows and the shelf below by a second label.
346 const result = preview(
347 { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 },
348 "a2",
349 "a1",
350 );
351 expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
352 expect(result.get("p")?.y).toBe(16);
353 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(16);
354 expect(result.get("a2")).toEqual(stationary);
355 expect(result.get("a1")?.y).toBe(32 + 83);
356 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(32);
357 expect(result.get("s")?.y).toBe(32);
358 });
359
360 it("stacks the labels with their gaps when the pinned section is empty", () => {
361 const items = [
362 pinnedHeader,
363 divider,
364 thread("a1", "active"),
365 thread("a2", "active"),
366 settledHeader,
367 thread("s", "settled"),
368 ];
369 const result = preview(
370 { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 },
371 "a2",
372 "a1",
373 );
374 expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
375 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(16);
376 expect(result.get("a1")?.y).toBe(32 + 83);
377 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(32);
378 });
379
380 it("scales the label space with the measured root scale", () => {
381 const items = [pinnedHeader, thread("p", "pinned"), divider, thread("a1", "active")];
382 const result = preview(
383 { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 },
384 "a1",
385 "p",
386 2,
387 );
388 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(32 + 165);
389 });
390
331391 it("keeps the pinned header above the first arriving pin", () => {
332392 const items = [
333393 pinnedHeader,
apps/web/src/components/Sidebar.drag.ts+16-3
......@@ -62,6 +62,9 @@ export function createSidebarSortingStrategy(input: {
6262 snoozedThreadCount?: number;
6363 cardHeight?: number;
6464 slimHeight?: number;
65 /** Space each pinned boundary opens for its label while dragging. The
66 * markers stay zero height at rest, so nothing is reserved until pickup. */
67 boundaryLabelHeight?: number;
6568}): SortingStrategy {
6669 const { items } = input;
6770 const indices = new Map(items.map((item, index) => [sidebarListItemId(item), index]));
......@@ -74,8 +77,10 @@ export function createSidebarSortingStrategy(input: {
7477 if (active?.kind !== "thread" || !over || !rects[0]) return [];
7578 const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over));
7679 if (!target) return [];
77 if (target.section === active.section && over.kind === "thread")
78 return target.section === "settled" ? [] : null;
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 [];
7984 const groups: Record<SidebarSection, ThreadItem[]> = {
8085 pinned: [],
8186 active: [],
......@@ -98,6 +103,7 @@ export function createSidebarSortingStrategy(input: {
98103 const scale = slimHeight !== undefined ? slimHeight / 36 : (cardHeight ?? 82) / 82;
99104 cardHeight ??= 82 * scale;
100105 slimHeight ??= 36 * scale;
106 const labelHeight = (input.boundaryLabelHeight ?? 0) * scale;
101107 const group = groups[target.section];
102108 const order =
103109 target.section === "pinned"
......@@ -153,7 +159,14 @@ export function createSidebarSortingStrategy(input: {
153159 ? cardHeight
154160 : slimHeight;
155161 const moved = item.kind === "thread" && item.key === active.key;
156 top += (moved ? fallback : (rect?.height ?? fallback)) + 1;
162 const height =
163 item.kind === "marker" &&
164 (item.marker === "pinned-header" || item.marker === "pinned-divider")
165 ? labelHeight
166 : moved
167 ? fallback
168 : (rect?.height ?? fallback);
169 top += height + 1;
157170 }
158171 result[activeIndex] = stationary;
159172 return result;
apps/web/src/components/Sidebar.logic.test.ts+22
......@@ -48,6 +48,7 @@ import {
4848 type SidebarListItem,
4949 type SidebarListMarker,
5050 type SidebarSection,
51 resolveSidebarDropVerb,
5152} from "./Sidebar.logic";
5253import {
5354 EnvironmentId,
......@@ -2469,3 +2470,24 @@ describe("sortLogicalProjectsForSidebar", () => {
24692470 ).toEqual(["logical-newer", "logical-older"]);
24702471 });
24712472});
2473
2474describe("resolveSidebarDropVerb", () => {
2475 it("names the state change a cross-section drop performs", () => {
2476 expect(resolveSidebarDropVerb("active", "pinned")).toBe("pin");
2477 expect(resolveSidebarDropVerb("settled", "pinned")).toBe("pin");
2478 expect(resolveSidebarDropVerb("snoozed", "pinned")).toBe("pin");
2479 expect(resolveSidebarDropVerb("pinned", "active")).toBe("unpin");
2480 expect(resolveSidebarDropVerb("settled", "active")).toBe("unsettle");
2481 expect(resolveSidebarDropVerb("snoozed", "active")).toBe("wake");
2482 expect(resolveSidebarDropVerb("active", "settled")).toBe("settle");
2483 expect(resolveSidebarDropVerb("pinned", "settled")).toBe("settle");
2484 expect(resolveSidebarDropVerb("snoozed", "settled")).toBe("settle");
2485 });
2486
2487 it("stays silent for same-section reorders, no target, and the snoozed shelf", () => {
2488 expect(resolveSidebarDropVerb("active", "active")).toBeNull();
2489 expect(resolveSidebarDropVerb("pinned", "pinned")).toBeNull();
2490 expect(resolveSidebarDropVerb("active", null)).toBeNull();
2491 expect(resolveSidebarDropVerb("active", "snoozed")).toBeNull();
2492 });
2493});
apps/web/src/components/Sidebar.logic.ts+17
......@@ -197,6 +197,23 @@ export type SidebarThreadDropPlan =
197197 }
198198 | { readonly kind: "settle" };
199199
200/** What dropping in `to` does to a thread lifted from `from`, for the badge
201 on the lifted row. Null while reordering inside one section and for the
202 snoozed shelf, which cannot be a drop target. */
203export type SidebarDropVerb = "pin" | "unpin" | "settle" | "unsettle" | "wake";
204
205export function resolveSidebarDropVerb(
206 from: SidebarSection,
207 to: SidebarSection | null,
208): SidebarDropVerb | null {
209 if (to === null || to === from || to === "snoozed") return null;
210 if (to === "pinned") return "pin";
211 if (to === "settled") return "settle";
212 if (from === "pinned") return "unpin";
213 if (from === "settled") return "unsettle";
214 return "wake";
215}
216
200217export function planSidebarThreadDrop(input: {
201218 readonly activeKey: string;
202219 readonly activeSection: SidebarSection;
apps/web/src/components/Sidebar.motion.test.ts+37
......@@ -70,6 +70,7 @@ function fixture(rows: TestRow[]) {
7070 const parent = {
7171 children: rows,
7272 ownerDocument: { defaultView: { matchMedia: () => media } },
73 getBoundingClientRect: () => ({ top: 0 }),
7374 append(node: TestRow) {
7475 parent.children.push(node);
7576 node.remove.mockImplementation(() => {
......@@ -149,6 +150,42 @@ describe("sidebar list motion", () => {
149150 expectMove(a, -83);
150151 });
151152
153 it("glides every row from its released position into its committed slot", () => {
154 const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")];
155 const { motion, layout } = fixture([a, b, c]);
156 motion.update(true);
157 motion.suspend();
158 // a is lifted 130px below its slot; b previews upward into a's place
159 // and c holds a 16px label gap open below the divider.
160 a.dragTranslate = 130;
161 b.dragTranslate = -83;
162 c.dragTranslate = 16;
163 motion.update(false);
164 motion.release();
165 layout([b, a, c]);
166 a.dragTranslate = b.dragTranslate = c.dragTranslate = 0;
167 motion.update(true);
168 // Lifted top 138, committed slot top 91: glide the remaining 47px.
169 expectMove(a, 47);
170 // b already sits where it lands; c closes its 16px gap.
171 expect(b.animations).toHaveLength(0);
172 expectMove(c, 16);
173 });
174
175 it("does not glide on release when motion is reduced", () => {
176 const [a, b] = [new TestRow("a"), new TestRow("b")];
177 const { motion, layout, media } = fixture([a, b]);
178 motion.update(true);
179 media.matches = true;
180 a.dragTranslate = 100;
181 motion.release();
182 layout([b, a]);
183 a.dragTranslate = 0;
184 motion.update(true);
185 expect(a.animations).toHaveLength(0);
186 expect(b.animations).toHaveLength(0);
187 });
188
152189 it("does not carry a canceled drag's transformed position into the next move", () => {
153190 const a = new TestRow("a");
154191 const b = new TestRow("b");
apps/web/src/components/Sidebar.motion.ts+43-15
......@@ -19,6 +19,9 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
1919 const running = new Map<HTMLElement, { animation: Animation; offset: number }>();
2020 const entering = new Map<HTMLElement, Animation>();
2121 const exiting = new Map<HTMLElement, Animation>();
22 // Visual tops at drag release, relative to the list, so the release
23 // commit can glide every row from where dnd-kit left it into its slot.
24 let released: Map<HTMLElement, number> | null = null;
2225
2326 const remainingOffset = (node: HTMLElement) => {
2427 const current = running.get(node);
......@@ -86,6 +89,23 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
8689 for (const node of running.keys()) cancel(node);
8790 clearFades();
8891 positions = null;
92 released = null;
93 };
94 const move = (node: HTMLElement, offset: number) => {
95 cancel(node);
96 if (offset === 0) return;
97 const animation = node.animate(
98 [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],
99 motionTiming,
100 );
101 running.set(node, { animation, offset });
102 animation.addEventListener(
103 "finish",
104 () => {
105 if (running.get(node)?.animation === animation) running.delete(node);
106 },
107 { once: true },
108 );
89109 };
90110
91111 return {
......@@ -140,25 +160,33 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
140160 if (previousTop === position.top) continue;
141161 // Computed progress includes the effect's easing. Only our own
142162 // translate is carried forward; dnd-kit's transforms are never read.
143 const offset = previousTop + remainingOffset(node) - position.top;
144 cancel(node);
145 if (offset === 0) continue;
146 const animation = node.animate(
147 [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],
148 motionTiming,
149 );
150 running.set(node, { animation, offset });
151 animation.addEventListener(
152 "finish",
153 () => {
154 if (running.get(node)?.animation === animation) running.delete(node);
155 },
156 { once: true },
157 );
163 move(node, previousTop + remainingOffset(node) - position.top);
158164 }
159165 }
166 if (released !== null) {
167 if (!reducedMotion?.matches) {
168 for (const [node, position] of next) {
169 const top = released.get(node);
170 if (top !== undefined) move(node, top - position.top);
171 }
172 }
173 released = null;
174 }
160175 positions = next;
161176 },
177 /** Called on drag release, before the commit that clears dnd-kit's
178 * transforms. Takes every row's visual top, including the lifted row
179 * under the pointer and the peers holding the label gaps open, so the
180 * next update glides each of them into its committed slot. */
181 release() {
182 suspend();
183 const origin = parent.getBoundingClientRect().top;
184 released = new Map(
185 Array.from(parent.children)
186 .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node))
187 .map((node) => [node, node.getBoundingClientRect().top - origin]),
188 );
189 },
162190 suspend,
163191 dispose() {
164192 suspend();
apps/web/src/components/Sidebar.tsx+123-65
......@@ -46,6 +46,7 @@ import {
4646 FolderPlusIcon,
4747 GitBranchIcon,
4848 PinIcon,
49 PinOffIcon,
4950 PlusIcon,
5051 SearchIcon,
5152 SettingsIcon,
......@@ -150,6 +151,8 @@ import {
150151 reduceSidebarProjectScopeMenuState,
151152 resolveAdjacentThreadId,
152153 resolveSidebarDropTarget,
154 resolveSidebarDropVerb,
155 type SidebarDropVerb,
153156 resolveSidebarThreadStatus,
154157 searchSidebarThreadsByTitle,
155158 shouldCreateNewThreadInCurrentProject,
......@@ -554,7 +557,7 @@ function SidebarSectionPlaceholder(props: {
554557 data-testid={`sidebar-${props.marker}`}
555558 className={cn(
556559 "mx-0.5 flex h-9 items-center justify-center rounded-md border border-dashed border-transparent text-xs text-sidebar-muted-foreground/60",
557 props.showHint && "border-sidebar-border",
560 props.showHint && "border-sidebar-foreground/25 text-sidebar-foreground/80",
558561 props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
559562 )}
560563 >
......@@ -563,8 +566,16 @@ function SidebarSectionPlaceholder(props: {
563566 );
564567}
565568
566// Boundary labels overlay the cards' padding during a drag. The measured
567// marker stays empty, so showing a label never pushes a row out of the way.
569// Boundary labels appear during a drag in space the sorting strategy opens
570// below each marker (SIDEBAR_DRAG_LABEL_HEIGHT), so they never sit on a row.
571// The marker itself stays zero height, so nothing is reserved at rest and
572// pickup measurements are unchanged. They read at full strength so the
573// sections are easy to find, and the section under the lifted row takes the
574// accent. They paint above the lifted row so a card dragged across a
575// boundary never hides its label.
576// Matches the label's h-4 below.
577const SIDEBAR_DRAG_LABEL_HEIGHT = 16;
578
568579function SidebarDragBoundary(props: {
569580 marker: "pinned-header" | "pinned-divider";
570581 label: string;
......@@ -575,21 +586,26 @@ function SidebarDragBoundary(props: {
575586 <SortableSidebarMarker
576587 marker={props.marker}
577588 data-testid={`sidebar-${props.marker}`}
578 className="pointer-events-none relative z-10 mx-0.5 h-0"
589 className="pointer-events-none relative z-30 mx-0.5 h-0"
579590 >
580591 {props.visible ? (
581 <div className="absolute inset-x-2 top-0 flex h-4 -translate-y-1/2 items-center gap-1.5">
592 <div className="absolute inset-x-2 top-0 flex h-4 items-center gap-1.5">
582593 <span
583594 className={cn(
584 "inline-flex h-4 shrink-0 items-center gap-2 rounded-sm border border-sidebar-border bg-sidebar px-1.5 text-[10px] leading-none font-medium text-sidebar-muted-foreground",
585 props.isDropTarget && "border-primary/40 text-primary",
595 "inline-flex h-4 shrink-0 items-center rounded-sm border bg-sidebar px-1.5 text-[10px] leading-none font-medium",
596 props.isDropTarget
597 ? "border-primary/40 text-primary"
598 : "border-sidebar-foreground/25 text-sidebar-foreground/80",
586599 )}
587600 >
588601 {props.label}
589602 </span>
590603 <span
591604 aria-hidden
592 className={cn("h-px flex-1 bg-sidebar-border", props.isDropTarget && "bg-primary/50")}
605 className={cn(
606 "h-px flex-1",
607 props.isDropTarget ? "bg-primary/50" : "bg-sidebar-foreground/25",
608 )}
593609 />
594610 </div>
595611 ) : null}
......@@ -601,6 +617,9 @@ function SidebarDragBoundary(props: {
601617function SidebarSectionHeader(props: {
602618 marker: "snoozed-header" | "settled-header";
603619 label: string;
620 // While dragging, the settled header reads at full strength and takes the
621 // accent while the lifted row is over it.
622 dragging?: boolean;
604623 isDropTarget?: boolean;
605624 toggle: { expanded: boolean; onToggle: () => void };
606625}) {
......@@ -608,6 +627,7 @@ function SidebarSectionHeader(props: {
608627 const className = cn(
609628 "flex h-full w-full items-center gap-2 rounded-md border border-dashed border-transparent px-2 text-left text-xs font-medium",
610629 snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60",
630 props.dragging && "text-sidebar-foreground/80",
611631 props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
612632 );
613633 const content = (
......@@ -618,7 +638,8 @@ function SidebarSectionHeader(props: {
618638 className={cn(
619639 "h-px min-w-2 flex-1",
620640 snoozed ? "bg-blue-500/20 dark:bg-blue-400/15" : "bg-sidebar-border/60",
621 props.isDropTarget && "bg-primary/30",
641 props.dragging && "bg-sidebar-foreground/25",
642 props.isDropTarget && "bg-primary/50",
622643 )}
623644 />
624645 <ChevronDownIcon
......@@ -887,6 +908,42 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
887908 );
888909});
889910
911// Verb and icon on the lifted row while it hovers over another section. Uses
912// the same icons as the row actions and context menu so the drop reads as the
913// action it performs.
914const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = {
915 pin: (
916 <>
917 <PinIcon aria-hidden className="size-3" />
918 Pin
919 </>
920 ),
921 unpin: (
922 <>
923 <PinOffIcon aria-hidden className="size-3" />
924 Unpin
925 </>
926 ),
927 settle: (
928 <>
929 <CircleCheckIcon aria-hidden className="size-3" />
930 Settle
931 </>
932 ),
933 unsettle: (
934 <>
935 <Undo2Icon aria-hidden className="size-3" />
936 Un-settle
937 </>
938 ),
939 wake: (
940 <>
941 <AlarmClockOffIcon aria-hidden className="size-3" />
942 Wake
943 </>
944 ),
945};
946
890947const SidebarThreadRow = memo(function SidebarThreadRow(props: {
891948 thread: SidebarThreadSummary;
892949 variant: "card" | "slim";
......@@ -906,7 +963,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
906963 // sortable bag applied to the row root so the whole row drags (the
907964 // pointer sensor's distance constraint keeps plain clicks working).
908965 sortable?: SortableThreadRowBag | undefined;
909 dropSection: Exclude<SidebarSection, "snoozed"> | null;
966 dropVerb: SidebarDropVerb | null;
967 // While dragging, the pin marker stays only for a pinned thread still over
968 // the pinned section. Any other position shows the verb badge instead, and
969 // the badge carries its own icon.
970 dragOverPinned: boolean;
910971 // Compact wake countdown ("2h") for rows in the snoozed shelf.
911972 snoozeWakeLabelText: string | null;
912973 // When a snooze ended (timer or early wake); drives the Woke pill until
......@@ -1301,6 +1362,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
13011362 !props.isActive &&
13021363 !isSelected &&
13031364 "opacity-70 transition-opacity hover:opacity-100",
1365 // The lifted row is an opaque card so the rows beneath it never show
1366 // through. The row tint is translucent in dark themes and the pointer
1367 // keeps the hover color applied, so both the tint and the solid sidebar
1368 // color are stacked as background images.
1369 props.sortable?.isDragging &&
1370 "bg-[linear-gradient(var(--sidebar-row-active),var(--sidebar-row-active)),linear-gradient(var(--sidebar),var(--sidebar))] text-sidebar-foreground opacity-100 shadow-lg",
13041371 );
13051372 // dnd-kit props for the row root. Same bag on both variants: every row in
13061373 // the list translates around the gap as the drag passes it.
......@@ -1322,18 +1389,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
13221389 }
13231390 : {};
13241391 const dragDestination =
1325 sortable?.isDragging && props.dropSection !== null ? (
1392 sortable?.isDragging && props.dropVerb !== null ? (
13261393 <span
13271394 role="status"
1328 className="pointer-events-none ml-auto inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-primary/30 bg-sidebar px-1.5 text-[11px] font-medium text-primary"
1395 className="pointer-events-none ml-auto inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-primary/40 bg-primary/10 px-1.5 text-[11px] font-medium text-primary"
13291396 >
1330 <span className="sr-only">Move to </span>
1331 <span aria-hidden>→</span>
1332 {props.dropSection === "pinned"
1333 ? "Pinned"
1334 : props.dropSection === "active"
1335 ? "Active"
1336 : "Settled"}
1397 {dropVerbBadge[props.dropVerb]}
13371398 </span>
13381399 ) : null;
13391400
......@@ -1437,32 +1498,33 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14371498 <TooltipPopup side="top">Unsent draft</TooltipPopup>
14381499 </Tooltip>
14391500 ) : null;
1440 const pinIndicator =
1441 props.isPinned && !sortable?.isDragging ? (
1442 props.pinningSupported ? (
1443 <Tooltip>
1444 <TooltipTrigger
1445 render={
1446 <button
1447 type="button"
1448 aria-label="Unpin thread"
1449 onClick={handleUnpinClick}
1450 className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
1451 />
1452 }
1453 >
1454 <PinIcon aria-hidden className="size-3 shrink-0" />
1455 </TooltipTrigger>
1456 <TooltipPopup>Unpin thread</TooltipPopup>
1457 </Tooltip>
1458 ) : (
1459 <PinIcon
1460 aria-label="Pinned"
1461 role="img"
1462 className="size-3 shrink-0 text-muted-foreground/65"
1463 />
1464 )
1465 ) : null;
1501 const showPin =
1502 props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null));
1503 const pinIndicator = showPin ? (
1504 props.pinningSupported && !sortable?.isDragging ? (
1505 <Tooltip>
1506 <TooltipTrigger
1507 render={
1508 <button
1509 type="button"
1510 aria-label="Unpin thread"
1511 onClick={handleUnpinClick}
1512 className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
1513 />
1514 }
1515 >
1516 <PinIcon aria-hidden className="size-3 shrink-0" />
1517 </TooltipTrigger>
1518 <TooltipPopup>Unpin thread</TooltipPopup>
1519 </Tooltip>
1520 ) : (
1521 <PinIcon
1522 aria-label="Pinned"
1523 role="img"
1524 className="size-3 shrink-0 text-muted-foreground/65"
1525 />
1526 )
1527 ) : null;
14661528
14671529 if (variant === "slim") {
14681530 return (
......@@ -1471,7 +1533,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14711533 {...sortableRootProps}
14721534 className={cn(
14731535 "list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]",
1474 sortable?.isDragging && "z-20 opacity-80",
1536 sortable?.isDragging && "relative z-20",
14751537 )}
14761538 >
14771539 <Tooltip disabled={sortable?.isDragging}>
......@@ -1629,7 +1691,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
16291691 {...sortableRootProps}
16301692 className={cn(
16311693 "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]",
1632 sortable?.isDragging && "z-20 opacity-80",
1694 sortable?.isDragging && "relative z-20",
16331695 )}
16341696 >
16351697 <Tooltip disabled={snoozeMenuOpen || sortable?.isDragging}>
......@@ -3129,7 +3191,7 @@ export default function Sidebar() {
31293191 [sectionByThreadKey],
31303192 );
31313193 const handleThreadDragCancel = useCallback(() => {
3132 listMotionRef.current?.suspend();
3194 listMotionRef.current?.release();
31333195 setDragState(null);
31343196 setDragTargetSection(null);
31353197 }, []);
......@@ -3184,7 +3246,8 @@ export default function Sidebar() {
31843246 const listMotionPaused = dragState !== null;
31853247 useLayoutEffect(() => {
31863248 // Drag release clears the baseline, so its commit cannot replay the
3187 // sortable preview. Later thread actions can animate while writes settle.
3249 // sortable preview; rows glide from their released positions instead.
3250 // Later thread actions can animate while writes settle.
31883251 // Draft navigation can reveal a frozen row without changing the draft count.
31893252 listMotionRef.current?.update(
31903253 !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0,
......@@ -3214,6 +3277,7 @@ export default function Sidebar() {
32143277 () =>
32153278 createSidebarSortingStrategy({
32163279 items: sidebarListItems,
3280 boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT,
32173281 settledOrder: draggedSettledOrder,
32183282 settledExpanded: settledShelfExpanded,
32193283 settledVisibleCount,
......@@ -3291,7 +3355,7 @@ export default function Sidebar() {
32913355 ]);
32923356 const handleThreadDragEnd = useCallback(
32933357 (event: DragEndEvent) => {
3294 listMotionRef.current?.suspend();
3358 listMotionRef.current?.release();
32953359 setDragState(null);
32963360 setDragTargetSection(null);
32973361 const activeKey = String(event.active.id);
......@@ -4533,13 +4597,14 @@ export default function Sidebar() {
45334597 }
45344598 isPinned={thread.pinnedAt != null}
45354599 sortable={sortable}
4536 dropSection={
4537 dragState?.activeKey === threadKey &&
4538 dragTargetSection !== dragState.activeSection &&
4539 dragTargetSection !== "snoozed"
4540 ? dragTargetSection
4600 dropVerb={
4601 dragState?.activeKey === threadKey
4602 ? resolveSidebarDropVerb(dragState.activeSection, dragTargetSection)
45414603 : null
45424604 }
4605 dragOverPinned={
4606 dragState?.activeKey === threadKey && dragTargetSection === "pinned"
4607 }
45434608 snoozeWakeLabelText={
45444609 section === "snoozed" && thread.snoozedUntil != null
45454610 ? snoozeWakeLabel(thread.snoozedUntil, {
......@@ -4630,14 +4695,6 @@ export default function Sidebar() {
46304695 );
46314696 };
46324697 const from = dragState?.activeSection ?? null;
4633 const previewPinnedCount =
4634 pinnedThreads.length +
4635 (from !== "pinned" && dragTargetSection === "pinned" ? 1 : 0) -
4636 (from === "pinned" &&
4637 dragTargetSection !== null &&
4638 dragTargetSection !== "pinned"
4639 ? 1
4640 : 0);
46414698 const items: ReactNode[] = [
46424699 <SidebarDraftBlock
46434700 key="draft-sessions"
......@@ -4663,8 +4720,8 @@ export default function Sidebar() {
46634720 key="pinned-header"
46644721 marker="pinned-header"
46654722 label="Pinned"
4666 isDropTarget={dragTargetSection === "pinned"}
46674723 visible={from !== null}
4724 isDropTarget={dragTargetSection === "pinned"}
46684725 />,
46694726 );
46704727 break;
......@@ -4674,8 +4731,8 @@ export default function Sidebar() {
46744731 key="pinned-divider"
46754732 marker="pinned-divider"
46764733 label="Active"
4734 visible={from !== null}
46774735 isDropTarget={dragTargetSection === "active"}
4678 visible={from !== null && previewPinnedCount > 0}
46794736 />,
46804737 );
46814738 break;
......@@ -4717,6 +4774,7 @@ export default function Sidebar() {
47174774 ? "Settled"
47184775 : `Settled (${settledThreads.length})`
47194776 }
4777 dragging={from !== null}
47204778 isDropTarget={dragTargetSection === "settled"}
47214779 toggle={{
47224780 expanded: settledShelfExpanded,
docs/user/thread-sidebar.md+8-5
......@@ -33,11 +33,14 @@ thread into the active list un-settles it. A snoozed thread can be dragged out o
3333shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake
3434time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation.
3535Pinned and active boundary labels appear only while dragging, without moving the rows. The
36destination boundary highlights. When you cross into another section, the dragged thread shows
37its destination, such as **→ Active**. Its usual pin, status, and hover actions hide during the
38drag. Reordering within the same section does not show a destination badge. When there are no
39pins, drag to the top edge to pin a thread. Section labels also identify empty sections and a
40collapsed settled shelf.
36other rows slide aside to show where the thread will land. When you cross into another section,
37the dragged thread shows the action the drop performs, with its icon: **Pin**, **Unpin**,
38**Settle**, **Un-settle**, or **Wake**. Its status and hover actions hide during the drag. A pinned
39thread keeps its pin only while it stays in the pinned section; once it leaves, the badge takes
40over. Reordering within the same section shows no badge. When there are no pins, drag to the top
41edge to pin a thread. Section labels stay readable for the whole drag, and the section the
42thread is over takes the accent color. Section labels also
43identify empty sections and a collapsed settled shelf.
4144
4245Drag within the pinned or active section to change its order. Other rows slide aside to show the
4346spot where the thread will land. Drops into either section keep the position you choose. On