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", () => {...@@ -245,7 +245,9 @@ describe("sidebar drag projection", () => {
245 settledExpanded: true,245 settledExpanded: true,
246 });246 });
247 const args = layout(pinned, active, over);247 const args = layout(pinned, active, over);
248 // dnd-kit moves the lifted row by the pointer delta, so only peers matter.
248 for (let index = 0; index < pinned.length; index += 1) {249 for (let index = 0; index < pinned.length; index += 1) {
250 if (index === args.activeIndex) continue;
249 expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));251 expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));
250 }252 }
251 });253 });
...@@ -282,6 +284,7 @@ describe("sidebar drag projection", () => {...@@ -282,6 +284,7 @@ describe("sidebar drag projection", () => {
282 });284 });
283 const args = layout(items, active, over);285 const args = layout(items, active, over);
284 for (let index = 0; index < items.length; index += 1) {286 for (let index = 0; index < items.length; index += 1) {
287 if (index === args.activeIndex) continue;
285 expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));288 expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));
286 }289 }
287 });290 });
...@@ -328,6 +331,63 @@ describe("sidebar drag projection", () => {...@@ -328,6 +331,63 @@ describe("sidebar drag projection", () => {
328 },331 },
329 );332 );
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
331 it("keeps the pinned header above the first arriving pin", () => {391 it("keeps the pinned header above the first arriving pin", () => {
332 const items = [392 const items = [
333 pinnedHeader,393 pinnedHeader,
apps/web/src/components/Sidebar.drag.ts+16-3
...@@ -62,6 +62,9 @@ export function createSidebarSortingStrategy(input: {...@@ -62,6 +62,9 @@ export function createSidebarSortingStrategy(input: {
62 snoozedThreadCount?: number;62 snoozedThreadCount?: number;
63 cardHeight?: number;63 cardHeight?: number;
64 slimHeight?: number;64 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;
65}): SortingStrategy {68}): SortingStrategy {
66 const { items } = input;69 const { items } = input;
67 const indices = new Map(items.map((item, index) => [sidebarListItemId(item), index]));70 const indices = new Map(items.map((item, index) => [sidebarListItemId(item), index]));
...@@ -74,8 +77,10 @@ export function createSidebarSortingStrategy(input: {...@@ -74,8 +77,10 @@ export function createSidebarSortingStrategy(input: {
74 if (active?.kind !== "thread" || !over || !rects[0]) return [];77 if (active?.kind !== "thread" || !over || !rects[0]) return [];
75 const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over));78 const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over));
76 if (!target) return [];79 if (!target) return [];
77 if (target.section === active.section && over.kind === "thread")80 // Settled keeps time order, so a reorder inside it previews nothing.
78 return target.section === "settled" ? [] : null;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 [];
79 const groups: Record<SidebarSection, ThreadItem[]> = {84 const groups: Record<SidebarSection, ThreadItem[]> = {
80 pinned: [],85 pinned: [],
81 active: [],86 active: [],
...@@ -98,6 +103,7 @@ export function createSidebarSortingStrategy(input: {...@@ -98,6 +103,7 @@ export function createSidebarSortingStrategy(input: {
98 const scale = slimHeight !== undefined ? slimHeight / 36 : (cardHeight ?? 82) / 82;103 const scale = slimHeight !== undefined ? slimHeight / 36 : (cardHeight ?? 82) / 82;
99 cardHeight ??= 82 * scale;104 cardHeight ??= 82 * scale;
100 slimHeight ??= 36 * scale;105 slimHeight ??= 36 * scale;
106 const labelHeight = (input.boundaryLabelHeight ?? 0) * scale;
101 const group = groups[target.section];107 const group = groups[target.section];
102 const order =108 const order =
103 target.section === "pinned"109 target.section === "pinned"
...@@ -153,7 +159,14 @@ export function createSidebarSortingStrategy(input: {...@@ -153,7 +159,14 @@ export function createSidebarSortingStrategy(input: {
153 ? cardHeight159 ? cardHeight
154 : slimHeight;160 : slimHeight;
155 const moved = item.kind === "thread" && item.key === active.key;161 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;
157 }170 }
158 result[activeIndex] = stationary;171 result[activeIndex] = stationary;
159 return result;172 return result;
apps/web/src/components/Sidebar.logic.test.ts+22
...@@ -48,6 +48,7 @@ import {...@@ -48,6 +48,7 @@ import {
48 type SidebarListItem,48 type SidebarListItem,
49 type SidebarListMarker,49 type SidebarListMarker,
50 type SidebarSection,50 type SidebarSection,
51 resolveSidebarDropVerb,
51} from "./Sidebar.logic";52} from "./Sidebar.logic";
52import {53import {
53 EnvironmentId,54 EnvironmentId,
...@@ -2469,3 +2470,24 @@ describe("sortLogicalProjectsForSidebar", () => {...@@ -2469,3 +2470,24 @@ describe("sortLogicalProjectsForSidebar", () => {
2469 ).toEqual(["logical-newer", "logical-older"]);2470 ).toEqual(["logical-newer", "logical-older"]);
2470 });2471 });
2471});2472});
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 =...@@ -197,6 +197,23 @@ export type SidebarThreadDropPlan =
197 }197 }
198 | { readonly kind: "settle" };198 | { 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
200export function planSidebarThreadDrop(input: {217export function planSidebarThreadDrop(input: {
201 readonly activeKey: string;218 readonly activeKey: string;
202 readonly activeSection: SidebarSection;219 readonly activeSection: SidebarSection;
apps/web/src/components/Sidebar.motion.test.ts+37
...@@ -70,6 +70,7 @@ function fixture(rows: TestRow[]) {...@@ -70,6 +70,7 @@ function fixture(rows: TestRow[]) {
70 const parent = {70 const parent = {
71 children: rows,71 children: rows,
72 ownerDocument: { defaultView: { matchMedia: () => media } },72 ownerDocument: { defaultView: { matchMedia: () => media } },
73 getBoundingClientRect: () => ({ top: 0 }),
73 append(node: TestRow) {74 append(node: TestRow) {
74 parent.children.push(node);75 parent.children.push(node);
75 node.remove.mockImplementation(() => {76 node.remove.mockImplementation(() => {
...@@ -149,6 +150,42 @@ describe("sidebar list motion", () => {...@@ -149,6 +150,42 @@ describe("sidebar list motion", () => {
149 expectMove(a, -83);150 expectMove(a, -83);
150 });151 });
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
152 it("does not carry a canceled drag's transformed position into the next move", () => {189 it("does not carry a canceled drag's transformed position into the next move", () => {
153 const a = new TestRow("a");190 const a = new TestRow("a");
154 const b = new TestRow("b");191 const b = new TestRow("b");
apps/web/src/components/Sidebar.motion.ts+43-15
...@@ -19,6 +19,9 @@ export function createSidebarListMotion(parent: HTMLUListElement) {...@@ -19,6 +19,9 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
19 const running = new Map<HTMLElement, { animation: Animation; offset: number }>();19 const running = new Map<HTMLElement, { animation: Animation; offset: number }>();
20 const entering = new Map<HTMLElement, Animation>();20 const entering = new Map<HTMLElement, Animation>();
21 const exiting = new Map<HTMLElement, Animation>();21 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
23 const remainingOffset = (node: HTMLElement) => {26 const remainingOffset = (node: HTMLElement) => {
24 const current = running.get(node);27 const current = running.get(node);
...@@ -86,6 +89,23 @@ export function createSidebarListMotion(parent: HTMLUListElement) {...@@ -86,6 +89,23 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
86 for (const node of running.keys()) cancel(node);89 for (const node of running.keys()) cancel(node);
87 clearFades();90 clearFades();
88 positions = null;91 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 );
89 };109 };
90110
91 return {111 return {
...@@ -140,25 +160,33 @@ export function createSidebarListMotion(parent: HTMLUListElement) {...@@ -140,25 +160,33 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
140 if (previousTop === position.top) continue;160 if (previousTop === position.top) continue;
141 // Computed progress includes the effect's easing. Only our own161 // Computed progress includes the effect's easing. Only our own
142 // translate is carried forward; dnd-kit's transforms are never read.162 // translate is carried forward; dnd-kit's transforms are never read.
143 const offset = previousTop + remainingOffset(node) - position.top;163 move(node, 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 );
158 }164 }
159 }165 }
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 }
160 positions = next;175 positions = next;
161 },176 },
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 },
162 suspend,190 suspend,
163 dispose() {191 dispose() {
164 suspend();192 suspend();
apps/web/src/components/Sidebar.tsx+123-65
...@@ -46,6 +46,7 @@ import {...@@ -46,6 +46,7 @@ import {
46 FolderPlusIcon,46 FolderPlusIcon,
47 GitBranchIcon,47 GitBranchIcon,
48 PinIcon,48 PinIcon,
49 PinOffIcon,
49 PlusIcon,50 PlusIcon,
50 SearchIcon,51 SearchIcon,
51 SettingsIcon,52 SettingsIcon,
...@@ -150,6 +151,8 @@ import {...@@ -150,6 +151,8 @@ import {
150 reduceSidebarProjectScopeMenuState,151 reduceSidebarProjectScopeMenuState,
151 resolveAdjacentThreadId,152 resolveAdjacentThreadId,
152 resolveSidebarDropTarget,153 resolveSidebarDropTarget,
154 resolveSidebarDropVerb,
155 type SidebarDropVerb,
153 resolveSidebarThreadStatus,156 resolveSidebarThreadStatus,
154 searchSidebarThreadsByTitle,157 searchSidebarThreadsByTitle,
155 shouldCreateNewThreadInCurrentProject,158 shouldCreateNewThreadInCurrentProject,
...@@ -554,7 +557,7 @@ function SidebarSectionPlaceholder(props: {...@@ -554,7 +557,7 @@ function SidebarSectionPlaceholder(props: {
554 data-testid={`sidebar-${props.marker}`}557 data-testid={`sidebar-${props.marker}`}
555 className={cn(558 className={cn(
556 "mx-0.5 flex h-9 items-center justify-center rounded-md border border-dashed border-transparent text-xs text-sidebar-muted-foreground/60",559 "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",
558 props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",561 props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
559 )}562 )}
560 >563 >
...@@ -563,8 +566,16 @@ function SidebarSectionPlaceholder(props: {...@@ -563,8 +566,16 @@ function SidebarSectionPlaceholder(props: {
563 );566 );
564}567}
565568
566// Boundary labels overlay the cards' padding during a drag. The measured569// Boundary labels appear during a drag in space the sorting strategy opens
567// marker stays empty, so showing a label never pushes a row out of the way.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
568function SidebarDragBoundary(props: {579function SidebarDragBoundary(props: {
569 marker: "pinned-header" | "pinned-divider";580 marker: "pinned-header" | "pinned-divider";
570 label: string;581 label: string;
...@@ -575,21 +586,26 @@ function SidebarDragBoundary(props: {...@@ -575,21 +586,26 @@ function SidebarDragBoundary(props: {
575 <SortableSidebarMarker586 <SortableSidebarMarker
576 marker={props.marker}587 marker={props.marker}
577 data-testid={`sidebar-${props.marker}`}588 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"
579 >590 >
580 {props.visible ? (591 {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">
582 <span593 <span
583 className={cn(594 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",595 "inline-flex h-4 shrink-0 items-center rounded-sm border bg-sidebar px-1.5 text-[10px] leading-none font-medium",
585 props.isDropTarget && "border-primary/40 text-primary",596 props.isDropTarget
597 ? "border-primary/40 text-primary"
598 : "border-sidebar-foreground/25 text-sidebar-foreground/80",
586 )}599 )}
587 >600 >
588 {props.label}601 {props.label}
589 </span>602 </span>
590 <span603 <span
591 aria-hidden604 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 )}
593 />609 />
594 </div>610 </div>
595 ) : null}611 ) : null}
...@@ -601,6 +617,9 @@ function SidebarDragBoundary(props: {...@@ -601,6 +617,9 @@ function SidebarDragBoundary(props: {
601function SidebarSectionHeader(props: {617function SidebarSectionHeader(props: {
602 marker: "snoozed-header" | "settled-header";618 marker: "snoozed-header" | "settled-header";
603 label: string;619 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;
604 isDropTarget?: boolean;623 isDropTarget?: boolean;
605 toggle: { expanded: boolean; onToggle: () => void };624 toggle: { expanded: boolean; onToggle: () => void };
606}) {625}) {
...@@ -608,6 +627,7 @@ function SidebarSectionHeader(props: {...@@ -608,6 +627,7 @@ function SidebarSectionHeader(props: {
608 const className = cn(627 const className = cn(
609 "flex h-full w-full items-center gap-2 rounded-md border border-dashed border-transparent px-2 text-left text-xs font-medium",628 "flex h-full w-full items-center gap-2 rounded-md border border-dashed border-transparent px-2 text-left text-xs font-medium",
610 snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60",629 snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60",
630 props.dragging && "text-sidebar-foreground/80",
611 props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",631 props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
612 );632 );
613 const content = (633 const content = (
...@@ -618,7 +638,8 @@ function SidebarSectionHeader(props: {...@@ -618,7 +638,8 @@ function SidebarSectionHeader(props: {
618 className={cn(638 className={cn(
619 "h-px min-w-2 flex-1",639 "h-px min-w-2 flex-1",
620 snoozed ? "bg-blue-500/20 dark:bg-blue-400/15" : "bg-sidebar-border/60",640 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",
622 )}643 )}
623 />644 />
624 <ChevronDownIcon645 <ChevronDownIcon
...@@ -887,6 +908,42 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {...@@ -887,6 +908,42 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: {
887 );908 );
888});909});
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
890const SidebarThreadRow = memo(function SidebarThreadRow(props: {947const SidebarThreadRow = memo(function SidebarThreadRow(props: {
891 thread: SidebarThreadSummary;948 thread: SidebarThreadSummary;
892 variant: "card" | "slim";949 variant: "card" | "slim";
...@@ -906,7 +963,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -906,7 +963,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
906 // sortable bag applied to the row root so the whole row drags (the963 // sortable bag applied to the row root so the whole row drags (the
907 // pointer sensor's distance constraint keeps plain clicks working).964 // pointer sensor's distance constraint keeps plain clicks working).
908 sortable?: SortableThreadRowBag | undefined;965 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;
910 // Compact wake countdown ("2h") for rows in the snoozed shelf.971 // Compact wake countdown ("2h") for rows in the snoozed shelf.
911 snoozeWakeLabelText: string | null;972 snoozeWakeLabelText: string | null;
912 // When a snooze ended (timer or early wake); drives the Woke pill until973 // When a snooze ended (timer or early wake); drives the Woke pill until
...@@ -1301,6 +1362,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1301,6 +1362,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1301 !props.isActive &&1362 !props.isActive &&
1302 !isSelected &&1363 !isSelected &&
1303 "opacity-70 transition-opacity hover:opacity-100",1364 "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",
1304 );1371 );
1305 // dnd-kit props for the row root. Same bag on both variants: every row in1372 // dnd-kit props for the row root. Same bag on both variants: every row in
1306 // the list translates around the gap as the drag passes it.1373 // the list translates around the gap as the drag passes it.
...@@ -1322,18 +1389,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1322,18 +1389,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1322 }1389 }
1323 : {};1390 : {};
1324 const dragDestination =1391 const dragDestination =
1325 sortable?.isDragging && props.dropSection !== null ? (1392 sortable?.isDragging && props.dropVerb !== null ? (
1326 <span1393 <span
1327 role="status"1394 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"
1329 >1396 >
1330 <span className="sr-only">Move to </span>1397 {dropVerbBadge[props.dropVerb]}
1331 <span aria-hidden>→</span>
1332 {props.dropSection === "pinned"
1333 ? "Pinned"
1334 : props.dropSection === "active"
1335 ? "Active"
1336 : "Settled"}
1337 </span>1398 </span>
1338 ) : null;1399 ) : null;
13391400
...@@ -1437,32 +1498,33 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1437,32 +1498,33 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1437 <TooltipPopup side="top">Unsent draft</TooltipPopup>1498 <TooltipPopup side="top">Unsent draft</TooltipPopup>
1438 </Tooltip>1499 </Tooltip>
1439 ) : null;1500 ) : null;
1440 const pinIndicator =1501 const showPin =
1441 props.isPinned && !sortable?.isDragging ? (1502 props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null));
1442 props.pinningSupported ? (1503 const pinIndicator = showPin ? (
1443 <Tooltip>1504 props.pinningSupported && !sortable?.isDragging ? (
1444 <TooltipTrigger1505 <Tooltip>
1445 render={1506 <TooltipTrigger
1446 <button1507 render={
1447 type="button"1508 <button
1448 aria-label="Unpin thread"1509 type="button"
1449 onClick={handleUnpinClick}1510 aria-label="Unpin thread"
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"1511 onClick={handleUnpinClick}
1451 />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"
1452 }1513 />
1453 >1514 }
1454 <PinIcon aria-hidden className="size-3 shrink-0" />1515 >
1455 </TooltipTrigger>1516 <PinIcon aria-hidden className="size-3 shrink-0" />
1456 <TooltipPopup>Unpin thread</TooltipPopup>1517 </TooltipTrigger>
1457 </Tooltip>1518 <TooltipPopup>Unpin thread</TooltipPopup>
1458 ) : (1519 </Tooltip>
1459 <PinIcon1520 ) : (
1460 aria-label="Pinned"1521 <PinIcon
1461 role="img"1522 aria-label="Pinned"
1462 className="size-3 shrink-0 text-muted-foreground/65"1523 role="img"
1463 />1524 className="size-3 shrink-0 text-muted-foreground/65"
1464 )1525 />
1465 ) : null;1526 )
1527 ) : null;
14661528
1467 if (variant === "slim") {1529 if (variant === "slim") {
1468 return (1530 return (
...@@ -1471,7 +1533,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1471,7 +1533,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1471 {...sortableRootProps}1533 {...sortableRootProps}
1472 className={cn(1534 className={cn(
1473 "list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]",1535 "list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]",
1474 sortable?.isDragging && "z-20 opacity-80",1536 sortable?.isDragging && "relative z-20",
1475 )}1537 )}
1476 >1538 >
1477 <Tooltip disabled={sortable?.isDragging}>1539 <Tooltip disabled={sortable?.isDragging}>
...@@ -1629,7 +1691,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1629,7 +1691,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1629 {...sortableRootProps}1691 {...sortableRootProps}
1630 className={cn(1692 className={cn(
1631 "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]",1693 "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",
1633 )}1695 )}
1634 >1696 >
1635 <Tooltip disabled={snoozeMenuOpen || sortable?.isDragging}>1697 <Tooltip disabled={snoozeMenuOpen || sortable?.isDragging}>
...@@ -3129,7 +3191,7 @@ export default function Sidebar() {...@@ -3129,7 +3191,7 @@ export default function Sidebar() {
3129 [sectionByThreadKey],3191 [sectionByThreadKey],
3130 );3192 );
3131 const handleThreadDragCancel = useCallback(() => {3193 const handleThreadDragCancel = useCallback(() => {
3132 listMotionRef.current?.suspend();3194 listMotionRef.current?.release();
3133 setDragState(null);3195 setDragState(null);
3134 setDragTargetSection(null);3196 setDragTargetSection(null);
3135 }, []);3197 }, []);
...@@ -3184,7 +3246,8 @@ export default function Sidebar() {...@@ -3184,7 +3246,8 @@ export default function Sidebar() {
3184 const listMotionPaused = dragState !== null;3246 const listMotionPaused = dragState !== null;
3185 useLayoutEffect(() => {3247 useLayoutEffect(() => {
3186 // Drag release clears the baseline, so its commit cannot replay the3248 // 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.
3188 // Draft navigation can reveal a frozen row without changing the draft count.3251 // Draft navigation can reveal a frozen row without changing the draft count.
3189 listMotionRef.current?.update(3252 listMotionRef.current?.update(
3190 !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0,3253 !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0,
...@@ -3214,6 +3277,7 @@ export default function Sidebar() {...@@ -3214,6 +3277,7 @@ export default function Sidebar() {
3214 () =>3277 () =>
3215 createSidebarSortingStrategy({3278 createSidebarSortingStrategy({
3216 items: sidebarListItems,3279 items: sidebarListItems,
3280 boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT,
3217 settledOrder: draggedSettledOrder,3281 settledOrder: draggedSettledOrder,
3218 settledExpanded: settledShelfExpanded,3282 settledExpanded: settledShelfExpanded,
3219 settledVisibleCount,3283 settledVisibleCount,
...@@ -3291,7 +3355,7 @@ export default function Sidebar() {...@@ -3291,7 +3355,7 @@ export default function Sidebar() {
3291 ]);3355 ]);
3292 const handleThreadDragEnd = useCallback(3356 const handleThreadDragEnd = useCallback(
3293 (event: DragEndEvent) => {3357 (event: DragEndEvent) => {
3294 listMotionRef.current?.suspend();3358 listMotionRef.current?.release();
3295 setDragState(null);3359 setDragState(null);
3296 setDragTargetSection(null);3360 setDragTargetSection(null);
3297 const activeKey = String(event.active.id);3361 const activeKey = String(event.active.id);
...@@ -4533,13 +4597,14 @@ export default function Sidebar() {...@@ -4533,13 +4597,14 @@ export default function Sidebar() {
4533 }4597 }
4534 isPinned={thread.pinnedAt != null}4598 isPinned={thread.pinnedAt != null}
4535 sortable={sortable}4599 sortable={sortable}
4536 dropSection={4600 dropVerb={
4537 dragState?.activeKey === threadKey &&4601 dragState?.activeKey === threadKey
4538 dragTargetSection !== dragState.activeSection &&4602 ? resolveSidebarDropVerb(dragState.activeSection, dragTargetSection)
4539 dragTargetSection !== "snoozed"
4540 ? dragTargetSection
4541 : null4603 : null
4542 }4604 }
4605 dragOverPinned={
4606 dragState?.activeKey === threadKey && dragTargetSection === "pinned"
4607 }
4543 snoozeWakeLabelText={4608 snoozeWakeLabelText={
4544 section === "snoozed" && thread.snoozedUntil != null4609 section === "snoozed" && thread.snoozedUntil != null
4545 ? snoozeWakeLabel(thread.snoozedUntil, {4610 ? snoozeWakeLabel(thread.snoozedUntil, {
...@@ -4630,14 +4695,6 @@ export default function Sidebar() {...@@ -4630,14 +4695,6 @@ export default function Sidebar() {
4630 );4695 );
4631 };4696 };
4632 const from = dragState?.activeSection ?? null;4697 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);
4641 const items: ReactNode[] = [4698 const items: ReactNode[] = [
4642 <SidebarDraftBlock4699 <SidebarDraftBlock
4643 key="draft-sessions"4700 key="draft-sessions"
...@@ -4663,8 +4720,8 @@ export default function Sidebar() {...@@ -4663,8 +4720,8 @@ export default function Sidebar() {
4663 key="pinned-header"4720 key="pinned-header"
4664 marker="pinned-header"4721 marker="pinned-header"
4665 label="Pinned"4722 label="Pinned"
4666 isDropTarget={dragTargetSection === "pinned"}
4667 visible={from !== null}4723 visible={from !== null}
4724 isDropTarget={dragTargetSection === "pinned"}
4668 />,4725 />,
4669 );4726 );
4670 break;4727 break;
...@@ -4674,8 +4731,8 @@ export default function Sidebar() {...@@ -4674,8 +4731,8 @@ export default function Sidebar() {
4674 key="pinned-divider"4731 key="pinned-divider"
4675 marker="pinned-divider"4732 marker="pinned-divider"
4676 label="Active"4733 label="Active"
4734 visible={from !== null}
4677 isDropTarget={dragTargetSection === "active"}4735 isDropTarget={dragTargetSection === "active"}
4678 visible={from !== null && previewPinnedCount > 0}
4679 />,4736 />,
4680 );4737 );
4681 break;4738 break;
...@@ -4717,6 +4774,7 @@ export default function Sidebar() {...@@ -4717,6 +4774,7 @@ export default function Sidebar() {
4717 ? "Settled"4774 ? "Settled"
4718 : `Settled (${settledThreads.length})`4775 : `Settled (${settledThreads.length})`
4719 }4776 }
4777 dragging={from !== null}
4720 isDropTarget={dragTargetSection === "settled"}4778 isDropTarget={dragTargetSection === "settled"}
4721 toggle={{4779 toggle={{
4722 expanded: settledShelfExpanded,4780 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...@@ -33,11 +33,14 @@ thread into the active list un-settles it. A snoozed thread can be dragged out o
33shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake33shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake
34time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation.34time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation.
35Pinned and active boundary labels appear only while dragging, without moving the rows. The35Pinned 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 shows36other rows slide aside to show where the thread will land. When you cross into another section,
37its destination, such as **→ Active**. Its usual pin, status, and hover actions hide during the37the dragged thread shows the action the drop performs, with its icon: **Pin**, **Unpin**,
38drag. Reordering within the same section does not show a destination badge. When there are no38**Settle**, **Un-settle**, or **Wake**. Its status and hover actions hide during the drag. A pinned
39pins, drag to the top edge to pin a thread. Section labels also identify empty sections and a39thread keeps its pin only while it stays in the pinned section; once it leaves, the badge takes
40collapsed settled shelf.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
42Drag within the pinned or active section to change its order. Other rows slide aside to show the45Drag within the pinned or active section to change its order. Other rows slide aside to show the
43spot where the thread will land. Drops into either section keep the position you choose. On46spot where the thread will land. Drops into either section keep the position you choose. On