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