| author | |
| committer | |
| log | 5b0c923ea3413c038f32ac4c523ae96863443f9f |
| tree | 2b2f6d24315030ee73435b6063bc4f0183ef5374 |
| parent | 9ab0635db6abb1abf15e664141a0f8b6f0c33eae |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>8 files changed, 326 insertions(+), 88 deletions(-)
apps/web/src/components/Sidebar.drag.test.ts+60| ... | ... | @@ -245,7 +245,9 @@ describe("sidebar drag projection", () => { |
| 245 | 245 | settledExpanded: true, |
| 246 | 246 | }); |
| 247 | 247 | const args = layout(pinned, active, over); |
| 248 | // dnd-kit moves the lifted row by the pointer delta, so only peers matter. | |
| 248 | 249 | for (let index = 0; index < pinned.length; index += 1) { |
| 250 | if (index === args.activeIndex) continue; | |
| 249 | 251 | expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index })); |
| 250 | 252 | } |
| 251 | 253 | }); |
| ... | ... | @@ -282,6 +284,7 @@ describe("sidebar drag projection", () => { |
| 282 | 284 | }); |
| 283 | 285 | const args = layout(items, active, over); |
| 284 | 286 | for (let index = 0; index < items.length; index += 1) { |
| 287 | if (index === args.activeIndex) continue; | |
| 285 | 288 | expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index })); |
| 286 | 289 | } |
| 287 | 290 | }); |
| ... | ... | @@ -328,6 +331,63 @@ describe("sidebar drag projection", () => { |
| 328 | 331 | }, |
| 329 | 332 | ); |
| 330 | 333 | |
| 334 | it("opens label space below each pinned boundary while dragging", () => { | |
| 335 | const items = [ | |
| 336 | pinnedHeader, | |
| 337 | thread("p", "pinned"), | |
| 338 | divider, | |
| 339 | thread("a1", "active"), | |
| 340 | thread("a2", "active"), | |
| 341 | settledHeader, | |
| 342 | thread("s", "settled"), | |
| 343 | ]; | |
| 344 | // Reorder inside active: the header gap shifts every row, the divider | |
| 345 | // gap shifts the active rows and the shelf below by a second label. | |
| 346 | const result = preview( | |
| 347 | { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 }, | |
| 348 | "a2", | |
| 349 | "a1", | |
| 350 | ); | |
| 351 | expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); | |
| 352 | expect(result.get("p")?.y).toBe(16); | |
| 353 | expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(16); | |
| 354 | expect(result.get("a2")).toEqual(stationary); | |
| 355 | expect(result.get("a1")?.y).toBe(32 + 83); | |
| 356 | expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(32); | |
| 357 | expect(result.get("s")?.y).toBe(32); | |
| 358 | }); | |
| 359 | ||
| 360 | it("stacks the labels with their gaps when the pinned section is empty", () => { | |
| 361 | const items = [ | |
| 362 | pinnedHeader, | |
| 363 | divider, | |
| 364 | thread("a1", "active"), | |
| 365 | thread("a2", "active"), | |
| 366 | settledHeader, | |
| 367 | thread("s", "settled"), | |
| 368 | ]; | |
| 369 | const result = preview( | |
| 370 | { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 }, | |
| 371 | "a2", | |
| 372 | "a1", | |
| 373 | ); | |
| 374 | expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); | |
| 375 | expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(16); | |
| 376 | expect(result.get("a1")?.y).toBe(32 + 83); | |
| 377 | expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(32); | |
| 378 | }); | |
| 379 | ||
| 380 | it("scales the label space with the measured root scale", () => { | |
| 381 | const items = [pinnedHeader, thread("p", "pinned"), divider, thread("a1", "active")]; | |
| 382 | const result = preview( | |
| 383 | { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 }, | |
| 384 | "a1", | |
| 385 | "p", | |
| 386 | 2, | |
| 387 | ); | |
| 388 | expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(32 + 165); | |
| 389 | }); | |
| 390 | ||
| 331 | 391 | it("keeps the pinned header above the first arriving pin", () => { |
| 332 | 392 | const items = [ |
| 333 | 393 | pinnedHeader, |
apps/web/src/components/Sidebar.drag.ts+16-3| ... | ... | @@ -62,6 +62,9 @@ export function createSidebarSortingStrategy(input: { |
| 62 | 62 | snoozedThreadCount?: number; |
| 63 | 63 | cardHeight?: number; |
| 64 | 64 | slimHeight?: number; |
| 65 | /** Space each pinned boundary opens for its label while dragging. The | |
| 66 | * markers stay zero height at rest, so nothing is reserved until pickup. */ | |
| 67 | boundaryLabelHeight?: number; | |
| 65 | 68 | }): SortingStrategy { |
| 66 | 69 | const { items } = input; |
| 67 | 70 | const indices = new Map(items.map((item, index) => [sidebarListItemId(item), index])); |
| ... | ... | @@ -74,8 +77,10 @@ export function createSidebarSortingStrategy(input: { |
| 74 | 77 | if (active?.kind !== "thread" || !over || !rects[0]) return []; |
| 75 | 78 | const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over)); |
| 76 | 79 | if (!target) return []; |
| 77 | if (target.section === active.section && over.kind === "thread") | |
| 78 | return target.section === "settled" ? [] : null; | |
| 80 | // Settled keeps time order, so a reorder inside it previews nothing. | |
| 81 | // Every other drag projects so the boundary labels get their space. | |
| 82 | if (target.section === active.section && over.kind === "thread" && target.section === "settled") | |
| 83 | return []; | |
| 79 | 84 | const groups: Record<SidebarSection, ThreadItem[]> = { |
| 80 | 85 | pinned: [], |
| 81 | 86 | active: [], |
| ... | ... | @@ -98,6 +103,7 @@ export function createSidebarSortingStrategy(input: { |
| 98 | 103 | const scale = slimHeight !== undefined ? slimHeight / 36 : (cardHeight ?? 82) / 82; |
| 99 | 104 | cardHeight ??= 82 * scale; |
| 100 | 105 | slimHeight ??= 36 * scale; |
| 106 | const labelHeight = (input.boundaryLabelHeight ?? 0) * scale; | |
| 101 | 107 | const group = groups[target.section]; |
| 102 | 108 | const order = |
| 103 | 109 | target.section === "pinned" |
| ... | ... | @@ -153,7 +159,14 @@ export function createSidebarSortingStrategy(input: { |
| 153 | 159 | ? cardHeight |
| 154 | 160 | : slimHeight; |
| 155 | 161 | const moved = item.kind === "thread" && item.key === active.key; |
| 156 | top += (moved ? fallback : (rect?.height ?? fallback)) + 1; | |
| 162 | const height = | |
| 163 | item.kind === "marker" && | |
| 164 | (item.marker === "pinned-header" || item.marker === "pinned-divider") | |
| 165 | ? labelHeight | |
| 166 | : moved | |
| 167 | ? fallback | |
| 168 | : (rect?.height ?? fallback); | |
| 169 | top += height + 1; | |
| 157 | 170 | } |
| 158 | 171 | result[activeIndex] = stationary; |
| 159 | 172 | return result; |
apps/web/src/components/Sidebar.logic.test.ts+22| ... | ... | @@ -48,6 +48,7 @@ import { |
| 48 | 48 | type SidebarListItem, |
| 49 | 49 | type SidebarListMarker, |
| 50 | 50 | type SidebarSection, |
| 51 | resolveSidebarDropVerb, | |
| 51 | 52 | } from "./Sidebar.logic"; |
| 52 | 53 | import { |
| 53 | 54 | EnvironmentId, |
| ... | ... | @@ -2469,3 +2470,24 @@ describe("sortLogicalProjectsForSidebar", () => { |
| 2469 | 2470 | ).toEqual(["logical-newer", "logical-older"]); |
| 2470 | 2471 | }); |
| 2471 | 2472 | }); |
| 2473 | ||
| 2474 | describe("resolveSidebarDropVerb", () => { | |
| 2475 | it("names the state change a cross-section drop performs", () => { | |
| 2476 | expect(resolveSidebarDropVerb("active", "pinned")).toBe("pin"); | |
| 2477 | expect(resolveSidebarDropVerb("settled", "pinned")).toBe("pin"); | |
| 2478 | expect(resolveSidebarDropVerb("snoozed", "pinned")).toBe("pin"); | |
| 2479 | expect(resolveSidebarDropVerb("pinned", "active")).toBe("unpin"); | |
| 2480 | expect(resolveSidebarDropVerb("settled", "active")).toBe("unsettle"); | |
| 2481 | expect(resolveSidebarDropVerb("snoozed", "active")).toBe("wake"); | |
| 2482 | expect(resolveSidebarDropVerb("active", "settled")).toBe("settle"); | |
| 2483 | expect(resolveSidebarDropVerb("pinned", "settled")).toBe("settle"); | |
| 2484 | expect(resolveSidebarDropVerb("snoozed", "settled")).toBe("settle"); | |
| 2485 | }); | |
| 2486 | ||
| 2487 | it("stays silent for same-section reorders, no target, and the snoozed shelf", () => { | |
| 2488 | expect(resolveSidebarDropVerb("active", "active")).toBeNull(); | |
| 2489 | expect(resolveSidebarDropVerb("pinned", "pinned")).toBeNull(); | |
| 2490 | expect(resolveSidebarDropVerb("active", null)).toBeNull(); | |
| 2491 | expect(resolveSidebarDropVerb("active", "snoozed")).toBeNull(); | |
| 2492 | }); | |
| 2493 | }); |
apps/web/src/components/Sidebar.logic.ts+17| ... | ... | @@ -197,6 +197,23 @@ export type SidebarThreadDropPlan = |
| 197 | 197 | } |
| 198 | 198 | | { readonly kind: "settle" }; |
| 199 | 199 | |
| 200 | /** What dropping in `to` does to a thread lifted from `from`, for the badge | |
| 201 | on the lifted row. Null while reordering inside one section and for the | |
| 202 | snoozed shelf, which cannot be a drop target. */ | |
| 203 | export type SidebarDropVerb = "pin" | "unpin" | "settle" | "unsettle" | "wake"; | |
| 204 | ||
| 205 | export function resolveSidebarDropVerb( | |
| 206 | from: SidebarSection, | |
| 207 | to: SidebarSection | null, | |
| 208 | ): SidebarDropVerb | null { | |
| 209 | if (to === null || to === from || to === "snoozed") return null; | |
| 210 | if (to === "pinned") return "pin"; | |
| 211 | if (to === "settled") return "settle"; | |
| 212 | if (from === "pinned") return "unpin"; | |
| 213 | if (from === "settled") return "unsettle"; | |
| 214 | return "wake"; | |
| 215 | } | |
| 216 | ||
| 200 | 217 | export function planSidebarThreadDrop(input: { |
| 201 | 218 | readonly activeKey: string; |
| 202 | 219 | readonly activeSection: SidebarSection; |
apps/web/src/components/Sidebar.motion.test.ts+37| ... | ... | @@ -70,6 +70,7 @@ function fixture(rows: TestRow[]) { |
| 70 | 70 | const parent = { |
| 71 | 71 | children: rows, |
| 72 | 72 | ownerDocument: { defaultView: { matchMedia: () => media } }, |
| 73 | getBoundingClientRect: () => ({ top: 0 }), | |
| 73 | 74 | append(node: TestRow) { |
| 74 | 75 | parent.children.push(node); |
| 75 | 76 | node.remove.mockImplementation(() => { |
| ... | ... | @@ -149,6 +150,42 @@ describe("sidebar list motion", () => { |
| 149 | 150 | expectMove(a, -83); |
| 150 | 151 | }); |
| 151 | 152 | |
| 153 | it("glides every row from its released position into its committed slot", () => { | |
| 154 | const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")]; | |
| 155 | const { motion, layout } = fixture([a, b, c]); | |
| 156 | motion.update(true); | |
| 157 | motion.suspend(); | |
| 158 | // a is lifted 130px below its slot; b previews upward into a's place | |
| 159 | // and c holds a 16px label gap open below the divider. | |
| 160 | a.dragTranslate = 130; | |
| 161 | b.dragTranslate = -83; | |
| 162 | c.dragTranslate = 16; | |
| 163 | motion.update(false); | |
| 164 | motion.release(); | |
| 165 | layout([b, a, c]); | |
| 166 | a.dragTranslate = b.dragTranslate = c.dragTranslate = 0; | |
| 167 | motion.update(true); | |
| 168 | // Lifted top 138, committed slot top 91: glide the remaining 47px. | |
| 169 | expectMove(a, 47); | |
| 170 | // b already sits where it lands; c closes its 16px gap. | |
| 171 | expect(b.animations).toHaveLength(0); | |
| 172 | expectMove(c, 16); | |
| 173 | }); | |
| 174 | ||
| 175 | it("does not glide on release when motion is reduced", () => { | |
| 176 | const [a, b] = [new TestRow("a"), new TestRow("b")]; | |
| 177 | const { motion, layout, media } = fixture([a, b]); | |
| 178 | motion.update(true); | |
| 179 | media.matches = true; | |
| 180 | a.dragTranslate = 100; | |
| 181 | motion.release(); | |
| 182 | layout([b, a]); | |
| 183 | a.dragTranslate = 0; | |
| 184 | motion.update(true); | |
| 185 | expect(a.animations).toHaveLength(0); | |
| 186 | expect(b.animations).toHaveLength(0); | |
| 187 | }); | |
| 188 | ||
| 152 | 189 | it("does not carry a canceled drag's transformed position into the next move", () => { |
| 153 | 190 | const a = new TestRow("a"); |
| 154 | 191 | const b = new TestRow("b"); |
apps/web/src/components/Sidebar.motion.ts+43-15| ... | ... | @@ -19,6 +19,9 @@ export function createSidebarListMotion(parent: HTMLUListElement) { |
| 19 | 19 | const running = new Map<HTMLElement, { animation: Animation; offset: number }>(); |
| 20 | 20 | const entering = new Map<HTMLElement, Animation>(); |
| 21 | 21 | const exiting = new Map<HTMLElement, Animation>(); |
| 22 | // Visual tops at drag release, relative to the list, so the release | |
| 23 | // commit can glide every row from where dnd-kit left it into its slot. | |
| 24 | let released: Map<HTMLElement, number> | null = null; | |
| 22 | 25 | |
| 23 | 26 | const remainingOffset = (node: HTMLElement) => { |
| 24 | 27 | const current = running.get(node); |
| ... | ... | @@ -86,6 +89,23 @@ export function createSidebarListMotion(parent: HTMLUListElement) { |
| 86 | 89 | for (const node of running.keys()) cancel(node); |
| 87 | 90 | clearFades(); |
| 88 | 91 | positions = null; |
| 92 | released = null; | |
| 93 | }; | |
| 94 | const move = (node: HTMLElement, offset: number) => { | |
| 95 | cancel(node); | |
| 96 | if (offset === 0) return; | |
| 97 | const animation = node.animate( | |
| 98 | [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], | |
| 99 | motionTiming, | |
| 100 | ); | |
| 101 | running.set(node, { animation, offset }); | |
| 102 | animation.addEventListener( | |
| 103 | "finish", | |
| 104 | () => { | |
| 105 | if (running.get(node)?.animation === animation) running.delete(node); | |
| 106 | }, | |
| 107 | { once: true }, | |
| 108 | ); | |
| 89 | 109 | }; |
| 90 | 110 | |
| 91 | 111 | return { |
| ... | ... | @@ -140,25 +160,33 @@ export function createSidebarListMotion(parent: HTMLUListElement) { |
| 140 | 160 | if (previousTop === position.top) continue; |
| 141 | 161 | // Computed progress includes the effect's easing. Only our own |
| 142 | 162 | // translate is carried forward; dnd-kit's transforms are never read. |
| 143 | const offset = previousTop + remainingOffset(node) - position.top; | |
| 144 | cancel(node); | |
| 145 | if (offset === 0) continue; | |
| 146 | const animation = node.animate( | |
| 147 | [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], | |
| 148 | motionTiming, | |
| 149 | ); | |
| 150 | running.set(node, { animation, offset }); | |
| 151 | animation.addEventListener( | |
| 152 | "finish", | |
| 153 | () => { | |
| 154 | if (running.get(node)?.animation === animation) running.delete(node); | |
| 155 | }, | |
| 156 | { once: true }, | |
| 157 | ); | |
| 163 | move(node, previousTop + remainingOffset(node) - position.top); | |
| 158 | 164 | } |
| 159 | 165 | } |
| 166 | if (released !== null) { | |
| 167 | if (!reducedMotion?.matches) { | |
| 168 | for (const [node, position] of next) { | |
| 169 | const top = released.get(node); | |
| 170 | if (top !== undefined) move(node, top - position.top); | |
| 171 | } | |
| 172 | } | |
| 173 | released = null; | |
| 174 | } | |
| 160 | 175 | positions = next; |
| 161 | 176 | }, |
| 177 | /** Called on drag release, before the commit that clears dnd-kit's | |
| 178 | * transforms. Takes every row's visual top, including the lifted row | |
| 179 | * under the pointer and the peers holding the label gaps open, so the | |
| 180 | * next update glides each of them into its committed slot. */ | |
| 181 | release() { | |
| 182 | suspend(); | |
| 183 | const origin = parent.getBoundingClientRect().top; | |
| 184 | released = new Map( | |
| 185 | Array.from(parent.children) | |
| 186 | .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) | |
| 187 | .map((node) => [node, node.getBoundingClientRect().top - origin]), | |
| 188 | ); | |
| 189 | }, | |
| 162 | 190 | suspend, |
| 163 | 191 | dispose() { |
| 164 | 192 | suspend(); |
apps/web/src/components/Sidebar.tsx+123-65| ... | ... | @@ -46,6 +46,7 @@ import { |
| 46 | 46 | FolderPlusIcon, |
| 47 | 47 | GitBranchIcon, |
| 48 | 48 | PinIcon, |
| 49 | PinOffIcon, | |
| 49 | 50 | PlusIcon, |
| 50 | 51 | SearchIcon, |
| 51 | 52 | SettingsIcon, |
| ... | ... | @@ -150,6 +151,8 @@ import { |
| 150 | 151 | reduceSidebarProjectScopeMenuState, |
| 151 | 152 | resolveAdjacentThreadId, |
| 152 | 153 | resolveSidebarDropTarget, |
| 154 | resolveSidebarDropVerb, | |
| 155 | type SidebarDropVerb, | |
| 153 | 156 | resolveSidebarThreadStatus, |
| 154 | 157 | searchSidebarThreadsByTitle, |
| 155 | 158 | shouldCreateNewThreadInCurrentProject, |
| ... | ... | @@ -554,7 +557,7 @@ function SidebarSectionPlaceholder(props: { |
| 554 | 557 | data-testid={`sidebar-${props.marker}`} |
| 555 | 558 | className={cn( |
| 556 | 559 | "mx-0.5 flex h-9 items-center justify-center rounded-md border border-dashed border-transparent text-xs text-sidebar-muted-foreground/60", |
| 557 | props.showHint && "border-sidebar-border", | |
| 560 | props.showHint && "border-sidebar-foreground/25 text-sidebar-foreground/80", | |
| 558 | 561 | props.isDropTarget && "border-primary/40 bg-primary/5 text-primary", |
| 559 | 562 | )} |
| 560 | 563 | > |
| ... | ... | @@ -563,8 +566,16 @@ function SidebarSectionPlaceholder(props: { |
| 563 | 566 | ); |
| 564 | 567 | } |
| 565 | 568 | |
| 566 | // Boundary labels overlay the cards' padding during a drag. The measured | |
| 567 | // marker stays empty, so showing a label never pushes a row out of the way. | |
| 569 | // Boundary labels appear during a drag in space the sorting strategy opens | |
| 570 | // below each marker (SIDEBAR_DRAG_LABEL_HEIGHT), so they never sit on a row. | |
| 571 | // The marker itself stays zero height, so nothing is reserved at rest and | |
| 572 | // pickup measurements are unchanged. They read at full strength so the | |
| 573 | // sections are easy to find, and the section under the lifted row takes the | |
| 574 | // accent. They paint above the lifted row so a card dragged across a | |
| 575 | // boundary never hides its label. | |
| 576 | // Matches the label's h-4 below. | |
| 577 | const SIDEBAR_DRAG_LABEL_HEIGHT = 16; | |
| 578 | ||
| 568 | 579 | function SidebarDragBoundary(props: { |
| 569 | 580 | marker: "pinned-header" | "pinned-divider"; |
| 570 | 581 | label: string; |
| ... | ... | @@ -575,21 +586,26 @@ function SidebarDragBoundary(props: { |
| 575 | 586 | <SortableSidebarMarker |
| 576 | 587 | marker={props.marker} |
| 577 | 588 | data-testid={`sidebar-${props.marker}`} |
| 578 | className="pointer-events-none relative z-10 mx-0.5 h-0" | |
| 589 | className="pointer-events-none relative z-30 mx-0.5 h-0" | |
| 579 | 590 | > |
| 580 | 591 | {props.visible ? ( |
| 581 | <div className="absolute inset-x-2 top-0 flex h-4 -translate-y-1/2 items-center gap-1.5"> | |
| 592 | <div className="absolute inset-x-2 top-0 flex h-4 items-center gap-1.5"> | |
| 582 | 593 | <span |
| 583 | 594 | className={cn( |
| 584 | "inline-flex h-4 shrink-0 items-center gap-2 rounded-sm border border-sidebar-border bg-sidebar px-1.5 text-[10px] leading-none font-medium text-sidebar-muted-foreground", | |
| 585 | props.isDropTarget && "border-primary/40 text-primary", | |
| 595 | "inline-flex h-4 shrink-0 items-center rounded-sm border bg-sidebar px-1.5 text-[10px] leading-none font-medium", | |
| 596 | props.isDropTarget | |
| 597 | ? "border-primary/40 text-primary" | |
| 598 | : "border-sidebar-foreground/25 text-sidebar-foreground/80", | |
| 586 | 599 | )} |
| 587 | 600 | > |
| 588 | 601 | {props.label} |
| 589 | 602 | </span> |
| 590 | 603 | <span |
| 591 | 604 | aria-hidden |
| 592 | className={cn("h-px flex-1 bg-sidebar-border", props.isDropTarget && "bg-primary/50")} | |
| 605 | className={cn( | |
| 606 | "h-px flex-1", | |
| 607 | props.isDropTarget ? "bg-primary/50" : "bg-sidebar-foreground/25", | |
| 608 | )} | |
| 593 | 609 | /> |
| 594 | 610 | </div> |
| 595 | 611 | ) : null} |
| ... | ... | @@ -601,6 +617,9 @@ function SidebarDragBoundary(props: { |
| 601 | 617 | function SidebarSectionHeader(props: { |
| 602 | 618 | marker: "snoozed-header" | "settled-header"; |
| 603 | 619 | label: string; |
| 620 | // While dragging, the settled header reads at full strength and takes the | |
| 621 | // accent while the lifted row is over it. | |
| 622 | dragging?: boolean; | |
| 604 | 623 | isDropTarget?: boolean; |
| 605 | 624 | toggle: { expanded: boolean; onToggle: () => void }; |
| 606 | 625 | }) { |
| ... | ... | @@ -608,6 +627,7 @@ function SidebarSectionHeader(props: { |
| 608 | 627 | const className = cn( |
| 609 | 628 | "flex h-full w-full items-center gap-2 rounded-md border border-dashed border-transparent px-2 text-left text-xs font-medium", |
| 610 | 629 | snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60", |
| 630 | props.dragging && "text-sidebar-foreground/80", | |
| 611 | 631 | props.isDropTarget && "border-primary/40 bg-primary/5 text-primary", |
| 612 | 632 | ); |
| 613 | 633 | const content = ( |
| ... | ... | @@ -618,7 +638,8 @@ function SidebarSectionHeader(props: { |
| 618 | 638 | className={cn( |
| 619 | 639 | "h-px min-w-2 flex-1", |
| 620 | 640 | snoozed ? "bg-blue-500/20 dark:bg-blue-400/15" : "bg-sidebar-border/60", |
| 621 | props.isDropTarget && "bg-primary/30", | |
| 641 | props.dragging && "bg-sidebar-foreground/25", | |
| 642 | props.isDropTarget && "bg-primary/50", | |
| 622 | 643 | )} |
| 623 | 644 | /> |
| 624 | 645 | <ChevronDownIcon |
| ... | ... | @@ -887,6 +908,42 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { |
| 887 | 908 | ); |
| 888 | 909 | }); |
| 889 | 910 | |
| 911 | // Verb and icon on the lifted row while it hovers over another section. Uses | |
| 912 | // the same icons as the row actions and context menu so the drop reads as the | |
| 913 | // action it performs. | |
| 914 | const dropVerbBadge: Record<SidebarDropVerb, ReactNode> = { | |
| 915 | pin: ( | |
| 916 | <> | |
| 917 | <PinIcon aria-hidden className="size-3" /> | |
| 918 | Pin | |
| 919 | </> | |
| 920 | ), | |
| 921 | unpin: ( | |
| 922 | <> | |
| 923 | <PinOffIcon aria-hidden className="size-3" /> | |
| 924 | Unpin | |
| 925 | </> | |
| 926 | ), | |
| 927 | settle: ( | |
| 928 | <> | |
| 929 | <CircleCheckIcon aria-hidden className="size-3" /> | |
| 930 | Settle | |
| 931 | </> | |
| 932 | ), | |
| 933 | unsettle: ( | |
| 934 | <> | |
| 935 | <Undo2Icon aria-hidden className="size-3" /> | |
| 936 | Un-settle | |
| 937 | </> | |
| 938 | ), | |
| 939 | wake: ( | |
| 940 | <> | |
| 941 | <AlarmClockOffIcon aria-hidden className="size-3" /> | |
| 942 | Wake | |
| 943 | </> | |
| 944 | ), | |
| 945 | }; | |
| 946 | ||
| 890 | 947 | const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 891 | 948 | thread: SidebarThreadSummary; |
| 892 | 949 | variant: "card" | "slim"; |
| ... | ... | @@ -906,7 +963,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 906 | 963 | // sortable bag applied to the row root so the whole row drags (the |
| 907 | 964 | // pointer sensor's distance constraint keeps plain clicks working). |
| 908 | 965 | sortable?: SortableThreadRowBag | undefined; |
| 909 | dropSection: Exclude<SidebarSection, "snoozed"> | null; | |
| 966 | dropVerb: SidebarDropVerb | null; | |
| 967 | // While dragging, the pin marker stays only for a pinned thread still over | |
| 968 | // the pinned section. Any other position shows the verb badge instead, and | |
| 969 | // the badge carries its own icon. | |
| 970 | dragOverPinned: boolean; | |
| 910 | 971 | // Compact wake countdown ("2h") for rows in the snoozed shelf. |
| 911 | 972 | snoozeWakeLabelText: string | null; |
| 912 | 973 | // When a snooze ended (timer or early wake); drives the Woke pill until |
| ... | ... | @@ -1301,6 +1362,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1301 | 1362 | !props.isActive && |
| 1302 | 1363 | !isSelected && |
| 1303 | 1364 | "opacity-70 transition-opacity hover:opacity-100", |
| 1365 | // The lifted row is an opaque card so the rows beneath it never show | |
| 1366 | // through. The row tint is translucent in dark themes and the pointer | |
| 1367 | // keeps the hover color applied, so both the tint and the solid sidebar | |
| 1368 | // color are stacked as background images. | |
| 1369 | props.sortable?.isDragging && | |
| 1370 | "bg-[linear-gradient(var(--sidebar-row-active),var(--sidebar-row-active)),linear-gradient(var(--sidebar),var(--sidebar))] text-sidebar-foreground opacity-100 shadow-lg", | |
| 1304 | 1371 | ); |
| 1305 | 1372 | // dnd-kit props for the row root. Same bag on both variants: every row in |
| 1306 | 1373 | // the list translates around the gap as the drag passes it. |
| ... | ... | @@ -1322,18 +1389,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1322 | 1389 | } |
| 1323 | 1390 | : {}; |
| 1324 | 1391 | const dragDestination = |
| 1325 | sortable?.isDragging && props.dropSection !== null ? ( | |
| 1392 | sortable?.isDragging && props.dropVerb !== null ? ( | |
| 1326 | 1393 | <span |
| 1327 | 1394 | role="status" |
| 1328 | className="pointer-events-none ml-auto inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-primary/30 bg-sidebar px-1.5 text-[11px] font-medium text-primary" | |
| 1395 | className="pointer-events-none ml-auto inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-primary/40 bg-primary/10 px-1.5 text-[11px] font-medium text-primary" | |
| 1329 | 1396 | > |
| 1330 | <span className="sr-only">Move to </span> | |
| 1331 | <span aria-hidden>→</span> | |
| 1332 | {props.dropSection === "pinned" | |
| 1333 | ? "Pinned" | |
| 1334 | : props.dropSection === "active" | |
| 1335 | ? "Active" | |
| 1336 | : "Settled"} | |
| 1397 | {dropVerbBadge[props.dropVerb]} | |
| 1337 | 1398 | </span> |
| 1338 | 1399 | ) : null; |
| 1339 | 1400 | |
| ... | ... | @@ -1437,32 +1498,33 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1437 | 1498 | <TooltipPopup side="top">Unsent draft</TooltipPopup> |
| 1438 | 1499 | </Tooltip> |
| 1439 | 1500 | ) : null; |
| 1440 | const pinIndicator = | |
| 1441 | props.isPinned && !sortable?.isDragging ? ( | |
| 1442 | props.pinningSupported ? ( | |
| 1443 | <Tooltip> | |
| 1444 | <TooltipTrigger | |
| 1445 | render={ | |
| 1446 | <button | |
| 1447 | type="button" | |
| 1448 | aria-label="Unpin thread" | |
| 1449 | onClick={handleUnpinClick} | |
| 1450 | className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" | |
| 1451 | /> | |
| 1452 | } | |
| 1453 | > | |
| 1454 | <PinIcon aria-hidden className="size-3 shrink-0" /> | |
| 1455 | </TooltipTrigger> | |
| 1456 | <TooltipPopup>Unpin thread</TooltipPopup> | |
| 1457 | </Tooltip> | |
| 1458 | ) : ( | |
| 1459 | <PinIcon | |
| 1460 | aria-label="Pinned" | |
| 1461 | role="img" | |
| 1462 | className="size-3 shrink-0 text-muted-foreground/65" | |
| 1463 | /> | |
| 1464 | ) | |
| 1465 | ) : null; | |
| 1501 | const showPin = | |
| 1502 | props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); | |
| 1503 | const pinIndicator = showPin ? ( | |
| 1504 | props.pinningSupported && !sortable?.isDragging ? ( | |
| 1505 | <Tooltip> | |
| 1506 | <TooltipTrigger | |
| 1507 | render={ | |
| 1508 | <button | |
| 1509 | type="button" | |
| 1510 | aria-label="Unpin thread" | |
| 1511 | onClick={handleUnpinClick} | |
| 1512 | className="inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" | |
| 1513 | /> | |
| 1514 | } | |
| 1515 | > | |
| 1516 | <PinIcon aria-hidden className="size-3 shrink-0" /> | |
| 1517 | </TooltipTrigger> | |
| 1518 | <TooltipPopup>Unpin thread</TooltipPopup> | |
| 1519 | </Tooltip> | |
| 1520 | ) : ( | |
| 1521 | <PinIcon | |
| 1522 | aria-label="Pinned" | |
| 1523 | role="img" | |
| 1524 | className="size-3 shrink-0 text-muted-foreground/65" | |
| 1525 | /> | |
| 1526 | ) | |
| 1527 | ) : null; | |
| 1466 | 1528 | |
| 1467 | 1529 | if (variant === "slim") { |
| 1468 | 1530 | return ( |
| ... | ... | @@ -1471,7 +1533,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1471 | 1533 | {...sortableRootProps} |
| 1472 | 1534 | className={cn( |
| 1473 | 1535 | "list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]", |
| 1474 | sortable?.isDragging && "z-20 opacity-80", | |
| 1536 | sortable?.isDragging && "relative z-20", | |
| 1475 | 1537 | )} |
| 1476 | 1538 | > |
| 1477 | 1539 | <Tooltip disabled={sortable?.isDragging}> |
| ... | ... | @@ -1629,7 +1691,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1629 | 1691 | {...sortableRootProps} |
| 1630 | 1692 | className={cn( |
| 1631 | 1693 | "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]", |
| 1632 | sortable?.isDragging && "z-20 opacity-80", | |
| 1694 | sortable?.isDragging && "relative z-20", | |
| 1633 | 1695 | )} |
| 1634 | 1696 | > |
| 1635 | 1697 | <Tooltip disabled={snoozeMenuOpen || sortable?.isDragging}> |
| ... | ... | @@ -3129,7 +3191,7 @@ export default function Sidebar() { |
| 3129 | 3191 | [sectionByThreadKey], |
| 3130 | 3192 | ); |
| 3131 | 3193 | const handleThreadDragCancel = useCallback(() => { |
| 3132 | listMotionRef.current?.suspend(); | |
| 3194 | listMotionRef.current?.release(); | |
| 3133 | 3195 | setDragState(null); |
| 3134 | 3196 | setDragTargetSection(null); |
| 3135 | 3197 | }, []); |
| ... | ... | @@ -3184,7 +3246,8 @@ export default function Sidebar() { |
| 3184 | 3246 | const listMotionPaused = dragState !== null; |
| 3185 | 3247 | useLayoutEffect(() => { |
| 3186 | 3248 | // Drag release clears the baseline, so its commit cannot replay the |
| 3187 | // sortable preview. Later thread actions can animate while writes settle. | |
| 3249 | // sortable preview; rows glide from their released positions instead. | |
| 3250 | // Later thread actions can animate while writes settle. | |
| 3188 | 3251 | // Draft navigation can reveal a frozen row without changing the draft count. |
| 3189 | 3252 | listMotionRef.current?.update( |
| 3190 | 3253 | !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0, |
| ... | ... | @@ -3214,6 +3277,7 @@ export default function Sidebar() { |
| 3214 | 3277 | () => |
| 3215 | 3278 | createSidebarSortingStrategy({ |
| 3216 | 3279 | items: sidebarListItems, |
| 3280 | boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT, | |
| 3217 | 3281 | settledOrder: draggedSettledOrder, |
| 3218 | 3282 | settledExpanded: settledShelfExpanded, |
| 3219 | 3283 | settledVisibleCount, |
| ... | ... | @@ -3291,7 +3355,7 @@ export default function Sidebar() { |
| 3291 | 3355 | ]); |
| 3292 | 3356 | const handleThreadDragEnd = useCallback( |
| 3293 | 3357 | (event: DragEndEvent) => { |
| 3294 | listMotionRef.current?.suspend(); | |
| 3358 | listMotionRef.current?.release(); | |
| 3295 | 3359 | setDragState(null); |
| 3296 | 3360 | setDragTargetSection(null); |
| 3297 | 3361 | const activeKey = String(event.active.id); |
| ... | ... | @@ -4533,13 +4597,14 @@ export default function Sidebar() { |
| 4533 | 4597 | } |
| 4534 | 4598 | isPinned={thread.pinnedAt != null} |
| 4535 | 4599 | sortable={sortable} |
| 4536 | dropSection={ | |
| 4537 | dragState?.activeKey === threadKey && | |
| 4538 | dragTargetSection !== dragState.activeSection && | |
| 4539 | dragTargetSection !== "snoozed" | |
| 4540 | ? dragTargetSection | |
| 4600 | dropVerb={ | |
| 4601 | dragState?.activeKey === threadKey | |
| 4602 | ? resolveSidebarDropVerb(dragState.activeSection, dragTargetSection) | |
| 4541 | 4603 | : null |
| 4542 | 4604 | } |
| 4605 | dragOverPinned={ | |
| 4606 | dragState?.activeKey === threadKey && dragTargetSection === "pinned" | |
| 4607 | } | |
| 4543 | 4608 | snoozeWakeLabelText={ |
| 4544 | 4609 | section === "snoozed" && thread.snoozedUntil != null |
| 4545 | 4610 | ? snoozeWakeLabel(thread.snoozedUntil, { |
| ... | ... | @@ -4630,14 +4695,6 @@ export default function Sidebar() { |
| 4630 | 4695 | ); |
| 4631 | 4696 | }; |
| 4632 | 4697 | const from = dragState?.activeSection ?? null; |
| 4633 | const previewPinnedCount = | |
| 4634 | pinnedThreads.length + | |
| 4635 | (from !== "pinned" && dragTargetSection === "pinned" ? 1 : 0) - | |
| 4636 | (from === "pinned" && | |
| 4637 | dragTargetSection !== null && | |
| 4638 | dragTargetSection !== "pinned" | |
| 4639 | ? 1 | |
| 4640 | : 0); | |
| 4641 | 4698 | const items: ReactNode[] = [ |
| 4642 | 4699 | <SidebarDraftBlock |
| 4643 | 4700 | key="draft-sessions" |
| ... | ... | @@ -4663,8 +4720,8 @@ export default function Sidebar() { |
| 4663 | 4720 | key="pinned-header" |
| 4664 | 4721 | marker="pinned-header" |
| 4665 | 4722 | label="Pinned" |
| 4666 | isDropTarget={dragTargetSection === "pinned"} | |
| 4667 | 4723 | visible={from !== null} |
| 4724 | isDropTarget={dragTargetSection === "pinned"} | |
| 4668 | 4725 | />, |
| 4669 | 4726 | ); |
| 4670 | 4727 | break; |
| ... | ... | @@ -4674,8 +4731,8 @@ export default function Sidebar() { |
| 4674 | 4731 | key="pinned-divider" |
| 4675 | 4732 | marker="pinned-divider" |
| 4676 | 4733 | label="Active" |
| 4734 | visible={from !== null} | |
| 4677 | 4735 | isDropTarget={dragTargetSection === "active"} |
| 4678 | visible={from !== null && previewPinnedCount > 0} | |
| 4679 | 4736 | />, |
| 4680 | 4737 | ); |
| 4681 | 4738 | break; |
| ... | ... | @@ -4717,6 +4774,7 @@ export default function Sidebar() { |
| 4717 | 4774 | ? "Settled" |
| 4718 | 4775 | : `Settled (${settledThreads.length})` |
| 4719 | 4776 | } |
| 4777 | dragging={from !== null} | |
| 4720 | 4778 | isDropTarget={dragTargetSection === "settled"} |
| 4721 | 4779 | toggle={{ |
| 4722 | 4780 | expanded: settledShelfExpanded, |
docs/user/thread-sidebar.md+8-5| ... | ... | @@ -33,11 +33,14 @@ thread into the active list un-settles it. A snoozed thread can be dragged out o |
| 33 | 33 | shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake |
| 34 | 34 | time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation. |
| 35 | 35 | Pinned and active boundary labels appear only while dragging, without moving the rows. The |
| 36 | destination boundary highlights. When you cross into another section, the dragged thread shows | |
| 37 | its destination, such as **→ Active**. Its usual pin, status, and hover actions hide during the | |
| 38 | drag. Reordering within the same section does not show a destination badge. When there are no | |
| 39 | pins, drag to the top edge to pin a thread. Section labels also identify empty sections and a | |
| 40 | collapsed settled shelf. | |
| 36 | other rows slide aside to show where the thread will land. When you cross into another section, | |
| 37 | the dragged thread shows the action the drop performs, with its icon: **Pin**, **Unpin**, | |
| 38 | **Settle**, **Un-settle**, or **Wake**. Its status and hover actions hide during the drag. A pinned | |
| 39 | thread keeps its pin only while it stays in the pinned section; once it leaves, the badge takes | |
| 40 | over. Reordering within the same section shows no badge. When there are no pins, drag to the top | |
| 41 | edge to pin a thread. Section labels stay readable for the whole drag, and the section the | |
| 42 | thread is over takes the accent color. Section labels also | |
| 43 | identify empty sections and a collapsed settled shelf. | |
| 41 | 44 | |
| 42 | 45 | Drag within the pinned or active section to change its order. Other rows slide aside to show the |
| 43 | 46 | spot where the thread will land. Drops into either section keep the position you choose. On |