authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 11:10:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 11:10:14-07:00
log4023d93bce3f610349fbf38e991f737c1c5e0c98
treeef507440dbcbca526693e144fa9b7e1a9c860154
parent6766e682ac1a0aa33020358ba98707f4f8fee467
signature Signed by PGP key B5690EEEBB952194

feat(web): drag threads across sections with consistent motion (#9731)


12 files changed, 3534 insertions(+), 658 deletions(-)

apps/web/src/components/Sidebar.drag.test.ts created+616
......@@ -0,0 +1,616 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2import { closestCenter, type CollisionDetection } from "@dnd-kit/core";
3import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";
4import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag";
5import {
6 sidebarListItemId,
7 sidebarMarkerId,
8 type SidebarListItem,
9 type SidebarListMarker,
10 type SidebarSection,
11} from "./Sidebar.logic";
12
13const thread = (key: string, section: SidebarSection): SidebarListItem => ({
14 kind: "thread",
15 key,
16 section,
17});
18const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker });
19const pinnedHeader = marker("pinned-header");
20const divider = marker("pinned-divider");
21const settledHeader = marker("settled-header");
22const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 };
23
24function layout(
25 items: readonly SidebarListItem[],
26 active: string,
27 over: string,
28 scale = 1,
29 cardHeight = 82,
30) {
31 let top = 100;
32 const rects = items.map((item) => {
33 const height =
34 item.kind === "thread"
35 ? (item.section === "pinned" || item.section === "active" ? cardHeight : 36) * scale
36 : item.marker === "pinned-header" || item.marker === "pinned-divider"
37 ? 0
38 : (item.marker.endsWith("placeholder") ? 36 : 32) * scale;
39 const rect = { top, height, bottom: top + height, left: 0, right: 260, width: 260 };
40 top += height + 1;
41 return rect;
42 });
43 const activeIndex = items.findIndex((item) => sidebarListItemId(item) === active);
44 return {
45 activeIndex,
46 overIndex: items.findIndex((item) => sidebarListItemId(item) === over),
47 activeNodeRect: rects[activeIndex]!,
48 rects,
49 index: 0,
50 } satisfies Parameters<SortingStrategy>[0];
51}
52
53function preview(
54 input: Parameters<typeof createSidebarSortingStrategy>[0],
55 active: string,
56 over: string,
57 scale = 1,
58) {
59 const strategy = createSidebarSortingStrategy(input);
60 const args = layout(input.items, active, over, scale);
61 return new Map(
62 input.items.map((item, index) => [sidebarListItemId(item), strategy({ ...args, index })]),
63 );
64}
65
66describe("sidebar collision detection", () => {
67 function collisionArgs(blockedAboveSource = false) {
68 const rows = [thread("source", "active"), thread("blocked", "active")];
69 const items = [
70 pinnedHeader,
71 divider,
72 ...(blockedAboveSource ? rows.toReversed() : rows),
73 settledHeader,
74 marker("settled-placeholder"),
75 ];
76 const { rects, activeIndex, overIndex } = layout(items, "source", "blocked");
77 const collisionRect = rects[overIndex]!;
78 return {
79 active: {
80 id: "source",
81 data: { current: {} },
82 rect: { current: { initial: rects[activeIndex]!, translated: collisionRect } },
83 },
84 collisionRect,
85 droppableRects: new Map(items.map((item, index) => [sidebarListItemId(item), rects[index]!])),
86 droppableContainers: items.map((item, index) => ({
87 id: sidebarListItemId(item),
88 key: sidebarListItemId(item),
89 disabled: false,
90 data: { current: {} },
91 node: { current: null },
92 rect: { current: rects[index]! },
93 })),
94 pointerCoordinates: null,
95 } satisfies Parameters<CollisionDetection>[0];
96 }
97
98 it.each([
99 [false, sidebarMarkerId("settled-header")],
100 [true, sidebarMarkerId("pinned-divider")],
101 ] as const)(
102 "rejects unsupported Active instead of selecting %s / %s",
103 (blockedAboveSource, nearbyTarget) => {
104 const args = collisionArgs(blockedAboveSource);
105 const detector = createSidebarCollisionDetection((id) => id !== "blocked");
106 const filtered = closestCenter({
107 ...args,
108 droppableContainers: args.droppableContainers.filter(
109 (container) => container.id !== "blocked",
110 ),
111 });
112 expect(filtered[0]?.id).toBe(nearbyTarget);
113 expect(detector(args).map((collision) => collision.id)).toEqual(["source"]);
114 },
115 );
116
117 it("selects the nearest supported target", () => {
118 const detector = createSidebarCollisionDetection(() => true);
119 expect(detector(collisionArgs())[0]?.id).toBe("blocked");
120 });
121
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([
154 { reason: "below the boundary cue", x: 130, y: 109, activationY: 140, emptyPins: true },
155 { reason: "left of the list", x: -1, y: 108, activationY: 140, emptyPins: true },
156 { reason: "right of the list", x: 261, y: 108, activationY: 140, emptyPins: true },
157 { reason: "less than 6px upward", x: 130, y: 108, activationY: 113, emptyPins: true },
158 { reason: "without an activation point", x: 130, y: 108, activationY: null, emptyPins: true },
159 { reason: "with populated Pins", x: 130, y: 108, activationY: 140, emptyPins: false },
160 ])("keeps ordinary collision behavior $reason", ({ x, y, activationY, emptyPins }) => {
161 const detector = createSidebarCollisionDetection(() => true, { emptyPins, activationY });
162 const args = { ...clampedArgs(), pointerCoordinates: { x, y } };
163 expect(detector(args)[0]?.id).toBe("source");
164 });
165
166 it("keeps ordinary collision behavior without pointer coordinates", () => {
167 const detector = createSidebarCollisionDetection(() => true, {
168 emptyPins: true,
169 activationY: 140,
170 });
171 expect(detector({ ...clampedArgs(), pointerCoordinates: null })[0]?.id).toBe("source");
172 });
173
174 it("validates the empty Pins override and caches an unsupported result", () => {
175 const isValid = vi.fn(() => false);
176 const detector = createSidebarCollisionDetection(isValid, {
177 emptyPins: true,
178 activationY: 140,
179 });
180 const args = clampedArgs();
181 expect(detector(args).map((collision) => collision.id)).toEqual(["source"]);
182 expect(detector(args).map((collision) => collision.id)).toEqual(["source"]);
183 expect(isValid.mock.calls).toEqual([[sidebarMarkerId("pinned-header")]]);
184 });
185
186 it("returns no collision if an unsupported target has no source fallback", () => {
187 const args = collisionArgs();
188 const detector = createSidebarCollisionDetection(() => false);
189 expect(
190 detector({
191 ...args,
192 droppableContainers: args.droppableContainers.filter(
193 (container) => container.id !== "source",
194 ),
195 }),
196 ).toEqual([]);
197 });
198
199 it("validates each hovered target once and always allows returning to the source", () => {
200 const args = collisionArgs();
201 const isValid = vi.fn((id: string) => id !== "blocked");
202 const detector = createSidebarCollisionDetection(isValid);
203 expect(detector(args)[0]?.id).toBe("source");
204 expect(
205 detector({
206 ...args,
207 collisionRect: {
208 ...args.collisionRect,
209 top: args.collisionRect.top + 3,
210 bottom: args.collisionRect.bottom + 3,
211 },
212 })[0]?.id,
213 ).toBe("source");
214 expect(detector({ ...args, collisionRect: args.droppableRects.get("source")! })[0]?.id).toBe(
215 "source",
216 );
217 expect(
218 detector({
219 ...args,
220 collisionRect: args.droppableRects.get(sidebarMarkerId("settled-placeholder"))!,
221 })[0]?.id,
222 ).toBe(sidebarMarkerId("settled-placeholder"));
223 expect(isValid.mock.calls).toEqual([["blocked"], [sidebarMarkerId("settled-placeholder")]]);
224 });
225});
226
227describe("sidebar drag projection", () => {
228 const pinned = [
229 pinnedHeader,
230 thread("p1", "pinned"),
231 thread("p2", "pinned"),
232 divider,
233 thread("a1", "active"),
234 settledHeader,
235 thread("s1", "settled"),
236 ];
237
238 it.each([
239 ["p1", "p2"],
240 ["p2", "p1"],
241 ])("preserves existing pinned transforms from %s to %s", (active, over) => {
242 const strategy = createSidebarSortingStrategy({
243 items: pinned,
244 settledOrder: [],
245 settledExpanded: true,
246 });
247 const args = layout(pinned, active, over);
248 for (let index = 0; index < pinned.length; index += 1) {
249 expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));
250 }
251 });
252
253 it("keeps the pinned header above the gap when a lower pin moves to the top", () => {
254 const result = preview(
255 { items: pinned, settledOrder: [], settledExpanded: true },
256 "p2",
257 sidebarMarkerId("pinned-header"),
258 );
259 expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
260 expect(result.get("p1")).toEqual({ ...stationary, y: 83 });
261 expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual(stationary);
262 expect(result.get("a1")).toEqual(stationary);
263 });
264
265 it.each([
266 ["a1", "a2"],
267 ["a2", "a1"],
268 ])("uses pinned dragging behavior for Active from %s to %s", (active, over) => {
269 const items = [
270 pinnedHeader,
271 thread("p", "pinned"),
272 divider,
273 thread("a1", "active"),
274 thread("a2", "active"),
275 settledHeader,
276 thread("s", "settled"),
277 ];
278 const strategy = createSidebarSortingStrategy({
279 items,
280 settledOrder: [],
281 settledExpanded: true,
282 });
283 const args = layout(items, active, over);
284 for (let index = 0; index < items.length; index += 1) {
285 expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index }));
286 }
287 });
288
289 it("leaves canonically sorted settled peers in place", () => {
290 const items = [
291 pinnedHeader,
292 divider,
293 marker("active-placeholder"),
294 settledHeader,
295 thread("first", "settled"),
296 thread("second", "settled"),
297 ];
298 const result = preview(
299 { items, settledOrder: ["first", "second"], settledExpanded: true },
300 "second",
301 "first",
302 );
303 expect([...result.values()]).toEqual(items.map(() => stationary));
304 });
305
306 it.each([
307 [sidebarMarkerId("pinned-divider"), 0, 0],
308 ["a1", -83, 0],
309 ["a2", -83, -83],
310 ] as const)(
311 "opens the active pointer slot over %s without adding an empty pinned row",
312 (over, a1Offset, a2Offset) => {
313 const items = [
314 pinnedHeader,
315 thread("p", "pinned"),
316 divider,
317 thread("a1", "active"),
318 thread("a2", "active"),
319 settledHeader,
320 thread("s", "settled"),
321 ];
322 const result = preview({ items, settledOrder: [], settledExpanded: true }, "p", over);
323 expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
324 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(-83);
325 expect(result.get("a1")?.y).toBe(a1Offset);
326 expect(result.get("a2")?.y).toBe(a2Offset);
327 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0);
328 },
329 );
330
331 it("keeps the pinned header above the first arriving pin", () => {
332 const items = [
333 pinnedHeader,
334 divider,
335 thread("a1", "active"),
336 thread("a2", "active"),
337 settledHeader,
338 thread("s", "settled"),
339 ];
340 const result = preview(
341 { items, settledOrder: [], settledExpanded: true },
342 "a2",
343 sidebarMarkerId("pinned-header"),
344 );
345 expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
346 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(83);
347 expect(result.get("a1")?.y).toBe(83);
348 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0);
349 });
350
351 it.each([
352 ["p", -83, -37],
353 ["s", 0, 46],
354 ] as const)(
355 "replaces the empty Active target when %s enters",
356 (active, dividerOffset, settledOffset) => {
357 const items = [
358 pinnedHeader,
359 thread("p", "pinned"),
360 divider,
361 marker("active-placeholder"),
362 settledHeader,
363 thread("s", "settled"),
364 ];
365 const result = preview(
366 { items, settledOrder: [], settledExpanded: true },
367 active,
368 sidebarMarkerId("active-placeholder"),
369 );
370 expect(result.get(sidebarMarkerId("active-placeholder"))?.scaleY).toBe(0);
371 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(dividerOffset);
372 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(settledOffset);
373 },
374 );
375
376 it("uses the canonical settled rank and the destination's slim height", () => {
377 const items = [
378 pinnedHeader,
379 thread("p", "pinned"),
380 divider,
381 thread("a", "active"),
382 settledHeader,
383 thread("s1", "settled"),
384 thread("s2", "settled"),
385 ];
386 const result = preview(
387 { items, settledOrder: ["s1", "a", "s2"], settledExpanded: true },
388 "a",
389 "s2",
390 );
391 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-46);
392 expect(result.get("s1")?.y).toBe(-46);
393 expect(result.get("s2")?.y).toBe(-9);
394 });
395
396 it.each([
397 ["a1", 83],
398 ["a2", 0],
399 ] as const)(
400 "reserves a full card at the pointer slot over %s when a slim row enters Active",
401 (over, firstOffset) => {
402 const items = [
403 pinnedHeader,
404 thread("p", "pinned"),
405 divider,
406 thread("a1", "active"),
407 thread("a2", "active"),
408 settledHeader,
409 thread("s", "settled"),
410 ];
411 const result = preview({ items, settledOrder: [], settledExpanded: true }, "s", over);
412 expect(result.get("a1")?.y).toBe(firstOffset);
413 expect(result.get("a2")?.y).toBe(83);
414 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83);
415 },
416 );
417
418 it("removes the snoozed header when its last row leaves", () => {
419 const items = [
420 pinnedHeader,
421 thread("p", "pinned"),
422 divider,
423 thread("a", "active"),
424 marker("snoozed-header"),
425 thread("z", "snoozed"),
426 settledHeader,
427 thread("s", "settled"),
428 ];
429 const result = preview({ items, settledOrder: [], settledExpanded: true }, "z", "a");
430 expect(result.get(sidebarMarkerId("snoozed-header"))?.scaleY).toBe(0);
431 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(13);
432 expect(result.get("s")?.y).toBe(13);
433 });
434
435 it("keeps a collapsed settled target without inserting a hidden row", () => {
436 const items = [
437 pinnedHeader,
438 thread("p", "pinned"),
439 divider,
440 thread("a1", "active"),
441 thread("a2", "active"),
442 settledHeader,
443 marker("settled-placeholder"),
444 ];
445 const result = preview(
446 { items, settledOrder: [], settledExpanded: false },
447 "a2",
448 sidebarMarkerId("settled-placeholder"),
449 );
450 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-83);
451 expect(result.get(sidebarMarkerId("settled-placeholder"))).toEqual({ ...stationary, y: -83 });
452 });
453
454 it("preserves a collapsed snoozed header while another section changes", () => {
455 const items = [
456 pinnedHeader,
457 thread("p", "pinned"),
458 divider,
459 thread("a", "active"),
460 marker("snoozed-header"),
461 settledHeader,
462 marker("settled-placeholder"),
463 ];
464 const result = preview(
465 { items, settledOrder: [], settledExpanded: false },
466 "a",
467 sidebarMarkerId("settled-placeholder"),
468 );
469 expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: -46 });
470 });
471
472 it("derives missing card geometry from the measured root scale", () => {
473 const items = [
474 pinnedHeader,
475 divider,
476 marker("active-placeholder"),
477 settledHeader,
478 thread("s", "settled"),
479 ];
480 const result = preview(
481 { items, settledOrder: [], settledExpanded: true },
482 "s",
483 sidebarMarkerId("pinned-header"),
484 0.75,
485 );
486 expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary);
487 expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(62.5);
488 expect(result.get(sidebarMarkerId("active-placeholder"))?.y).toBe(62.5);
489 });
490
491 it("updates the projection when the target or measured geometry changes", () => {
492 const strategy = createSidebarSortingStrategy({
493 items: pinned,
494 settledOrder: [],
495 settledExpanded: true,
496 });
497 const args = layout(pinned, "p1", "p1");
498 expect(strategy({ ...args, index: 2 })?.y).toBe(0);
499 expect(strategy({ ...args, index: 2, overIndex: 4 })?.y).toBe(-83);
500 const smaller = layout(pinned, "p1", "a1", 0.75);
501 expect(strategy({ ...smaller, index: 2 })?.y).toBe(-62.5);
502 });
503
504 it("uses measured placeholder sizing when card height differs from its default", () => {
505 const items = [
506 pinnedHeader,
507 thread("p", "pinned"),
508 divider,
509 thread("a", "active"),
510 settledHeader,
511 marker("settled-placeholder"),
512 ];
513 const strategy = createSidebarSortingStrategy({
514 items,
515 settledOrder: [],
516 settledExpanded: false,
517 });
518 const args = layout(items, "a", sidebarMarkerId("settled-placeholder"), 1, 78);
519 expect(strategy({ ...args, index: 4 })?.y).toBe(-42);
520 });
521
522 it("keeps the route row visible after a settled drop pushes it beyond the page", () => {
523 const items = [
524 pinnedHeader,
525 thread("p", "pinned"),
526 divider,
527 thread("a", "active"),
528 settledHeader,
529 thread("s", "settled"),
530 ];
531 const input = {
532 items,
533 settledOrder: ["a", "s", "hidden"],
534 settledExpanded: true,
535 settledVisibleCount: 1,
536 };
537 const withRoute = preview({ ...input, routeThreadKey: "s" }, "a", "s");
538 const withoutRoute = preview(input, "a", "s");
539 expect(withRoute.get("s")).toEqual({ ...stationary, y: -9 });
540 expect(withoutRoute.get("s")?.scaleY).toBe(0);
541 });
542
543 it("reserves the next page row when a visible settled thread leaves", () => {
544 const items = [
545 pinnedHeader,
546 thread("p", "pinned"),
547 divider,
548 thread("a", "active"),
549 settledHeader,
550 thread("s1", "settled"),
551 thread("route", "settled"),
552 ];
553 const result = preview(
554 {
555 items,
556 settledOrder: ["s1", "hidden", "route"],
557 settledExpanded: true,
558 settledVisibleCount: 1,
559 routeThreadKey: "route",
560 },
561 "s1",
562 "a",
563 );
564 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83);
565 expect(result.get("route")?.y).toBe(83);
566 });
567
568 it("keeps the dropped route thread visible in a collapsed settled shelf", () => {
569 const items = [
570 pinnedHeader,
571 thread("p", "pinned"),
572 divider,
573 thread("a", "active"),
574 settledHeader,
575 marker("settled-placeholder"),
576 ];
577 const result = preview(
578 {
579 items,
580 settledOrder: ["a", "hidden"],
581 settledExpanded: false,
582 settledVisibleCount: 1,
583 routeThreadKey: "a",
584 },
585 "a",
586 sidebarMarkerId("settled-placeholder"),
587 );
588 expect(result.get(sidebarMarkerId("settled-placeholder"))?.scaleY).toBe(0);
589 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-46);
590 });
591
592 it("preserves hidden snoozed membership when the only rendered route row leaves", () => {
593 const items = [
594 pinnedHeader,
595 thread("p", "pinned"),
596 divider,
597 thread("a", "active"),
598 marker("snoozed-header"),
599 thread("z", "snoozed"),
600 settledHeader,
601 thread("s", "settled"),
602 ];
603 const result = preview(
604 {
605 items,
606 settledOrder: ["s"],
607 settledExpanded: true,
608 snoozedThreadCount: 2,
609 },
610 "z",
611 "a",
612 );
613 expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: 83 });
614 expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(46);
615 });
616});
apps/web/src/components/Sidebar.drag.ts created+175
......@@ -0,0 +1,175 @@
1import { closestCenter, type CollisionDetection } from "@dnd-kit/core";
2import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable";
3import {
4 resolveSidebarDropTarget,
5 sidebarListItemId,
6 sidebarMarkerId,
7 type SidebarListItem,
8 type SidebarListMarker,
9 type SidebarSection,
10} from "./Sidebar.logic";
11
12const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 };
13const hidden = { ...stationary, scaleY: 0 };
14type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>;
15type Layout = Parameters<SortingStrategy>[0];
16
17/** Reject the nearest unsupported target without selecting another section.
18 * Recreate this detector when drop eligibility changes. */
19export function createSidebarCollisionDetection(
20 isValidTarget: (id: string) => boolean,
21 options: { emptyPins?: boolean; activationY?: number | null } = {},
22): CollisionDetection {
23 const validity = new Map<string, boolean>();
24 const pinnedHeaderId = sidebarMarkerId("pinned-header");
25 return (args) => {
26 let collisions = closestCenter(args);
27 const pinnedRect = options.emptyPins ? args.droppableRects.get(pinnedHeaderId) : undefined;
28 const pointer = args.pointerCoordinates;
29 // The card itself is clamped by the scroll container. An upward pointer
30 // gesture can still reach the empty pinned boundary without reserving a row.
31 if (
32 pinnedRect &&
33 pointer &&
34 options.activationY != null &&
35 pointer.y <= options.activationY - 6 &&
36 pointer.y <= pinnedRect.top + 8 &&
37 pointer.x >= pinnedRect.left &&
38 pointer.x <= pinnedRect.right
39 ) {
40 const pinned = collisions.find((collision) => collision.id === pinnedHeaderId);
41 if (pinned) {
42 collisions = [pinned, ...collisions.filter((collision) => collision !== pinned)];
43 }
44 }
45 const nearest = collisions[0];
46 if (!nearest || nearest.id === args.active.id) return collisions;
47 const id = String(nearest.id);
48 const valid = validity.get(id) ?? isValidTarget(id);
49 validity.set(id, valid);
50 return valid ? collisions : collisions.filter((collision) => collision.id === args.active.id);
51 };
52}
53
54/** Preview the committed section layout without moving or mounting DOM nodes.
55 * A zero scaleY marks rows/markers to hide while retaining their measured nodes. */
56export function createSidebarSortingStrategy(input: {
57 items: readonly SidebarListItem[];
58 settledOrder: readonly string[];
59 settledExpanded: boolean;
60 settledVisibleCount?: number;
61 routeThreadKey?: string | null;
62 snoozedThreadCount?: number;
63 cardHeight?: number;
64 slimHeight?: number;
65}): SortingStrategy {
66 const { items } = input;
67 const indices = new Map(items.map((item, index) => [sidebarListItemId(item), index]));
68 let previous: Pick<Layout, "rects" | "activeIndex" | "overIndex"> | undefined;
69 let transforms: ReturnType<SortingStrategy>[] | null = [];
70
71 function project({ rects, activeIndex, overIndex }: Layout) {
72 const active = items[activeIndex];
73 const over = items[overIndex];
74 if (active?.kind !== "thread" || !over || !rects[0]) return [];
75 const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over));
76 if (!target) return [];
77 if (target.section === active.section && over.kind === "thread")
78 return target.section === "settled" ? [] : null;
79 const groups: Record<SidebarSection, ThreadItem[]> = {
80 pinned: [],
81 active: [],
82 snoozed: [],
83 settled: [],
84 };
85 let cardHeight = input.cardHeight;
86 let slimHeight = input.slimHeight;
87 for (const [index, item] of items.entries()) {
88 if (item.kind === "marker") {
89 if (item.marker.endsWith("placeholder")) slimHeight ??= rects[index]?.height;
90 continue;
91 }
92 if (item.section === "pinned" || item.section === "active")
93 cardHeight ??= rects[index]?.height;
94 else slimHeight ??= rects[index]?.height;
95 if (item.key !== active.key) groups[item.section].push(item);
96 }
97 // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9.
98 const scale = slimHeight !== undefined ? slimHeight / 36 : (cardHeight ?? 82) / 82;
99 cardHeight ??= 82 * scale;
100 slimHeight ??= 36 * scale;
101 const group = groups[target.section];
102 const order =
103 target.section === "pinned"
104 ? target.pinnedOrder
105 : target.section === "settled"
106 ? input.settledOrder
107 : target.activeOrder;
108 const ranks = new Map(order.map((key, index) => [key, index]));
109 const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY;
110 const index = group.findIndex(
111 (item) => (ranks.get(item.key) ?? Number.POSITIVE_INFINITY) > rank,
112 );
113 group.splice(index < 0 ? group.length : index, 0, { ...active, section: target.section });
114 const settledOrder = (
115 input.settledOrder.length > 0 ? input.settledOrder : groups.settled.map((item) => item.key)
116 ).filter((key) => key !== active.key || target.section === "settled");
117 const visible = input.settledExpanded
118 ? settledOrder.slice(0, input.settledVisibleCount ?? settledOrder.length)
119 : [];
120 const routeKey = input.routeThreadKey;
121 if (routeKey && settledOrder.includes(routeKey) && !visible.includes(routeKey)) {
122 visible.push(routeKey);
123 }
124 groups.settled = visible.map((key) => ({ kind: "thread", key, section: "settled" }));
125 const projected: SidebarListItem[] = [];
126 const marker = (name: SidebarListMarker) => projected.push({ kind: "marker", marker: name });
127 const section = (name: "active" | "settled") => {
128 if (groups[name].length > 0) projected.push(...groups[name]);
129 else marker(`${name}-placeholder`);
130 };
131 marker("pinned-header");
132 projected.push(...groups.pinned);
133 marker("pinned-divider");
134 section("active");
135 if (
136 groups.snoozed.length > 0 ||
137 ((active.section !== "snoozed" || (input.snoozedThreadCount ?? 0) > 1) &&
138 items.some((item) => item.kind === "marker" && item.marker === "snoozed-header"))
139 ) {
140 marker("snoozed-header");
141 projected.push(...groups.snoozed);
142 }
143 marker("settled-header");
144 section("settled");
145 const result = items.map(() => hidden);
146 let top = rects[0].top;
147 for (const item of projected) {
148 const index = indices.get(sidebarListItemId(item));
149 const rect = index === undefined ? undefined : rects[index];
150 if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top };
151 const fallback =
152 item.kind === "thread" && (item.section === "pinned" || item.section === "active")
153 ? cardHeight
154 : slimHeight;
155 const moved = item.kind === "thread" && item.key === active.key;
156 top += (moved ? fallback : (rect?.height ?? fallback)) + 1;
157 }
158 result[activeIndex] = stationary;
159 return result;
160 }
161
162 return (args) => {
163 if (
164 previous?.rects !== args.rects ||
165 previous.activeIndex !== args.activeIndex ||
166 previous.overIndex !== args.overIndex
167 ) {
168 previous = args;
169 transforms = project(args);
170 }
171 return transforms === null
172 ? verticalListSortingStrategy(args)
173 : (transforms[args.index] ?? stationary);
174 };
175}
apps/web/src/components/Sidebar.logic.test.ts+722-4
......@@ -3,7 +3,8 @@ import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit
33import * as Cause from "effect/Cause";
44import { AsyncResult } from "effect/unstable/reactivity";
55import {
6 animatePinnedLayoutChanges,
6 animateSidebarLayoutChanges,
7 applySidebarThreadDrop,
78 archiveSelectedThreadEntries,
89 buildBulkTitleRegenerationContextMenuItem,
910 buildBulkUnpinContextMenuItem,
......@@ -32,14 +33,21 @@ import {
3233 shouldRecedeSidebarThread,
3334 sortLogicalProjectsForSidebar,
3435 sortSettledThreadsForSidebar,
36 resolveSidebarDropTarget,
3537 pinOrderKeyBetween,
3638 planPinnedReorder,
39 planSidebarThreadDrop,
40 sidebarMarkerId,
41 sidebarListItemId,
3742 sortPinnedThreadsForSidebar,
3843 sortThreadsForSidebar,
3944 sortProjectsForSidebar,
4045 sortScopedProjectsForSidebar,
4146 shouldCreateNewThreadInCurrentProject,
4247 THREAD_JUMP_HINT_SHOW_DELAY_MS,
48 type SidebarListItem,
49 type SidebarListMarker,
50 type SidebarSection,
4351} from "./Sidebar.logic";
4452import {
4553 EnvironmentId,
......@@ -53,12 +61,13 @@ import {
5361 DEFAULT_INTERACTION_MODE,
5462 DEFAULT_RUNTIME_MODE,
5563 type Project,
64 type SidebarThreadSummary,
5665 type Thread,
5766} from "../types";
5867
5968const localEnvironmentId = EnvironmentId.make("environment-local");
6069
61describe("animatePinnedLayoutChanges", () => {
70describe("animateSidebarLayoutChanges", () => {
6271 const baseArgs: Parameters<AnimateLayoutChanges>[0] = {
6372 active: null,
6473 containerId: "pinned-threads",
......@@ -76,11 +85,11 @@ describe("animatePinnedLayoutChanges", () => {
7685
7786 it("does not replay layout movement after the pointer is released", () => {
7887 expect(defaultAnimateLayoutChanges(baseArgs)).toBe(true);
79 expect(animatePinnedLayoutChanges(baseArgs)).toBe(false);
88 expect(animateSidebarLayoutChanges(baseArgs)).toBe(false);
8089 });
8190
8291 it("keeps layout movement while the user is sorting", () => {
83 expect(animatePinnedLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true);
92 expect(animateSidebarLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true);
8493 });
8594});
8695
......@@ -1025,6 +1034,715 @@ describe("planPinnedReorder", () => {
10251034 });
10261035});
10271036
1037describe("resolveSidebarDropTarget", () => {
1038 const thread = (key: string, section: SidebarSection): SidebarListItem => ({
1039 kind: "thread",
1040 key,
1041 section,
1042 });
1043 const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker });
1044 // Pinned p1 p2 | Active a1 a2 | Snoozed z1 | Settled s1
1045 const items: readonly SidebarListItem[] = [
1046 marker("pinned-header"),
1047 thread("p1", "pinned"),
1048 thread("p2", "pinned"),
1049 marker("pinned-divider"),
1050 thread("a1", "active"),
1051 thread("a2", "active"),
1052 marker("snoozed-header"),
1053 thread("z1", "snoozed"),
1054 marker("settled-header"),
1055 thread("s1", "settled"),
1056 ];
1057 const resolve = (activeKey: string, overId: string) =>
1058 resolveSidebarDropTarget(items, activeKey, overId);
1059
1060 it("keeps marker-like scoped thread keys draggable", () => {
1061 const key = "marker:pinned-header";
1062 const list: SidebarListItem[] = [
1063 marker("pinned-header"),
1064 thread(key, "pinned"),
1065 thread("env:other", "pinned"),
1066 marker("pinned-divider"),
1067 ];
1068 expect(new Set(list.map(sidebarListItemId)).size).toBe(list.length);
1069 expect(resolveSidebarDropTarget(list, key, "env:other")).toEqual({
1070 section: "pinned",
1071 pinnedOrder: ["env:other", key],
1072 activeOrder: [],
1073 });
1074 });
1075
1076 it("reads the section off the markers above the gap", () => {
1077 expect(resolve("p1", "a2")).toEqual({
1078 section: "active",
1079 pinnedOrder: ["p2"],
1080 activeOrder: ["a1", "a2", "p1"],
1081 });
1082 expect(resolve("a1", "s1")).toEqual({
1083 section: "settled",
1084 pinnedOrder: ["p1", "p2"],
1085 activeOrder: ["a2"],
1086 });
1087 expect(resolve("s1", "a1")).toEqual({
1088 section: "active",
1089 pinnedOrder: ["p1", "p2"],
1090 activeOrder: ["s1", "a1", "a2"],
1091 });
1092 });
1093
1094 it("uses arrayMove placement, so a marker hovered from below lands above it", () => {
1095 // Dragging a1 up onto the divider: the divider shifts down, a1 becomes
1096 // the last pinned row.
1097 expect(resolve("a1", sidebarMarkerId("pinned-divider"))).toEqual({
1098 section: "pinned",
1099 pinnedOrder: ["p1", "p2", "a1"],
1100 activeOrder: ["a2"],
1101 });
1102 // Dragging p2 down onto the divider: the divider shifts up, p2 is the
1103 // first inbox row — an unpin.
1104 expect(resolve("p2", sidebarMarkerId("pinned-divider"))).toEqual({
1105 section: "active",
1106 pinnedOrder: ["p1"],
1107 activeOrder: ["p2", "a1", "a2"],
1108 });
1109 // Same on the Settled header: from above it settles; from below the
1110 // gap lands in whatever is above the header — here the snoozed shelf,
1111 // which is never a target.
1112 expect(resolve("a2", sidebarMarkerId("settled-header"))?.section).toBe("settled");
1113 expect(resolve("s1", sidebarMarkerId("settled-header"))).toBeNull();
1114 });
1115
1116 it("reorders inside the pinned block with the dragged row at the over slot", () => {
1117 expect(resolve("p1", "p2")).toEqual({
1118 section: "pinned",
1119 pinnedOrder: ["p2", "p1"],
1120 activeOrder: ["a1", "a2"],
1121 });
1122 expect(resolve("a2", "p1")).toEqual({
1123 section: "pinned",
1124 pinnedOrder: ["a2", "p1", "p2"],
1125 activeOrder: ["a1"],
1126 });
1127 });
1128
1129 it("lands first in Pinned when hovering its permanent header", () => {
1130 expect(resolve("a2", sidebarMarkerId("pinned-header"))).toEqual({
1131 section: "pinned",
1132 pinnedOrder: ["a2", "p1", "p2"],
1133 activeOrder: ["a1"],
1134 });
1135 });
1136
1137 it("reorders active rows in either direction without changing sections", () => {
1138 for (const [from, to] of [
1139 ["a1", "a2"],
1140 ["a2", "a1"],
1141 ] as const) {
1142 expect(resolve(from, to)).toEqual({
1143 section: "active",
1144 pinnedOrder: ["p1", "p2"],
1145 activeOrder: ["a2", "a1"],
1146 });
1147 }
1148 });
1149
1150 it("never lands in the snoozed shelf", () => {
1151 expect(resolve("a1", "z1")).toBeNull();
1152 expect(resolve("a1", sidebarMarkerId("snoozed-header"))).toBeNull();
1153 });
1154
1155 it("lands on a placeholder when the section is otherwise empty", () => {
1156 const withPlaceholder: readonly SidebarListItem[] = [
1157 marker("pinned-header"),
1158 marker("pinned-divider"),
1159 thread("a1", "active"),
1160 marker("settled-header"),
1161 marker("settled-placeholder"),
1162 ];
1163 expect(
1164 resolveSidebarDropTarget(withPlaceholder, "a1", sidebarMarkerId("settled-placeholder")),
1165 ).toEqual({ section: "settled", pinnedOrder: [], activeOrder: [] });
1166 });
1167
1168 it("lands in empty Pinned using its header without an extra placeholder", () => {
1169 const emptyPinned: readonly SidebarListItem[] = [
1170 marker("pinned-header"),
1171 marker("pinned-divider"),
1172 thread("a1", "active"),
1173 ];
1174 expect(resolveSidebarDropTarget(emptyPinned, "a1", sidebarMarkerId("pinned-header"))).toEqual({
1175 section: "pinned",
1176 pinnedOrder: ["a1"],
1177 activeOrder: [],
1178 });
1179 expect(resolveSidebarDropTarget(emptyPinned, "a1", sidebarMarkerId("pinned-divider"))).toEqual({
1180 section: "pinned",
1181 pinnedOrder: ["a1"],
1182 activeOrder: [],
1183 });
1184 });
1185
1186 it("rejects ids that are not in the list", () => {
1187 expect(resolve("a1", "nope")).toBeNull();
1188 expect(resolve("nope", "a1")).toBeNull();
1189 expect(resolve(sidebarMarkerId("pinned-divider"), "a1")).toBeNull();
1190 });
1191});
1192
1193describe("planSidebarThreadDrop", () => {
1194 const pinnedKeysById = new Map<string, string | null>([
1195 ["p1", "f"],
1196 ["p2", "m"],
1197 ["p3", "t"],
1198 ]);
1199 const activeKeysById = new Map<string, string | null>([
1200 ["a1", "f"],
1201 ["a2", "m"],
1202 ["a3", "t"],
1203 ]);
1204 const plan = (
1205 overrides: Partial<Omit<Parameters<typeof planSidebarThreadDrop>[0], "target">> & {
1206 activeKey: string;
1207 activeSection: "pinned" | "active" | "snoozed" | "settled";
1208 target: Omit<Parameters<typeof planSidebarThreadDrop>[0]["target"], "activeOrder"> & {
1209 activeOrder?: readonly string[];
1210 };
1211 },
1212 ) =>
1213 planSidebarThreadDrop({
1214 pinnedOrder: ["p1", "p2", "p3"],
1215 pinnedKeysById,
1216 activeOrder: ["a1", "a2", "a3"],
1217 activeKeysById,
1218 ...overrides,
1219 target: { activeOrder: [], ...overrides.target },
1220 });
1221
1222 it("allows old-server pinned reordering while rejecting settlement", () => {
1223 expect(
1224 plan({
1225 activeKey: "p1",
1226 activeSection: "pinned",
1227 supportsSettlement: false,
1228 target: { section: "pinned", pinnedOrder: ["p2", "p1", "p3"] },
1229 }).kind,
1230 ).toBe("reorder-pinned");
1231 expect(
1232 plan({
1233 activeKey: "p1",
1234 activeSection: "pinned",
1235 supportsSettlement: false,
1236 target: { section: "settled", pinnedOrder: ["p2", "p3"] },
1237 }),
1238 ).toEqual({ kind: "none" });
1239 });
1240
1241 it.each(["pinned", "active"] as const)("reserves hidden %s slots during a drop", (section) => {
1242 const order = section === "pinned" ? ["p2", "p1", "p3"] : ["a2", "a1", "a3"];
1243 const keys = new Map(section === "pinned" ? pinnedKeysById : activeKeysById);
1244 const moved = section === "pinned" ? "p1" : "a1";
1245 const reserved = pinOrderKeyBetween(keys.get(order[0]!)!, keys.get(order[2]!)!)!;
1246 keys.set("snoozed", reserved);
1247 const result = plan({
1248 activeKey: moved,
1249 activeSection: section,
1250 pinnedKeysById: section === "pinned" ? keys : pinnedKeysById,
1251 activeKeysById: section === "active" ? keys : activeKeysById,
1252 target: {
1253 section,
1254 pinnedOrder: section === "pinned" ? order : [],
1255 activeOrder: section === "active" ? order : [],
1256 },
1257 });
1258 if (result.kind !== "reorder-pinned" && result.kind !== "move-active")
1259 throw new Error("Expected reorder");
1260 expect(result.assignments).toHaveLength(1);
1261 expect(result.assignments[0]!.orderKey).not.toBe(reserved);
1262 });
1263
1264 it.each([
1265 { key: "p2", section: "pinned" as const, unpin: true, unsettle: false, unsnooze: false },
1266 { key: "s1", section: "settled" as const, unpin: false, unsettle: true, unsnooze: false },
1267 { key: "z1", section: "snoozed" as const, unpin: false, unsettle: false, unsnooze: true },
1268 ])("moves a $section thread to the chosen Active slot", (source) => {
1269 const order = ["a1", source.key, "a2", "a3"];
1270 const result = plan({
1271 activeKey: source.key,
1272 activeSection: source.section,
1273 target: { section: "active", pinnedOrder: [], activeOrder: order },
1274 });
1275 expect(result).toEqual({
1276 kind: "move-active",
1277 order,
1278 assignments: [{ id: source.key, orderKey: expect.any(String) }],
1279 unpin: source.unpin,
1280 unsettle: source.unsettle,
1281 unsnooze: source.unsnooze,
1282 });
1283 if (result.kind !== "move-active") return;
1284 const key = result.assignments[0]!.orderKey;
1285 expect(key > "f" && key < "m").toBe(true);
1286 });
1287
1288 it.each([
1289 { state: "pinned", activePinned: true, activeSettled: false },
1290 { state: "settled", activePinned: false, activeSettled: true },
1291 { state: "pinned and settled", activePinned: true, activeSettled: true },
1292 ])("clears a snoozed thread's $state state before waking it into Active", (hiddenState) => {
1293 expect(
1294 plan({
1295 activeKey: "z1",
1296 activeSection: "snoozed",
1297 activePinned: hiddenState.activePinned,
1298 activeSettled: hiddenState.activeSettled,
1299 target: {
1300 section: "active",
1301 pinnedOrder: ["p1", "p2", "p3"],
1302 activeOrder: ["a1", "z1", "a2", "a3"],
1303 },
1304 }),
1305 ).toEqual({
1306 kind: "move-active",
1307 order: ["a1", "z1", "a2", "a3"],
1308 assignments: [{ id: "z1", orderKey: expect.any(String) }],
1309 unpin: hiddenState.activePinned,
1310 unsettle: hiddenState.activeSettled,
1311 unsnooze: true,
1312 });
1313 });
1314
1315 it("saves the first Active reorder, then moves only one key on subsequent drops", () => {
1316 const rows = ["a1", "a2", "a3"].map((id, index) => ({
1317 id,
1318 createdAt: new Date(Date.UTC(2026, 8, 4, 12 - index)).toISOString(),
1319 activeOrderKey: null as string | null,
1320 }));
1321 const firstOrder = ["a2", "a3", "a1"];
1322 const first = plan({
1323 activeKey: "a1",
1324 activeSection: "active",
1325 target: { section: "active", pinnedOrder: [], activeOrder: firstOrder },
1326 activeKeysById: new Map(rows.map((row) => [row.id, row.activeOrderKey])),
1327 });
1328 expect(first.kind).toBe("move-active");
1329 if (first.kind !== "move-active") return;
1330 expect(first.unpin || first.unsettle || first.unsnooze).toBe(false);
1331 const savedKeys = new Map(first.assignments.map(({ id, orderKey }) => [id, orderKey]));
1332 const savedRows = rows.map((row) => ({
1333 ...row,
1334 activeOrderKey: savedKeys.get(row.id) ?? null,
1335 }));
1336 expect(sortThreadsForSidebar(savedRows).map((row) => row.id)).toEqual(firstOrder);
1337
1338 const secondOrder = ["a2", "a1", "a3"];
1339 const second = plan({
1340 activeKey: "a1",
1341 activeSection: "active",
1342 activeOrder: firstOrder,
1343 activeKeysById: savedKeys,
1344 target: { section: "active", pinnedOrder: [], activeOrder: secondOrder },
1345 });
1346 expect(second.kind).toBe("move-active");
1347 if (second.kind !== "move-active") return;
1348 expect(second.assignments).toEqual([{ id: "a1", orderKey: expect.any(String) }]);
1349 const finalRows = savedRows.map((row) =>
1350 row.id === "a1" ? { ...row, activeOrderKey: second.assignments[0]!.orderKey } : row,
1351 );
1352 expect(sortThreadsForSidebar(finalRows).map((row) => row.id)).toEqual(secondOrder);
1353 });
1354
1355 it("does not write when an Active thread is dropped in its existing slot", () => {
1356 expect(
1357 plan({
1358 activeKey: "a2",
1359 activeSection: "active",
1360 target: { section: "active", pinnedOrder: [], activeOrder: ["a1", "a2", "a3"] },
1361 }),
1362 ).toEqual({ kind: "none" });
1363 });
1364
1365 it("requires Active ordering support only for the threads whose keys must change", () => {
1366 const input = {
1367 activeKey: "a3",
1368 activeSection: "active" as const,
1369 target: { section: "active" as const, pinnedOrder: [], activeOrder: ["a1", "a3", "a2"] },
1370 activeReorderableKeys: new Set(["a3"]),
1371 };
1372 expect(plan(input).kind).toBe("move-active");
1373 expect(
1374 plan({
1375 ...input,
1376 activeKeysById: new Map([
1377 ["a1", null],
1378 ["a2", "m"],
1379 ["a3", "t"],
1380 ]),
1381 }),
1382 ).toEqual({ kind: "none" });
1383 expect(plan({ ...input, activeReorderableKeys: new Set() })).toEqual({ kind: "none" });
1384 });
1385
1386 it("settles anything dropped on Settled except a settled thread", () => {
1387 const target = { section: "settled", pinnedOrder: ["p1", "p2", "p3"] } as const;
1388 expect(plan({ activeKey: "a1", activeSection: "active", target })).toEqual({ kind: "settle" });
1389 expect(plan({ activeKey: "p1", activeSection: "pinned", target })).toEqual({ kind: "settle" });
1390 expect(plan({ activeKey: "z1", activeSection: "snoozed", target })).toEqual({ kind: "settle" });
1391 expect(plan({ activeKey: "s1", activeSection: "settled", target })).toEqual({ kind: "none" });
1392 });
1393
1394 it("pins a foreign thread with a key between its new neighbors", () => {
1395 const result = plan({
1396 activeKey: "a1",
1397 activeSection: "active",
1398 target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] },
1399 });
1400 expect(result.kind).toBe("pin");
1401 if (result.kind !== "pin") return;
1402 expect(result.order).toEqual(["p1", "a1", "p2", "p3"]);
1403 expect(result.orderKey).toBeDefined();
1404 expect(result.orderKey! > "f" && result.orderKey! < "m").toBe(true);
1405 expect(result.extraAssignments).toEqual([]);
1406
1407 const empty = plan({
1408 activeKey: "a1",
1409 activeSection: "active",
1410 target: { section: "pinned", pinnedOrder: ["a1"] },
1411 pinnedOrder: [],
1412 pinnedKeysById: new Map(),
1413 });
1414 expect(empty.kind).toBe("pin");
1415 if (empty.kind !== "pin") return;
1416 expect(empty.orderKey).toBeDefined();
1417 });
1418
1419 it("reorders an already-pinned snoozed thread after pinning wakes it", () => {
1420 const result = plan({
1421 activeKey: "z1",
1422 activeSection: "snoozed",
1423 activePinned: true,
1424 target: { section: "pinned", pinnedOrder: ["p1", "z1", "p2", "p3"] },
1425 pinnedKeysById: new Map([...pinnedKeysById, ["z1", "x"]]),
1426 });
1427 expect(result.kind).toBe("pin");
1428 if (result.kind !== "pin") return;
1429 expect(result.extraAssignments).toEqual([{ id: "z1", orderKey: result.orderKey }]);
1430 expect(result.orderKey! > "f" && result.orderKey! < "m").toBe(true);
1431 });
1432
1433 it("uses keyed disabled neighbors as anchors without writing to them", () => {
1434 const insertion = plan({
1435 activeKey: "a1",
1436 activeSection: "active",
1437 target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] },
1438 reorderableKeys: new Set(["a1"]),
1439 });
1440 expect(insertion.kind).toBe("pin");
1441 if (insertion.kind !== "pin") return;
1442 expect(insertion.order).toEqual(["p1", "a1", "p2", "p3"]);
1443 expect(insertion.orderKey! > "f" && insertion.orderKey! < "m").toBe(true);
1444 expect(insertion.extraAssignments).toEqual([]);
1445
1446 const reorder = plan({
1447 activeKey: "p3",
1448 activeSection: "pinned",
1449 target: { section: "pinned", pinnedOrder: ["p1", "p3", "p2"] },
1450 reorderableKeys: new Set(["p3"]),
1451 });
1452 expect(reorder.kind).toBe("reorder-pinned");
1453 if (reorder.kind !== "reorder-pinned") return;
1454 expect(reorder.assignments).toEqual([{ id: "p3", orderKey: expect.any(String) }]);
1455 expect(reorder.assignments[0]!.orderKey > "f").toBe(true);
1456 expect(reorder.assignments[0]!.orderKey < "m").toBe(true);
1457 });
1458
1459 it.each([
1460 {
1461 activeKey: "a1",
1462 activeSection: "active" as const,
1463 order: ["p1", "p3", "a1", "p2"],
1464 },
1465 { activeKey: "p1", activeSection: "pinned" as const, order: ["p3", "p1", "p2"] },
1466 ])("rejects $activeSection drops that require rewriting a disabled neighbor", (source) => {
1467 expect(
1468 plan({
1469 activeKey: source.activeKey,
1470 activeSection: source.activeSection,
1471 target: { section: "pinned", pinnedOrder: source.order },
1472 pinnedOrder: ["p1", "p3", "p2"],
1473 pinnedKeysById: new Map([
1474 ["p1", "f"],
1475 ["p2", null],
1476 ["p3", "t"],
1477 ]),
1478 reorderableKeys: new Set(["p1", "p3", source.activeKey]),
1479 }),
1480 ).toEqual({ kind: "none" });
1481 });
1482
1483 it("rewrites the section when a foreign thread lands next to a keyless pin", () => {
1484 const result = plan({
1485 activeKey: "a1",
1486 activeSection: "active",
1487 target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] },
1488 pinnedKeysById: new Map([
1489 ["p1", null],
1490 ["p2", "m"],
1491 ["p3", "t"],
1492 ]),
1493 });
1494 expect(result.kind).toBe("pin");
1495 if (result.kind !== "pin") return;
1496 expect(result.orderKey).toBeDefined();
1497 expect(result.extraAssignments.map((entry) => entry.id)).toEqual(["p1", "p2", "p3"]);
1498 const byId = new Map([
1499 ["a1", result.orderKey!],
1500 ...result.extraAssignments.map((e) => [e.id, e.orderKey] as const),
1501 ]);
1502 const ordered = result.order.map((id) => byId.get(id)!);
1503 expect([...ordered].sort()).toEqual(ordered);
1504 });
1505
1506 it("reorders within the pinned block, and is a no-op when the order is unchanged", () => {
1507 const down = plan({
1508 activeKey: "p1",
1509 activeSection: "pinned",
1510 target: { section: "pinned", pinnedOrder: ["p2", "p3", "p1"] },
1511 });
1512 expect(down.kind).toBe("reorder-pinned");
1513 if (down.kind !== "reorder-pinned") return;
1514 expect(down.assignments).toEqual([{ id: "p1", orderKey: expect.any(String) }]);
1515 expect(down.assignments[0]!.orderKey > "t").toBe(true);
1516
1517 expect(
1518 plan({
1519 activeKey: "p1",
1520 activeSection: "pinned",
1521 target: { section: "pinned", pinnedOrder: ["p1", "p2", "p3"] },
1522 }),
1523 ).toEqual({ kind: "none" });
1524 });
1525});
1526
1527describe("applySidebarThreadDrop", () => {
1528 const createdAt = "2026-03-09T08:00:00.000Z";
1529 const earlier = "2026-03-09T09:00:00.000Z";
1530 const now = "2026-03-09T12:00:00.000Z";
1531 const serverNow = "2026-03-09T12:00:01.000Z";
1532 const wakeAt = "2026-03-10T08:00:00.000Z";
1533 const thread = (overrides: Partial<SidebarThreadSummary> = {}) => ({
1534 id: ThreadId.make("dragged"),
1535 title: "Keep this title",
1536 createdAt,
1537 updatedAt: earlier,
1538 latestUserMessageAt: null,
1539 latestTurn: null,
1540 pinnedAt: null,
1541 pinOrderKey: null,
1542 activeOrderKey: null,
1543 snoozedAt: null,
1544 snoozedUntil: null,
1545 settledAt: null,
1546 settledOverride: null,
1547 unsettledAt: null,
1548 ...overrides,
1549 });
1550 const newer = thread({ id: ThreadId.make("newer"), createdAt: "2026-03-09T11:00:00.000Z" });
1551
1552 it("previews an un-settle at the same active position as the eventual server row", () => {
1553 const source = thread({ settledOverride: "settled", settledAt: earlier });
1554 const preview = applySidebarThreadDrop(source, "active", now);
1555 const final = {
1556 ...source,
1557 settledOverride: "active" as const,
1558 settledAt: null,
1559 unsettledAt: serverNow,
1560 };
1561 expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual([
1562 "dragged",
1563 "newer",
1564 ]);
1565 expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual(
1566 sortThreadsForSidebar([newer, final]).map((row) => row.id),
1567 );
1568 });
1569
1570 it.each([
1571 { state: "pin", pinnedAt: earlier, pinOrderKey: "m", snoozedAt: null, snoozedUntil: null },
1572 {
1573 state: "snooze",
1574 pinnedAt: null,
1575 pinOrderKey: null,
1576 snoozedAt: earlier,
1577 snoozedUntil: wakeAt,
1578 },
1579 {
1580 state: "snoozed pin",
1581 pinnedAt: earlier,
1582 pinOrderKey: "m",
1583 snoozedAt: earlier,
1584 snoozedUntil: wakeAt,
1585 },
1586 ])("preserves the active sort anchor when clearing a $state", ({ state: _state, ...parked }) => {
1587 const source = thread({ ...parked, settledOverride: "active", unsettledAt: earlier });
1588 const preview = applySidebarThreadDrop(source, "active", now);
1589 const final = {
1590 ...source,
1591 pinnedAt: null,
1592 pinOrderKey: null,
1593 snoozedAt: null,
1594 snoozedUntil: null,
1595 updatedAt: serverNow,
1596 };
1597 expect(preview).toEqual({ ...final, updatedAt: source.updatedAt });
1598 expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual([
1599 "newer",
1600 "dragged",
1601 ]);
1602 expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual(
1603 sortThreadsForSidebar([newer, final]).map((row) => row.id),
1604 );
1605 });
1606
1607 it("clears underlying pinning and settlement when waking into Active", () => {
1608 const source = thread({
1609 pinnedAt: earlier,
1610 pinOrderKey: "m",
1611 snoozedAt: earlier,
1612 snoozedUntil: wakeAt,
1613 settledOverride: "settled",
1614 settledAt: earlier,
1615 });
1616 expect(applySidebarThreadDrop(source, "active", now)).toEqual({
1617 ...source,
1618 pinnedAt: null,
1619 pinOrderKey: null,
1620 snoozedAt: null,
1621 snoozedUntil: null,
1622 settledOverride: "active",
1623 settledAt: null,
1624 unsettledAt: now,
1625 });
1626 });
1627
1628 it("previews a new settlement at the same position as the eventual server row", () => {
1629 const source = thread({
1630 pinnedAt: earlier,
1631 pinOrderKey: "m",
1632 snoozedAt: earlier,
1633 snoozedUntil: wakeAt,
1634 unsettledAt: earlier,
1635 });
1636 const preview = applySidebarThreadDrop(source, "settled", now);
1637 const final = {
1638 ...source,
1639 pinnedAt: null,
1640 pinOrderKey: null,
1641 snoozedAt: null,
1642 snoozedUntil: null,
1643 settledOverride: "settled" as const,
1644 settledAt: serverNow,
1645 unsettledAt: null,
1646 };
1647 const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };
1648 expect(preview).toEqual({ ...final, settledAt: now });
1649 expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([
1650 "dragged",
1651 "newer",
1652 ]);
1653 expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual(
1654 sortSettledThreadsForSidebar([existing, final]).map((row) => row.id),
1655 );
1656 });
1657
1658 it("retains a snoozed thread's earlier settlement and its position when settling again", () => {
1659 const source = thread({
1660 snoozedAt: earlier,
1661 snoozedUntil: wakeAt,
1662 settledOverride: "settled",
1663 settledAt: earlier,
1664 });
1665 const preview = applySidebarThreadDrop(source, "settled", now);
1666 const final = { ...source, snoozedAt: null, snoozedUntil: null };
1667 const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };
1668 expect(preview).toEqual(final);
1669 expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([
1670 "newer",
1671 "dragged",
1672 ]);
1673 });
1674
1675 it("pins a settled thread at its requested slot and projects the re-entry stamp", () => {
1676 const source = thread({
1677 snoozedAt: earlier,
1678 snoozedUntil: wakeAt,
1679 settledOverride: "settled",
1680 settledAt: earlier,
1681 });
1682 const original = { ...source };
1683 const preview = applySidebarThreadDrop(source, "pinned", now, "m");
1684 expect(preview).toEqual({
1685 ...source,
1686 pinnedAt: now,
1687 pinOrderKey: "m",
1688 snoozedAt: null,
1689 snoozedUntil: null,
1690 settledOverride: "active",
1691 settledAt: null,
1692 unsettledAt: now,
1693 });
1694 expect(
1695 sortPinnedThreadsForSidebar([
1696 thread({ id: ThreadId.make("after"), pinnedAt: earlier, pinOrderKey: "t" }),
1697 preview,
1698 thread({ id: ThreadId.make("before"), pinnedAt: earlier, pinOrderKey: "f" }),
1699 ]).map((row) => row.id),
1700 ).toEqual(["before", "dragged", "after"]);
1701 expect(source).toEqual(original);
1702 });
1703
1704 it("keeps an existing pin's timestamp and key unless the drop supplies a new key", () => {
1705 const source = thread({
1706 pinnedAt: earlier,
1707 pinOrderKey: "t",
1708 snoozedAt: earlier,
1709 snoozedUntil: wakeAt,
1710 settledOverride: "active",
1711 unsettledAt: earlier,
1712 });
1713 const unchangedSlot = applySidebarThreadDrop(source, "pinned", now);
1714 expect(unchangedSlot).toEqual({ ...source, snoozedAt: null, snoozedUntil: null });
1715 expect(applySidebarThreadDrop(source, "pinned", now, "m")).toEqual({
1716 ...unchangedSlot,
1717 pinOrderKey: "m",
1718 });
1719 });
1720
1721 it("keeps an Active drop at its chosen position after unpinning", () => {
1722 const source = thread({ pinnedAt: earlier, pinOrderKey: "g", activeOrderKey: "z" });
1723 const preview = applySidebarThreadDrop(source, "active", now, "m");
1724 expect(preview).toMatchObject({ pinnedAt: null, pinOrderKey: null, activeOrderKey: "m" });
1725 expect(
1726 sortThreadsForSidebar([
1727 thread({ id: ThreadId.make("after"), activeOrderKey: "t" }),
1728 preview,
1729 thread({ id: ThreadId.make("before"), activeOrderKey: "f" }),
1730 ]).map((row) => row.id),
1731 ).toEqual(["before", "dragged", "after"]);
1732 });
1733
1734 it("clears the manual Active position when settling so reopening returns to the top", () => {
1735 const source = thread({ activeOrderKey: "z" });
1736 const settled = applySidebarThreadDrop(source, "settled", now);
1737 expect(settled.activeOrderKey).toBeNull();
1738 const reopened = applySidebarThreadDrop(settled, "active", serverNow);
1739 expect(sortThreadsForSidebar([newer, reopened]).map((row) => row.id)).toEqual([
1740 "dragged",
1741 "newer",
1742 ]);
1743 });
1744});
1745
10281746describe("sortPinnedThreadsForSidebar", () => {
10291747 const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({
10301748 id: input.id,
apps/web/src/components/Sidebar.logic.ts+251-25
......@@ -7,8 +7,8 @@ import {
77import type { ContextMenuItem } from "@t3tools/contracts";
88import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
99import type { AsyncResult } from "effect/unstable/reactivity";
10import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
1011import {
11 activeThreadAnchorTimestampMs,
1212 getThreadSortTimestamp,
1313 resolveSettledThreadTimestamp,
1414 sortThreads,
......@@ -81,12 +81,257 @@ export function useRetainedValue<T>(key: string | null, value: T | null): T | nu
8181 return key !== null && retained.current?.key === key ? retained.current.value : null;
8282}
8383
84// The list already reaches its destination through sortable transforms while
85// the pointer is down. dnd-kit's default also animates the committed DOM order
86// after release, replaying the same movement across every affected row.
87export const animatePinnedLayoutChanges: AnimateLayoutChanges = (args) =>
84// Sidebar.motion handles ordinary section changes. Sortable transforms own
85// dragging; replaying their committed DOM order would animate the drop twice.
86export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) =>
8887 args.isSorting ? defaultAnimateLayoutChanges(args) : false;
8988
89// Rows and section markers share one sortable list. The separators resolve
90// the lifecycle action; Sidebar.drag previews the resulting layout. Pinned
91// and active threads keep the dragged position; settled threads use time
92// order. Snoozed rows can leave the shelf, but dropping into it is not
93// supported because snoozing requires a wake time.
94
95export type SidebarSection = "pinned" | "active" | "snoozed" | "settled";
96
97/** Sortable ids: thread rows use their scoped key; structural items use a
98 colon-free prefix: scoped thread keys always contain a colon. */
99const SIDEBAR_MARKER_PREFIX = "sidebar-marker-";
100
101export type SidebarListMarker =
102 /** The top boundary is also a landing target when there are no pins. */
103 | "pinned-header"
104 /** Stand-in rows so an empty section has somewhere for the gap to open. */
105 | "active-placeholder"
106 | "settled-placeholder"
107 /** The boundary between pinned and active rows. */
108 | "pinned-divider"
109 | "snoozed-header"
110 | "settled-header";
111
112export function sidebarMarkerId(marker: SidebarListMarker): string {
113 return `${SIDEBAR_MARKER_PREFIX}${marker}`;
114}
115
116export type SidebarListItem =
117 | { readonly kind: "thread"; readonly key: string; readonly section: SidebarSection }
118 | { readonly kind: "marker"; readonly marker: SidebarListMarker };
119
120export function sidebarListItemId(item: SidebarListItem): string {
121 return item.kind === "thread" ? item.key : sidebarMarkerId(item.marker);
122}
123
124/** The section a slot belongs to, read off the markers around it: from
125 the top down, everything before the pinned divider is pinned, then the
126 inbox until the snoozed header, the shelf until the settled header,
127 then settled. */
128function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): SidebarSection {
129 let section: SidebarSection = "pinned";
130 for (let i = 0; i < index && i < items.length; i += 1) {
131 const item = items[i]!;
132 if (item.kind !== "marker") continue;
133 if (item.marker === "pinned-divider") section = "active";
134 else if (item.marker === "snoozed-header") section = "snoozed";
135 else if (item.marker === "settled-header") section = "settled";
136 }
137 return section;
138}
139
140/** Resolve the destination section and manual order from an arrayMove across
141 * the separators. The snoozed shelf is never a destination. */
142export type SidebarDropTarget = {
143 readonly section: "pinned" | "active" | "settled";
144 readonly pinnedOrder: readonly string[];
145 readonly activeOrder: readonly string[];
146};
147
148export function resolveSidebarDropTarget(
149 items: readonly SidebarListItem[],
150 activeKey: string,
151 overId: string,
152): SidebarDropTarget | null {
153 const activeIndex = items.findIndex((item) => sidebarListItemId(item) === activeKey);
154 const overIndex = items.findIndex((item) => sidebarListItemId(item) === overId);
155 if (activeIndex === -1 || overIndex === -1 || items[activeIndex]?.kind !== "thread") return null;
156 const moved = items.filter((_, index) => index !== activeIndex);
157 moved.splice(overIndex, 0, items[activeIndex]!);
158 const section = sectionAtSidebarSlot(moved, overIndex);
159 if (section === "snoozed") return null;
160 const pinnedOrder: string[] = [];
161 const activeOrder: string[] = [];
162 let currentSection: SidebarSection = "pinned";
163 for (const item of moved) {
164 if (item.kind === "marker") {
165 if (item.marker === "pinned-divider") currentSection = "active";
166 else if (item.marker === "snoozed-header" || item.marker === "settled-header") break;
167 } else if (currentSection === "pinned") pinnedOrder.push(item.key);
168 else activeOrder.push(item.key);
169 }
170 return { section, pinnedOrder, activeOrder };
171}
172
173export type SidebarThreadDropPlan =
174 | { readonly kind: "none" }
175 /** Within the pinned block: the existing key writes. */
176 | {
177 readonly kind: "reorder-pinned";
178 readonly order: readonly string[];
179 readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
180 }
181 /** From another section into the pinned block. Fresh pins take `orderKey`
182 on the pin command. `extraAssignments` land afterward, including the
183 moved row when it was already pinned beneath a snooze. */
184 | {
185 readonly kind: "pin";
186 readonly order: readonly string[];
187 readonly orderKey: string | undefined;
188 readonly extraAssignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
189 }
190 | {
191 readonly kind: "move-active";
192 readonly order: readonly string[];
193 readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>;
194 readonly unpin: boolean;
195 readonly unsettle: boolean;
196 readonly unsnooze: boolean;
197 }
198 | { readonly kind: "settle" };
199
200export function planSidebarThreadDrop(input: {
201 readonly activeKey: string;
202 readonly activeSection: SidebarSection;
203 /** Snoozed threads can retain pinning and settlement beneath the shelf. */
204 readonly activePinned?: boolean;
205 readonly activeSettled?: boolean;
206 readonly supportsSettlement?: boolean;
207 readonly target: SidebarDropTarget;
208 /** All pinned keys in displayed order before the drop. */
209 readonly pinnedOrder: readonly string[];
210 readonly pinnedKeysById: ReadonlyMap<string, string | null | undefined>;
211 readonly reorderableKeys?: ReadonlySet<string>;
212 readonly activeOrder: readonly string[];
213 readonly activeKeysById: ReadonlyMap<string, string | null | undefined>;
214 readonly activeReorderableKeys?: ReadonlySet<string>;
215}): SidebarThreadDropPlan {
216 const {
217 activeKey,
218 activeSection,
219 activePinned = activeSection === "pinned",
220 activeSettled = activeSection === "settled",
221 target,
222 pinnedOrder,
223 pinnedKeysById,
224 reorderableKeys,
225 activeOrder,
226 activeKeysById,
227 activeReorderableKeys,
228 } = input;
229 if (input.supportsSettlement === false && (target.section === "settled" || activeSettled)) {
230 return { kind: "none" };
231 }
232 switch (target.section) {
233 case "active": {
234 const order = target.activeOrder;
235 if (
236 activeSection === "active" &&
237 order.length === activeOrder.length &&
238 order.every((key, index) => key === activeOrder[index])
239 ) {
240 return { kind: "none" };
241 }
242 const assignments = planPinnedReorder({
243 orderedIds: order,
244 keysById: activeKeysById,
245 movedId: activeKey,
246 });
247 if (activeReorderableKeys && assignments.some(({ id }) => !activeReorderableKeys.has(id))) {
248 return { kind: "none" };
249 }
250 return {
251 kind: "move-active",
252 order,
253 assignments,
254 unpin: activePinned,
255 unsettle: activeSettled,
256 unsnooze: activeSection === "snoozed",
257 };
258 }
259 case "settled":
260 return activeSection === "settled" ? { kind: "none" } : { kind: "settle" };
261 case "pinned": {
262 const order = target.pinnedOrder;
263 // Dropped back where it started: nothing to write.
264 if (
265 activeSection === "pinned" &&
266 order.length === pinnedOrder.length &&
267 order.every((key, index) => key === pinnedOrder[index])
268 ) {
269 return { kind: "none" };
270 }
271 const assignments = planPinnedReorder({
272 orderedIds: order,
273 keysById: pinnedKeysById,
274 movedId: activeKey,
275 });
276 if (reorderableKeys && assignments.some(({ id }) => !reorderableKeys.has(id))) {
277 return { kind: "none" };
278 }
279 if (activeSection === "pinned") {
280 return assignments.length === 0
281 ? { kind: "none" }
282 : { kind: "reorder-pinned", order, assignments };
283 }
284 return {
285 kind: "pin",
286 order,
287 orderKey: assignments.find((assignment) => assignment.id === activeKey)?.orderKey,
288 extraAssignments: activePinned
289 ? assignments
290 : assignments.filter((assignment) => assignment.id !== activeKey),
291 };
292 }
293 }
294}
295
296/** Project a drop's lifecycle fields before sorting its destination. Reusing
297 the server's re-entry rules keeps the preview in place when events arrive. */
298export function applySidebarThreadDrop<
299 T extends Pick<
300 SidebarThreadSummary,
301 | "pinnedAt"
302 | "pinOrderKey"
303 | "activeOrderKey"
304 | "snoozedAt"
305 | "snoozedUntil"
306 | "settledAt"
307 | "settledOverride"
308 | "unsettledAt"
309 >,
310>(thread: T, section: "pinned" | "active" | "settled", now: string, orderKey?: string): T {
311 const wasSettled = thread.settledOverride === "settled";
312 const awake = { ...thread, snoozedAt: null, snoozedUntil: null };
313 if (section === "settled") {
314 return {
315 ...awake,
316 pinnedAt: null,
317 pinOrderKey: null,
318 activeOrderKey: null,
319 settledOverride: "settled",
320 settledAt: wasSettled ? (thread.settledAt ?? now) : now,
321 unsettledAt: null,
322 };
323 }
324 const resumed = wasSettled
325 ? { ...awake, settledOverride: "active" as const, settledAt: null, unsettledAt: now }
326 : awake;
327 return {
328 ...resumed,
329 pinnedAt: section === "pinned" ? (thread.pinnedAt ?? now) : null,
330 pinOrderKey: section === "pinned" ? (orderKey ?? thread.pinOrderKey) : null,
331 ...(section === "active" && orderKey !== undefined ? { activeOrderKey: orderKey } : {}),
332 };
333}
334
90335type SidebarProject = {
91336 id: string;
92337 title: string;
......@@ -605,26 +850,7 @@ function firstValidTimestamp(
605850 return null;
606851}
607852
608// Sidebar sort: static order, newest anchor on top. Activity NEVER reorders
609// the list — a row holds its position between lifecycle transitions, so the
610// screen only moves when a thread enters or leaves the active list. The
611// anchor is creation time until an un-settle re-anchors it (see
612// activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the
613// top instead of sinking back to its creation-order slot. Status (including
614// pending approval) is carried by each card's edge strip, not by position.
615export function sortThreadsForSidebar<
616 T extends {
617 readonly id: string;
618 readonly createdAt: string;
619 readonly unsettledAt?: string | null | undefined;
620 },
621>(threads: readonly T[]): T[] {
622 return [...threads].toSorted(
623 (left, right) =>
624 activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||
625 left.id.localeCompare(right.id),
626 );
627}
853export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort";
628854
629855// Pinned-reorder key math and the keyed sort live in client-runtime
630856// (state/thread-sort) so web and mobile compute identical pinned orders.
apps/web/src/components/Sidebar.motion.test.ts created+339
......@@ -0,0 +1,339 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { createSidebarListMotion } from "./Sidebar.motion";
4
5class TestAnimation {
6 progress: number | null = 0;
7 playState: AnimationPlayState = "running";
8 effect = { getComputedTiming: () => ({ progress: this.progress }) };
9 cancel = vi.fn(() => {
10 this.playState = "idle";
11 });
12 private onFinish: (() => void) | undefined;
13 addEventListener(_type: string, listener: () => void) {
14 this.onFinish = listener;
15 }
16 finish() {
17 this.playState = "finished";
18 this.onFinish?.();
19 }
20}
21
22class TestRow {
23 offsetTop = 0;
24 offsetLeft = 4;
25 offsetWidth = 260;
26 namespaceURI = "http://www.w3.org/1999/xhtml";
27 dragTranslate = 0;
28 style: Record<string, string> = {};
29 inert = false;
30 attributes: { name: string; value: string }[] = [];
31 children: TestRow[] = [];
32 clones: TestRow[] = [];
33 remove = vi.fn();
34 animations: TestAnimation[] = [];
35 constructor(
36 readonly name: string,
37 public offsetHeight = 82,
38 ) {}
39 getBoundingClientRect() {
40 return { top: this.offsetTop + this.dragTranslate, height: this.offsetHeight };
41 }
42 setAttribute(name: string, value: string) {
43 this.removeAttribute(name);
44 this.attributes.push({ name, value });
45 }
46 removeAttribute(name: string) {
47 this.attributes = this.attributes.filter((attribute) => attribute.name !== name);
48 }
49 querySelectorAll(_selector: string): TestRow[] {
50 return this.children.flatMap((child) => [child, ...child.querySelectorAll("*")]);
51 }
52 cloneNode(_deep: boolean): TestRow {
53 const clone = new TestRow(`${this.name} clone`, this.offsetHeight);
54 clone.namespaceURI = this.namespaceURI;
55 clone.style = { ...this.style };
56 clone.attributes = this.attributes.map((attribute) => ({ ...attribute }));
57 clone.children = this.children.map((child) => child.cloneNode(true));
58 this.clones.push(clone);
59 return clone;
60 }
61 animate = vi.fn((_frames: Keyframe[], _options: KeyframeAnimationOptions) => {
62 const animation = new TestAnimation();
63 this.animations.push(animation);
64 return animation;
65 });
66}
67
68function fixture(rows: TestRow[]) {
69 const media = { matches: false };
70 const parent = {
71 children: rows,
72 ownerDocument: { defaultView: { matchMedia: () => media } },
73 append(node: TestRow) {
74 parent.children.push(node);
75 node.remove.mockImplementation(() => {
76 parent.children = parent.children.filter((child) => child !== node);
77 });
78 },
79 };
80 function layout(next: TestRow[]) {
81 let top = 8;
82 for (const row of next) {
83 row.offsetTop = top;
84 top += row.offsetHeight + 1;
85 }
86 parent.children = [
87 ...next,
88 ...parent.children.filter((row) => row.style.position === "absolute"),
89 ];
90 }
91 layout(rows);
92 const motion = createSidebarListMotion(parent as unknown as HTMLUListElement);
93 return { motion, layout, media, parent };
94}
95
96function expectMove(row: TestRow, offset: number) {
97 expect(row.animate).toHaveBeenLastCalledWith(
98 [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],
99 { duration: 150, easing: "ease-out" },
100 );
101}
102
103beforeEach(() => vi.stubGlobal("HTMLElement", TestRow));
104afterEach(() => vi.unstubAllGlobals());
105
106describe("sidebar list motion", () => {
107 it("moves a retained Active row into Settled with its displaced peers", () => {
108 const pinnedHeader = new TestRow("Pinned", 0);
109 const pinned = new TestRow("pin");
110 const divider = new TestRow("Active", 0);
111 const a = new TestRow("a");
112 const b = new TestRow("b");
113 const settledHeader = new TestRow("Settled", 32);
114 const settled = new TestRow("settled", 36);
115 const rows = [pinnedHeader, pinned, divider, a, b, settledHeader, settled];
116 const { motion, layout } = fixture(rows);
117 motion.update(true);
118 expect(rows.every((row) => row.animations.length === 0)).toBe(true);
119
120 a.offsetHeight = 36;
121 layout([pinnedHeader, pinned, divider, b, settledHeader, settled, a]);
122 motion.update(true);
123 expectMove(a, -153);
124 expectMove(b, 83);
125 expectMove(settledHeader, 83);
126 expectMove(settled, 83);
127 expect(pinned.animate).not.toHaveBeenCalled();
128 expect(divider.animate).not.toHaveBeenCalled();
129 });
130
131 it("refreshes the drop baseline without replay and animates the next ordinary move", () => {
132 const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")];
133 const { motion, layout } = fixture([a, b, c]);
134 motion.update(true);
135 motion.suspend();
136 a.dragTranslate = 300;
137 b.dragTranslate = -83;
138 motion.update(false);
139 motion.suspend();
140 layout([b, a, c]);
141 a.dragTranslate = b.dragTranslate = 0;
142 motion.update(true);
143 expect([a, b, c].every((row) => row.animations.length === 0)).toBe(true);
144
145 layout([c, b, a]);
146 motion.update(true);
147 expectMove(c, 166);
148 expectMove(b, -83);
149 expectMove(a, -83);
150 });
151
152 it("does not carry a canceled drag's transformed position into the next move", () => {
153 const a = new TestRow("a");
154 const b = new TestRow("b");
155 const { motion, layout } = fixture([a, b]);
156 motion.update(true);
157 motion.suspend();
158 a.dragTranslate = 500;
159 b.dragTranslate = -83;
160 motion.update(false);
161 motion.suspend();
162 a.dragTranslate = b.dragTranslate = 0;
163 motion.update(true);
164 expect(a.animate).not.toHaveBeenCalled();
165 expect(b.animate).not.toHaveBeenCalled();
166
167 layout([b, a]);
168 motion.update(true);
169 expectMove(a, -83);
170 expectMove(b, 83);
171 });
172
173 it("retargets rapid changes from the current visual position", () => {
174 const [a, b, c] = [new TestRow("a", 99), new TestRow("b", 99), new TestRow("c", 99)];
175 const { motion, layout } = fixture([a, b, c]);
176 motion.update(true);
177 layout([b, c, a]);
178 motion.update(true);
179 expectMove(a, -200);
180 const first = a.animations[0]!;
181 first.progress = 0.25;
182
183 layout([b, a, c]);
184 motion.update(true);
185 expect(first.cancel).toHaveBeenCalledOnce();
186 expectMove(a, -50);
187 first.finish();
188 motion.suspend();
189 expect(a.animations[1]!.cancel).toHaveBeenCalledOnce();
190 });
191
192 it("keeps an uninterrupted movement when the layout position does not change", () => {
193 const a = new TestRow("a");
194 const b = new TestRow("b");
195 const { motion, layout } = fixture([a, b]);
196 motion.update(true);
197 layout([b, a]);
198 motion.update(true);
199 a.animations[0]!.progress = 0.5;
200 motion.update(true);
201 expect(a.animate).toHaveBeenCalledOnce();
202 expect(a.animations[0]!.cancel).not.toHaveBeenCalled();
203 });
204
205 it("cancels owned motion on suspension and never animates a disposed list", () => {
206 const a = new TestRow("a");
207 const b = new TestRow("b");
208 const { motion, layout } = fixture([a, b]);
209 motion.update(true);
210 layout([b, a]);
211 motion.update(true);
212 motion.suspend();
213 expect(a.animations[0]!.cancel).toHaveBeenCalledOnce();
214 expect(b.animations[0]!.cancel).toHaveBeenCalledOnce();
215 motion.update(false);
216 layout([a, b]);
217 motion.update(true);
218 motion.dispose();
219 expect(a.animations[1]!.cancel).toHaveBeenCalledOnce();
220 layout([b, a]);
221 motion.update(true);
222 expect(a.animate).toHaveBeenCalledTimes(2);
223 });
224
225 it("fades a collapsed-shelf exit at its current visual box and a new wake in", () => {
226 const a = new TestRow("a");
227 const b = new TestRow("b");
228 const fresh = new TestRow("new");
229 a.setAttribute("data-thread-item", "a");
230 a.children = [new TestRow("button")];
231 a.children[0]!.setAttribute("id", "thread-control");
232 a.children[0]!.setAttribute("data-testid", "thread-control");
233 a.children[0]!.setAttribute("data-state", "open");
234 const icon = new TestRow("provider icon");
235 icon.namespaceURI = "http://www.w3.org/2000/svg";
236 icon.setAttribute("id", "provider-mask");
237 icon.setAttribute("mask", "url(#provider-mask)");
238 a.children.push(icon);
239 const { motion, layout, parent } = fixture([a, b]);
240 motion.update(true);
241 layout([b, a]);
242 motion.update(true);
243 a.animations[0]!.progress = 0.5;
244 layout([b, fresh]);
245 motion.update(true);
246 expect(a.animations[0]!.cancel).toHaveBeenCalledOnce();
247 expect(fresh.animate).toHaveBeenLastCalledWith([{ opacity: 0 }, { opacity: 1 }], {
248 duration: 150,
249 easing: "ease-out",
250 });
251 const clone = a.clones[0]!;
252 expect(clone.style).toMatchObject({
253 position: "absolute",
254 top: "49.5px",
255 left: "4px",
256 width: "260px",
257 height: "82px",
258 transform: "none",
259 pointerEvents: "none",
260 });
261 expect(clone.inert).toBe(true);
262 expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]);
263 expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]);
264 expect(clone.children[1]!.attributes).toEqual(icon.attributes);
265 expect(clone.animate).toHaveBeenCalledWith([{ opacity: 1 }, { opacity: 0 }], {
266 duration: 150,
267 easing: "ease-out",
268 });
269 expect(parent.children.includes(clone)).toBe(true);
270 motion.update(true);
271 expect(clone.animations).toHaveLength(1);
272 expect(clone.clones).toHaveLength(0);
273 clone.animations[0]!.finish();
274 expect(parent.children.includes(clone)).toBe(false);
275 });
276
277 it("clears exit clones on pickup and does not fade the release commit", () => {
278 const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")];
279 const { motion, layout, parent } = fixture([a, b]);
280 motion.update(false);
281 layout([b]);
282 motion.update(true);
283 const clone = a.clones[0]!;
284 motion.suspend();
285 expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce();
286 expect(parent.children.includes(clone)).toBe(false);
287 motion.update(false);
288 motion.suspend();
289 layout([c]);
290 motion.update(true);
291 expect(b.clones).toHaveLength(0);
292 expect(c.animations).toHaveLength(0);
293 layout([c, a]);
294 motion.update(true);
295 expect(a.animate).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], {
296 duration: 150,
297 easing: "ease-out",
298 });
299 motion.dispose();
300 expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce();
301 });
302
303 it("carries entry opacity into a quick exit and removes artifacts on a silent update", () => {
304 const a = new TestRow("a");
305 const marker = new TestRow("boundary", 0);
306 const { motion, layout, parent } = fixture([marker]);
307 motion.update(false);
308 layout([marker, a]);
309 motion.update(true);
310 a.animations[0]!.progress = 0.4;
311 layout([]);
312 motion.update(true);
313 expect(marker.clones).toHaveLength(0);
314 const clone = a.clones[0]!;
315 expect(clone.animate).toHaveBeenCalledWith([{ opacity: 0.4 }, { opacity: 0 }], {
316 duration: 150,
317 easing: "ease-out",
318 });
319 motion.update(false);
320 expect(parent.children).toEqual([]);
321 expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce();
322 });
323
324 it("respects reduced motion while keeping the next baseline fresh", () => {
325 const a = new TestRow("a");
326 const b = new TestRow("b");
327 const { motion, layout, media } = fixture([a, b]);
328 motion.update(true);
329 media.matches = true;
330 layout([b, a]);
331 motion.update(true);
332 expect(a.animate).not.toHaveBeenCalled();
333 media.matches = false;
334 layout([a, b]);
335 motion.update(true);
336 expectMove(a, 83);
337 expectMove(b, -83);
338 });
339});
apps/web/src/components/Sidebar.motion.ts created+168
......@@ -0,0 +1,168 @@
1const motionTiming = { duration: 150, easing: "ease-out" };
2
3type RowPosition = { top: number; left: number; width: number; height: number };
4
5function progress(animation: Animation) {
6 return animation.playState === "finished"
7 ? 1
8 : (animation.effect?.getComputedTiming().progress ?? 0);
9}
10
11/** Animate rows between their layout positions. The list must be
12 * positioned so every direct child's offsetTop has the same origin. */
13export function createSidebarListMotion(parent: HTMLUListElement) {
14 let positions: Map<HTMLElement, RowPosition> | null = null;
15 let disposed = false;
16 const reducedMotion = parent.ownerDocument.defaultView?.matchMedia(
17 "(prefers-reduced-motion: reduce)",
18 );
19 const running = new Map<HTMLElement, { animation: Animation; offset: number }>();
20 const entering = new Map<HTMLElement, Animation>();
21 const exiting = new Map<HTMLElement, Animation>();
22
23 const remainingOffset = (node: HTMLElement) => {
24 const current = running.get(node);
25 return current ? current.offset * (1 - progress(current.animation)) : 0;
26 };
27 const clearFades = () => {
28 for (const animation of [...entering.values(), ...exiting.values()]) animation.cancel();
29 for (const node of exiting.keys()) node.remove();
30 entering.clear();
31 exiting.clear();
32 };
33 const fadeOut = (node: HTMLElement, position: RowPosition) => {
34 if (position.height === 0) return;
35 // React owns the removed row; only a noninteractive copy stays for the fade.
36 const clone = node.cloneNode(true) as HTMLElement;
37 for (const element of [clone, ...clone.querySelectorAll("*")]) {
38 for (const attribute of Array.from(element.attributes)) {
39 if (
40 (attribute.name === "id" && element.namespaceURI !== "http://www.w3.org/2000/svg") ||
41 attribute.name === "data-thread-item" ||
42 attribute.name === "data-thread-selection-safe" ||
43 attribute.name === "data-testid"
44 ) {
45 element.removeAttribute(attribute.name);
46 }
47 }
48 }
49 clone.setAttribute("aria-hidden", "true");
50 clone.inert = true;
51 Object.assign(clone.style, {
52 position: "absolute",
53 top: `${position.top + remainingOffset(node)}px`,
54 left: `${position.left}px`,
55 width: `${position.width}px`,
56 height: `${position.height}px`,
57 margin: "0",
58 boxSizing: "border-box",
59 contentVisibility: "visible",
60 transform: "none",
61 transition: "none",
62 pointerEvents: "none",
63 });
64 parent.append(clone);
65 const entry = entering.get(node);
66 const animation = clone.animate(
67 [{ opacity: entry ? progress(entry) : 1 }, { opacity: 0 }],
68 motionTiming,
69 );
70 exiting.set(clone, animation);
71 animation.addEventListener(
72 "finish",
73 () => {
74 clone.remove();
75 exiting.delete(clone);
76 },
77 { once: true },
78 );
79 };
80
81 const cancel = (node: HTMLElement) => {
82 running.get(node)?.animation.cancel();
83 running.delete(node);
84 };
85 const suspend = () => {
86 for (const node of running.keys()) cancel(node);
87 clearFades();
88 positions = null;
89 };
90
91 return {
92 update(animate: boolean) {
93 if (disposed) return;
94 const next = new Map(
95 Array.from(parent.children)
96 .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node))
97 .map((node) => [
98 node,
99 {
100 top: node.offsetTop,
101 left: node.offsetLeft,
102 width: node.offsetWidth,
103 height: node.offsetHeight,
104 },
105 ]),
106 );
107 const shouldAnimate = animate && positions !== null && !reducedMotion?.matches;
108 if (!shouldAnimate) clearFades();
109 else {
110 for (const [node, position] of positions!) {
111 if (!next.has(node)) fadeOut(node, position);
112 }
113 }
114 for (const [node, animation] of entering) {
115 if (!next.has(node)) {
116 animation.cancel();
117 entering.delete(node);
118 }
119 }
120 for (const node of running.keys()) {
121 if (!shouldAnimate || !next.has(node)) cancel(node);
122 }
123 if (shouldAnimate) {
124 for (const [node, position] of next) {
125 const previousTop = positions?.get(node)?.top;
126 if (previousTop === undefined) {
127 if (position.height > 0) {
128 const animation = node.animate([{ opacity: 0 }, { opacity: 1 }], motionTiming);
129 entering.set(node, animation);
130 animation.addEventListener(
131 "finish",
132 () => {
133 if (entering.get(node) === animation) entering.delete(node);
134 },
135 { once: true },
136 );
137 }
138 continue;
139 }
140 if (previousTop === position.top) continue;
141 // Computed progress includes the effect's easing. Only our own
142 // 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 );
158 }
159 }
160 positions = next;
161 },
162 suspend,
163 dispose() {
164 suspend();
165 disposed = true;
166 },
167 };
168}
apps/web/src/components/Sidebar.tsx+1186-624
......@@ -1,20 +1,15 @@
1import { autoAnimate } from "@formkit/auto-animate";
21import { useAtomValue } from "@effect/atom-react";
32import * as Schema from "effect/Schema";
43import {
54 DndContext,
65 PointerSensor,
7 closestCenter,
86 useSensor,
97 useSensors,
108 type DragEndEvent,
9 type DragOverEvent,
10 type DragStartEvent,
1111} from "@dnd-kit/core";
12import {
13 SortableContext,
14 arrayMove,
15 useSortable,
16 verticalListSortingStrategy,
17} from "@dnd-kit/sortable";
12import { SortableContext, useSortable } from "@dnd-kit/sortable";
1813import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
1914import { CSS } from "@dnd-kit/utilities";
2015import {
......@@ -63,6 +58,7 @@ import {
6358 memo,
6459 useCallback,
6560 useEffect,
61 useLayoutEffect,
6662 useMemo,
6763 useReducer,
6864 useRef,
......@@ -77,6 +73,7 @@ import {
7773 isAtomCommandInterrupted,
7874 settlePromise,
7975 squashAtomCommandFailure,
76 type AtomCommandResult,
8077} from "@t3tools/client-runtime/state/runtime";
8178import { isElectron } from "../env";
8279import {
......@@ -137,7 +134,8 @@ import { cn } from "~/lib/utils";
137134import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
138135import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
139136import {
140 animatePinnedLayoutChanges,
137 animateSidebarLayoutChanges,
138 applySidebarThreadDrop,
141139 buildBulkTitleRegenerationContextMenuItem,
142140 buildBulkUnpinContextMenuItem,
143141 deleteSelectedThreadEntries,
......@@ -148,14 +146,17 @@ import {
148146 isSidebarNestedLinkClick,
149147 isTrailingDoubleClick,
150148 orderItemsByPreferredIds,
151 planPinnedReorder,
149 planSidebarThreadDrop,
152150 reduceSidebarProjectScopeMenuState,
153151 resolveAdjacentThreadId,
152 resolveSidebarDropTarget,
154153 resolveSidebarThreadStatus,
155154 searchSidebarThreadsByTitle,
156155 shouldCreateNewThreadInCurrentProject,
157156 shouldRecedeSidebarThread,
158157 resolveWorkingStartedAt,
158 sidebarListItemId,
159 sidebarMarkerId,
159160 sortLogicalProjectsForSidebar,
160161 sortPinnedThreadsForSidebar,
161162 sortSettledThreadsForSidebar,
......@@ -163,8 +164,13 @@ import {
163164 useRetainedValue,
164165 useSidebarRowSubscriptionLease,
165166 useThreadJumpHintVisibility,
167 type SidebarListItem,
168 type SidebarListMarker,
169 type SidebarSection,
166170} from "./Sidebar.logic";
167171import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
172import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag";
173import { createSidebarListMotion } from "./Sidebar.motion";
168174import {
169175 ThreadWorktreeIndicator,
170176 prStatusIndicator,
......@@ -473,23 +479,25 @@ function SnoozePopoverButton(props: {
473479 );
474480}
475481
476// Subset of useSortable applied to a pinned card's root <li>. Listeners go
477// on the whole card (no dedicated handle): the pointer sensor's distance
482// Subset of useSortable applied to a thread row's root <li>. Listeners go
483// on the whole row (no dedicated handle): the pointer sensor's distance
478484// constraint keeps plain clicks working, and we skip dnd-kit's aria
479// attributes since there is no keyboard sensor and the card body already
485// attributes since there is no keyboard sensor and the row body already
480486// carries its own button semantics.
481type SortablePinnedRowBag = Pick<
487type SortableThreadRowBag = Pick<
482488 ReturnType<typeof useSortable>,
483489 "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging"
484490>;
485491
486function SortablePinnedThreadRow(props: {
492function SortableThreadRow(props: {
487493 id: string;
488 children: (bag: SortablePinnedRowBag) => ReactNode;
494 disabled: boolean;
495 children: (bag: SortableThreadRowBag) => ReactNode;
489496}) {
490497 const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
491498 id: props.id,
492 animateLayoutChanges: animatePinnedLayoutChanges,
499 disabled: { draggable: props.disabled },
500 animateLayoutChanges: animateSidebarLayoutChanges,
493501 });
494502 return props.children({ listeners, setNodeRef, transform, transition, isDragging });
495503}
......@@ -499,6 +507,152 @@ function SortablePinnedThreadRow(props: {
499507const draftSurfaceClassName = "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]";
500508const draftPenClassName = "size-3 shrink-0 text-amber-600 dark:text-amber-300/80";
501509
510// Structural list items — the section headers and the
511// empty-section placeholders — take part in the sortable list so they shift
512// with the rows and the gap can open on either side of them. They can't be
513// picked up, and a marker is the sortable `over` when the pointer is on it,
514// which resolveSidebarDropTarget turns into the section the gap sits in.
515function SortableSidebarMarker(props: {
516 marker: SidebarListMarker;
517 className?: string;
518 children?: ReactNode;
519 "data-testid"?: string;
520}) {
521 const { setNodeRef, transform, transition } = useSortable({
522 id: sidebarMarkerId(props.marker),
523 disabled: { draggable: true },
524 animateLayoutChanges: animateSidebarLayoutChanges,
525 });
526 return (
527 <li
528 ref={setNodeRef}
529 data-thread-selection-safe
530 data-testid={props["data-testid"]}
531 className={cn("list-none", props.className)}
532 style={{
533 transform: CSS.Translate.toString(transform),
534 transition,
535 visibility: transform?.scaleY === 0 ? "hidden" : undefined,
536 }}
537 >
538 {props.children}
539 </li>
540 );
541}
542
543// Empty targets stay mounted before pickup so starting a drag never changes
544// the list's measured positions.
545function SidebarSectionPlaceholder(props: {
546 marker: "active-placeholder" | "settled-placeholder";
547 label: string;
548 showHint: boolean;
549 isDropTarget: boolean;
550}) {
551 return (
552 <SortableSidebarMarker
553 marker={props.marker}
554 data-testid={`sidebar-${props.marker}`}
555 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",
557 props.showHint && "border-sidebar-border",
558 props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
559 )}
560 >
561 {props.showHint ? props.label : null}
562 </SortableSidebarMarker>
563 );
564}
565
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.
568function SidebarDragBoundary(props: {
569 marker: "pinned-header" | "pinned-divider";
570 label: string;
571 hint: string | null;
572 visible: boolean;
573 isDropTarget: boolean;
574}) {
575 return (
576 <SortableSidebarMarker
577 marker={props.marker}
578 data-testid={`sidebar-${props.marker}`}
579 className="pointer-events-none relative z-10 mx-0.5 h-0"
580 >
581 {props.visible ? (
582 <div className="absolute inset-x-2 top-0 flex h-4 -translate-y-1/2 items-center gap-1.5">
583 <span
584 className={cn(
585 "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",
586 props.isDropTarget && "border-primary/40 text-primary",
587 )}
588 >
589 {props.label}
590 {props.hint ? <span className="font-normal">{props.hint}</span> : null}
591 </span>
592 <span
593 aria-hidden
594 className={cn("h-px flex-1 bg-sidebar-border", props.isDropTarget && "bg-primary/50")}
595 />
596 </div>
597 ) : null}
598 </SortableSidebarMarker>
599 );
600}
601
602// Shelf headers stay visible and keep their measured height while dragging.
603function SidebarSectionHeader(props: {
604 marker: "snoozed-header" | "settled-header";
605 label: string;
606 hint?: string | null;
607 isDropTarget?: boolean;
608 toggle: { expanded: boolean; onToggle: () => void };
609}) {
610 const snoozed = props.marker === "snoozed-header";
611 const className = cn(
612 "flex h-full w-full items-center gap-2 rounded-md border border-dashed border-transparent px-2 text-left text-xs font-medium",
613 snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60",
614 props.isDropTarget && "border-primary/40 bg-primary/5 text-primary",
615 );
616 const content = (
617 <>
618 <span className="shrink-0">{props.label}</span>
619 <span
620 aria-hidden
621 className={cn(
622 "h-px min-w-2 flex-1",
623 snoozed ? "bg-blue-500/20 dark:bg-blue-400/15" : "bg-sidebar-border/60",
624 props.isDropTarget && "bg-primary/30",
625 )}
626 />
627 {props.hint ? <span className="truncate text-[11px] font-normal">{props.hint}</span> : null}
628 <ChevronDownIcon
629 aria-hidden
630 className={cn(
631 "size-3 shrink-0 transition-transform",
632 props.toggle.expanded && "rotate-180",
633 )}
634 />
635 </>
636 );
637 return (
638 <SortableSidebarMarker
639 marker={props.marker}
640 data-testid={`sidebar-${props.marker}`}
641 className="mx-0.5 h-8"
642 >
643 <button
644 type="button"
645 onClick={props.toggle.onToggle}
646 aria-expanded={props.toggle.expanded}
647 data-testid={`sidebar-${snoozed ? "snoozed" : "settled"}-shelf-toggle`}
648 className={cn(className, "cursor-pointer")}
649 >
650 {content}
651 </button>
652 </SortableSidebarMarker>
653 );
654}
655
502656// One unsent draft session the user has invested content in. Two lines,
503657// nothing else: project name, then the typed prompt. All the draft's
504658// settings (model, env mode, branch, worktree) still travel with it —
......@@ -752,10 +906,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
752906 // rows. The marker can unpin the thread when the server supports pinning.
753907 pinningSupported: boolean;
754908 isPinned: boolean;
755 // Present only on pinned cards whose server supports reordering: dnd-kit
756 // sortable bag applied to the card root so the whole card drags (the
909 // Present on rows whose server supports every drop outcome: dnd-kit
910 // sortable bag applied to the row root so the whole row drags (the
757911 // pointer sensor's distance constraint keeps plain clicks working).
758 sortable?: SortablePinnedRowBag | undefined;
912 sortable?: SortableThreadRowBag | undefined;
913 dropSection: SidebarSection | null;
759914 // Compact wake countdown ("2h") for rows in the snoozed shelf.
760915 snoozeWakeLabelText: string | null;
761916 // When a snooze ended (timer or early wake); drives the Woke pill until
......@@ -1151,6 +1306,41 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
11511306 !isSelected &&
11521307 "opacity-70 transition-opacity hover:opacity-100",
11531308 );
1309 // dnd-kit props for the row root. Same bag on both variants: every row in
1310 // the list translates around the gap as the drag passes it.
1311 const sortable = props.sortable;
1312 const sortableRootProps = sortable
1313 ? {
1314 ref: sortable.setNodeRef,
1315 style: {
1316 transform: CSS.Translate.toString(sortable.transform),
1317 transition: sortable.transition,
1318 // A zero-height boundary also makes dnd-kit scale the source to
1319 // zero. Only projected peers use scaleY as a visibility sentinel.
1320 visibility:
1321 !sortable.isDragging && sortable.transform?.scaleY === 0
1322 ? ("hidden" as const)
1323 : undefined,
1324 },
1325 ...sortable.listeners,
1326 }
1327 : {};
1328 const dragDestination =
1329 sortable?.isDragging && props.dropSection !== null ? (
1330 <span
1331 role="status"
1332 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"
1333 >
1334 <span aria-hidden>→</span>
1335 {props.dropSection === "pinned"
1336 ? "Pinned"
1337 : props.dropSection === "active"
1338 ? "Active"
1339 : props.dropSection === "settled"
1340 ? "Settled"
1341 : "Snoozed"}
1342 </span>
1343 ) : null;
11541344
11551345 const title = isRenaming ? (
11561346 <input
......@@ -1282,9 +1472,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12821472 return (
12831473 <li
12841474 data-thread-item
1285 className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]"
1475 {...sortableRootProps}
1476 className={cn(
1477 "list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]",
1478 sortable?.isDragging && "z-20 opacity-80",
1479 )}
12861480 >
1287 <Tooltip>
1481 <Tooltip disabled={sortable?.isDragging}>
12881482 <TooltipTrigger
12891483 render={
12901484 <div
......@@ -1332,93 +1526,95 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
13321526 remain visible AND clickable while the row is hovered. Only
13331527 the time/jump label yields to the settle affordance. */}
13341528 {prBadge}
1335 <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
1336 <span
1337 className={cn(
1338 "inline-flex justify-end tabular-nums text-secondary-label transition-opacity",
1339 !isWoke && "group-hover/sidebar-row:opacity-0",
1340 )}
1341 >
1342 {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
1343 // Snoozed rows show when they come BACK, not when they were
1344 // last touched — the return ticket is the row's whole story.
1345 <span className="text-xs text-blue-600 tabular-nums dark:text-blue-400">
1346 {props.snoozeWakeLabelText}
1347 </span>
1348 ) : isWoke ? (
1349 // A wake can land straight in the settled tail (e.g. PR
1350 // merged while snoozed); the signal must survive the trip.
1529 {dragDestination ?? (
1530 <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
1531 <span
1532 className={cn(
1533 "inline-flex justify-end tabular-nums text-secondary-label transition-opacity",
1534 !isWoke && "group-hover/sidebar-row:opacity-0",
1535 )}
1536 >
1537 {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
1538 // Snoozed rows show when they come BACK, not when they were
1539 // last touched — the return ticket is the row's whole story.
1540 <span className="text-xs text-blue-600 tabular-nums dark:text-blue-400">
1541 {props.snoozeWakeLabelText}
1542 </span>
1543 ) : isWoke ? (
1544 // A wake can land straight in the settled tail (e.g. PR
1545 // merged while snoozed); the signal must survive the trip.
1546 <Tooltip>
1547 <TooltipTrigger
1548 render={
1549 <button
1550 type="button"
1551 aria-label="Dismiss Woke notification"
1552 onClick={handleAcknowledgeWokeClick}
1553 className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
1554 >
1555 <AlarmClockIcon aria-hidden className="size-3" />
1556 <span role="status">Woke</span>
1557 </button>
1558 }
1559 />
1560 <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
1561 </Tooltip>
1562 ) : (
1563 <span className="text-xs">
1564 {variantAction === "unsettle"
1565 ? settledTimeLabel(thread)
1566 : threadTimeLabel(thread)}
1567 </span>
1568 )}
1569 </span>
1570 {variantAction === "unsnooze" ? (
1571 !props.snoozeSupported ? null : (
1572 <button
1573 type="button"
1574 aria-label="Wake thread now"
1575 onClick={handleUnsnoozeClick}
1576 className={cn(
1577 "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100",
1578 isWoke && "group-hover/sidebar-row:static",
1579 )}
1580 >
1581 <AlarmClockOffIcon className="mb-px size-3" />
1582 </button>
1583 )
1584 ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
13511585 <Tooltip>
13521586 <TooltipTrigger
13531587 render={
13541588 <button
13551589 type="button"
1356 aria-label="Dismiss Woke notification"
1357 onClick={handleAcknowledgeWokeClick}
1358 className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
1359 >
1360 <AlarmClockIcon aria-hidden className="size-3" />
1361 <span role="status">Woke</span>
1362 </button>
1590 aria-label="Un-settle thread"
1591 onClick={handleUnsettleClick}
1592 className={cn(
1593 "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100",
1594 isWoke && "group-hover/sidebar-row:static",
1595 )}
1596 />
13631597 }
1364 />
1365 <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
1598 >
1599 <Undo2Icon className="mb-px size-3.5" />
1600 </TooltipTrigger>
1601 <TooltipPopup side="top">Un-settle thread</TooltipPopup>
13661602 </Tooltip>
13671603 ) : (
1368 <span className="text-xs">
1369 {variantAction === "unsettle"
1370 ? settledTimeLabel(thread)
1371 : threadTimeLabel(thread)}
1372 </span>
1373 )}
1374 </span>
1375 {variantAction === "unsnooze" ? (
1376 !props.snoozeSupported ? null : (
13771604 <button
13781605 type="button"
1379 aria-label="Wake thread now"
1380 onClick={handleUnsnoozeClick}
1606 aria-label="Settle thread"
1607 onClick={handleSettleClick}
13811608 className={cn(
1382 "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100",
1609 "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100",
13831610 isWoke && "group-hover/sidebar-row:static",
13841611 )}
13851612 >
1386 <AlarmClockOffIcon className="mb-px size-3" />
1613 <CheckIcon className="size-3" />
13871614 </button>
1388 )
1389 ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
1390 <Tooltip>
1391 <TooltipTrigger
1392 render={
1393 <button
1394 type="button"
1395 aria-label="Un-settle thread"
1396 onClick={handleUnsettleClick}
1397 className={cn(
1398 "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100",
1399 isWoke && "group-hover/sidebar-row:static",
1400 )}
1401 />
1402 }
1403 >
1404 <Undo2Icon className="mb-px size-3.5" />
1405 </TooltipTrigger>
1406 <TooltipPopup side="top">Un-settle thread</TooltipPopup>
1407 </Tooltip>
1408 ) : (
1409 <button
1410 type="button"
1411 aria-label="Settle thread"
1412 onClick={handleSettleClick}
1413 className={cn(
1414 "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100",
1415 isWoke && "group-hover/sidebar-row:static",
1416 )}
1417 >
1418 <CheckIcon className="size-3" />
1419 </button>
1420 )}
1421 </span>
1615 )}
1616 </span>
1617 )}
14221618 {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null}
14231619 </TooltipTrigger>
14241620 {detailsTooltip}
......@@ -1429,26 +1625,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14291625
14301626 const diff = latestTurnDiff(thread);
14311627
1432 const sortable = props.sortable;
14331628 return (
14341629 <li
14351630 data-thread-item
1436 ref={sortable?.setNodeRef}
1437 style={
1438 sortable
1439 ? {
1440 transform: CSS.Translate.toString(sortable.transform),
1441 transition: sortable.transition,
1442 }
1443 : undefined
1444 }
1445 {...(sortable?.listeners ?? {})}
1631 {...sortableRootProps}
14461632 className={cn(
14471633 "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]",
14481634 sortable?.isDragging && "z-20 opacity-80",
14491635 )}
14501636 >
1451 <Tooltip disabled={snoozeMenuOpen}>
1637 <Tooltip disabled={snoozeMenuOpen || sortable?.isDragging}>
14521638 <TooltipTrigger
14531639 render={
14541640 <div
......@@ -1493,125 +1679,127 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14931679 actions on hover/keyboard focus or while the popover is open. Keeping
14941680 the hidden state out of flow lets the project label reclaim
14951681 space without either state overlapping it. */}
1496 <span className="group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
1497 {/* Read-only status labels yield to the hover actions. Woke is
1682 {dragDestination ?? (
1683 <span className="group/sidebar-status-slot relative ml-auto flex h-5 min-w-8 shrink-0 items-stretch justify-end text-xs">
1684 {/* Read-only status labels yield to the hover actions. Woke is
14981685 itself an action, so it stays pointer-enabled and visible
14991686 while the other controls appear beside it. */}
1500 <span
1501 className={cn(
1502 isWokeStatus
1503 ? "pointer-events-auto"
1504 : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0",
1505 "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
1506 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
1507 )}
1508 >
1509 {topStatus ? (
1510 isWokeStatus ? (
1511 <Tooltip>
1512 <TooltipTrigger
1513 render={
1514 <button
1515 type="button"
1516 aria-label="Dismiss Woke notification"
1517 onClick={handleAcknowledgeWokeClick}
1518 className={cn(
1519 "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
1520 topStatus.className,
1521 )}
1522 >
1523 <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
1524 <span role="status">{topStatus.label}</span>
1525 </button>
1526 }
1527 />
1528 <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
1529 </Tooltip>
1530 ) : (
1531 <span
1532 className={cn(
1533 "inline-flex items-center gap-1 font-medium",
1534 topStatus.className,
1535 )}
1536 >
1537 {topStatus.icon === "working" ? (
1538 <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
1539 ) : topStatus.icon === "done" ? (
1540 <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
1541 ) : null}
1542 {/* The label alone is the live region: a role="status"
1543 wrapper around the ticking duration would make
1544 screen readers announce every second. */}
1545 <span role="status">{topStatus.label}</span>
1546 {status === "working" ? (
1547 <span aria-hidden>
1548 <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
1549 </span>
1550 ) : null}
1551 </span>
1552 )
1553 ) : (
1554 threadTimeLabel(thread)
1555 )}
1556 </span>
1557 {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? (
15581687 <span
15591688 className={cn(
1560 // focus-visible, not focus-within: a mouse click leaves
1561 // the Settle button focused, and a plain focus-within
1562 // would keep the controls pinned over the status label
1563 // once the pointer moves away (e.g. after a failed
1564 // settle) instead of cross-fading back.
1565 "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100",
1566 snoozeMenuOpen && "pointer-events-auto static opacity-100",
1689 isWokeStatus
1690 ? "pointer-events-auto"
1691 : "pointer-events-none group-has-[:focus-visible]/sidebar-status-slot:absolute group-has-[:focus-visible]/sidebar-status-slot:right-0 group-has-[:focus-visible]/sidebar-status-slot:opacity-0 group-hover/sidebar-row:absolute group-hover/sidebar-row:right-0 group-hover/sidebar-row:opacity-0",
1692 "flex items-center self-center justify-self-end tabular-nums text-secondary-label transition-opacity",
1693 snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0",
15671694 )}
15681695 >
1569 {hasUnsentDraft ? (
1570 <Tooltip>
1571 <TooltipTrigger
1572 render={
1573 <button
1574 type="button"
1575 aria-label="Discard draft"
1576 onClick={handleDiscardDraftClick}
1577 className="inline-flex cursor-pointer items-center rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
1578 />
1579 }
1580 >
1581 <XIcon className="size-3.5" />
1582 </TooltipTrigger>
1583 <TooltipPopup side="top">Discard draft</TooltipPopup>
1584 </Tooltip>
1585 ) : null}
1586 {showSnoozeButton ? (
1587 <SnoozePopoverButton
1588 open={snoozeMenuOpen}
1589 onOpenChange={setSnoozeMenuOpen}
1590 onSnooze={handleSnoozePreset}
1591 timestampFormat={props.timestampFormat}
1592 />
1593 ) : null}
1594 {props.settlementSupported ? (
1595 <Tooltip>
1596 <TooltipTrigger
1597 render={
1598 <button
1599 type="button"
1600 aria-label="Settle thread"
1601 onClick={handleSettleClick}
1602 className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
1603 />
1604 }
1696 {topStatus ? (
1697 isWokeStatus ? (
1698 <Tooltip>
1699 <TooltipTrigger
1700 render={
1701 <button
1702 type="button"
1703 aria-label="Dismiss Woke notification"
1704 onClick={handleAcknowledgeWokeClick}
1705 className={cn(
1706 "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
1707 topStatus.className,
1708 )}
1709 >
1710 <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
1711 <span role="status">{topStatus.label}</span>
1712 </button>
1713 }
1714 />
1715 <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
1716 </Tooltip>
1717 ) : (
1718 <span
1719 className={cn(
1720 "inline-flex items-center gap-1 font-medium",
1721 topStatus.className,
1722 )}
16051723 >
1606 <CheckIcon className="size-3.5" />
1607 Settle
1608 </TooltipTrigger>
1609 <TooltipPopup>Settle thread</TooltipPopup>
1610 </Tooltip>
1611 ) : null}
1724 {topStatus.icon === "working" ? (
1725 <CircleDashedIcon aria-hidden className="size-4 shrink-0" />
1726 ) : topStatus.icon === "done" ? (
1727 <CircleCheckIcon aria-hidden className="size-4 shrink-0" />
1728 ) : null}
1729 {/* The label alone is the live region: a role="status"
1730 wrapper around the ticking duration would make
1731 screen readers announce every second. */}
1732 <span role="status">{topStatus.label}</span>
1733 {status === "working" ? (
1734 <span aria-hidden>
1735 <WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
1736 </span>
1737 ) : null}
1738 </span>
1739 )
1740 ) : (
1741 threadTimeLabel(thread)
1742 )}
16121743 </span>
1613 ) : null}
1614 </span>
1744 {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? (
1745 <span
1746 className={cn(
1747 // focus-visible, not focus-within: a mouse click leaves
1748 // the Settle button focused, and a plain focus-within
1749 // would keep the controls pinned over the status label
1750 // once the pointer moves away (e.g. after a failed
1751 // settle) instead of cross-fading back.
1752 "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:static group-hover/sidebar-row:opacity-100",
1753 snoozeMenuOpen && "pointer-events-auto static opacity-100",
1754 )}
1755 >
1756 {hasUnsentDraft ? (
1757 <Tooltip>
1758 <TooltipTrigger
1759 render={
1760 <button
1761 type="button"
1762 aria-label="Discard draft"
1763 onClick={handleDiscardDraftClick}
1764 className="inline-flex cursor-pointer items-center rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
1765 />
1766 }
1767 >
1768 <XIcon className="size-3.5" />
1769 </TooltipTrigger>
1770 <TooltipPopup side="top">Discard draft</TooltipPopup>
1771 </Tooltip>
1772 ) : null}
1773 {showSnoozeButton ? (
1774 <SnoozePopoverButton
1775 open={snoozeMenuOpen}
1776 onOpenChange={setSnoozeMenuOpen}
1777 onSnooze={handleSnoozePreset}
1778 timestampFormat={props.timestampFormat}
1779 />
1780 ) : null}
1781 {props.settlementSupported ? (
1782 <Tooltip>
1783 <TooltipTrigger
1784 render={
1785 <button
1786 type="button"
1787 aria-label="Settle thread"
1788 onClick={handleSettleClick}
1789 className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
1790 />
1791 }
1792 >
1793 <CheckIcon className="size-3.5" />
1794 Settle
1795 </TooltipTrigger>
1796 <TooltipPopup>Settle thread</TooltipPopup>
1797 </Tooltip>
1798 ) : null}
1799 </span>
1800 ) : null}
1801 </span>
1802 )}
16151803 </div>
16161804 <div className="mt-1 flex min-w-0">
16171805 {title}
......@@ -1834,8 +2022,10 @@ export default function Sidebar() {
18342022 snoozeThread,
18352023 unsnoozeThread,
18362024 pinThread,
2025 unpinThread,
18372026 confirmAndUnpinThread,
18382027 reorderPinnedThread,
2028 reorderActiveThread,
18392029 archiveThread,
18402030 deleteThread,
18412031 } = useThreadActions();
......@@ -2196,13 +2386,27 @@ export default function Sidebar() {
21962386 [openProjectSettings],
21972387 );
21982388
2199 // Settled threads stay in the live shell stream (settled ≠ archived), so
2200 // the partition works directly off live shells: no archived-snapshot
2201 // merging, no optimistic holds. Archived threads remain hidden here —
2202 // archive keeps its original "remove from sidebar" meaning.
2389 // Keep a dropped row at its destination while its server applies the
2390 // lifecycle command and any order-key writes. The next pickup waits for
2391 // this hold so a second drop cannot replace an unconfirmed placement.
2392 const [optimisticDrop, setOptimisticDrop] = useState<{
2393 readonly key: string;
2394 readonly sourceSection: SidebarSection;
2395 readonly section: "pinned" | "active" | "settled";
2396 readonly occurredAt: string;
2397 readonly clearsSnooze: boolean;
2398 /** Full destination order for pinned and active drops. */
2399 readonly order: readonly string[] | null;
2400 /** Destination order keys before the drop, to recognize concurrent writes. */
2401 readonly keysAtDrop: ReadonlyMap<string, string | null>;
2402 /** The keys this drop writes (one per planned assignment). The
2403 override holds until all of them appear in canonical state. */
2404 readonly assignedKeys: ReadonlyMap<string, string>;
2405 } | null>(null);
22032406 const {
22042407 pinnedThreads,
2205 reorderablePinnedKeys,
2408 draggableThreadKeys,
2409 activeReorderableThreadKeys,
22062410 activeThreads,
22072411 snoozedThreads,
22082412 settledThreads,
......@@ -2224,17 +2428,42 @@ export default function Sidebar() {
22242428 const active: EnvironmentThreadShell[] = [];
22252429 const snoozed: EnvironmentThreadShell[] = [];
22262430 const settled: EnvironmentThreadShell[] = [];
2431 const draggable = new Set<string>();
2432 const activeReorderable = new Set<string>();
22272433 for (const thread of visible) {
2434 const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities;
22282435 // Threads on servers without the settlement capability (old server,
22292436 // or descriptor not loaded yet) never classify as settled: the user
22302437 // could neither un-settle nor pin them, so auto-settling them would
22312438 // strand rows in a tail with no working affordances.
2232 const supportsSettlement =
2233 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;
2234 const supportsSnooze =
2235 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
2236 // Snooze outranks settlement and pinning until the thread wakes.
2237 if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
2439 const supportsSettlement = capabilities?.threadSettlement === true;
2440 const supportsSnooze = capabilities?.threadSnooze === true;
2441 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
2442 if (capabilities?.threadActiveReorder === true) activeReorderable.add(threadKey);
2443 // Older servers retain their existing drag actions. Active placement
2444 // additionally requires its own ordering capability at the drop target.
2445 if (capabilities?.threadPinning === true && capabilities.threadPinReorder === true) {
2446 draggable.add(threadKey);
2447 }
2448 if (optimisticDrop?.key === threadKey) {
2449 const projected = applySidebarThreadDrop(
2450 thread,
2451 optimisticDrop.section,
2452 optimisticDrop.occurredAt,
2453 optimisticDrop.assignedKeys.get(threadKey),
2454 );
2455 (optimisticDrop.section === "pinned"
2456 ? pinned
2457 : optimisticDrop.section === "settled"
2458 ? settled
2459 : active
2460 ).push(
2461 optimisticDrop.clearsSnooze
2462 ? projected
2463 : { ...projected, snoozedAt: thread.snoozedAt, snoozedUntil: thread.snoozedUntil },
2464 );
2465 } else if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) {
2466 // Snooze outranks settlement and pinning until the thread wakes.
22382467 snoozed.push(thread);
22392468 } else if (supportsSettlement && thread.settledOverride === "settled") {
22402469 settled.push(thread);
......@@ -2249,18 +2478,27 @@ export default function Sidebar() {
22492478 // Server capability only gates DRAGGING — it must not influence the
22502479 // sort, or mixed-version fleets would render different pinned orders on
22512480 // web and mobile from the same data.
2481 const sortedPinned = sortPinnedThreadsForSidebar(pinned);
2482 const sortedActive = sortThreadsForSidebar(active);
22522483 return {
2253 pinnedThreads: sortPinnedThreadsForSidebar(pinned),
2254 reorderablePinnedKeys: new Set(
2255 pinned
2256 .filter(
2257 (thread) =>
2258 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder ===
2259 true,
2260 )
2261 .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
2262 ),
2263 activeThreads: sortThreadsForSidebar(active),
2484 pinnedThreads:
2485 optimisticDrop?.section !== "pinned" || optimisticDrop.order === null
2486 ? sortedPinned
2487 : orderItemsByPreferredIds({
2488 items: sortedPinned,
2489 preferredIds: optimisticDrop.order,
2490 getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2491 }),
2492 draggableThreadKeys: draggable,
2493 activeReorderableThreadKeys: activeReorderable,
2494 activeThreads:
2495 optimisticDrop?.section !== "active" || optimisticDrop.order === null
2496 ? sortedActive
2497 : orderItemsByPreferredIds({
2498 items: sortedActive,
2499 preferredIds: optimisticDrop.order,
2500 getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2501 }),
22642502 // Soonest wake first: "what comes back next" is the shelf's question.
22652503 snoozedThreads: snoozed.toSorted(
22662504 (left, right) =>
......@@ -2270,7 +2508,7 @@ export default function Sidebar() {
22702508 settledThreads: sortSettledThreadsForSidebar(settled),
22712509 snoozeNow: preciseNow,
22722510 };
2273 }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
2511 }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
22742512
22752513 const threadSearchInputRef = useRef<HTMLInputElement>(null);
22762514 const [threadSearchQuery, setThreadSearchQuery] = useState("");
......@@ -2715,77 +2953,122 @@ export default function Sidebar() {
27152953 },
27162954 [unsnoozeThread],
27172955 );
2718 // Drag-to-reorder for the pinned block. A drop computes ONE fractional key
2719 // for the moved thread and sends it to that thread's own server (see
2720 // planPinnedReorder for the keyless-neighbor materialization case, which
2721 // instead rewrites every key in the section). The optimistic order keeps
2722 // the card where it was dropped until EVERY key the drop wrote is
2723 // reflected in canonical state — a section rewrite is several sequential
2724 // writes, and releasing on the first landed key would expose the
2725 // half-written canonical order, reshuffling the block once per write.
2726 // A failed write clears the override (the card snaps back) with a toast.
2727 // A key we did NOT write landing (a concurrent client's reorder that must
2728 // win) and ANY membership change (new pin, unpin, snooze/wake) also
2729 // release it: the override can't say where members it never saw belong,
2730 // and holding it would launder a stale order into later drags.
2731 const pinnedDndSensors = useSensors(
2956 const listMotionRef = useRef<ReturnType<typeof createSidebarListMotion> | null>(null);
2957 const attachListMotionRef = useCallback((node: HTMLUListElement | null) => {
2958 listMotionRef.current?.dispose();
2959 listMotionRef.current = node === null ? null : createSidebarListMotion(node);
2960 listMotionRef.current?.update(false);
2961 }, []);
2962
2963 // Hold the chosen section and order until every key write arrives. This
2964 // also covers first-time ordering, which assigns keys to keyless neighbors.
2965 // A failed write, concurrent reorder, or membership change releases the hold.
2966 const dndSensors = useSensors(
27322967 useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
27332968 );
2734 const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{
2735 readonly order: readonly string[];
2736 /** pinOrderKey per thread as of the drop — the baseline that tells a
2737 concurrent client's write apart from one of our own landing. */
2738 readonly keysAtDrop: ReadonlyMap<string, string | null>;
2739 /** The keys this drop writes (one per planned assignment). The
2740 override holds until all of them appear in canonical state. */
2741 readonly assignedKeys: ReadonlyMap<string, string>;
2969 const [dragState, setDragState] = useState<{
2970 readonly activeKey: string;
2971 readonly activeSection: SidebarSection;
2972 readonly occurredAt: string;
2973 readonly activationY: number | null;
27422974 } | null>(null);
2743 const orderedPinnedThreads = useMemo(() => {
2744 if (optimisticPinnedOrder === null) return pinnedThreads;
2745 return orderItemsByPreferredIds({
2746 items: pinnedThreads,
2747 preferredIds: optimisticPinnedOrder.order,
2748 getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2749 });
2750 }, [optimisticPinnedOrder, pinnedThreads]);
2975 const [dragTargetSection, setDragTargetSection] = useState<SidebarSection | null>(null);
2976 const sectionByThreadKey = useMemo(() => {
2977 const map = new Map<string, SidebarSection>();
2978 const add = (list: readonly EnvironmentThreadShell[], section: SidebarSection) => {
2979 for (const thread of list) {
2980 map.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), section);
2981 }
2982 };
2983 add(pinnedThreads, "pinned");
2984 add(activeThreads, "active");
2985 add(snoozedThreads, "snoozed");
2986 add(settledThreads, "settled");
2987 return map;
2988 }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads]);
2989 const pinnedKeys = useMemo(
2990 () =>
2991 pinnedThreads.map((thread) =>
2992 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2993 ),
2994 [pinnedThreads],
2995 );
2996 const activeKeys = useMemo(
2997 () =>
2998 activeThreads.map((thread) =>
2999 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3000 ),
3001 [activeThreads],
3002 );
27513003 useEffect(() => {
2752 if (optimisticPinnedOrder === null) return;
2753 const canonical = pinnedThreads.filter((thread) =>
2754 reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
3004 if (optimisticDrop === null) return;
3005 const canonicalByKey = new Map(
3006 threads.map((thread) => [
3007 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3008 thread,
3009 ]),
27553010 );
2756 const canonicalKeys = canonical.map((thread) =>
2757 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3011 const thread = canonicalByKey.get(optimisticDrop.key);
3012 if (thread === undefined || thread.archivedAt !== null) {
3013 setOptimisticDrop(null);
3014 return;
3015 }
3016 const canonicalSection = effectiveSnoozed(thread, { now: new Date().toISOString() })
3017 ? "snoozed"
3018 : thread.settledOverride === "settled"
3019 ? "settled"
3020 : thread.pinnedAt != null
3021 ? "pinned"
3022 : "active";
3023 if (
3024 canonicalSection !== optimisticDrop.sourceSection &&
3025 canonicalSection !== optimisticDrop.section
3026 ) {
3027 setOptimisticDrop(null);
3028 return;
3029 }
3030 if (optimisticDrop.order === null) {
3031 // Settle also emits unpin/unsnooze events. Wait for the entire move
3032 // before releasing the projected fields and sort timestamps.
3033 if (
3034 canonicalSection === optimisticDrop.section &&
3035 thread.pinnedAt == null &&
3036 (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null)
3037 ) {
3038 setOptimisticDrop(null);
3039 }
3040 return;
3041 }
3042 if (canonicalSection !== optimisticDrop.section) return;
3043 if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return;
3044 const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys;
3045 const canonicalDestination = destinationKeys.flatMap((key) => {
3046 const canonical = canonicalByKey.get(key);
3047 return canonical === undefined ? [] : [canonical];
3048 });
3049 const keyByThread = new Map(
3050 canonicalDestination.map((thread) => [
3051 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3052 (optimisticDrop.section === "pinned" ? thread.pinOrderKey : thread.activeOrderKey) ?? null,
3053 ]),
27583054 );
2759 // The override represents one drop against one snapshot of the world.
2760 // Release it when the world moves on: membership changed (pin/unpin/
2761 // snooze/wake — the override can't say where members it never saw
2762 // belong), a key changed to something we did NOT write (a concurrent
2763 // client's reorder that must win), every key we wrote has landed, or
2764 // canonical already matches. Releasing on the FIRST landed key instead
2765 // of the last exposes the half-written order mid-materialization and
2766 // the block visibly reshuffles once per write.
3055 const heldOrder = optimisticDrop.order;
3056 const heldKeys = new Set(heldOrder);
27673057 const membershipChanged =
2768 canonicalKeys.length !== optimisticPinnedOrder.order.length ||
2769 canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key));
2770 const foreignKeyLanded = canonical.some((thread, index) => {
2771 const threadKey = canonicalKeys[index]!;
2772 const currentKey = thread.pinOrderKey ?? null;
2773 if (currentKey === optimisticPinnedOrder.keysAtDrop.get(threadKey)) return false;
2774 return currentKey !== optimisticPinnedOrder.assignedKeys.get(threadKey);
3058 destinationKeys.length !== heldOrder.length ||
3059 destinationKeys.some((key) => !heldKeys.has(key));
3060 const foreignKeyLanded = destinationKeys.some((threadKey) => {
3061 const currentKey = keyByThread.get(threadKey) ?? null;
3062 if (currentKey === (optimisticDrop.keysAtDrop.get(threadKey) ?? null)) return false;
3063 return currentKey !== optimisticDrop.assignedKeys.get(threadKey);
27753064 });
2776 const currentKeyByThreadKey = new Map(
2777 canonical.map((thread, index) => [canonicalKeys[index]!, thread.pinOrderKey ?? null]),
2778 );
2779 const allAssignmentsLanded = [...optimisticPinnedOrder.assignedKeys].every(
2780 ([threadKey, orderKey]) => currentKeyByThreadKey.get(threadKey) === orderKey,
3065 const allAssignmentsLanded = [...optimisticDrop.assignedKeys].every(
3066 ([threadKey, orderKey]) => keyByThread.get(threadKey) === orderKey,
27813067 );
2782 const orderConfirmed =
2783 !membershipChanged &&
2784 canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]);
2785 if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) {
2786 setOptimisticPinnedOrder(null);
3068 if (membershipChanged || foreignKeyLanded || allAssignmentsLanded) {
3069 setOptimisticDrop(null);
27873070 }
2788 }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]);
3071 }, [activeKeys, optimisticDrop, pinnedKeys, threads]);
27893072 const attemptPin = useCallback(
27903073 (threadRef: ScopedThreadRef) => {
27913074 void (async () => {
......@@ -2827,71 +3110,338 @@ export default function Sidebar() {
28273110 [confirmAndUnpinThread],
28283111 );
28293112
2830 const handlePinnedDragEnd = useCallback(
2831 (event: DragEndEvent) => {
3113 const handleThreadDragStart = useCallback(
3114 (event: DragStartEvent) => {
28323115 const activeKey = String(event.active.id);
2833 const overKey = event.over === null ? null : String(event.over.id);
2834 if (overKey === null || activeKey === overKey) return;
2835 const reorderable = orderedPinnedThreads.filter((thread) =>
2836 reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))),
2837 );
2838 const keys = reorderable.map((thread) =>
2839 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
2840 );
2841 const fromIndex = keys.indexOf(activeKey);
2842 const toIndex = keys.indexOf(overKey);
2843 if (fromIndex === -1 || toIndex === -1) return;
2844 const newOrder = arrayMove([...keys], fromIndex, toIndex);
2845 const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread]));
2846 const keysAtDrop = new Map(
2847 reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]),
2848 );
2849 const assignments = planPinnedReorder({
2850 orderedIds: newOrder,
2851 keysById: keysAtDrop,
2852 movedId: activeKey,
3116 const activeSection = sectionByThreadKey.get(activeKey);
3117 if (activeSection === undefined) return;
3118 // Stop normal section motion before dnd-kit measures the picked-up row.
3119 listMotionRef.current?.suspend();
3120 setDragState({
3121 activeKey,
3122 activeSection,
3123 occurredAt: new Date().toISOString(),
3124 activationY:
3125 event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null,
28533126 });
2854 if (assignments.length === 0) return;
2855 setOptimisticPinnedOrder({
2856 order: newOrder,
2857 keysAtDrop,
2858 assignedKeys: new Map(
2859 assignments.map((assignment) => [assignment.id, assignment.orderKey]),
2860 ),
3127 setDragTargetSection(activeSection);
3128 },
3129 [sectionByThreadKey],
3130 );
3131 const handleThreadDragCancel = useCallback(() => {
3132 listMotionRef.current?.suspend();
3133 setDragState(null);
3134 setDragTargetSection(null);
3135 }, []);
3136 // Include every visible row in the measured order. Older servers disable
3137 // pickup on their rows without changing where those rows render.
3138 const sidebarListItems = useMemo((): readonly SidebarListItem[] => {
3139 const rowsOf = (
3140 list: readonly EnvironmentThreadShell[],
3141 section: SidebarSection,
3142 ): SidebarListItem[] =>
3143 list.map((thread) => {
3144 const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
3145 return { kind: "thread", key, section };
3146 });
3147 if (
3148 pinnedThreads.length +
3149 activeThreads.length +
3150 snoozedThreads.length +
3151 settledThreads.length ===
3152 0
3153 ) {
3154 return [];
3155 }
3156 const items: SidebarListItem[] = [{ kind: "marker", marker: "pinned-header" }];
3157 const pinnedRows = rowsOf(pinnedThreads, "pinned");
3158 items.push(...pinnedRows);
3159 items.push({ kind: "marker", marker: "pinned-divider" });
3160 const activeRows = rowsOf(activeThreads, "active");
3161 if (activeRows.length === 0) {
3162 items.push({ kind: "marker", marker: "active-placeholder" });
3163 }
3164 items.push(...activeRows);
3165 if (snoozedThreads.length > 0) {
3166 items.push({ kind: "marker", marker: "snoozed-header" });
3167 items.push(...rowsOf(visibleSnoozedThreads, "snoozed"));
3168 }
3169 items.push({ kind: "marker", marker: "settled-header" });
3170 const settledRows = rowsOf(renderedSettledThreads, "settled");
3171 if (settledRows.length === 0) {
3172 items.push({ kind: "marker", marker: "settled-placeholder" });
3173 }
3174 items.push(...settledRows);
3175 return items;
3176 }, [
3177 activeThreads,
3178 pinnedThreads,
3179 renderedSettledThreads,
3180 settledThreads.length,
3181 snoozedThreads.length,
3182 visibleSnoozedThreads,
3183 ]);
3184 const listMotionPaused = dragState !== null;
3185 useLayoutEffect(() => {
3186 // Drag release clears the baseline, so its commit cannot replay the
3187 // sortable preview. Later thread actions can animate while writes settle.
3188 // Draft navigation can reveal a frozen row without changing the draft count.
3189 listMotionRef.current?.update(
3190 !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0,
3191 );
3192 }, [listMotionPaused, routeDraftIdForRows, sidebarListItems, visibleDraftSessionCount]);
3193 const handleThreadDragOver = useCallback(
3194 (event: DragOverEvent) => {
3195 const target = event.over
3196 ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id))
3197 : null;
3198 setDragTargetSection(target?.section ?? null);
3199 },
3200 [sidebarListItems],
3201 );
3202 const sortableIds = useMemo(() => sidebarListItems.map(sidebarListItemId), [sidebarListItems]);
3203 const draggedSettledOrder = useMemo(() => {
3204 const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey);
3205 if (dragState === null || thread === undefined) return [];
3206 const key = (candidate: EnvironmentThreadShell) =>
3207 scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id));
3208 return sortSettledThreadsForSidebar([
3209 ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
3210 applySidebarThreadDrop(thread, "settled", dragState.occurredAt),
3211 ]).map(key);
3212 }, [dragState, settledThreads, threadByKey]);
3213 const sidebarSortingStrategy = useMemo(
3214 () =>
3215 createSidebarSortingStrategy({
3216 items: sidebarListItems,
3217 settledOrder: draggedSettledOrder,
3218 settledExpanded: settledShelfExpanded,
3219 settledVisibleCount,
3220 routeThreadKey,
3221 snoozedThreadCount: snoozedThreads.length,
3222 }),
3223 [
3224 draggedSettledOrder,
3225 routeThreadKey,
3226 settledShelfExpanded,
3227 settledVisibleCount,
3228 sidebarListItems,
3229 snoozedThreads.length,
3230 ],
3231 );
3232 // Hidden and filtered threads keep their keys. Reserve those slots without
3233 // including the rows in the visible drop order or writing to them.
3234 const { pinnedKeysById, activeKeysById } = useMemo(
3235 () => ({
3236 pinnedKeysById: new Map(
3237 threads.map((thread) => [
3238 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3239 thread.pinOrderKey ?? null,
3240 ]),
3241 ),
3242 activeKeysById: new Map(
3243 threads.map((thread) => [
3244 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
3245 thread.activeOrderKey ?? null,
3246 ]),
3247 ),
3248 }),
3249 [threads],
3250 );
3251 const dndCollisionDetection = useMemo(() => {
3252 if (dragState === null) return createSidebarCollisionDetection(() => true);
3253 const source = threadByKey.get(dragState.activeKey);
3254 if (source === undefined) return createSidebarCollisionDetection(() => false);
3255 return createSidebarCollisionDetection(
3256 (id) => {
3257 const target = resolveSidebarDropTarget(sidebarListItems, dragState.activeKey, id);
3258 if (target === null) return false;
3259 return (
3260 planSidebarThreadDrop({
3261 activeKey: dragState.activeKey,
3262 activeSection: dragState.activeSection,
3263 activePinned: source.pinnedAt != null,
3264 activeSettled: source.settledOverride === "settled",
3265 supportsSettlement:
3266 serverConfigs.get(source.environmentId)?.environment.capabilities.threadSettlement ===
3267 true,
3268 target,
3269 pinnedOrder: pinnedKeys,
3270 pinnedKeysById,
3271 reorderableKeys: draggableThreadKeys,
3272 activeOrder: activeKeys,
3273 activeKeysById,
3274 activeReorderableKeys: activeReorderableThreadKeys,
3275 }).kind !== "none"
3276 );
3277 },
3278 { emptyPins: pinnedKeys.length === 0, activationY: dragState.activationY },
3279 );
3280 }, [
3281 activeKeysById,
3282 pinnedKeysById,
3283 serverConfigs,
3284 activeKeys,
3285 activeReorderableThreadKeys,
3286 dragState,
3287 draggableThreadKeys,
3288 pinnedKeys,
3289 sidebarListItems,
3290 threadByKey,
3291 ]);
3292 const handleThreadDragEnd = useCallback(
3293 (event: DragEndEvent) => {
3294 listMotionRef.current?.suspend();
3295 setDragState(null);
3296 setDragTargetSection(null);
3297 const activeKey = String(event.active.id);
3298 const activeSection = sectionByThreadKey.get(activeKey);
3299 const target =
3300 event.over === null
3301 ? null
3302 : resolveSidebarDropTarget(sidebarListItems, activeKey, String(event.over.id));
3303 const activeThread = threadByKey.get(activeKey);
3304 if (activeSection === undefined || target === null || activeThread === undefined) return;
3305 const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id);
3306 const plan = planSidebarThreadDrop({
3307 activeKey,
3308 activeSection,
3309 activePinned: activeThread.pinnedAt != null,
3310 activeSettled: activeThread.settledOverride === "settled",
3311 supportsSettlement:
3312 serverConfigs.get(activeThread.environmentId)?.environment.capabilities
3313 .threadSettlement === true,
3314 target,
3315 pinnedOrder: pinnedKeys,
3316 pinnedKeysById,
3317 reorderableKeys: draggableThreadKeys,
3318 activeOrder: activeKeys,
3319 activeKeysById,
3320 activeReorderableKeys: activeReorderableThreadKeys,
28613321 });
3322 if (plan.kind === "none") return;
3323 if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return;
3324 const assignments =
3325 plan.kind === "pin"
3326 ? [
3327 ...(plan.orderKey === undefined ? [] : [{ id: activeKey, orderKey: plan.orderKey }]),
3328 ...plan.extraAssignments,
3329 ]
3330 : plan.kind === "reorder-pinned" || plan.kind === "move-active"
3331 ? plan.assignments
3332 : [];
3333 const drop = {
3334 key: activeKey,
3335 sourceSection: activeSection,
3336 section: target.section,
3337 occurredAt: new Date().toISOString(),
3338 clearsSnooze:
3339 plan.kind === "pin" ||
3340 plan.kind === "settle" ||
3341 (plan.kind === "move-active" && plan.unsnooze),
3342 order: plan.kind === "settle" ? null : plan.order,
3343 keysAtDrop: target.section === "active" ? activeKeysById : pinnedKeysById,
3344 assignedKeys: new Map(assignments.map(({ id, orderKey }) => [id, orderKey])),
3345 };
3346 setOptimisticDrop(drop);
28623347 void (async () => {
2863 // Sequential, stop on first failure. There is deliberately no
2864 // rollback: every key write is a complete, valid placement on its
2865 // own, so a partial materialization leaves a sensible order (and
2866 // the next drag repairs the rest) — unwinding writes across
2867 // servers would trade that for real inconsistency windows.
2868 for (const assignment of assignments) {
2869 const thread = threadByKey.get(assignment.id);
2870 if (thread === undefined) continue;
2871 const result = await reorderPinnedThread(
2872 scopeThreadRef(thread.environmentId, thread.id),
2873 assignment.orderKey,
2874 );
2875 if (result._tag === "Failure") {
2876 // Any failure — interrupted included — releases the override:
2877 // a key that never lands would otherwise hold it until some
2878 // unrelated world change came along.
2879 setOptimisticPinnedOrder(null);
2880 if (isAtomCommandInterrupted(result)) return;
3348 const run = async (
3349 operation: Promise<AtomCommandResult<unknown, unknown>>,
3350 title: string,
3351 ) => {
3352 const result = await operation;
3353 if (result._tag === "Success") return true;
3354 // A late failure must not cancel a newer drag's preview.
3355 setOptimisticDrop((current) => (current === drop ? null : current));
3356 if (!isAtomCommandInterrupted(result)) {
28813357 const error = squashAtomCommandFailure(result);
28823358 toastManager.add(
28833359 stackedThreadToast({
28843360 type: "error",
2885 title: "Failed to reorder pinned threads",
3361 title,
28863362 description: error instanceof Error ? error.message : "An error occurred.",
28873363 }),
28883364 );
3365 }
3366 return false;
3367 };
3368 switch (plan.kind) {
3369 case "settle": {
3370 settlingThreadKeysRef.current.add(activeKey);
3371 const navigateAfterSettle = planForwardNavigation(activeKey);
3372 const settled = await run(settleThread(threadRef), "Failed to settle thread").finally(
3373 () => settlingThreadKeysRef.current.delete(activeKey),
3374 );
3375 if (settled && routeThreadKeyRef.current === activeKey) navigateAfterSettle?.();
28893376 return;
28903377 }
3378 case "move-active":
3379 // The drag expresses unpin intent; button/menu confirmation is unchanged.
3380 if (plan.unpin && !(await run(unpinThread(threadRef), "Failed to unpin thread")))
3381 return;
3382 if (
3383 plan.unsettle &&
3384 !(await run(unsettleThread(threadRef), "Failed to un-settle thread"))
3385 )
3386 return;
3387 if (plan.unsnooze && !(await run(unsnoozeThread(threadRef), "Failed to wake thread")))
3388 return;
3389 break;
3390 case "pin":
3391 if (
3392 !(await run(
3393 pinThread(
3394 threadRef,
3395 plan.orderKey === undefined ? {} : { orderKey: plan.orderKey },
3396 ),
3397 "Failed to pin thread",
3398 ))
3399 )
3400 return;
3401 break;
3402 case "reorder-pinned":
3403 break;
3404 }
3405 // Stop on failure; each successful key write remains a valid placement.
3406 const keyWrites = plan.kind === "pin" ? plan.extraAssignments : plan.assignments;
3407 for (const assignment of keyWrites) {
3408 const thread = threadByKey.get(assignment.id);
3409 if (thread === undefined) continue;
3410 if (
3411 !(await run(
3412 (plan.kind === "move-active" ? reorderActiveThread : reorderPinnedThread)(
3413 scopeThreadRef(thread.environmentId, thread.id),
3414 assignment.orderKey,
3415 ),
3416 plan.kind === "move-active"
3417 ? "Failed to reorder active threads"
3418 : "Failed to reorder pinned threads",
3419 ))
3420 )
3421 return;
28913422 }
28923423 })();
28933424 },
2894 [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys],
3425 [
3426 activeKeysById,
3427 pinnedKeysById,
3428 serverConfigs,
3429 activeKeys,
3430 activeReorderableThreadKeys,
3431 draggableThreadKeys,
3432 pinThread,
3433 pinnedKeys,
3434 planForwardNavigation,
3435 reorderPinnedThread,
3436 reorderActiveThread,
3437 sectionByThreadKey,
3438 settleThread,
3439 sidebarListItems,
3440 threadByKey,
3441 unpinThread,
3442 unsettleThread,
3443 unsnoozeThread,
3444 ],
28953445 );
28963446 // One snooze per thread at a time — same double-dispatch guard as settle.
28973447 const snoozingThreadKeysRef = useRef(new Set<string>());
......@@ -3535,11 +4085,6 @@ export default function Sidebar() {
35354085 updateThreadJumpHintsVisibility(shouldShowJumpHintsNow);
35364086 }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]);
35374087
3538 const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => {
3539 if (!node) return;
3540 autoAnimate(node, { duration: 150, easing: "ease-out" });
3541 }, []);
3542
35434088 // New thread defaults to the project you're in (active thread's project,
35444089 // falling back to the top project) — same resolution the command palette
35454090 // uses. The command palette already offers a "New thread in..." submenu
......@@ -3929,281 +4474,298 @@ export default function Sidebar() {
39294474 closeDelay={0}
39304475 timeout={400}
39314476 >
3932 <ul ref={attachListAutoAnimateRef} role="list" className="flex flex-col gap-px">
3933 {(() => {
3934 const renderThreadRow = (
3935 thread: EnvironmentThreadShell,
3936 section: "pinned" | "active" | "snoozed" | "settled",
3937 sortable?: SortablePinnedRowBag,
3938 ) => {
3939 const threadKey = scopedThreadKey(
3940 scopeThreadRef(thread.environmentId, thread.id),
3941 );
3942 // Settled and snoozed are the ONLY things that collapse a
3943 // row: every other thread is a full card. Density comes
3944 // from users (or the auto rules) actually parking work,
3945 // not from the sidebar second-guessing what still matters.
3946 const isCard = section === "active" || section === "pinned";
3947 const rowVariant = isCard ? "card" : "slim";
3948 return (
3949 <SidebarThreadRow
3950 // Keyed per variant on purpose: when a thread settles,
3951 // the card fades out in place and the slim row fades
3952 // in at its settled position instead of one element
3953 // FLIP-sliding through every row in between (rows here
3954 // are translucent, so a crossing row reads as text
3955 // painted over text).
3956 key={`${threadKey}:${rowVariant}`}
3957 thread={thread}
3958 variant={rowVariant}
3959 // Snoozed rows wake, settled rows un-settle, and cards settle.
3960 variantAction={
3961 section === "snoozed"
3962 ? "unsnooze"
3963 : section === "settled"
3964 ? "unsettle"
3965 : "settle"
3966 }
3967 settlementSupported={
3968 serverConfigs.get(thread.environmentId)?.environment.capabilities
3969 .threadSettlement === true
3970 }
3971 snoozeSupported={
3972 serverConfigs.get(thread.environmentId)?.environment.capabilities
3973 .threadSnooze === true
3974 }
3975 pinningSupported={
3976 serverConfigs.get(thread.environmentId)?.environment.capabilities
3977 .threadPinning === true
3978 }
3979 isPinned={thread.pinnedAt != null}
3980 sortable={sortable}
3981 snoozeWakeLabelText={
3982 section === "snoozed" && thread.snoozedUntil != null
3983 ? snoozeWakeLabel(thread.snoozedUntil, {
3984 now: new Date().toISOString(),
3985 })
3986 : null
3987 }
3988 // All sections: a woken thread can classify straight
3989 // into the settled tail (PR merged while snoozed), and
3990 // the wake signal must survive the trip. Still-snoozed
3991 // rows resolve to null on their own.
3992 wokeAt={threadWokeAt(thread, { now: snoozeNow })}
3993 isActive={routeThreadKey === threadKey}
3994 openPullRequestsInRightPanel={routeThreadRef !== null}
3995 jumpLabel={
3996 showThreadJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null
3997 }
3998 currentEnvironmentId={primaryEnvironmentId}
3999 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
4000 environmentMachine={
4001 environmentMachineById.get(thread.environmentId) ?? "server"
4002 }
4003 projectCwd={
4004 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
4005 }
4006 projectFaviconPath={
4007 projectFaviconPathByKey.get(
4008 `${thread.environmentId}:${thread.projectId}`,
4009 ) ?? null
4010 }
4011 projectIcon={
4012 projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
4013 null
4014 }
4015 projectTitle={
4016 projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
4017 null
4018 }
4019 projectDisplayName={
4020 projectDisplayNameByKey.get(
4021 `${thread.environmentId}:${thread.projectId}`,
4022 ) ?? null
4023 }
4024 providerEntryByInstanceId={
4025 providerEntriesByEnvironment.get(thread.environmentId) ??
4026 EMPTY_PROVIDER_ENTRIES
4027 }
4028 timestampFormat={timestampFormat}
4029 onThreadClick={handleThreadClick}
4030 onThreadActivate={navigateToThread}
4031 onStartRename={startThreadRename}
4032 onRenameTitleChange={setRenamingTitle}
4033 onCommitRename={commitThreadRename}
4034 onCancelRename={cancelThreadRename}
4035 isRenaming={renamingThreadKey === threadKey}
4036 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
4037 onContextMenu={handleThreadContextMenu}
4038 onSettle={attemptSettle}
4039 onUnsettle={attemptUnsettle}
4040 onSnooze={attemptSnooze}
4041 onUnsnooze={attemptUnsnooze}
4042 onUnpin={attemptUnpin}
4043 onAcknowledgeWoke={acknowledgeWoke}
4044 />
4045 );
4046 };
4047 // Draft block above everything, then the pinned block:
4048 // full cards above the inbox, closed by a thin divider (the
4049 // pin glyphs carry the meaning, so no header text). Both
4050 // vanish entirely at count 0.
4051 // Pinned rows render in the one shared pinned order; only
4052 // reorder-capable rows register as sortable (legacy-server
4053 // pins render in place as plain rows).
4054 const items: ReactNode[] = [
4055 <SidebarDraftBlock
4056 key="draft-sessions"
4057 projectTitleByKey={projectTitleByKey}
4058 projectDisplayNameByKey={projectDisplayNameByKey}
4059 projectCwdByKey={projectCwdByKey}
4060 projectFaviconPathByKey={projectFaviconPathByKey}
4061 projectIconByKey={projectIconByKey}
4062 scopedProjectKeys={scopedProjectKeys}
4063 routeDraftId={routeDraftIdForRows}
4064 onNavigateToDraft={navigateToDraft}
4065 />,
4066 pinnedThreads.length > 0 ? (
4067 <li key="pinned-dnd" className="list-none">
4068 <DndContext
4069 sensors={pinnedDndSensors}
4070 collisionDetection={closestCenter}
4071 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
4072 onDragEnd={handlePinnedDragEnd}
4073 >
4074 <SortableContext
4075 items={orderedPinnedThreads
4076 .map((thread) =>
4077 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
4078 )
4079 .filter((threadKey) => reorderablePinnedKeys.has(threadKey))}
4080 strategy={verticalListSortingStrategy}
4477 <DndContext
4478 sensors={dndSensors}
4479 collisionDetection={dndCollisionDetection}
4480 modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
4481 onDragStart={handleThreadDragStart}
4482 onDragOver={handleThreadDragOver}
4483 onDragCancel={handleThreadDragCancel}
4484 onDragEnd={handleThreadDragEnd}
4485 >
4486 <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}>
4487 <ul
4488 ref={attachListMotionRef}
4489 role="list"
4490 className="relative flex flex-col gap-px pt-2"
4491 >
4492 {(() => {
4493 const renderThreadRowInner = (
4494 thread: EnvironmentThreadShell,
4495 section: SidebarSection,
4496 sortable?: SortableThreadRowBag,
4497 ) => {
4498 const threadKey = scopedThreadKey(
4499 scopeThreadRef(thread.environmentId, thread.id),
4500 );
4501 // Settled and snoozed are the ONLY things that collapse a
4502 // row: every other thread is a full card. Density comes
4503 // from users (or the auto rules) actually parking work,
4504 // not from the sidebar second-guessing what still matters.
4505 const isCard = section === "active" || section === "pinned";
4506 const rowVariant = isCard ? "card" : "slim";
4507 return (
4508 <SidebarThreadRow
4509 // Fade between card and compact rows while the outer
4510 // sortable wrapper keeps its identity during a drag.
4511 key={`${threadKey}:${rowVariant}`}
4512 thread={thread}
4513 variant={rowVariant}
4514 // Snoozed rows wake, settled rows un-settle, and cards settle.
4515 variantAction={
4516 section === "snoozed"
4517 ? "unsnooze"
4518 : section === "settled"
4519 ? "unsettle"
4520 : "settle"
4521 }
4522 settlementSupported={
4523 serverConfigs.get(thread.environmentId)?.environment.capabilities
4524 .threadSettlement === true
4525 }
4526 snoozeSupported={
4527 serverConfigs.get(thread.environmentId)?.environment.capabilities
4528 .threadSnooze === true
4529 }
4530 pinningSupported={
4531 serverConfigs.get(thread.environmentId)?.environment.capabilities
4532 .threadPinning === true
4533 }
4534 isPinned={thread.pinnedAt != null}
4535 sortable={sortable}
4536 dropSection={
4537 dragState?.activeKey === threadKey ? dragTargetSection : null
4538 }
4539 snoozeWakeLabelText={
4540 section === "snoozed" && thread.snoozedUntil != null
4541 ? snoozeWakeLabel(thread.snoozedUntil, {
4542 now: new Date().toISOString(),
4543 })
4544 : null
4545 }
4546 // All sections: a woken thread can classify straight
4547 // into the settled tail (PR merged while snoozed), and
4548 // the wake signal must survive the trip. Still-snoozed
4549 // rows resolve to null on their own.
4550 wokeAt={threadWokeAt(thread, { now: snoozeNow })}
4551 isActive={routeThreadKey === threadKey}
4552 openPullRequestsInRightPanel={routeThreadRef !== null}
4553 jumpLabel={
4554 showThreadJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null
4555 }
4556 currentEnvironmentId={primaryEnvironmentId}
4557 environmentLabel={
4558 environmentLabelById.get(thread.environmentId) ?? null
4559 }
4560 environmentMachine={
4561 environmentMachineById.get(thread.environmentId) ?? "server"
4562 }
4563 projectCwd={
4564 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
4565 null
4566 }
4567 projectFaviconPath={
4568 projectFaviconPathByKey.get(
4569 `${thread.environmentId}:${thread.projectId}`,
4570 ) ?? null
4571 }
4572 projectIcon={
4573 projectIconByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
4574 null
4575 }
4576 projectTitle={
4577 projectTitleByKey.get(
4578 `${thread.environmentId}:${thread.projectId}`,
4579 ) ?? null
4580 }
4581 projectDisplayName={
4582 projectDisplayNameByKey.get(
4583 `${thread.environmentId}:${thread.projectId}`,
4584 ) ?? null
4585 }
4586 providerEntryByInstanceId={
4587 providerEntriesByEnvironment.get(thread.environmentId) ??
4588 EMPTY_PROVIDER_ENTRIES
4589 }
4590 timestampFormat={timestampFormat}
4591 onThreadClick={handleThreadClick}
4592 onThreadActivate={navigateToThread}
4593 onStartRename={startThreadRename}
4594 onRenameTitleChange={setRenamingTitle}
4595 onCommitRename={commitThreadRename}
4596 onCancelRename={cancelThreadRename}
4597 isRenaming={renamingThreadKey === threadKey}
4598 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
4599 onContextMenu={handleThreadContextMenu}
4600 onSettle={attemptSettle}
4601 onUnsettle={attemptUnsettle}
4602 onSnooze={attemptSnooze}
4603 onUnsnooze={attemptUnsnooze}
4604 onUnpin={attemptUnpin}
4605 onAcknowledgeWoke={acknowledgeWoke}
4606 />
4607 );
4608 };
4609 const renderThreadRow = (
4610 thread: EnvironmentThreadShell,
4611 section: SidebarSection,
4612 ) => {
4613 const threadKey = scopedThreadKey(
4614 scopeThreadRef(thread.environmentId, thread.id),
4615 );
4616 return (
4617 <SortableThreadRow
4618 key={threadKey}
4619 id={threadKey}
4620 disabled={
4621 !draggableThreadKeys.has(threadKey) || optimisticDrop !== null
4622 }
40814623 >
4082 <ul
4083 role="list"
4084 aria-label="Pinned threads"
4085 className="flex flex-col gap-px"
4086 >
4087 {orderedPinnedThreads.map((thread) => {
4088 const threadKey = scopedThreadKey(
4089 scopeThreadRef(thread.environmentId, thread.id),
4090 );
4091 if (!reorderablePinnedKeys.has(threadKey)) {
4092 return renderThreadRow(thread, "pinned");
4624 {(bag) => renderThreadRowInner(thread, section, bag)}
4625 </SortableThreadRow>
4626 );
4627 };
4628 const from = dragState?.activeSection ?? null;
4629 const previewPinnedCount =
4630 pinnedThreads.length +
4631 (from !== "pinned" && dragTargetSection === "pinned" ? 1 : 0) -
4632 (from === "pinned" &&
4633 dragTargetSection !== null &&
4634 dragTargetSection !== "pinned"
4635 ? 1
4636 : 0);
4637 const activeHint =
4638 from === "pinned"
4639 ? "Drop to unpin"
4640 : from === "settled"
4641 ? "Drop to un-settle"
4642 : from === "snoozed"
4643 ? "Drop to wake"
4644 : null;
4645 const items: ReactNode[] = [
4646 <SidebarDraftBlock
4647 key="draft-sessions"
4648 projectTitleByKey={projectTitleByKey}
4649 projectDisplayNameByKey={projectDisplayNameByKey}
4650 projectCwdByKey={projectCwdByKey}
4651 projectFaviconPathByKey={projectFaviconPathByKey}
4652 projectIconByKey={projectIconByKey}
4653 scopedProjectKeys={scopedProjectKeys}
4654 routeDraftId={routeDraftIdForRows}
4655 onNavigateToDraft={navigateToDraft}
4656 />,
4657 ];
4658 for (const item of sidebarListItems) {
4659 if (item.kind === "thread") {
4660 items.push(renderThreadRow(threadByKey.get(item.key)!, item.section));
4661 continue;
4662 }
4663 switch (item.marker) {
4664 case "pinned-header":
4665 items.push(
4666 <SidebarDragBoundary
4667 key="pinned-header"
4668 marker="pinned-header"
4669 label="Pinned"
4670 hint={from !== null && from !== "pinned" ? "Drop to pin" : null}
4671 isDropTarget={dragTargetSection === "pinned"}
4672 visible={from !== null}
4673 />,
4674 );
4675 break;
4676 case "pinned-divider":
4677 items.push(
4678 <SidebarDragBoundary
4679 key="pinned-divider"
4680 marker="pinned-divider"
4681 label="Active"
4682 hint={dragTargetSection === "active" ? activeHint : null}
4683 isDropTarget={dragTargetSection === "active"}
4684 visible={from !== null && previewPinnedCount > 0}
4685 />,
4686 );
4687 break;
4688 case "active-placeholder":
4689 items.push(
4690 <SidebarSectionPlaceholder
4691 key="active-placeholder"
4692 marker="active-placeholder"
4693 label={activeHint ?? "Drop here"}
4694 showHint={from !== null}
4695 isDropTarget={dragTargetSection === "active"}
4696 />,
4697 );
4698 break;
4699 case "snoozed-header":
4700 items.push(
4701 <SidebarSectionHeader
4702 key="snoozed-shelf-header"
4703 marker="snoozed-header"
4704 label={
4705 snoozedShelfExpanded
4706 ? "Snoozed"
4707 : `Snoozed (${snoozedThreads.length})`
40934708 }
4094 return (
4095 <SortablePinnedThreadRow key={threadKey} id={threadKey}>
4096 {(bag) => renderThreadRow(thread, "pinned", bag)}
4097 </SortablePinnedThreadRow>
4098 );
4099 })}
4100 </ul>
4101 </SortableContext>
4102 </DndContext>
4103 </li>
4104 ) : null,
4105 ];
4106 if (pinnedThreads.length > 0) {
4107 items.push(
4108 <li
4109 key="pinned-divider"
4110 aria-hidden
4111 data-testid="sidebar-pinned-divider"
4112 className="mx-2.5 my-1.5 h-px list-none bg-sidebar-border/60"
4113 />,
4114 );
4115 }
4116 for (const thread of activeThreads) {
4117 items.push(renderThreadRow(thread, "active"));
4118 }
4119 // Snoozed shelf: between the inbox and Settled — out of the
4120 // way, never gone. The header always renders while anything
4121 // is snoozed (the count is the whole footprint when
4122 // collapsed); rows only when expanded. Vanishes entirely at
4123 // count 0.
4124 if (snoozedThreads.length > 0) {
4125 items.push(
4126 <li
4127 key="snoozed-shelf-header"
4128 data-thread-selection-safe
4129 className="list-none"
4130 >
4131 <button
4132 type="button"
4133 onClick={toggleSnoozedShelf}
4134 aria-expanded={snoozedShelfExpanded}
4135 data-testid="sidebar-snoozed-shelf-toggle"
4136 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
4137 >
4138 <span className="text-xs font-medium text-blue-600 dark:text-blue-400">
4139 {snoozedShelfExpanded
4140 ? "Snoozed"
4141 : `Snoozed (${snoozedThreads.length})`}
4142 </span>
4143 <span className="h-px flex-1 bg-blue-500/20 dark:bg-blue-400/15" />
4144 <ChevronDownIcon
4145 aria-hidden
4146 className={cn(
4147 "size-3 text-blue-600 transition-transform dark:text-blue-400",
4148 snoozedShelfExpanded && "rotate-180",
4149 )}
4150 />
4151 </button>
4152 </li>,
4153 );
4154 for (const thread of visibleSnoozedThreads) {
4155 items.push(renderThreadRow(thread, "snoozed"));
4156 }
4157 }
4158 if (settledThreads.length > 0) {
4159 items.push(
4160 <li
4161 key="settled-shelf-header"
4162 data-thread-selection-safe
4163 className="list-none"
4164 >
4709 toggle={{
4710 expanded: snoozedShelfExpanded,
4711 onToggle: toggleSnoozedShelf,
4712 }}
4713 />,
4714 );
4715 break;
4716 case "settled-header":
4717 items.push(
4718 <SidebarSectionHeader
4719 key="settled-shelf-header"
4720 marker="settled-header"
4721 label={
4722 settledShelfExpanded
4723 ? "Settled"
4724 : `Settled (${settledThreads.length})`
4725 }
4726 hint={
4727 dragTargetSection === "settled" && from !== "settled"
4728 ? "Drop to settle"
4729 : null
4730 }
4731 isDropTarget={dragTargetSection === "settled"}
4732 toggle={{
4733 expanded: settledShelfExpanded,
4734 onToggle: toggleSettledShelf,
4735 }}
4736 />,
4737 );
4738 break;
4739 case "settled-placeholder":
4740 items.push(
4741 <SidebarSectionPlaceholder
4742 key="settled-placeholder"
4743 marker="settled-placeholder"
4744 label="Drop to settle"
4745 showHint={from !== null && from !== "settled"}
4746 isDropTarget={dragTargetSection === "settled"}
4747 />,
4748 );
4749 break;
4750 }
4751 }
4752 return items;
4753 })()}
4754 {settledShelfExpanded && hiddenSettledCount > 0 ? (
4755 <li className="list-none">
41654756 <button
41664757 type="button"
4167 onClick={toggleSettledShelf}
4168 aria-expanded={settledShelfExpanded}
4169 data-testid="sidebar-settled-shelf-toggle"
4170 className="mb-1 mt-3 flex w-full cursor-pointer items-center gap-2 px-2.5 text-left"
4758 onClick={showMoreSettled}
4759 className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground/55 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
41714760 >
4172 <span className="text-xs font-medium text-muted-foreground/50">
4173 {settledShelfExpanded
4174 ? "Settled"
4175 : `Settled (${settledThreads.length})`}
4176 </span>
4177 <span className="h-px flex-1 bg-sidebar-border/60" />
4178 <ChevronDownIcon
4179 aria-hidden
4180 className={cn(
4181 "size-3 text-muted-foreground/50 transition-transform",
4182 settledShelfExpanded && "rotate-180",
4183 )}
4184 />
4761 <PlusIcon aria-hidden className="size-4 shrink-0" />
4762 Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
41854763 </button>
4186 </li>,
4187 );
4188 }
4189 for (const thread of renderedSettledThreads) {
4190 items.push(renderThreadRow(thread, "settled"));
4191 }
4192 return items;
4193 })()}
4194 {settledShelfExpanded && hiddenSettledCount > 0 ? (
4195 <li className="list-none">
4196 <button
4197 type="button"
4198 onClick={showMoreSettled}
4199 className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground/55 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
4200 >
4201 <PlusIcon aria-hidden className="size-4 shrink-0" />
4202 Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
4203 </button>
4204 </li>
4205 ) : null}
4206 </ul>
4764 </li>
4765 ) : null}
4766 </ul>
4767 </SortableContext>
4768 </DndContext>
42074769 </TooltipProvider>
42084770 ) : null}
42094771 {!isSearchingThreads &&
apps/web/src/hooks/useThreadActions.ts+38
......@@ -25,6 +25,7 @@ import { readLocalApi } from "../localApi";
2525import {
2626 readEnvironmentSupportsPinning,
2727 readEnvironmentSupportsPinReorder,
28 readEnvironmentSupportsActiveReorder,
2829 readEnvironmentSupportsSettlement,
2930 readEnvironmentSupportsSnooze,
3031 readEnvironmentThreadRefs,
......@@ -124,6 +125,18 @@ export class ThreadPinReorderUnsupportedError extends Schema.TaggedErrorClass<Th
124125 }
125126}
126127
128export class ThreadActiveReorderUnsupportedError extends Schema.TaggedErrorClass<ThreadActiveReorderUnsupportedError>()(
129 "ThreadActiveReorderUnsupportedError",
130 {
131 environmentId: EnvironmentId,
132 threadId: ThreadId,
133 },
134) {
135 override get message(): string {
136 return "Update this environment's server to reorder active threads.";
137 }
138}
139
127140export async function requestThreadUnpinConfirmation(input: {
128141 enabled: boolean;
129142 title: string;
......@@ -185,6 +198,9 @@ export function useThreadActions() {
185198 const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, {
186199 reportFailure: false,
187200 });
201 const reorderActiveThreadMutation = useAtomCommand(threadEnvironment.reorderActive, {
202 reportFailure: false,
203 });
188204 const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, {
189205 reportFailure: false,
190206 });
......@@ -631,6 +647,26 @@ export function useThreadActions() {
631647 [reorderPinnedThreadMutation],
632648 );
633649
650 const reorderActiveThread = useCallback(
651 async (target: ScopedThreadRef, orderKey: string) => {
652 if (!readEnvironmentSupportsActiveReorder(target.environmentId)) {
653 return AsyncResult.failure(
654 Cause.fail(
655 new ThreadActiveReorderUnsupportedError({
656 environmentId: target.environmentId,
657 threadId: target.threadId,
658 }),
659 ),
660 );
661 }
662 return reorderActiveThreadMutation({
663 environmentId: target.environmentId,
664 input: { threadId: target.threadId, orderKey },
665 });
666 },
667 [reorderActiveThreadMutation],
668 );
669
634670 const snoozeThread = useCallback(
635671 async (target: ScopedThreadRef, snoozedUntil: string) => {
636672 // Version skew: never send the command to a server that predates it.
......@@ -729,6 +765,7 @@ export function useThreadActions() {
729765 unpinThread,
730766 confirmAndUnpinThread,
731767 reorderPinnedThread,
768 reorderActiveThread,
732769 }),
733770 [
734771 archiveThread,
......@@ -737,6 +774,7 @@ export function useThreadActions() {
737774 deleteThread,
738775 pinThread,
739776 reorderPinnedThread,
777 reorderActiveThread,
740778 settleThread,
741779 snoozeThread,
742780 unarchiveThread,
apps/web/src/lib/threadSort.ts-1
......@@ -1,5 +1,4 @@
11export {
2 activeThreadAnchorTimestampMs,
32 getLatestThreadForProject,
43 getThreadSortTimestamp,
54 resolveSettledThreadTimestamp,
apps/web/src/state/entities.ts+7
......@@ -229,6 +229,13 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId):
229229 );
230230}
231231
232export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean {
233 return (
234 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
235 .threadActiveReorder === true
236 );
237}
238
232239export function readEnvironmentThreadRefs(
233240 environmentId: EnvironmentId,
234241): ReadonlyArray<ScopedThreadRef> {
docs/user/thread-sidebar.md+31-3
......@@ -22,12 +22,40 @@ worktree**, each background submission creates its own worktree.
2222
2323## Pin and reorder threads
2424
25Pin a thread from its menu to keep it above your active work. Drag pinned threads
26to reorder them on web and desktop, or use **Move up** and **Move down** on mobile.
27The order syncs across devices.
25Pin a thread from its menu to keep it above your active work.
2826
2927Pinning does not prevent automatic settlement. Settling a thread removes its pin.
3028
29On web and desktop, drag a thread between sections to change its state. Drag a thread up into
30the pinned section to pin it at the spot you drop it; drag a pinned thread down into the active
31list to unpin it. Dragging a thread onto the **Settled** header settles it, and dragging a settled
32thread into the active list un-settles it. A snoozed thread can be dragged out of the snoozed
33shelf, 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.
35Pinned and active boundary labels appear only while dragging, without moving the rows. The
36destination boundary highlights and the thread shows which section it will land in. When there
37are no pins, drag to the top edge to pin a thread. Drop instructions also appear for empty sections
38and a collapsed settled shelf.
39
40Drag within the pinned or active section to change its order. Other rows slide aside to show the
41spot where the thread will land. Drops into either section keep the position you choose. On
42mobile, open a pinned or active thread's menu and choose **Move up** or **Move down**. The server
43saves the order, so it survives a refresh and appears on your other connected devices.
44
45On web and desktop, the list also animates section changes made with thread actions such as
46**Pin**, **Settle**, and **Snooze**. These transitions respect your system's reduced-motion
47preference. While dragging, rows follow the insertion gap without replaying a second transition
48after the drop.
49
50New threads appear above the active threads you have arranged. Settling clears a thread's active
51position, so using **Un-settle** returns it to the top. Pinning and snoozing preserve its active
52position until you move it again. Thread activity does not change the order. The settled shelf
53continues to use settlement time.
54
55If dragging is unavailable for one environment, update the T3 Code server running in that
56environment. Pinned and active reordering require server support. Threads from older servers keep
57their default order until the server is updated.
58
3159## Settle finished work
3260
3361Choose **Settle thread** from its menu to move finished work out of the active list
packages/client-runtime/src/state/threadSort.ts+1-1
......@@ -107,7 +107,7 @@ export function getThreadSortTimestamp(
107107 * top instead of sinking back to its creation-order slot. Shared by web and
108108 * mobile so both render the same order. Malformed timestamps sink to 0.
109109 */
110export function activeThreadAnchorTimestampMs(thread: {
110function activeThreadAnchorTimestampMs(thread: {
111111 readonly createdAt: string;
112112 readonly unsettledAt?: string | null | undefined;
113113}): number {