| author | |
| committer | |
| log | 4023d93bce3f610349fbf38e991f737c1c5e0c98 |
| tree | ef507440dbcbca526693e144fa9b7e1a9c860154 |
| parent | 6766e682ac1a0aa33020358ba98707f4f8fee467 |
| signature | Signed by PGP key B5690EEEBB952194 |
12 files changed, 3534 insertions(+), 658 deletions(-)
apps/web/src/components/Sidebar.drag.test.ts created+616| ... | ... | @@ -0,0 +1,616 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | import { closestCenter, type CollisionDetection } from "@dnd-kit/core"; | |
| 3 | import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; | |
| 4 | import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag"; | |
| 5 | import { | |
| 6 | sidebarListItemId, | |
| 7 | sidebarMarkerId, | |
| 8 | type SidebarListItem, | |
| 9 | type SidebarListMarker, | |
| 10 | type SidebarSection, | |
| 11 | } from "./Sidebar.logic"; | |
| 12 | ||
| 13 | const thread = (key: string, section: SidebarSection): SidebarListItem => ({ | |
| 14 | kind: "thread", | |
| 15 | key, | |
| 16 | section, | |
| 17 | }); | |
| 18 | const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker }); | |
| 19 | const pinnedHeader = marker("pinned-header"); | |
| 20 | const divider = marker("pinned-divider"); | |
| 21 | const settledHeader = marker("settled-header"); | |
| 22 | const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; | |
| 23 | ||
| 24 | function 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 | ||
| 53 | function 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 | ||
| 66 | describe("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 | ||
| 227 | describe("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 @@ |
| 1 | import { closestCenter, type CollisionDetection } from "@dnd-kit/core"; | |
| 2 | import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; | |
| 3 | import { | |
| 4 | resolveSidebarDropTarget, | |
| 5 | sidebarListItemId, | |
| 6 | sidebarMarkerId, | |
| 7 | type SidebarListItem, | |
| 8 | type SidebarListMarker, | |
| 9 | type SidebarSection, | |
| 10 | } from "./Sidebar.logic"; | |
| 11 | ||
| 12 | const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; | |
| 13 | const hidden = { ...stationary, scaleY: 0 }; | |
| 14 | type ThreadItem = Extract<SidebarListItem, { kind: "thread" }>; | |
| 15 | type Layout = Parameters<SortingStrategy>[0]; | |
| 16 | ||
| 17 | /** Reject the nearest unsupported target without selecting another section. | |
| 18 | * Recreate this detector when drop eligibility changes. */ | |
| 19 | export 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. */ | |
| 56 | export 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 |
| 3 | 3 | import * as Cause from "effect/Cause"; |
| 4 | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | 5 | import { |
| 6 | animatePinnedLayoutChanges, | |
| 6 | animateSidebarLayoutChanges, | |
| 7 | applySidebarThreadDrop, | |
| 7 | 8 | archiveSelectedThreadEntries, |
| 8 | 9 | buildBulkTitleRegenerationContextMenuItem, |
| 9 | 10 | buildBulkUnpinContextMenuItem, |
| ... | ... | @@ -32,14 +33,21 @@ import { |
| 32 | 33 | shouldRecedeSidebarThread, |
| 33 | 34 | sortLogicalProjectsForSidebar, |
| 34 | 35 | sortSettledThreadsForSidebar, |
| 36 | resolveSidebarDropTarget, | |
| 35 | 37 | pinOrderKeyBetween, |
| 36 | 38 | planPinnedReorder, |
| 39 | planSidebarThreadDrop, | |
| 40 | sidebarMarkerId, | |
| 41 | sidebarListItemId, | |
| 37 | 42 | sortPinnedThreadsForSidebar, |
| 38 | 43 | sortThreadsForSidebar, |
| 39 | 44 | sortProjectsForSidebar, |
| 40 | 45 | sortScopedProjectsForSidebar, |
| 41 | 46 | shouldCreateNewThreadInCurrentProject, |
| 42 | 47 | THREAD_JUMP_HINT_SHOW_DELAY_MS, |
| 48 | type SidebarListItem, | |
| 49 | type SidebarListMarker, | |
| 50 | type SidebarSection, | |
| 43 | 51 | } from "./Sidebar.logic"; |
| 44 | 52 | import { |
| 45 | 53 | EnvironmentId, |
| ... | ... | @@ -53,12 +61,13 @@ import { |
| 53 | 61 | DEFAULT_INTERACTION_MODE, |
| 54 | 62 | DEFAULT_RUNTIME_MODE, |
| 55 | 63 | type Project, |
| 64 | type SidebarThreadSummary, | |
| 56 | 65 | type Thread, |
| 57 | 66 | } from "../types"; |
| 58 | 67 | |
| 59 | 68 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 60 | 69 | |
| 61 | describe("animatePinnedLayoutChanges", () => { | |
| 70 | describe("animateSidebarLayoutChanges", () => { | |
| 62 | 71 | const baseArgs: Parameters<AnimateLayoutChanges>[0] = { |
| 63 | 72 | active: null, |
| 64 | 73 | containerId: "pinned-threads", |
| ... | ... | @@ -76,11 +85,11 @@ describe("animatePinnedLayoutChanges", () => { |
| 76 | 85 | |
| 77 | 86 | it("does not replay layout movement after the pointer is released", () => { |
| 78 | 87 | expect(defaultAnimateLayoutChanges(baseArgs)).toBe(true); |
| 79 | expect(animatePinnedLayoutChanges(baseArgs)).toBe(false); | |
| 88 | expect(animateSidebarLayoutChanges(baseArgs)).toBe(false); | |
| 80 | 89 | }); |
| 81 | 90 | |
| 82 | 91 | 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); | |
| 84 | 93 | }); |
| 85 | 94 | }); |
| 86 | 95 | |
| ... | ... | @@ -1025,6 +1034,715 @@ describe("planPinnedReorder", () => { |
| 1025 | 1034 | }); |
| 1026 | 1035 | }); |
| 1027 | 1036 | |
| 1037 | describe("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 | ||
| 1193 | describe("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 | ||
| 1527 | describe("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 | ||
| 1028 | 1746 | describe("sortPinnedThreadsForSidebar", () => { |
| 1029 | 1747 | const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({ |
| 1030 | 1748 | id: input.id, |
apps/web/src/components/Sidebar.logic.ts+251-25| ... | ... | @@ -7,8 +7,8 @@ import { |
| 7 | 7 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 8 | 8 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 9 | 9 | import type { AsyncResult } from "effect/unstable/reactivity"; |
| 10 | import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; | |
| 10 | 11 | import { |
| 11 | activeThreadAnchorTimestampMs, | |
| 12 | 12 | getThreadSortTimestamp, |
| 13 | 13 | resolveSettledThreadTimestamp, |
| 14 | 14 | sortThreads, |
| ... | ... | @@ -81,12 +81,257 @@ export function useRetainedValue<T>(key: string | null, value: T | null): T | nu |
| 81 | 81 | return key !== null && retained.current?.key === key ? retained.current.value : null; |
| 82 | 82 | } |
| 83 | 83 | |
| 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. | |
| 87 | export 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. | |
| 86 | export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) => | |
| 88 | 87 | args.isSorting ? defaultAnimateLayoutChanges(args) : false; |
| 89 | 88 | |
| 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 | ||
| 95 | export 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. */ | |
| 99 | const SIDEBAR_MARKER_PREFIX = "sidebar-marker-"; | |
| 100 | ||
| 101 | export 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 | ||
| 112 | export function sidebarMarkerId(marker: SidebarListMarker): string { | |
| 113 | return `${SIDEBAR_MARKER_PREFIX}${marker}`; | |
| 114 | } | |
| 115 | ||
| 116 | export type SidebarListItem = | |
| 117 | | { readonly kind: "thread"; readonly key: string; readonly section: SidebarSection } | |
| 118 | | { readonly kind: "marker"; readonly marker: SidebarListMarker }; | |
| 119 | ||
| 120 | export 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. */ | |
| 128 | function 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. */ | |
| 142 | export type SidebarDropTarget = { | |
| 143 | readonly section: "pinned" | "active" | "settled"; | |
| 144 | readonly pinnedOrder: readonly string[]; | |
| 145 | readonly activeOrder: readonly string[]; | |
| 146 | }; | |
| 147 | ||
| 148 | export 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 | ||
| 173 | export 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 | ||
| 200 | export 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. */ | |
| 298 | export 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 | ||
| 90 | 335 | type SidebarProject = { |
| 91 | 336 | id: string; |
| 92 | 337 | title: string; |
| ... | ... | @@ -605,26 +850,7 @@ function firstValidTimestamp( |
| 605 | 850 | return null; |
| 606 | 851 | } |
| 607 | 852 | |
| 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. | |
| 615 | export 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 | } | |
| 853 | export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; | |
| 628 | 854 | |
| 629 | 855 | // Pinned-reorder key math and the keyed sort live in client-runtime |
| 630 | 856 | // (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 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { createSidebarListMotion } from "./Sidebar.motion"; | |
| 4 | ||
| 5 | class 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 | ||
| 22 | class 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 | ||
| 68 | function 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 | ||
| 96 | function 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 | ||
| 103 | beforeEach(() => vi.stubGlobal("HTMLElement", TestRow)); | |
| 104 | afterEach(() => vi.unstubAllGlobals()); | |
| 105 | ||
| 106 | describe("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 @@ |
| 1 | const motionTiming = { duration: 150, easing: "ease-out" }; | |
| 2 | ||
| 3 | type RowPosition = { top: number; left: number; width: number; height: number }; | |
| 4 | ||
| 5 | function 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. */ | |
| 13 | export 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 @@ |
| 1 | import { autoAnimate } from "@formkit/auto-animate"; | |
| 2 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | 2 | import * as Schema from "effect/Schema"; |
| 4 | 3 | import { |
| 5 | 4 | DndContext, |
| 6 | 5 | PointerSensor, |
| 7 | closestCenter, | |
| 8 | 6 | useSensor, |
| 9 | 7 | useSensors, |
| 10 | 8 | type DragEndEvent, |
| 9 | type DragOverEvent, | |
| 10 | type DragStartEvent, | |
| 11 | 11 | } from "@dnd-kit/core"; |
| 12 | import { | |
| 13 | SortableContext, | |
| 14 | arrayMove, | |
| 15 | useSortable, | |
| 16 | verticalListSortingStrategy, | |
| 17 | } from "@dnd-kit/sortable"; | |
| 12 | import { SortableContext, useSortable } from "@dnd-kit/sortable"; | |
| 18 | 13 | import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; |
| 19 | 14 | import { CSS } from "@dnd-kit/utilities"; |
| 20 | 15 | import { |
| ... | ... | @@ -63,6 +58,7 @@ import { |
| 63 | 58 | memo, |
| 64 | 59 | useCallback, |
| 65 | 60 | useEffect, |
| 61 | useLayoutEffect, | |
| 66 | 62 | useMemo, |
| 67 | 63 | useReducer, |
| 68 | 64 | useRef, |
| ... | ... | @@ -77,6 +73,7 @@ import { |
| 77 | 73 | isAtomCommandInterrupted, |
| 78 | 74 | settlePromise, |
| 79 | 75 | squashAtomCommandFailure, |
| 76 | type AtomCommandResult, | |
| 80 | 77 | } from "@t3tools/client-runtime/state/runtime"; |
| 81 | 78 | import { isElectron } from "../env"; |
| 82 | 79 | import { |
| ... | ... | @@ -137,7 +134,8 @@ import { cn } from "~/lib/utils"; |
| 137 | 134 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 138 | 135 | import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; |
| 139 | 136 | import { |
| 140 | animatePinnedLayoutChanges, | |
| 137 | animateSidebarLayoutChanges, | |
| 138 | applySidebarThreadDrop, | |
| 141 | 139 | buildBulkTitleRegenerationContextMenuItem, |
| 142 | 140 | buildBulkUnpinContextMenuItem, |
| 143 | 141 | deleteSelectedThreadEntries, |
| ... | ... | @@ -148,14 +146,17 @@ import { |
| 148 | 146 | isSidebarNestedLinkClick, |
| 149 | 147 | isTrailingDoubleClick, |
| 150 | 148 | orderItemsByPreferredIds, |
| 151 | planPinnedReorder, | |
| 149 | planSidebarThreadDrop, | |
| 152 | 150 | reduceSidebarProjectScopeMenuState, |
| 153 | 151 | resolveAdjacentThreadId, |
| 152 | resolveSidebarDropTarget, | |
| 154 | 153 | resolveSidebarThreadStatus, |
| 155 | 154 | searchSidebarThreadsByTitle, |
| 156 | 155 | shouldCreateNewThreadInCurrentProject, |
| 157 | 156 | shouldRecedeSidebarThread, |
| 158 | 157 | resolveWorkingStartedAt, |
| 158 | sidebarListItemId, | |
| 159 | sidebarMarkerId, | |
| 159 | 160 | sortLogicalProjectsForSidebar, |
| 160 | 161 | sortPinnedThreadsForSidebar, |
| 161 | 162 | sortSettledThreadsForSidebar, |
| ... | ... | @@ -163,8 +164,13 @@ import { |
| 163 | 164 | useRetainedValue, |
| 164 | 165 | useSidebarRowSubscriptionLease, |
| 165 | 166 | useThreadJumpHintVisibility, |
| 167 | type SidebarListItem, | |
| 168 | type SidebarListMarker, | |
| 169 | type SidebarSection, | |
| 166 | 170 | } from "./Sidebar.logic"; |
| 167 | 171 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| 172 | import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag"; | |
| 173 | import { createSidebarListMotion } from "./Sidebar.motion"; | |
| 168 | 174 | import { |
| 169 | 175 | ThreadWorktreeIndicator, |
| 170 | 176 | prStatusIndicator, |
| ... | ... | @@ -473,23 +479,25 @@ function SnoozePopoverButton(props: { |
| 473 | 479 | ); |
| 474 | 480 | } |
| 475 | 481 | |
| 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 | |
| 478 | 484 | // 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 | |
| 480 | 486 | // carries its own button semantics. |
| 481 | type SortablePinnedRowBag = Pick< | |
| 487 | type SortableThreadRowBag = Pick< | |
| 482 | 488 | ReturnType<typeof useSortable>, |
| 483 | 489 | "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" |
| 484 | 490 | >; |
| 485 | 491 | |
| 486 | function SortablePinnedThreadRow(props: { | |
| 492 | function SortableThreadRow(props: { | |
| 487 | 493 | id: string; |
| 488 | children: (bag: SortablePinnedRowBag) => ReactNode; | |
| 494 | disabled: boolean; | |
| 495 | children: (bag: SortableThreadRowBag) => ReactNode; | |
| 489 | 496 | }) { |
| 490 | 497 | const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ |
| 491 | 498 | id: props.id, |
| 492 | animateLayoutChanges: animatePinnedLayoutChanges, | |
| 499 | disabled: { draggable: props.disabled }, | |
| 500 | animateLayoutChanges: animateSidebarLayoutChanges, | |
| 493 | 501 | }); |
| 494 | 502 | return props.children({ listeners, setNodeRef, transform, transition, isDragging }); |
| 495 | 503 | } |
| ... | ... | @@ -499,6 +507,152 @@ function SortablePinnedThreadRow(props: { |
| 499 | 507 | const draftSurfaceClassName = "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]"; |
| 500 | 508 | const draftPenClassName = "size-3 shrink-0 text-amber-600 dark:text-amber-300/80"; |
| 501 | 509 | |
| 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. | |
| 515 | function 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. | |
| 545 | function 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. | |
| 568 | function 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. | |
| 603 | function 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 | ||
| 502 | 656 | // One unsent draft session the user has invested content in. Two lines, |
| 503 | 657 | // nothing else: project name, then the typed prompt. All the draft's |
| 504 | 658 | // settings (model, env mode, branch, worktree) still travel with it — |
| ... | ... | @@ -752,10 +906,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 752 | 906 | // rows. The marker can unpin the thread when the server supports pinning. |
| 753 | 907 | pinningSupported: boolean; |
| 754 | 908 | 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 | |
| 757 | 911 | // pointer sensor's distance constraint keeps plain clicks working). |
| 758 | sortable?: SortablePinnedRowBag | undefined; | |
| 912 | sortable?: SortableThreadRowBag | undefined; | |
| 913 | dropSection: SidebarSection | null; | |
| 759 | 914 | // Compact wake countdown ("2h") for rows in the snoozed shelf. |
| 760 | 915 | snoozeWakeLabelText: string | null; |
| 761 | 916 | // When a snooze ended (timer or early wake); drives the Woke pill until |
| ... | ... | @@ -1151,6 +1306,41 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1151 | 1306 | !isSelected && |
| 1152 | 1307 | "opacity-70 transition-opacity hover:opacity-100", |
| 1153 | 1308 | ); |
| 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; | |
| 1154 | 1344 | |
| 1155 | 1345 | const title = isRenaming ? ( |
| 1156 | 1346 | <input |
| ... | ... | @@ -1282,9 +1472,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1282 | 1472 | return ( |
| 1283 | 1473 | <li |
| 1284 | 1474 | 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 | )} | |
| 1286 | 1480 | > |
| 1287 | <Tooltip> | |
| 1481 | <Tooltip disabled={sortable?.isDragging}> | |
| 1288 | 1482 | <TooltipTrigger |
| 1289 | 1483 | render={ |
| 1290 | 1484 | <div |
| ... | ... | @@ -1332,93 +1526,95 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1332 | 1526 | remain visible AND clickable while the row is hovered. Only |
| 1333 | 1527 | the time/jump label yields to the settle affordance. */} |
| 1334 | 1528 | {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" ? ( | |
| 1351 | 1585 | <Tooltip> |
| 1352 | 1586 | <TooltipTrigger |
| 1353 | 1587 | render={ |
| 1354 | 1588 | <button |
| 1355 | 1589 | 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 | /> | |
| 1363 | 1597 | } |
| 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> | |
| 1366 | 1602 | </Tooltip> |
| 1367 | 1603 | ) : ( |
| 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 : ( | |
| 1377 | 1604 | <button |
| 1378 | 1605 | type="button" |
| 1379 | aria-label="Wake thread now" | |
| 1380 | onClick={handleUnsnoozeClick} | |
| 1606 | aria-label="Settle thread" | |
| 1607 | onClick={handleSettleClick} | |
| 1381 | 1608 | 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", | |
| 1383 | 1610 | isWoke && "group-hover/sidebar-row:static", |
| 1384 | 1611 | )} |
| 1385 | 1612 | > |
| 1386 | <AlarmClockOffIcon className="mb-px size-3" /> | |
| 1613 | <CheckIcon className="size-3" /> | |
| 1387 | 1614 | </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 | )} | |
| 1422 | 1618 | {props.jumpLabel ? <JumpHintBadge label={props.jumpLabel} /> : null} |
| 1423 | 1619 | </TooltipTrigger> |
| 1424 | 1620 | {detailsTooltip} |
| ... | ... | @@ -1429,26 +1625,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1429 | 1625 | |
| 1430 | 1626 | const diff = latestTurnDiff(thread); |
| 1431 | 1627 | |
| 1432 | const sortable = props.sortable; | |
| 1433 | 1628 | return ( |
| 1434 | 1629 | <li |
| 1435 | 1630 | 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} | |
| 1446 | 1632 | className={cn( |
| 1447 | 1633 | "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]", |
| 1448 | 1634 | sortable?.isDragging && "z-20 opacity-80", |
| 1449 | 1635 | )} |
| 1450 | 1636 | > |
| 1451 | <Tooltip disabled={snoozeMenuOpen}> | |
| 1637 | <Tooltip disabled={snoozeMenuOpen || sortable?.isDragging}> | |
| 1452 | 1638 | <TooltipTrigger |
| 1453 | 1639 | render={ |
| 1454 | 1640 | <div |
| ... | ... | @@ -1493,125 +1679,127 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1493 | 1679 | actions on hover/keyboard focus or while the popover is open. Keeping |
| 1494 | 1680 | the hidden state out of flow lets the project label reclaim |
| 1495 | 1681 | 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 | |
| 1498 | 1685 | itself an action, so it stays pointer-enabled and visible |
| 1499 | 1686 | 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 ? ( | |
| 1558 | 1687 | <span |
| 1559 | 1688 | 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", | |
| 1567 | 1694 | )} |
| 1568 | 1695 | > |
| 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 | )} | |
| 1605 | 1723 | > |
| 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 | )} | |
| 1612 | 1743 | </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 | )} | |
| 1615 | 1803 | </div> |
| 1616 | 1804 | <div className="mt-1 flex min-w-0"> |
| 1617 | 1805 | {title} |
| ... | ... | @@ -1834,8 +2022,10 @@ export default function Sidebar() { |
| 1834 | 2022 | snoozeThread, |
| 1835 | 2023 | unsnoozeThread, |
| 1836 | 2024 | pinThread, |
| 2025 | unpinThread, | |
| 1837 | 2026 | confirmAndUnpinThread, |
| 1838 | 2027 | reorderPinnedThread, |
| 2028 | reorderActiveThread, | |
| 1839 | 2029 | archiveThread, |
| 1840 | 2030 | deleteThread, |
| 1841 | 2031 | } = useThreadActions(); |
| ... | ... | @@ -2196,13 +2386,27 @@ export default function Sidebar() { |
| 2196 | 2386 | [openProjectSettings], |
| 2197 | 2387 | ); |
| 2198 | 2388 | |
| 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); | |
| 2203 | 2406 | const { |
| 2204 | 2407 | pinnedThreads, |
| 2205 | reorderablePinnedKeys, | |
| 2408 | draggableThreadKeys, | |
| 2409 | activeReorderableThreadKeys, | |
| 2206 | 2410 | activeThreads, |
| 2207 | 2411 | snoozedThreads, |
| 2208 | 2412 | settledThreads, |
| ... | ... | @@ -2224,17 +2428,42 @@ export default function Sidebar() { |
| 2224 | 2428 | const active: EnvironmentThreadShell[] = []; |
| 2225 | 2429 | const snoozed: EnvironmentThreadShell[] = []; |
| 2226 | 2430 | const settled: EnvironmentThreadShell[] = []; |
| 2431 | const draggable = new Set<string>(); | |
| 2432 | const activeReorderable = new Set<string>(); | |
| 2227 | 2433 | for (const thread of visible) { |
| 2434 | const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; | |
| 2228 | 2435 | // Threads on servers without the settlement capability (old server, |
| 2229 | 2436 | // or descriptor not loaded yet) never classify as settled: the user |
| 2230 | 2437 | // could neither un-settle nor pin them, so auto-settling them would |
| 2231 | 2438 | // 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. | |
| 2238 | 2467 | snoozed.push(thread); |
| 2239 | 2468 | } else if (supportsSettlement && thread.settledOverride === "settled") { |
| 2240 | 2469 | settled.push(thread); |
| ... | ... | @@ -2249,18 +2478,27 @@ export default function Sidebar() { |
| 2249 | 2478 | // Server capability only gates DRAGGING — it must not influence the |
| 2250 | 2479 | // sort, or mixed-version fleets would render different pinned orders on |
| 2251 | 2480 | // web and mobile from the same data. |
| 2481 | const sortedPinned = sortPinnedThreadsForSidebar(pinned); | |
| 2482 | const sortedActive = sortThreadsForSidebar(active); | |
| 2252 | 2483 | 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 | }), | |
| 2264 | 2502 | // Soonest wake first: "what comes back next" is the shelf's question. |
| 2265 | 2503 | snoozedThreads: snoozed.toSorted( |
| 2266 | 2504 | (left, right) => |
| ... | ... | @@ -2270,7 +2508,7 @@ export default function Sidebar() { |
| 2270 | 2508 | settledThreads: sortSettledThreadsForSidebar(settled), |
| 2271 | 2509 | snoozeNow: preciseNow, |
| 2272 | 2510 | }; |
| 2273 | }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); | |
| 2511 | }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); | |
| 2274 | 2512 | |
| 2275 | 2513 | const threadSearchInputRef = useRef<HTMLInputElement>(null); |
| 2276 | 2514 | const [threadSearchQuery, setThreadSearchQuery] = useState(""); |
| ... | ... | @@ -2715,77 +2953,122 @@ export default function Sidebar() { |
| 2715 | 2953 | }, |
| 2716 | 2954 | [unsnoozeThread], |
| 2717 | 2955 | ); |
| 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( | |
| 2732 | 2967 | useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), |
| 2733 | 2968 | ); |
| 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; | |
| 2742 | 2974 | } | 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 | ); | |
| 2751 | 3003 | 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 | ]), | |
| 2755 | 3010 | ); |
| 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 | ]), | |
| 2758 | 3054 | ); |
| 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); | |
| 2767 | 3057 | 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); | |
| 2775 | 3064 | }); |
| 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, | |
| 2781 | 3067 | ); |
| 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); | |
| 2787 | 3070 | } |
| 2788 | }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); | |
| 3071 | }, [activeKeys, optimisticDrop, pinnedKeys, threads]); | |
| 2789 | 3072 | const attemptPin = useCallback( |
| 2790 | 3073 | (threadRef: ScopedThreadRef) => { |
| 2791 | 3074 | void (async () => { |
| ... | ... | @@ -2827,71 +3110,338 @@ export default function Sidebar() { |
| 2827 | 3110 | [confirmAndUnpinThread], |
| 2828 | 3111 | ); |
| 2829 | 3112 | |
| 2830 | const handlePinnedDragEnd = useCallback( | |
| 2831 | (event: DragEndEvent) => { | |
| 3113 | const handleThreadDragStart = useCallback( | |
| 3114 | (event: DragStartEvent) => { | |
| 2832 | 3115 | 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, | |
| 2853 | 3126 | }); |
| 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, | |
| 2861 | 3321 | }); |
| 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); | |
| 2862 | 3347 | 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)) { | |
| 2881 | 3357 | const error = squashAtomCommandFailure(result); |
| 2882 | 3358 | toastManager.add( |
| 2883 | 3359 | stackedThreadToast({ |
| 2884 | 3360 | type: "error", |
| 2885 | title: "Failed to reorder pinned threads", | |
| 3361 | title, | |
| 2886 | 3362 | description: error instanceof Error ? error.message : "An error occurred.", |
| 2887 | 3363 | }), |
| 2888 | 3364 | ); |
| 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?.(); | |
| 2889 | 3376 | return; |
| 2890 | 3377 | } |
| 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; | |
| 2891 | 3422 | } |
| 2892 | 3423 | })(); |
| 2893 | 3424 | }, |
| 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 | ], | |
| 2895 | 3445 | ); |
| 2896 | 3446 | // One snooze per thread at a time — same double-dispatch guard as settle. |
| 2897 | 3447 | const snoozingThreadKeysRef = useRef(new Set<string>()); |
| ... | ... | @@ -3535,11 +4085,6 @@ export default function Sidebar() { |
| 3535 | 4085 | updateThreadJumpHintsVisibility(shouldShowJumpHintsNow); |
| 3536 | 4086 | }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]); |
| 3537 | 4087 | |
| 3538 | const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { | |
| 3539 | if (!node) return; | |
| 3540 | autoAnimate(node, { duration: 150, easing: "ease-out" }); | |
| 3541 | }, []); | |
| 3542 | ||
| 3543 | 4088 | // New thread defaults to the project you're in (active thread's project, |
| 3544 | 4089 | // falling back to the top project) — same resolution the command palette |
| 3545 | 4090 | // uses. The command palette already offers a "New thread in..." submenu |
| ... | ... | @@ -3929,281 +4474,298 @@ export default function Sidebar() { |
| 3929 | 4474 | closeDelay={0} |
| 3930 | 4475 | timeout={400} |
| 3931 | 4476 | > |
| 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 | } | |
| 4081 | 4623 | > |
| 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})` | |
| 4093 | 4708 | } |
| 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"> | |
| 4165 | 4756 | <button |
| 4166 | 4757 | 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" | |
| 4171 | 4760 | > |
| 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 | |
| 4185 | 4763 | </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> | |
| 4207 | 4769 | </TooltipProvider> |
| 4208 | 4770 | ) : null} |
| 4209 | 4771 | {!isSearchingThreads && |
apps/web/src/hooks/useThreadActions.ts+38| ... | ... | @@ -25,6 +25,7 @@ import { readLocalApi } from "../localApi"; |
| 25 | 25 | import { |
| 26 | 26 | readEnvironmentSupportsPinning, |
| 27 | 27 | readEnvironmentSupportsPinReorder, |
| 28 | readEnvironmentSupportsActiveReorder, | |
| 28 | 29 | readEnvironmentSupportsSettlement, |
| 29 | 30 | readEnvironmentSupportsSnooze, |
| 30 | 31 | readEnvironmentThreadRefs, |
| ... | ... | @@ -124,6 +125,18 @@ export class ThreadPinReorderUnsupportedError extends Schema.TaggedErrorClass<Th |
| 124 | 125 | } |
| 125 | 126 | } |
| 126 | 127 | |
| 128 | export 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 | ||
| 127 | 140 | export async function requestThreadUnpinConfirmation(input: { |
| 128 | 141 | enabled: boolean; |
| 129 | 142 | title: string; |
| ... | ... | @@ -185,6 +198,9 @@ export function useThreadActions() { |
| 185 | 198 | const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, { |
| 186 | 199 | reportFailure: false, |
| 187 | 200 | }); |
| 201 | const reorderActiveThreadMutation = useAtomCommand(threadEnvironment.reorderActive, { | |
| 202 | reportFailure: false, | |
| 203 | }); | |
| 188 | 204 | const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { |
| 189 | 205 | reportFailure: false, |
| 190 | 206 | }); |
| ... | ... | @@ -631,6 +647,26 @@ export function useThreadActions() { |
| 631 | 647 | [reorderPinnedThreadMutation], |
| 632 | 648 | ); |
| 633 | 649 | |
| 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 | ||
| 634 | 670 | const snoozeThread = useCallback( |
| 635 | 671 | async (target: ScopedThreadRef, snoozedUntil: string) => { |
| 636 | 672 | // Version skew: never send the command to a server that predates it. |
| ... | ... | @@ -729,6 +765,7 @@ export function useThreadActions() { |
| 729 | 765 | unpinThread, |
| 730 | 766 | confirmAndUnpinThread, |
| 731 | 767 | reorderPinnedThread, |
| 768 | reorderActiveThread, | |
| 732 | 769 | }), |
| 733 | 770 | [ |
| 734 | 771 | archiveThread, |
| ... | ... | @@ -737,6 +774,7 @@ export function useThreadActions() { |
| 737 | 774 | deleteThread, |
| 738 | 775 | pinThread, |
| 739 | 776 | reorderPinnedThread, |
| 777 | reorderActiveThread, | |
| 740 | 778 | settleThread, |
| 741 | 779 | snoozeThread, |
| 742 | 780 | unarchiveThread, |
apps/web/src/lib/threadSort.ts-1| ... | ... | @@ -1,5 +1,4 @@ |
| 1 | 1 | export { |
| 2 | activeThreadAnchorTimestampMs, | |
| 3 | 2 | getLatestThreadForProject, |
| 4 | 3 | getThreadSortTimestamp, |
| 5 | 4 | resolveSettledThreadTimestamp, |
apps/web/src/state/entities.ts+7| ... | ... | @@ -229,6 +229,13 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): |
| 229 | 229 | ); |
| 230 | 230 | } |
| 231 | 231 | |
| 232 | export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean { | |
| 233 | return ( | |
| 234 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | |
| 235 | .threadActiveReorder === true | |
| 236 | ); | |
| 237 | } | |
| 238 | ||
| 232 | 239 | export function readEnvironmentThreadRefs( |
| 233 | 240 | environmentId: EnvironmentId, |
| 234 | 241 | ): ReadonlyArray<ScopedThreadRef> { |
docs/user/thread-sidebar.md+31-3| ... | ... | @@ -22,12 +22,40 @@ worktree**, each background submission creates its own worktree. |
| 22 | 22 | |
| 23 | 23 | ## Pin and reorder threads |
| 24 | 24 | |
| 25 | Pin a thread from its menu to keep it above your active work. Drag pinned threads | |
| 26 | to reorder them on web and desktop, or use **Move up** and **Move down** on mobile. | |
| 27 | The order syncs across devices. | |
| 25 | Pin a thread from its menu to keep it above your active work. | |
| 28 | 26 | |
| 29 | 27 | Pinning does not prevent automatic settlement. Settling a thread removes its pin. |
| 30 | 28 | |
| 29 | On web and desktop, drag a thread between sections to change its state. Drag a thread up into | |
| 30 | the pinned section to pin it at the spot you drop it; drag a pinned thread down into the active | |
| 31 | list to unpin it. Dragging a thread onto the **Settled** header settles it, and dragging a settled | |
| 32 | thread into the active list un-settles it. A snoozed thread can be dragged out of the snoozed | |
| 33 | shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake | |
| 34 | time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation. | |
| 35 | Pinned and active boundary labels appear only while dragging, without moving the rows. The | |
| 36 | destination boundary highlights and the thread shows which section it will land in. When there | |
| 37 | are no pins, drag to the top edge to pin a thread. Drop instructions also appear for empty sections | |
| 38 | and a collapsed settled shelf. | |
| 39 | ||
| 40 | Drag within the pinned or active section to change its order. Other rows slide aside to show the | |
| 41 | spot where the thread will land. Drops into either section keep the position you choose. On | |
| 42 | mobile, open a pinned or active thread's menu and choose **Move up** or **Move down**. The server | |
| 43 | saves the order, so it survives a refresh and appears on your other connected devices. | |
| 44 | ||
| 45 | On 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 | |
| 47 | preference. While dragging, rows follow the insertion gap without replaying a second transition | |
| 48 | after the drop. | |
| 49 | ||
| 50 | New threads appear above the active threads you have arranged. Settling clears a thread's active | |
| 51 | position, so using **Un-settle** returns it to the top. Pinning and snoozing preserve its active | |
| 52 | position until you move it again. Thread activity does not change the order. The settled shelf | |
| 53 | continues to use settlement time. | |
| 54 | ||
| 55 | If dragging is unavailable for one environment, update the T3 Code server running in that | |
| 56 | environment. Pinned and active reordering require server support. Threads from older servers keep | |
| 57 | their default order until the server is updated. | |
| 58 | ||
| 31 | 59 | ## Settle finished work |
| 32 | 60 | |
| 33 | 61 | Choose **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( |
| 107 | 107 | * top instead of sinking back to its creation-order slot. Shared by web and |
| 108 | 108 | * mobile so both render the same order. Malformed timestamps sink to 0. |
| 109 | 109 | */ |
| 110 | export function activeThreadAnchorTimestampMs(thread: { | |
| 110 | function activeThreadAnchorTimestampMs(thread: { | |
| 111 | 111 | readonly createdAt: string; |
| 112 | 112 | readonly unsettledAt?: string | null | undefined; |
| 113 | 113 | }): number { |