From 6d270b9d09dc067acbe4f14ec68ba7b314e3d26e Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Wed, 4 Nov 2026 04:04:42 +0200 Subject: [PATCH 1/2] feat(web): compact responsive pinned thread tiles Keep pinned threads in a responsive grid using the existing sidebar surfaces and status icons. Preserve draft placement and preview the live reorder destination, including its width. Carries the reviewed original prototype from 85ed893fa0 onto current upstream main without its accumulated development history. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: GPT-6.1 Sol --- .../components/Sidebar.drag.browser.test.ts | 162 +++++++++ apps/web/src/components/Sidebar.drag.test.ts | 320 +++++++++++++++--- apps/web/src/components/Sidebar.drag.ts | 205 ++++++++++- apps/web/src/components/Sidebar.motion.ts | 186 ++++++++-- apps/web/src/components/Sidebar.tsx | 119 +++++-- .../components/sidebar/SidebarListMarkers.tsx | 5 + .../components/sidebar/SidebarThreadRow.tsx | 142 +++++++- apps/web/src/index.css | 60 ++++ docs/user/thread-sidebar.md | 9 +- 9 files changed, 1092 insertions(+), 116 deletions(-) create mode 100644 apps/web/src/components/Sidebar.drag.browser.test.ts diff --git a/apps/web/src/components/Sidebar.drag.browser.test.ts b/apps/web/src/components/Sidebar.drag.browser.test.ts new file mode 100644 index 0000000000000..94bb08e87880b --- /dev/null +++ b/apps/web/src/components/Sidebar.drag.browser.test.ts @@ -0,0 +1,162 @@ +import "../index.css"; + +import { describe, expect, it, vi } from "vite-plus/test"; +import type { SortingStrategy } from "@dnd-kit/sortable"; +import { + createSidebarSortingStrategy, + measureSidebarPinnedGrid, + restrictSidebarDrag, + resolveSidebarDragSourceRect, +} from "./Sidebar.drag"; +import { sidebarListItemId, type SidebarListItem } from "./Sidebar.logic"; +import { createSidebarListMotion } from "./Sidebar.motion"; + +function mountPinnedGrid(width: number, count: number) { + const list = document.createElement("ul"); + list.style.cssText = `display:flex;flex-direction:column;gap:1px;width:${width}px;position:relative`; + const header = document.createElement("li"); + header.style.cssText = "height:0;margin-inline:0.125rem"; + const grid = document.createElement("ul"); + grid.className = "sidebar-pinned-grid"; + grid.dataset.sidebarMotionGroup = ""; + const tiles = Array.from({ length: count }, () => grid.appendChild(document.createElement("li"))); + list.append(header, grid); + document.body.append(list); + return { list, header, grid, tiles, [Symbol.dispose]: () => list.remove() }; +} + +describe("pinned grid layout contract", () => { + it("keeps a full-width pin under the pointer in its compact destination cell", () => { + using mounted = mountPinnedGrid(244, 4); + const source = mounted.tiles[3]!; + source.dataset.threadItem = "wide-pin"; + const bounds = source.getBoundingClientRect(); + const activation = new PointerEvent("pointerdown", { clientX: bounds.left + bounds.width / 2 }); + source.dispatchEvent(activation); + const from = resolveSidebarDragSourceRect(activation, null)!; + expect(from.width).toBe(bounds.width); + const target = mounted.tiles[2]!.getBoundingClientRect(); + const pointerX = target.left + target.width / 2; + const widthRatio = target.width / from.width; + const transform = restrictSidebarDrag( + { + activatorEvent: null, + active: null, + activeNodeRect: from, + draggingNodeRect: from, + containerNodeRect: mounted.grid.getBoundingClientRect(), + over: null, + overlayNodeRect: null, + scrollableAncestors: [mounted.grid], + scrollableAncestorRects: [mounted.grid.getBoundingClientRect()], + transform: { + x: pointerX - (from.left + from.width / 2), + y: target.top - from.top, + scaleX: 1, + scaleY: 1, + }, + windowRect: null, + }, + { width: target.width, offsetX: (from.width / 2) * (1 - widthRatio) }, + ); + source.style.width = `${target.width}px`; + source.style.transform = `translate(${transform.x}px, ${transform.y}px)`; + const preview = source.getBoundingClientRect(); + expect(preview.left).toBeCloseTo(target.left, 0); + expect(pointerX).toBeGreaterThanOrEqual(preview.left); + expect(pointerX).toBeLessThanOrEqual(preview.right); + }); + it("does not animate tiles from their old columns after the sidebar resizes", () => { + using mounted = mountPinnedGrid(244, 5); + const motion = createSidebarListMotion(mounted.list); + try { + motion.update(false); + mounted.list.style.width = "200px"; + motion.update(true); + expect(mounted.tiles.flatMap((tile) => tile.getAnimations())).toHaveLength(0); + } finally { + motion.dispose(); + } + }); + + // Pinning remounts a card as a tile; it should travel from the card, not appear from nowhere. + it.each(["menu", "drop"] as const)("lands a %s pin where its card was", async (path) => { + using mounted = mountPinnedGrid(244, 2); + const card = document.createElement("li"); + card.dataset.threadItem = "t"; + card.textContent = "Thread identity"; + card.style.height = "82px"; + mounted.list.append(card); + const motion = createSidebarListMotion(mounted.list); + try { + motion.update(false); + const from = card.getBoundingClientRect(); + if (path === "drop") motion.release(); + card.remove(); + const tile = mounted.grid.appendChild(document.createElement("li")); + tile.dataset.threadItem = "t"; + tile.textContent = "Thread identity"; + motion.update(true); + expect(mounted.list.textContent).toBe("Thread identity"); + const animations = tile.getAnimations(); + expect(animations).toHaveLength(1); + animations[0]!.pause(); + const start = tile.getBoundingClientRect(); + expect(start.left + start.width / 2).toBeCloseTo(from.left + from.width / 2, 0); + expect(start.top + start.height / 2).toBeCloseTo(from.top + from.height / 2, 0); + // Menu pinning and dropping both hold hover details until the row lands. + const settling = () => mounted.list.hasAttribute("data-sidebar-settling"); + expect(settling()).toBe(true); + for (const animation of mounted.list.getAnimations({ subtree: true })) animation.finish(); + await vi.waitFor(() => expect(settling()).toBe(false)); + } finally { + motion.dispose(); + } + }); + + it.each([ + [152, 1], + [200, 2], + [244, 3], + ])("projects a still drag onto the CSS grid at %ipx (%i columns)", (width, columns) => { + for (let count = 1; count <= 5; count++) { + using mounted = mountPinnedGrid(width, count); + const pinnedGrid = measureSidebarPinnedGrid(mounted.grid, 1); + expect(pinnedGrid?.columns).toBe(columns); + const items: SidebarListItem[] = [ + { kind: "marker", marker: "pinned-header" }, + ...mounted.tiles.map((_, index) => ({ + kind: "thread" as const, + key: `p${index}`, + section: "pinned" as const, + })), + { kind: "marker", marker: "pinned-divider" }, + ]; + const rects = [mounted.header, ...mounted.tiles, mounted.header].map((node) => + node.getBoundingClientRect(), + ); + const strategy = createSidebarSortingStrategy({ + items, + settledOrder: [], + settledExpanded: true, + pinnedGrid, + }); + const activeIndex = count; + const args = { + activeIndex, + overIndex: activeIndex, + activeNodeRect: rects[activeIndex]!, + rects, + index: 0, + } satisfies Parameters[0]; + for (const [index, item] of items.entries()) { + if (item.kind !== "thread") continue; + const transform = strategy({ ...args, index }); + const tile = `${sidebarListItemId(item)} of ${count}`; + expect(transform?.x, tile).toBeCloseTo(0, 1); + expect(transform?.y, tile).toBeCloseTo(0, 1); + expect(transform?.scaleX, tile).toBeCloseTo(1, 3); + } + } + }); +}); diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index e1febcd68d533..c106dfcbad4b7 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -69,6 +69,59 @@ function preview( } describe("sidebar collision detection", () => { + it("keeps the same pinned slot under the pointer when preview widths change", () => { + const items = [ + pinnedHeader, + thread("p1", "pinned"), + thread("p2", "pinned"), + thread("p3", "pinned"), + thread("source", "pinned"), + ]; + const rects = [ + { left: 0, top: 100, width: 260, height: 0, right: 260, bottom: 100 }, + ...[0, 88, 176].map((left) => ({ + left, + top: 101, + width: 84, + height: 46, + right: left + 84, + bottom: 147, + })), + { left: 0, top: 151, width: 260, height: 46, right: 260, bottom: 197 }, + ]; + const droppableRects = new Map(items.map((item, i) => [sidebarListItemId(item), rects[i]!])); + const args = { + ...collisionArgs(), + collisionRect: { left: 0, top: 125, width: 260, height: 46, right: 260, bottom: 171 }, + droppableRects, + droppableContainers: items.map((item, i) => ({ + id: sidebarListItemId(item), + key: sidebarListItemId(item), + disabled: false, + data: { current: {} }, + node: { current: null }, + rect: { current: rects[i]! }, + })), + pointerCoordinates: { x: 42, y: 148 }, + } satisfies Parameters[0]; + const detector = createSidebarCollisionDetection(() => true, { + items, + pinnedGrid: { + columns: 3, + rowHeight: 46, + gap: 4, + leading: 0, + trailing: 8, + width: 260, + labelHeight: 24, + }, + }); + expect(detector(args)[0]?.id).toBe("p1"); + droppableRects.set("p1", { ...rects[1]!, width: 260, right: 260 }); + droppableRects.set("source", { ...rects[4]!, width: 84, right: 84 }); + expect(detector(args)[0]?.id).toBe("p1"); + }); + function collisionArgs(blockedAboveSource = false) { const rows = [thread("source", "active"), thread("blocked", "active")]; const items = [ @@ -217,6 +270,44 @@ describe("sidebar collision detection", () => { }), ).toEqual([]); }); + + it("picks the pinned tile under the pointer for a full-width card", () => { + const rect = (left: number, top: number, width: number, height: number) => ({ + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }); + const rects = new Map([ + ["p1", rect(0, 100, 140, 46)], + ["p2", rect(144, 100, 116, 46)], + ["card", rect(0, 200, 260, 82)], + ]); + // The card keeps its column, so its center sits nearer p1 while the pointer is over p2. + const collisionRect = rect(0, 82, 260, 82); + const detector = createSidebarCollisionDetection(() => true); + const collisions = detector({ + active: { + id: "card", + data: { current: {} }, + rect: { current: { initial: rects.get("card")!, translated: collisionRect } }, + }, + collisionRect, + droppableRects: rects, + droppableContainers: [...rects].map(([id, current]) => ({ + id, + key: id, + disabled: false, + data: { current: {} }, + node: { current: null }, + rect: { current }, + })), + pointerCoordinates: { x: 220, y: 120 }, + }); + expect(collisions[0]?.id).toBe("p2"); + }); }); describe("sidebar drag projection", () => { @@ -296,14 +387,14 @@ describe("sidebar drag projection", () => { thread("s1", "settled"), ]; - it("keeps the pinned header above the gap when a lower pin moves to the top", () => { + it("holds pins in place when a lower pin moves to the top", () => { const result = preview( { items: pinned, settledOrder: [], settledExpanded: true }, "p2", sidebarMarkerId("pinned-header"), ); expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); - expect(result.get("p1")).toEqual({ ...stationary, y: 83 }); + expect(result.get("p1")).toEqual(stationary); expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual(stationary); expect(result.get("a1")).toEqual(stationary); }); @@ -329,11 +420,11 @@ describe("sidebar drag projection", () => { }); it.each([ - [sidebarMarkerId("pinned-divider"), 0, 0], - ["a1", -83, 0], - ["a2", -83, -83], + [sidebarMarkerId("pinned-divider"), 83, 83], + ["a1", 0, 83], + ["a2", 0, 0], ] as const)( - "opens the active pointer slot over %s without adding an empty pinned row", + "opens the active pointer slot over %s and keeps the pin's cell until the drop", (over, a1Offset, a2Offset) => { const items = [ pinnedHeader, @@ -346,10 +437,10 @@ describe("sidebar drag projection", () => { ]; const result = preview({ items, settledOrder: [], settledExpanded: true }, "p", over); expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); - expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(-83); + expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(0); expect(result.get("a1")?.y).toBe(a1Offset); expect(result.get("a2")?.y).toBe(a2Offset); - expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83); }, ); @@ -407,30 +498,24 @@ describe("sidebar drag projection", () => { }, ); - it.each([ - ["p", -83, -1], - ["s", 0, 82], - ] as const)( - "replaces the empty Active target when %s enters", - (active, dividerOffset, settledOffset) => { - const items = [ - pinnedHeader, - thread("p", "pinned"), - divider, - marker("active-placeholder"), - settledHeader, - thread("s", "settled"), - ]; - const result = preview( - { items, settledOrder: [], settledExpanded: true }, - active, - sidebarMarkerId("active-placeholder"), - ); - expect(result.get(sidebarMarkerId("active-placeholder"))?.scaleY).toBe(0); - expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(dividerOffset); - expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(settledOffset); - }, - ); + it.each(["p", "s"])("replaces the empty Active target when %s enters", (active) => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + marker("active-placeholder"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview( + { items, settledOrder: [], settledExpanded: true }, + active, + sidebarMarkerId("active-placeholder"), + ); + expect(result.get(sidebarMarkerId("active-placeholder"))?.scaleY).toBe(0); + expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(0); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(82); + }); it("uses the canonical settled rank and the destination's slim height", () => { const items = [ @@ -523,11 +608,11 @@ describe("sidebar drag projection", () => { marker("settled-placeholder"), ]; const input = { items, settledOrder: [], settledExpanded: false }; - expect(preview(input, "p", "a1").get("a2")?.y).toBe(0); + expect(preview(input, "p", "a1").get("a2")?.y).toBe(83); const byTime = preview({ ...input, activeOrder: ["a1", "a2", "p"] }, "p", "a1"); - expect(byTime.get("a2")?.y).toBe(-83); - expect(byTime.get(sidebarMarkerId("working-header"))).toEqual(stationary); - expect(byTime.get("w")).toEqual(stationary); + expect(byTime.get("a2")).toEqual(stationary); + expect(byTime.get(sidebarMarkerId("working-header"))?.y).toBe(83); + expect(byTime.get("w")?.y).toBe(83); }); it("updates the projection when the target or measured geometry changes", () => { @@ -537,10 +622,10 @@ describe("sidebar drag projection", () => { settledExpanded: true, }); const args = layout(pinned, "p1", "p1"); - expect(strategy({ ...args, index: 2 })?.y).toBe(0); - expect(strategy({ ...args, index: 2, overIndex: 4 })?.y).toBe(-83); + expect(strategy({ ...args, index: 5 })?.y).toBe(0); + expect(strategy({ ...args, index: 5, overIndex: 4 })?.y).toBe(83); const smaller = layout(pinned, "p1", "a1", 0.75); - expect(strategy({ ...smaller, index: 2 })?.y).toBe(-62.5); + expect(strategy({ ...smaller, index: 5 })?.y).toBe(62.5); }); it.each(["active", "settled"] as const)( @@ -682,6 +767,150 @@ describe("sidebar drag projection", () => { }); }); +describe("pinned grid projection", () => { + const grid = { + columns: 3, + rowHeight: 46, + gap: 4, + leading: 0, + trailing: 8, + width: 260, + labelHeight: 0, + }; + const items = [ + pinnedHeader, + thread("p1", "pinned"), + thread("p2", "pinned"), + thread("p3", "pinned"), + thread("p4", "pinned"), + divider, + thread("a1", "active"), + settledHeader, + marker("settled-placeholder"), + ]; + + // Mirrors the CSS grid: full rows split the width, a short last row stretches. + function gridPreview(active: string, over: string) { + const width = 260; + const pinnedKeys = items.flatMap((item) => + item.kind === "thread" && item.section === "pinned" ? [item.key] : [], + ); + const rows = Math.ceil(pinnedKeys.length / grid.columns); + let top = 100; + const rects = items.map((item) => { + const pinnedIndex = item.kind === "thread" ? pinnedKeys.indexOf(item.key) : -1; + if (pinnedIndex >= 0) { + const row = Math.floor(pinnedIndex / grid.columns); + const inRow = Math.min(grid.columns, pinnedKeys.length - row * grid.columns); + const cell = (width - (inRow - 1) * grid.gap) / inRow; + const left = (pinnedIndex % grid.columns) * (cell + grid.gap); + const rowTop = top + grid.leading + row * (grid.rowHeight + grid.gap); + const rect = { + top: rowTop, + height: grid.rowHeight, + bottom: rowTop + grid.rowHeight, + left, + right: left + cell, + width: cell, + }; + if (pinnedIndex === pinnedKeys.length - 1) { + top += grid.leading + rows * grid.rowHeight + (rows - 1) * grid.gap + grid.trailing + 1; + } + return rect; + } + const height = + item.kind === "thread" + ? item.section === "active" + ? 82 + : 36 + : item.marker === "settled-header" + ? 32 + : 0; + const rect = { top, height, bottom: top + height, left: 0, right: width, width }; + top += height + 1; + return rect; + }); + const strategy = createSidebarSortingStrategy({ + items, + settledOrder: [], + settledExpanded: true, + pinnedGrid: grid, + }); + const activeIndex = items.findIndex((item) => sidebarListItemId(item) === active); + const args = { + activeIndex, + overIndex: items.findIndex((item) => sidebarListItemId(item) === over), + activeNodeRect: rects[activeIndex]!, + rects, + index: 0, + } satisfies Parameters[0]; + return new Map( + items.map((item, index) => [sidebarListItemId(item), strategy({ ...args, index })]), + ); + } + + it("opens the first cell live for the full-width last pin", () => { + const result = gridPreview("p4", "p1"); + expect(result.get("p1")).toEqual({ ...stationary, x: 88 }); + expect(result.get("p2")).toEqual({ ...stationary, x: 88 }); + expect(result.get("p3")).toEqual({ ...stationary, x: -176, y: 50, scaleX: 260 / 84 }); + expect(result.get("p4")).toEqual(stationary); + }); + + it.each([ + ["a1", "p2", "a card comes in"], + ["p1", "a1", "a pin leaves"], + ])("projects pinned cells while %s hovers %s (%s)", (active, over) => { + const result = gridPreview(active, over); + const expected = + active === "a1" + ? [ + stationary, + { ...stationary, x: 88 }, + { ...stationary, x: -176, y: 50, scaleX: 128 / 84 }, + { ...stationary, x: 132, scaleX: 128 / 260 }, + ] + : [stationary, stationary, stationary, stationary]; + for (const [index, key] of ["p1", "p2", "p3", "p4"].entries()) + expect(result.get(key), key).toEqual(expected[index]); + expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual(stationary); + }); +}); + +it.each([ + [0, sidebarMarkerId("pinned-divider")], + [0, "a2"], + [3, sidebarMarkerId("pinned-divider")], +] as const)("opens a grid row with %i pins over %s", (count, over) => { + const items = [ + pinnedHeader, + ...Array.from({ length: count }, (_, index) => thread(`p${index}`, "pinned")), + divider, + thread("a1", "active"), + thread("a2", "active"), + ]; + const strategy = createSidebarSortingStrategy({ + items, + settledOrder: [], + settledExpanded: true, + pinnedGrid: { + columns: 3, + rowHeight: 46, + gap: 4, + leading: 0, + trailing: 8, + width: 260, + labelHeight: 0, + }, + }); + const args = layout(items, "a2", over); + const nextRows = Math.ceil((count + 1) / 3); + const previousHeight = count * 83; + const displacement = nextRows * 50 + 5 - previousHeight; + expect(strategy({ ...args, index: count + 1 })?.y).toBe(displacement); + expect(strategy({ ...args, index: count + 2 })?.y).toBe(displacement); +}); + describe("lifted card clearance", () => { const rect = (top: number, height: number) => ({ top, @@ -691,11 +920,18 @@ describe("lifted card clearance", () => { right: 260, width: 260, }); - const apply = (cardTop: number, cardHeight: number, y: number, listTop = 136, offset = 32) => + const apply = ( + cardTop: number, + cardHeight: number, + y: number, + listTop = 136, + offset = 32, + containerTop = listTop, + ) => restrictBelowSidebarLabel( { transform: { ...stationary, y }, - containerNodeRect: rect(listTop, 500), + containerNodeRect: rect(containerTop, 500), draggingNodeRect: rect(cardTop, cardHeight), activatorEvent: null, active: null, @@ -707,6 +943,7 @@ describe("lifted card clearance", () => { windowRect: null, }, offset, + listTop, ); it.each([36, 82])("keeps a %ipx row below empty Pins even past the top edge", (height) => { @@ -724,5 +961,6 @@ describe("lifted card clearance", () => { it("follows the list when it scrolls and includes content preceding Pins", () => { expect(511 + apply(511, 36, -500, 96).y).toBe(128); expect(511 + apply(511, 36, -500, 136, 114).y).toBe(250); + expect(511 + apply(511, 46, -500, 136, 114, 250).y).toBe(250); }); }); diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index 76f2c9bf3a3ec..4e9486fdd1434 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -1,5 +1,6 @@ import { closestCenter, type CollisionDetection, type Modifier } from "@dnd-kit/core"; import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; +import { restrictToFirstScrollableAncestor } from "@dnd-kit/modifiers"; import { resolveSidebarDropTarget, sidebarListItemId, @@ -10,6 +11,40 @@ import { } from "./Sidebar.logic"; const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; + +export function resolveSidebarDragSourceRect( + event: Event, + initialRect: Parameters[0]["activeNodeRect"], +) { + // DragStart precedes dnd-kit's first rectangle measurement. + return ( + initialRect ?? + (event.target instanceof Element + ? event.target.closest("[data-thread-item]")?.getBoundingClientRect() + : undefined) + ); +} + +export function restrictSidebarDrag( + args: Parameters[0], + preview?: { readonly width: number; readonly offsetX: number }, +) { + const rect = args.draggingNodeRect; + if (!preview || !rect) return restrictToFirstScrollableAncestor(args); + const width = preview.width; + return restrictToFirstScrollableAncestor({ + ...args, + transform: { ...args.transform, x: args.transform.x + preview.offsetX }, + draggingNodeRect: { + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.left + width, + width, + height: rect.height, + }, + }); +} const hidden = { ...stationary, scaleY: 0 }; type ThreadItem = Extract; type Layout = Parameters[0]; @@ -22,9 +57,10 @@ const isShelfHeader = (item: SidebarListItem | undefined) => export function restrictBelowSidebarLabel( { transform, containerNodeRect, draggingNodeRect }: Parameters[0], offset: number, + listTop = containerNodeRect?.top, ) { - if (!containerNodeRect || !draggingNodeRect) return transform; - const minimumY = containerNodeRect.top + offset - draggingNodeRect.top; + if (listTop === undefined || !draggingNodeRect) return transform; + const minimumY = listTop + offset - draggingNodeRect.top; return transform.y < minimumY ? { ...transform, y: minimumY } : transform; } @@ -33,15 +69,54 @@ export function createSidebarCollisionDetection( options: { items?: readonly SidebarListItem[]; activationY?: number | null; + pinnedGrid?: SidebarPinnedGrid | undefined; } = {}, ): CollisionDetection { const validity = new Map(); const sections = new Map(); let previousPointerY = options.activationY; let boundarySection: "pinned" | "active" | undefined; + const pins = + options.items?.filter( + (item): item is ThreadItem => item.kind === "thread" && item.section === "pinned", + ) ?? []; return (args) => { - let collisions = closestCenter(args); const pointer = args.pointerCoordinates; + let droppableRects = args.droppableRects; + const grid = options.pinnedGrid; + const header = args.droppableRects.get(sidebarMarkerId("pinned-header")); + if (grid && header && pins.length > 0) { + droppableRects = new Map(args.droppableRects); + for (const [index, pin] of pins.entries()) { + const cell = sidebarPinnedGridCell(grid, index, pins.length); + const left = header.left + cell.left; + const top = header.top + grid.labelHeight + 1 + grid.leading + cell.top; + droppableRects.set(pin.key, { + left, + top, + width: cell.width, + height: grid.rowHeight, + right: left + cell.width, + bottom: top + grid.rowHeight, + }); + } + } + // Rows span the list, so only pinned grid columns feel the pointer's x; cards keep their column. + let collisions = closestCenter( + pointer + ? { + ...args, + droppableRects, + collisionRect: { + ...args.collisionRect, + left: pointer.x - args.collisionRect.width / 2, + right: pointer.x + args.collisionRect.width / 2, + top: pointer.y - args.collisionRect.height / 2, + bottom: pointer.y + args.collisionRect.height / 2, + }, + } + : args, + ); const items = options.items; const source = items?.find((item) => item.kind === "thread" && item.key === args.active.id); const boundary = args.droppableContainers @@ -89,6 +164,57 @@ export function createSidebarCollisionDetection( }; } +/** Pinned tile grid geometry in client pixels, measured when a drag starts. */ +export type SidebarPinnedGrid = { + readonly width: number; + readonly labelHeight: number; + readonly columns: number; + readonly rowHeight: number; + readonly gap: number; + readonly leading: number; + readonly trailing: number; +}; + +export function sidebarPinnedGridCell(grid: SidebarPinnedGrid, index: number, count: number) { + const row = Math.floor(index / grid.columns); + const columns = Math.min(grid.columns, count - row * grid.columns); + const width = (grid.width - (columns - 1) * grid.gap) / columns; + return { + left: (index % grid.columns) * (width + grid.gap), + top: row * (grid.rowHeight + grid.gap), + width, + }; +} + +/** Reads the pinned grid's CSS-driven columns and spacing; `scale` maps CSS to client pixels. */ +export function measureSidebarPinnedGrid( + grid: HTMLElement, + scale: number, + labelHeight = 0, +): SidebarPinnedGrid | undefined { + const style = getComputedStyle(grid); + const rowHeight = Number.parseFloat(style.gridAutoRows); + if (!Number.isFinite(rowHeight)) return undefined; + const tile = grid.firstElementChild; + const columns = tile + ? Number.parseInt(getComputedStyle(tile).getPropertyValue("--sidebar-pinned-columns"), 10) + : 1; + const px = (value: string) => (Number.parseFloat(value) || 0) * scale; + return { + width: + grid.getBoundingClientRect().width || + (grid.parentElement?.getBoundingClientRect().width ?? 0) - + px(style.marginLeft) - + px(style.marginRight), + labelHeight, + columns: Number.isFinite(columns) && columns > 0 ? columns : 1, + rowHeight: rowHeight * scale, + gap: px(style.rowGap), + leading: px(style.marginTop) + px(style.borderTopWidth) + px(style.paddingTop), + trailing: px(style.paddingBottom) + px(style.borderBottomWidth) + px(style.marginBottom), + }; +} + export function createSidebarSortingStrategy(input: { items: readonly SidebarListItem[]; enabled?: boolean; @@ -101,6 +227,8 @@ export function createSidebarSortingStrategy(input: { cardHeight?: number; slimHeight?: number; boundaryLabelHeight?: number; + /** Omitted, pinned rows stack like cards: one column at their own height. */ + pinnedGrid?: SidebarPinnedGrid | undefined; }): SortingStrategy { if (input.enabled === false) return () => stationary; const { items } = input; @@ -123,6 +251,7 @@ export function createSidebarSortingStrategy(input: { }; let cardHeight = input.cardHeight; let slimHeight = input.slimHeight; + let pinnedHeight: number | undefined; let headerScale: number | undefined; for (const [index, item] of items.entries()) { if (item.kind === "marker") { @@ -132,11 +261,13 @@ export function createSidebarSortingStrategy(input: { } continue; } - if (item.section === "pinned" || item.section === "active" || item.section === "working") + if (item.section === "pinned") pinnedHeight ??= rects[index]?.height; + else if (item.section === "active" || item.section === "working") cardHeight ??= rects[index]?.height; else slimHeight ??= rects[index]?.height; if (item.key !== active.key) groups[item.section].push(item); } + if (!input.pinnedGrid) cardHeight ??= pinnedHeight; const scale = slimHeight !== undefined ? slimHeight / 36 : (headerScale ?? (cardHeight ?? 82) / 82); cardHeight ??= 82 * scale; @@ -173,7 +304,6 @@ export function createSidebarSortingStrategy(input: { else marker(`${name}-placeholder`); }; marker("pinned-header"); - projected.push(...groups.pinned); marker("pinned-divider"); section("active"); if (items.some((item) => item.kind === "marker" && item.marker === "working-header")) { @@ -190,12 +320,40 @@ export function createSidebarSortingStrategy(input: { } marker("settled-header"); section("settled"); + const pinnedGrid = input.pinnedGrid ?? { + columns: 1, + width: rects[0].width, + labelHeight: 0, + rowHeight: pinnedHeight ?? cardHeight, + gap: 1, + leading: 0, + trailing: 0, + }; + // An outgoing pin leaves its cell; an incoming pin reserves its destination row. + const restingPins = items.flatMap((item, index) => + item.kind === "thread" && item.section === "pinned" ? [index] : [], + ); + const pinnedRows = Math.ceil( + Math.max( + restingPins.length, + target.section === "pinned" ? groups.pinned.length : 0, + input.pinnedGrid && restingPins.length === 0 ? 1 : 0, + ) / pinnedGrid.columns, + ); + // The grid hides when empty, so it adds no list gap either. + const pinnedBlock = + pinnedRows === 0 + ? 0 + : pinnedGrid.leading + + pinnedRows * pinnedGrid.rowHeight + + (pinnedRows - 1) * pinnedGrid.gap + + pinnedGrid.trailing + + 1; const heights = projected.map((item) => { const index = indices.get(sidebarListItemId(item)); const rect = index === undefined ? undefined : rects[index]; const fallback = - item.kind === "thread" && - (item.section === "pinned" || item.section === "active" || item.section === "working") + item.kind === "thread" && (item.section === "active" || item.section === "working") ? cardHeight : slimHeight; const moved = item.kind === "thread" && item.key === active.key; @@ -216,12 +374,16 @@ export function createSidebarSortingStrategy(input: { shelfRect && beforeShelf && lastRect && shelfRect.top > beforeShelf.bottom + 1 ? Math.max( 0, - lastRect.bottom - rects[0].top - heights.reduce((sum, height) => sum + height + 1, -1), + lastRect.bottom - + rects[0].top - + heights.reduce((sum, height) => sum + height + 1, -1) - + pinnedBlock, ) : 0; const result = items.map(() => hidden); + const firstPin = rects[restingPins[0] ?? -1]; let top = rects[0].top; - for (const [projectedIndex, item] of projected.entries()) { + for (const [flowIndex, item] of projected.entries()) { if (isShelfHeader(item)) { top += shelfSpace; shelfSpace = 0; @@ -229,7 +391,30 @@ export function createSidebarSortingStrategy(input: { const index = indices.get(sidebarListItemId(item)); const rect = index === undefined ? undefined : rects[index]; if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top }; - top += heights[projectedIndex]! + 1; + top += heights[flowIndex]! + 1; + if (item.kind === "marker" && item.marker === "pinned-header") { + const y = firstPin ? top + pinnedGrid.leading - firstPin.top : 0; + for (const pin of restingPins) result[pin] = { ...stationary, y }; + if (input.pinnedGrid && target.section === "pinned") { + for (const [position, pin] of groups.pinned.entries()) { + const pinIndex = indices.get(pin.key); + if (pinIndex === undefined || pinIndex === activeIndex) continue; + const from = sidebarPinnedGridCell( + input.pinnedGrid, + restingPins.indexOf(pinIndex), + restingPins.length, + ); + const to = sidebarPinnedGridCell(input.pinnedGrid, position, groups.pinned.length); + result[pinIndex] = { + ...stationary, + x: to.left - from.left, + y: y + to.top - from.top, + scaleX: to.width / from.width, + }; + } + } + top += pinnedBlock; + } } result[activeIndex] = stationary; return result; diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index fbc0d8434a82b..7302dbdf8e3b1 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -1,9 +1,22 @@ const motionTiming = { duration: 150, easing: "ease-out" }; +// A drop lands every reordered row in one move; longer than list motion so a cross-row path reads. +const settleTiming = { duration: 200, easing: "cubic-bezier(0.23, 1, 0.32, 1)" }; +const SETTLING_ATTRIBUTE = "data-sidebar-settling"; +const LEAD_ATTRIBUTE = "data-sidebar-motion-lead"; +const MOVING_ATTRIBUTE = "data-sidebar-motion-moving"; +/** Matches a list that is dragging (Sidebar sets the attribute) or landing a drop. */ +export const SIDEBAR_SORTING_SELECTOR = `[data-sidebar-dragging], [${SETTLING_ATTRIBUTE}]`; const rowTravel = (height: number) => Math.min(height, 40); // Fades clone removed rows and animate clones and entries; intervening layout reads force reflows. const MAX_FADED_ROWS_PER_UPDATE = 40; type RowPosition = { top: number; left: number; width: number; height: number }; +const centerDelta = (from: RowPosition, to: RowPosition, listWidth: number) => ({ + x: Math.max(0, Math.min(from.left + (from.width - to.width) / 2, listWidth - to.width)) - to.left, + y: from.top + from.height / 2 - (to.top + to.height / 2), +}); +// Pinning or unpinning remounts a row as a tile or card; its thread key carries it across. +const threadKeyOf = (node: HTMLElement) => node.dataset.threadItem; function progress(animation: Animation) { return animation.playState === "finished" @@ -11,17 +24,44 @@ function progress(animation: Animation) { : (animation.effect?.getComputedTiming().progress ?? 0); } -/** Position the list so all direct children's offsetTop share an origin. */ +/** Animates the list's direct children, and the children of any `data-sidebar-motion-group`. */ export function createSidebarListMotion(parent: HTMLUListElement) { let positions: Map | null = null; + let previousWidth: number | null = null; let disposed = false; const reducedMotion = parent.ownerDocument.defaultView?.matchMedia( "(prefers-reduced-motion: reduce)", ); - const running = new Map(); + const running = new Map(); const entering = new Map(); const exiting = new Map(); - let released: Map | null = null; + let released: { + nodes: Map; + threads: Map; + activeKey: string | undefined; + } | null = null; + let settleGeneration = 0; + + const rows = () => + Array.from(parent.children) + .flatMap((node) => + node instanceof HTMLElement && node.hasAttribute("data-sidebar-motion-group") + ? Array.from(node.children) + : [node], + ) + .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)); + // Grouped rows may sit in a nested offsetParent; measure every row from the list. + const offsetInParent = (node: HTMLElement) => { + let top = 0; + let left = 0; + let current: Element | null = node; + while (current instanceof HTMLElement && current !== parent) { + top += current.offsetTop; + left += current.offsetLeft; + current = current.offsetParent; + } + return { top, left }; + }; const remainingOffset = (node: HTMLElement) => { const current = running.get(node); @@ -30,6 +70,10 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const enter = entry ? entry.travel * (1 - progress(entry.animation)) : 0; return run + enter; }; + const remainingOffsetX = (node: HTMLElement) => { + const current = running.get(node); + return current ? current.offsetX * (1 - progress(current.animation)) : 0; + }; const clearFades = () => { for (const entry of entering.values()) entry.animation.cancel(); for (const animation of exiting.values()) animation.cancel(); @@ -57,7 +101,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { Object.assign(clone.style, { position: "absolute", top: `${position.top + remainingOffset(node)}px`, - left: `${position.left}px`, + left: `${position.left + remainingOffsetX(node)}px`, width: `${position.width}px`, height: `${position.height}px`, margin: "0", @@ -91,47 +135,61 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const cancel = (node: HTMLElement) => { running.get(node)?.animation.cancel(); running.delete(node); + node.removeAttribute(LEAD_ATTRIBUTE); + node.removeAttribute(MOVING_ATTRIBUTE); }; const suspend = () => { for (const node of running.keys()) cancel(node); clearFades(); positions = null; released = null; + settleGeneration++; + parent.removeAttribute(SETTLING_ATTRIBUTE); }; - const move = (node: HTMLElement, offset: number) => { + const move = ( + node: HTMLElement, + offset: number, + offsetX: number, + timing = motionTiming, + lead = false, + ) => { + const isLead = lead || node.hasAttribute(LEAD_ATTRIBUTE); cancel(node); const entry = entering.get(node); if (entry) entry.travel = 0; - if (offset === 0 && !entry) return; + if (offset === 0 && offsetX === 0 && !entry) return undefined; + if (isLead) node.setAttribute(LEAD_ATTRIBUTE, ""); + node.setAttribute(MOVING_ATTRIBUTE, ""); const animation = node.animate( - [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], - motionTiming, + [{ transform: `translate(${offsetX}px, ${offset}px)` }, { transform: "translate(0px, 0px)" }], + timing, ); - running.set(node, { animation, offset }); + running.set(node, { animation, offset, offsetX }); animation.addEventListener( "finish", () => { - if (running.get(node)?.animation === animation) running.delete(node); + if (running.get(node)?.animation === animation) { + running.delete(node); + node.removeAttribute(LEAD_ATTRIBUTE); + node.removeAttribute(MOVING_ATTRIBUTE); + } }, { once: true }, ); + return animation; }; return { update(animate: boolean) { if (disposed) return; + const width = parent.clientWidth; + const widthUnchanged = previousWidth === width; + previousWidth = width; const next = new Map( - Array.from(parent.children) - .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) - .map((node) => [ - node, - { - top: node.offsetTop, - left: node.offsetLeft, - width: node.offsetWidth, - height: node.offsetHeight, - }, - ]), + rows().map((node) => [ + node, + { ...offsetInParent(node), width: node.offsetWidth, height: node.offsetHeight }, + ]), ); let fadeCount = 0; if (positions !== null) { @@ -144,11 +202,16 @@ export function createSidebarListMotion(parent: HTMLUListElement) { } const shouldAnimate = animate && + widthUnchanged && positions !== null && !reducedMotion?.matches && fadeCount <= MAX_FADED_ROWS_PER_UPDATE; const movedDelta = new Map(); + const movedDeltaX = new Map(); + const remounted = new Map(); const nextOrder = [...next.keys()]; + const nextThreadKeys = new Set(nextOrder.map(threadKeyOf)); + const landing: Animation[] = []; const oldOrder = positions === null ? [] : [...positions.keys()]; const ridingDelta = ( order: readonly HTMLElement[], @@ -166,12 +229,30 @@ export function createSidebarListMotion(parent: HTMLUListElement) { if (!shouldAnimate) clearFades(); else { for (const [node, position] of next) { - const previousTop = positions!.get(node)?.top; - if (previousTop === undefined || previousTop === position.top) continue; - movedDelta.set(node, previousTop + remainingOffset(node) - position.top); + const previous = positions!.get(node); + if ( + previous === undefined || + (previous.top === position.top && previous.left === position.left) + ) + continue; + movedDelta.set(node, previous.top + remainingOffset(node) - position.top); + movedDeltaX.set( + node, + Math.max(0, Math.min(previous.left + remainingOffsetX(node), width - position.width)) - + position.left, + ); } for (const [node, position] of positions!) { if (next.has(node)) continue; + const key = threadKeyOf(node); + if (key !== undefined && nextThreadKeys.has(key)) { + remounted.set(key, { + ...position, + top: position.top + remainingOffset(node), + left: position.left + remainingOffsetX(node), + }); + continue; + } const delta = ridingDelta(oldOrder, oldOrder.indexOf(node), (n) => next.has(n)); fadeOut(node, position, delta === undefined ? rowTravel(position.height) : -delta); } @@ -190,7 +271,13 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const position = next.get(node)!; const previousTop = positions!.get(node)?.top; if (previousTop === undefined) { - if (position.height > 0) { + const key = threadKeyOf(node); + const from = key === undefined ? undefined : remounted.get(key); + if (from !== undefined && position.height > 0) { + const delta = centerDelta(from, position, width); + const animation = move(node, delta.y, delta.x, motionTiming, true); + if (animation) landing.push(animation); + } else if (position.height > 0) { const delta = ridingDelta(nextOrder, index, (n) => positions!.has(n)); const travel = delta === undefined ? -rowTravel(position.height) : delta; const animation = node.animate( @@ -214,28 +301,57 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const delta = movedDelta.get(node); // Computed progress includes easing; carry forward only our translation, never dnd-kit's // transforms. - if (delta !== undefined) move(node, delta); + if (delta !== undefined) move(node, delta, movedDeltaX.get(node) ?? 0); } } if (released !== null) { if (!reducedMotion?.matches) { for (const [node, position] of next) { - const top = released.get(node); - if (top !== undefined) move(node, top - position.top); + const key = threadKeyOf(node); + const origin = + released.nodes.get(node) ?? + (key === undefined ? undefined : released.threads.get(key)); + if (origin === undefined) continue; + const delta = centerDelta(origin, position, width); + const animation = move( + node, + delta.y, + delta.x, + settleTiming, + key !== undefined && key === released.activeKey, + ); + if (animation) landing.push(animation); } } released = null; } + if (landing.length > 0) { + const generation = ++settleGeneration; + parent.setAttribute(SETTLING_ATTRIBUTE, ""); + void Promise.allSettled(landing.map((animation) => animation.finished)).then(() => { + if (generation === settleGeneration) parent.removeAttribute(SETTLING_ATTRIBUTE); + }); + } positions = next; }, - release() { + release(activeKey?: string) { suspend(); - const origin = parent.getBoundingClientRect().top; - released = new Map( - Array.from(parent.children) - .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) - .map((node) => [node, node.getBoundingClientRect().top - origin]), - ); + const origin = parent.getBoundingClientRect(); + const nodes = new Map(); + const threads = new Map(); + for (const node of rows()) { + const rect = node.getBoundingClientRect(); + const position = { + top: rect.top - origin.top, + left: rect.left - origin.left, + width: rect.width, + height: rect.height, + }; + nodes.set(node, position); + const key = threadKeyOf(node); + if (key !== undefined) threads.set(key, position); + } + released = { nodes, threads, activeKey }; }, suspend, dispose() { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 18aa27709e4c0..1638b6ec0d3da 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -24,7 +24,7 @@ import { type Modifier, } from "@dnd-kit/core"; import { SortableContext } from "@dnd-kit/sortable"; -import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { canSnooze, effectiveSnoozed, @@ -217,8 +217,13 @@ import { } from "./Sidebar.logic"; import { createSidebarCollisionDetection, + measureSidebarPinnedGrid, + sidebarPinnedGridCell, + type SidebarPinnedGrid, createSidebarSortingStrategy, restrictBelowSidebarLabel, + restrictSidebarDrag, + resolveSidebarDragSourceRect, } from "./Sidebar.drag"; import { SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; @@ -1069,6 +1074,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve readonly activationY: number | null; readonly targetSection: SidebarSection | null; readonly contextDrag: boolean; + readonly pinnedGrid: SidebarPinnedGrid | undefined; + readonly pinnedTargetIndex: number | null; + readonly grabOffsetX: number; + readonly sourceWidth: number; } | null>(null); const dragSensorRef = useRef(null); const contextDragKeyRef = useRef(null); @@ -1694,12 +1703,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve })(); }; const restrictBelowPins: Modifier = (args) => - restrictBelowSidebarLabel(args, dragLabelOffsetRef.current); - const dndModifiers = [ - restrictToVerticalAxis, - restrictBelowPins, - restrictToFirstScrollableAncestor, - ]; + restrictBelowSidebarLabel( + args, + dragLabelOffsetRef.current, + threadListRef.current?.getBoundingClientRect().top, + ); const attachListMotionRef = (node: HTMLUListElement) => { threadListRef.current = node; const motion = createSidebarListMotion(node); @@ -1728,11 +1736,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const isContextDrag = dragState?.contextDrag === true || dragState?.activeSection === "working"; const dragTargetSection = isContextDrag ? null : (dragState?.targetSection ?? null); const finishThreadDrag = (started: boolean) => { + const activeKey = contextDragKeyRef.current ?? undefined; dragSensorRef.current = null; contextDragKeyRef.current = null; endThreadContextDrag(); if (started) { - listMotionRef.current?.release(); + listMotionRef.current?.release(activeKey); setDragState(null); } }; @@ -1902,9 +1911,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve listMotionRef.current?.suspend(); const list = threadListRef.current; const header = list?.querySelector('[data-testid="sidebar-pinned-header"]'); - if (list && header) { - const listRect = list.getBoundingClientRect(); - const scale = list.offsetWidth > 0 ? listRect.width / list.offsetWidth : 1; + const grid = list?.querySelector('[data-testid="sidebar-pinned-grid"]'); + const listRect = list?.getBoundingClientRect(); + const sourceRect = resolveSidebarDragSourceRect( + event.activatorEvent, + event.active.rect.current.initial, + ); + const scale = list && listRect && list.offsetWidth > 0 ? listRect.width / list.offsetWidth : 1; + if (listRect && header) { dragLabelOffsetRef.current = header.getBoundingClientRect().top - listRect.top + SIDEBAR_DRAG_LABEL_HEIGHT * scale; } else { @@ -1915,6 +1929,15 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve activeSection, targetSection: activeSection, contextDrag: false, + pinnedGrid: grid + ? measureSidebarPinnedGrid(grid, scale, SIDEBAR_DRAG_LABEL_HEIGHT * scale) + : undefined, + pinnedTargetIndex: activeSection === "pinned" ? pinnedKeys.indexOf(activeKey) : null, + grabOffsetX: + event.activatorEvent instanceof PointerEvent + ? event.activatorEvent.clientX - (sourceRect?.left ?? event.activatorEvent.clientX) + : 0, + sourceWidth: sourceRect?.width ?? listRect?.width ?? 0, occurredAt: new Date().toISOString(), activationY: event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, @@ -2013,10 +2036,15 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const target = event.over ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id)) : null; + const pinnedTargetIndex = + target?.section === "pinned" ? target.pinnedOrder.indexOf(String(event.active.id)) : null; setDragState((current) => - current === null || current.activeKey !== String(event.active.id) + current === null || + current.activeKey !== String(event.active.id) || + (current.targetSection === (target?.section ?? null) && + current.pinnedTargetIndex === pinnedTargetIndex) ? current - : { ...current, targetSection: target?.section ?? null }, + : { ...current, targetSection: target?.section ?? null, pinnedTargetIndex }, ); }; const sortableIds = sidebarListItems.map(sidebarListItemId); @@ -2041,10 +2069,33 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve settledVisibleCount, routeThreadKey: routeRowThreadKey, snoozedThreadCount: snoozedThreads.length, + pinnedGrid: dragState?.pinnedGrid, }); const draggedThreadKey = dragState?.activeKey; const draggedFromSection = dragState?.activeSection; const dragActivationY = dragState?.activationY; + const pinnedDragPreview = (() => { + const grid = dragState?.pinnedGrid; + if (!grid || dragState.targetSection !== "pinned" || dragState.pinnedTargetIndex === null) + return undefined; + const from = pinnedKeys.indexOf(dragState.activeKey); + if (dragState.pinnedTargetIndex < 0) return undefined; + const sourceWidth = + from < 0 ? dragState.sourceWidth : sidebarPinnedGridCell(grid, from, pinnedKeys.length).width; + if (sourceWidth <= 0) return undefined; + const width = sidebarPinnedGridCell( + grid, + dragState.pinnedTargetIndex, + pinnedKeys.length + (from < 0 ? 1 : 0), + ).width; + const widthRatio = width / sourceWidth; + return { width, widthRatio, offsetX: dragState.grabOffsetX * (1 - widthRatio) }; + })(); + const restrictPreview: Modifier = (args) => restrictSidebarDrag(args, pinnedDragPreview); + const dndModifiers = + dragState?.activeSection === "pinned" + ? [restrictBelowPins, restrictPreview] + : [restrictToVerticalAxis, restrictBelowPins, restrictPreview]; const dndCollisionDetection = (() => { if (draggedThreadKey === undefined || draggedFromSection === undefined) return createSidebarCollisionDetection(() => true); @@ -2075,6 +2126,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve { items: sidebarListItems, activationY: dragActivationY ?? null, + pinnedGrid: dragState?.pinnedGrid, }, ); })(); @@ -3295,6 +3347,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
    , ]; + const pinnedTiles = sidebarListItems.flatMap((item) => + item.kind === "thread" && item.section === "pinned" + ? [renderThreadRow(threadByKey.get(item.key)!, item.section)] + : [], + ); for (const item of sidebarListItems) { if (item.kind === "thread") { - // Folders draw active rows in their own place below. - if (projectFolders !== null && item.section === "active") continue; + // Pins render in their grid; folders draw active rows in their own place below. + if ( + item.section === "pinned" || + (projectFolders !== null && item.section === "active") + ) + continue; items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); continue; } @@ -3482,13 +3549,25 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); break; case "pinned-divider": + // Stays mounted while empty so a drag can measure the grid it would open. items.push( +
      + {pinnedTiles} +
    , 0} />, ); break; diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index 3416d80ff5e53..0078210aff791 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -77,6 +77,8 @@ export function SidebarDragBoundary(props: { label: string; visible: boolean; isDropTarget: boolean; + /** A resting hairline, drawn in the space the pinned grid leaves above it. */ + rule?: boolean; }) { return ( + {props.rule && !props.visible ? ( + + ) : null} {props.visible ? (
    ; +// A drag and its drop settle hold hover details, so a popup never covers where a row lands. +function holdDetailsWhileSorting(open: boolean, details: { event: Event; cancel: () => void }) { + const target = details.event?.target; + if (open && target instanceof Element && target.closest(SIDEBAR_SORTING_SELECTOR)) { + details.cancel(); + } +} + export const draftSurfaceClassName = "bg-warning/4 hover:bg-warning/8"; export const draftPenClassName = "size-3 shrink-0 text-warning-foreground"; @@ -470,7 +479,14 @@ export function SortableThreadRow( const bag = { listeners, setNodeRef, - transform: contextDrag ? null : transform, + transform: contextDrag + ? null + : transform && isDragging + ? { + ...transform, + scaleX: rowProps.pinnedDragPreview?.widthRatio ?? 1, + } + : transform, transition: contextDrag && isDragging ? "transform 150ms ease-out" : transition, isDragging: isDragging && !contextDrag, }; @@ -535,8 +551,9 @@ function TopStatusIcon(props: { icon: keyof typeof TOP_STATUS_ICONS; className: type SidebarThreadRowProps = { thread: SidebarThreadSummary; // "folder" is a one-line row inside a boring sidebar project folder; - // "folder-multiline" adds the card's branch and PR line under it. - variant: "card" | "slim" | "folder" | "folder-multiline"; + // "folder-multiline" adds the card's branch and PR line under it; + // "tile" is a compact cell of the pinned grid. + variant: "card" | "slim" | "folder" | "folder-multiline" | "tile"; navigationParent?: string | undefined; variantAction: SidebarSweepAction; settlementSupported: boolean; @@ -544,6 +561,7 @@ type SidebarThreadRowProps = { pinningSupported: boolean; isPinned: boolean; sortable?: SortableThreadRowBag | undefined; + pinnedDragPreview?: { readonly widthRatio: number; readonly offsetX: number } | undefined; dropVerb: SidebarDropVerb | null; dragOverPinned: boolean; sweepAction: SidebarSweepAction | null; @@ -969,9 +987,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const sortableRootProps = sortable ? { ref: sortable.setNodeRef, + "data-sidebar-drag-source": sortable.isDragging ? "" : undefined, style: { transform: CSS.Translate.toString(sortable.transform), - transition: sortable.transition, + transition: + variant === "tile" && sortable.transition + ? `${sortable.transition}, ${sortable.transition.replace("transform", "width")}` + : sortable.transition, + width: variant === "tile" ? `${(sortable.transform?.scaleX ?? 1) * 100}%` : undefined, // A zero-height boundary also makes dnd-kit scale the source to // zero. Only projected peers use scaleY as a visibility sentinel. visibility: @@ -1189,6 +1212,110 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null; + if (variant === "tile") { + return ( +
  • + + + } + > + {accessibleTitle} +
    + {props.project ? ( + + ) : null} + {draftIndicator} + {props.projectDisplayName ? ( + + {props.projectDisplayName} + + ) : null} + {pinIndicator ? ( + + {pinIndicator} + + ) : null} + {sortable?.isDragging ? ( + dragDestination + ) : jumpHintLabel ? ( + + + + ) : topStatus ? ( + + + {topStatus.label} + + ) : ( + + + + )} +
    +
    + {isRenaming ? ( + title + ) : ( + + {thread.title} + + )} +
    + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} +
    + {detailsTooltip} +
    +
  • + ); + } + if (variant !== "card") { const multiline = variant === "folder-multiline"; const inFolder = variant === "folder" || multiline; @@ -1203,7 +1330,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP sortable?.isDragging && "relative z-20", )} > - + - + [data-sidebar-motion-moving], > [data-sidebar-drag-source]) { + z-index: 20; + } + [data-sidebar-motion-lead] { + position: relative; + z-index: 20; + } + [data-sidebar-motion-moving] [data-sidebar-row] { + background-color: var(--sidebar); + opacity: 1; + } + .sidebar-pinned-grid:has(> [data-sidebar-drag-source]) + > :not([data-sidebar-drag-source]) + [data-sidebar-row] { + background-color: var(--sidebar); + } + .sidebar-pinned-grid > * { + --sidebar-pinned-columns: 1; + grid-column: span 6; + } + @container sidebar-pinned (10rem <= width < 14rem) { + .sidebar-pinned-grid > * { + --sidebar-pinned-columns: 2; + grid-column: span 3; + } + .sidebar-pinned-grid > :nth-child(2n + 1):last-child { + grid-column: span 6; + } + } + @container sidebar-pinned (width >= 14rem) { + .sidebar-pinned-grid > * { + --sidebar-pinned-columns: 3; + grid-column: span 2; + } + .sidebar-pinned-grid > :nth-child(3n + 1):last-child { + grid-column: span 6; + } + .sidebar-pinned-grid > :nth-child(3n + 1):nth-last-child(2), + .sidebar-pinned-grid > :nth-child(3n + 1):nth-last-child(2) + * { + grid-column: span 3; + } + } } /* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 6a3145d17512f..8c423302c1860 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -85,8 +85,9 @@ list to unpin it. Dragging a thread onto the **Settled** header settles it, and thread into the active list un-settles it. A snoozed thread can be dragged out of the snoozed shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation. -Pinned and active boundary labels appear only while dragging, without moving the rows. The -other rows slide aside to show where the thread will land. When you cross into another section, +Pinned and active boundary labels appear only while dragging, without moving the rows. List +rows and pinned tiles slide aside to show where the thread will land. A pinned tile takes the +width of its destination while you drag it. When you cross into another section, the dragged thread shows the action the drop performs, with its icon: **Pin**, **Unpin**, **Settle**, **Un-settle**, or **Wake**. Its status and hover actions hide during the drag. A pinned thread keeps its pin only while it stays in the pinned section; once it leaves, the badge takes @@ -95,8 +96,8 @@ edge to pin a thread. Section labels stay readable for the whole drag, and the s thread is over takes the accent color. Section labels also identify empty sections and a collapsed settled shelf. -Drag within the pinned or active section to change its order. Other rows slide aside to show the -spot where the thread will land. Drops into either section keep the position you choose. On +Drag within the pinned or active section to change its order. Drops into either section keep +the position you choose. On mobile, open a thread's menu and choose **Arrange threads**. Drag a handle within or between **Pinned** and **Active** to reorder, pin, or unpin. Drop onto the **Settled** divider to settle a thread. The dragged card shows the action before you release it. Expand **Snoozed** From cc0c61e02feb1bea1be249a9f061d5ccbc2d5b91 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 10 Nov 2026 13:24:14 +0200 Subject: [PATCH 2/2] fix(web): use current timestamp helper for compact pins Co-Authored-By: GPT-6.1 Sol --- apps/web/src/components/sidebar/SidebarThreadRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 67c107fd0ff94..8a69b13042ac4 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1292,7 +1292,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : ( - + )}