From 5b5cd03815ab3b8ed1f2fa5edf0e5c2487bd8277 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 3 Nov 2026 01:00:26 +0200 Subject: [PATCH 01/13] feat(web): pinned threads become compact responsive tiles Pinned threads looked like every other card, so the pinned area was hard to tell apart from the list and spent 78px per pin. Pins now render as project-tinted tiles in a nested grid at the top of the sidebar tree: up to three per row by sidebar width (container queries), with a short last row stretching to fill and a hairline above Active. Each tile keeps the favicon, existing status icon and a truncated title; wider tiles add the project name and status label. Drag projection lays pinned peers into grid cells from the measured CSS geometry, collisions follow the pointer's x so cards can target a column, pinned drags may move horizontally, and the list motion animates x and nested grid rows. Implemented by Claude Opus 5.5 in Claude Code (T3 Code harness). Co-Authored-By: Claude Opus 5.5 --- .../components/Sidebar.drag.browser.test.ts | 67 ++++++++ apps/web/src/components/Sidebar.drag.test.ts | 140 ++++++++++++++++ apps/web/src/components/Sidebar.drag.ts | 109 ++++++++++++- apps/web/src/components/Sidebar.motion.ts | 84 ++++++---- apps/web/src/components/Sidebar.tsx | 58 +++++-- .../components/sidebar/SidebarListMarkers.tsx | 5 + .../components/sidebar/SidebarThreadRow.tsx | 150 ++++++++++++++++-- apps/web/src/index.css | 43 +++++ apps/web/src/projectIconColors.ts | 8 + 9 files changed, 597 insertions(+), 67 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..220d7b9436aa3 --- /dev/null +++ b/apps/web/src/components/Sidebar.drag.browser.test.ts @@ -0,0 +1,67 @@ +import "../index.css"; + +import { describe, expect, it } from "vite-plus/test"; +import type { SortingStrategy } from "@dnd-kit/sortable"; +import { createSidebarSortingStrategy, measureSidebarPinnedGrid } from "./Sidebar.drag"; +import { sidebarListItemId, type SidebarListItem } from "./Sidebar.logic"; + +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"; + const tiles = Array.from({ length: count }, () => grid.appendChild(document.createElement("li"))); + list.append(header, grid); + document.body.append(list); + return { header, grid, tiles, [Symbol.dispose]: () => list.remove() }; +} + +describe("pinned grid layout contract", () => { + 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..6e7b13054ccc4 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -217,6 +217,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", () => { @@ -682,6 +720,108 @@ describe("sidebar drag projection", () => { }); }); +describe("pinned grid projection", () => { + const grid = { columns: 3, rowHeight: 46, gap: 4, leading: 0, trailing: 8 }; + 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("moves pinned peers across columns and stretches a lone last-row tile", () => { + const result = gridPreview("p4", "p1"); + expect(result.get("p4")).toEqual(stationary); + 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(sidebarMarkerId("pinned-divider"))).toEqual(stationary); + expect(result.get("a1")).toEqual(stationary); + }); + + it("opens a grid cell for an incoming card and shares the new last row", () => { + const result = gridPreview("a1", "p2"); + expect(result.get("p1")).toEqual(stationary); + expect(result.get("p2")).toEqual({ ...stationary, x: 88 }); + expect(result.get("p3")).toEqual({ ...stationary, x: -176, y: 50, scaleX: 128 / 84 }); + expect(result.get("p4")).toEqual({ ...stationary, x: 132, scaleX: 128 / 260 }); + expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual(stationary); + }); + + it("collapses the grid by a row when a pin leaves", () => { + const result = gridPreview("p1", "a1"); + expect(result.get("p2")).toEqual({ ...stationary, x: -88 }); + expect(result.get("p3")).toEqual({ ...stationary, x: -88 }); + expect(result.get("p4")).toEqual({ ...stationary, x: 176, y: -50, scaleX: 84 / 260 }); + expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(-50); + }); +}); + describe("lifted card clearance", () => { const rect = (top: number, height: number) => ({ top, diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index 76f2c9bf3a3ec..8ac0f7dfb086f 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -40,8 +40,20 @@ export function createSidebarCollisionDetection( let previousPointerY = options.activationY; let boundarySection: "pinned" | "active" | undefined; return (args) => { - let collisions = closestCenter(args); const pointer = args.pointerCoordinates; + // Rows span the list, so only pinned grid columns feel the pointer's x; cards keep their column. + let collisions = closestCenter( + pointer + ? { + ...args, + collisionRect: { + ...args.collisionRect, + left: pointer.x - args.collisionRect.width / 2, + right: pointer.x + args.collisionRect.width / 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 +101,37 @@ export function createSidebarCollisionDetection( }; } +/** Pinned tile grid geometry in client pixels, measured when a drag starts. */ +export type SidebarPinnedGrid = { + readonly columns: number; + readonly rowHeight: number; + readonly gap: number; + readonly leading: number; + readonly trailing: number; +}; + +/** Reads the pinned grid's CSS-driven columns and spacing; `scale` maps CSS to client pixels. */ +export function measureSidebarPinnedGrid( + grid: HTMLElement, + scale: number, +): 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 { + 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 +144,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 +168,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 +178,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; @@ -190,12 +238,31 @@ export function createSidebarSortingStrategy(input: { } marker("settled-header"); section("settled"); - const heights = projected.map((item) => { + const pinnedGrid = input.pinnedGrid ?? { + columns: 1, + rowHeight: pinnedHeight ?? cardHeight, + gap: 1, + leading: 0, + trailing: 0, + }; + const pinnedRows = Math.ceil(groups.pinned.length / 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 isPinnedRow = (item: SidebarListItem) => + item.kind === "thread" && item.section === "pinned"; + const flow = projected.filter((item) => !isPinnedRow(item)); + const heights = flow.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 +283,34 @@ 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 gridRect = rects[indices.get(sidebarMarkerId("pinned-header")) ?? 0] ?? rects[0]; + const placePinned = (gridTop: number) => { + const { columns, rowHeight, gap, leading } = pinnedGrid; + for (const [position, item] of groups.pinned.entries()) { + const index = indices.get(sidebarListItemId(item)); + const rect = index === undefined ? undefined : rects[index]; + if (index === undefined || !rect) continue; + const row = Math.floor(position / columns); + const inRow = Math.min(columns, groups.pinned.length - row * columns); + const width = (gridRect.width - (inRow - 1) * gap) / inRow; + const left = gridRect.left + (position - row * columns) * (width + gap); + result[index] = { + x: left - rect.left, + y: gridTop + leading + row * (rowHeight + gap) - rect.top, + scaleX: rect.width > 0 ? width / rect.width : 1, + scaleY: 1, + }; + } + }; let top = rects[0].top; - for (const [projectedIndex, item] of projected.entries()) { + for (const [flowIndex, item] of flow.entries()) { if (isShelfHeader(item)) { top += shelfSpace; shelfSpace = 0; @@ -229,7 +318,11 @@ 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") { + placePinned(top); + 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..94f731bc224bb 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -11,17 +11,38 @@ 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 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: Map | null = null; + + 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 +51,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 +82,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", @@ -98,16 +123,16 @@ export function createSidebarListMotion(parent: HTMLUListElement) { positions = null; released = null; }; - const move = (node: HTMLElement, offset: number) => { + const move = (node: HTMLElement, offset: number, offsetX: number) => { cancel(node); const entry = entering.get(node); if (entry) entry.travel = 0; - if (offset === 0 && !entry) return; + if (offset === 0 && offsetX === 0 && !entry) return; const animation = node.animate( - [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], + [{ transform: `translate(${offsetX}px, ${offset}px)` }, { transform: "translate(0px, 0px)" }], motionTiming, ); - running.set(node, { animation, offset }); + running.set(node, { animation, offset, offsetX }); animation.addEventListener( "finish", () => { @@ -121,17 +146,10 @@ export function createSidebarListMotion(parent: HTMLUListElement) { update(animate: boolean) { if (disposed) return; 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) { @@ -148,6 +166,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { !reducedMotion?.matches && fadeCount <= MAX_FADED_ROWS_PER_UPDATE; const movedDelta = new Map(); + const movedDeltaX = new Map(); const nextOrder = [...next.keys()]; const oldOrder = positions === null ? [] : [...positions.keys()]; const ridingDelta = ( @@ -166,9 +185,14 @@ 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, previous.left + remainingOffsetX(node) - position.left); } for (const [node, position] of positions!) { if (next.has(node)) continue; @@ -214,14 +238,15 @@ 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 origin = released.get(node); + if (origin !== undefined) + move(node, origin.top - position.top, origin.left - position.left); } } released = null; @@ -230,11 +255,12 @@ export function createSidebarListMotion(parent: HTMLUListElement) { }, release() { suspend(); - const origin = parent.getBoundingClientRect().top; + const origin = parent.getBoundingClientRect(); 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]), + rows().map((node) => { + const rect = node.getBoundingClientRect(); + return [node, { top: rect.top - origin.top, left: rect.left - origin.left }]; + }), ); }, suspend, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index deede26763fdd..1869cf702e8f3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -216,6 +216,8 @@ import { } from "./Sidebar.logic"; import { createSidebarCollisionDetection, + measureSidebarPinnedGrid, + type SidebarPinnedGrid, createSidebarSortingStrategy, restrictBelowSidebarLabel, } from "./Sidebar.drag"; @@ -1068,6 +1070,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve readonly activationY: number | null; readonly targetSection: SidebarSection | null; readonly contextDrag: boolean; + readonly pinnedGrid: SidebarPinnedGrid | undefined; } | null>(null); const dragSensorRef = useRef(null); const contextDragKeyRef = useRef(null); @@ -1694,11 +1697,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }; const restrictBelowPins: Modifier = (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current); - const dndModifiers = [ - restrictToVerticalAxis, - restrictBelowPins, - restrictToFirstScrollableAncestor, - ]; + // Pinned tiles reorder across grid columns; list rows stay in their column. + const dndModifiers = + dragState?.activeSection === "pinned" + ? [restrictBelowPins, restrictToFirstScrollableAncestor] + : [restrictToVerticalAxis, restrictBelowPins, restrictToFirstScrollableAncestor]; const attachListMotionRef = (node: HTMLUListElement) => { threadListRef.current = node; const motion = createSidebarListMotion(node); @@ -1901,9 +1904,10 @@ 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 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 { @@ -1914,6 +1918,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve activeSection, targetSection: activeSection, contextDrag: false, + pinnedGrid: grid ? measureSidebarPinnedGrid(grid, scale) : undefined, occurredAt: new Date().toISOString(), activationY: event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, @@ -2040,6 +2045,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve settledVisibleCount, routeThreadKey: routeRowThreadKey, snoozedThreadCount: snoozedThreads.length, + pinnedGrid: dragState?.pinnedGrid, }); const draggedThreadKey = dragState?.activeKey; const draggedFromSection = dragState?.activeSection; @@ -3310,15 +3316,16 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); - const isCard = - section === "active" || section === "pinned" || section === "working"; + const isCard = section === "active" || section === "working"; const rowVariant = folderRow ? folderRowsMultiline ? "folder-multiline" : "folder" - : isCard - ? "card" - : "slim"; + : section === "pinned" + ? "tile" + : isCard + ? "card" + : "slim"; return ( , ]; + 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; } @@ -3478,13 +3494,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 104bda5582b4d..d115c965612b2 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 ? (
) : null; + if (variant === "tile") { + const icon = props.project?.projectIcon; + const tint = + icon && "color" in icon + ? icon.color + : props.project + ? deriveProjectIdentity(props.project.title).color + : null; + // The drag projection reports a grid cell's new width as scaleX; resize instead of stretching. + const projectedWidth = + sortable?.transform && !sortable.isDragging && Math.abs(sortable.transform.scaleX - 1) > 0.01 + ? `${sortable.transform.scaleX * 100}%` + : undefined; + return ( +
  • + + + } + > + {accessibleTitle} + {tint ? ( + + ) : null} +
    + {props.project ? ( + + ) : null} + {draftIndicator} + {props.projectDisplayName ? ( + + {props.projectDisplayName} + + ) : 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; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 15dd5e14d33fe..e12b2b9ea3167 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1074,6 +1074,49 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil all: revert; } } + + /* Pinned thread tiles: up to three a row by sidebar width, and a short last + row stretches to fill. Six tracks let 1, 2 or 3 tiles share any row evenly. + Sidebar.drag.ts reads the row height, gap, padding and + --sidebar-pinned-columns to project drags, so keep layout here. */ + .sidebar-pinned-grid { + container: sidebar-pinned / inline-size; + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-auto-rows: 2.875rem; + gap: 0.25rem; + margin-inline: 0.125rem; + padding-bottom: 0.5rem; + } + .sidebar-pinned-grid:empty { + display: none; + } + .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/apps/web/src/projectIconColors.ts b/apps/web/src/projectIconColors.ts index 0cf071b4debe4..809a08c2da48c 100644 --- a/apps/web/src/projectIconColors.ts +++ b/apps/web/src/projectIconColors.ts @@ -123,3 +123,11 @@ const PROJECT_ICON_COLOR_CLASSES = Object.fromEntries( export function projectIconColorClassName(color: ProjectIconColor): string { return PROJECT_ICON_COLOR_CLASSES[color]; } + +const PROJECT_ICON_SWATCH_CLASSES = Object.fromEntries( + PROJECT_ICON_COLORS.map(({ value, swatchClassName }) => [value, swatchClassName]), +) as Record; + +export function projectIconSwatchClassName(color: ProjectIconColor): string { + return PROJECT_ICON_SWATCH_CLASSES[color]; +} From 4daf589008c2a94a76bfef004f9edd3edca5ba78 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 3 Nov 2026 01:12:37 +0200 Subject: [PATCH 02/13] fix(web): measure pinned drag clearance from the thread list Co-Authored-By: GPT-6.1 Sol --- apps/web/src/components/Sidebar.drag.test.ts | 13 +++++++++++-- apps/web/src/components/Sidebar.drag.ts | 5 +++-- apps/web/src/components/Sidebar.tsx | 6 +++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index 6e7b13054ccc4..10bcc38415db9 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -831,11 +831,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, @@ -847,6 +854,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) => { @@ -864,5 +872,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 8ac0f7dfb086f..7bd8a49e67bf7 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -22,9 +22,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; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1869cf702e8f3..b5c3ba38966b9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1696,7 +1696,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve })(); }; const restrictBelowPins: Modifier = (args) => - restrictBelowSidebarLabel(args, dragLabelOffsetRef.current); + restrictBelowSidebarLabel( + args, + dragLabelOffsetRef.current, + threadListRef.current?.getBoundingClientRect().top, + ); // Pinned tiles reorder across grid columns; list rows stay in their column. const dndModifiers = dragState?.activeSection === "pinned" From 94aa8266587969720851e923cd6726605a41ffb3 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 3 Nov 2026 01:43:20 +0200 Subject: [PATCH 03/13] fix(web): distinguish selected pins and reset motion after resize Co-Authored-By: GPT-6.1 Sol --- .../src/components/Sidebar.drag.browser.test.ts | 17 ++++++++++++++++- apps/web/src/components/Sidebar.motion.ts | 5 +++++ .../src/components/sidebar/SidebarThreadRow.tsx | 9 +++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.drag.browser.test.ts b/apps/web/src/components/Sidebar.drag.browser.test.ts index 220d7b9436aa3..8384a128dd982 100644 --- a/apps/web/src/components/Sidebar.drag.browser.test.ts +++ b/apps/web/src/components/Sidebar.drag.browser.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { SortingStrategy } from "@dnd-kit/sortable"; import { createSidebarSortingStrategy, measureSidebarPinnedGrid } 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"); @@ -12,13 +13,27 @@ function mountPinnedGrid(width: number, count: number) { 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 { header, grid, tiles, [Symbol.dispose]: () => list.remove() }; + return { list, header, grid, tiles, [Symbol.dispose]: () => list.remove() }; } describe("pinned grid layout contract", () => { + 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(); + } + }); + it.each([ [152, 1], [200, 2], diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index 94f731bc224bb..85c16faaeb835 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -14,6 +14,7 @@ function progress(animation: Animation) { /** 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)", @@ -145,6 +146,9 @@ export function createSidebarListMotion(parent: HTMLUListElement) { return { update(animate: boolean) { if (disposed) return; + const width = parent.clientWidth; + const widthUnchanged = previousWidth === width; + previousWidth = width; const next = new Map( rows().map((node) => [ node, @@ -162,6 +166,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { } const shouldAnimate = animate && + widthUnchanged && positions !== null && !reducedMotion?.matches && fadeCount <= MAX_FADED_ROWS_PER_UPDATE; diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index a78fb2cf7d61a..30d34cb11ac22 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1227,7 +1227,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP aria-busy={isRegeneratingTitle || undefined} className={cn( rowSurfaceClassName, - "flex h-full flex-col justify-center gap-0.5 px-2 ring-1 ring-sidebar-foreground/6 ring-inset", + "flex h-full flex-col justify-center gap-0.5 px-2 ring-inset", + props.isActive + ? "ring-2 ring-primary light:ring-2 light:ring-primary" + : isSelected + ? "ring-2 ring-primary/70 light:ring-2 light:ring-primary/70" + : "ring-1 ring-sidebar-foreground/6", )} onClick={handleClick} onDoubleClick={handleDoubleClick} @@ -1237,7 +1242,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP } > {accessibleTitle} - {tint ? ( + {tint && !props.isActive && !isSelected ? ( Date: Tue, 3 Nov 2026 03:03:18 +0200 Subject: [PATCH 04/13] fix(web): pinned tiles hold still while dragging and land in one move Dragging a pin re-projected every peer into a new grid cell on each target change, sliding them across rows and snapping their widths, so a reorder read as a crowded shuffle. A details popup could also open over the grid as the drop settled. Pinned peers now keep their cells for the whole drag. The tile under the dragged one gets a dashed outline, since the dragged tile takes that spot. The grid keeps its resting size (an empty grid still opens one row for a first pin), and the drop lands every row in one 200ms move. A row remounted as a tile or card (pin or unpin, by drop or menu) now travels from where it was instead of fading in place. Hover details stay held while dragging and until the drop lands. The per-tile projected width style is gone. Co-Authored-By: Claude Opus 5.5 --- .../components/Sidebar.drag.browser.test.ts | 34 +++++- apps/web/src/components/Sidebar.drag.test.ts | 113 ++++++++---------- apps/web/src/components/Sidebar.drag.ts | 40 ++----- apps/web/src/components/Sidebar.motion.ts | 82 ++++++++++--- apps/web/src/components/Sidebar.tsx | 1 + .../components/sidebar/SidebarThreadRow.tsx | 56 +++++---- docs/user/thread-sidebar.md | 9 +- 7 files changed, 205 insertions(+), 130 deletions(-) diff --git a/apps/web/src/components/Sidebar.drag.browser.test.ts b/apps/web/src/components/Sidebar.drag.browser.test.ts index 8384a128dd982..e918aad903802 100644 --- a/apps/web/src/components/Sidebar.drag.browser.test.ts +++ b/apps/web/src/components/Sidebar.drag.browser.test.ts @@ -1,6 +1,6 @@ import "../index.css"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { SortingStrategy } from "@dnd-kit/sortable"; import { createSidebarSortingStrategy, measureSidebarPinnedGrid } from "./Sidebar.drag"; import { sidebarListItemId, type SidebarListItem } from "./Sidebar.logic"; @@ -34,6 +34,38 @@ describe("pinned grid layout contract", () => { } }); + // 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.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"; + motion.update(true); + 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); + // Hover details stay held until the drop lands. + const settling = () => mounted.list.hasAttribute("data-sidebar-settling"); + expect(settling()).toBe(path === "drop"); + 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], diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index 10bcc38415db9..50b818d25cb4d 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -334,14 +334,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); }); @@ -367,11 +367,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, @@ -384,10 +384,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); }, ); @@ -445,30 +445,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 = [ @@ -561,11 +555,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", () => { @@ -575,10 +569,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)( @@ -794,32 +788,29 @@ describe("pinned grid projection", () => { ); } - it("moves pinned peers across columns and stretches a lone last-row tile", () => { - const result = gridPreview("p4", "p1"); - expect(result.get("p4")).toEqual(stationary); - 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(sidebarMarkerId("pinned-divider"))).toEqual(stationary); - expect(result.get("a1")).toEqual(stationary); - }); - - it("opens a grid cell for an incoming card and shares the new last row", () => { - const result = gridPreview("a1", "p2"); - expect(result.get("p1")).toEqual(stationary); - expect(result.get("p2")).toEqual({ ...stationary, x: 88 }); - expect(result.get("p3")).toEqual({ ...stationary, x: -176, y: 50, scaleX: 128 / 84 }); - expect(result.get("p4")).toEqual({ ...stationary, x: 132, scaleX: 128 / 260 }); + // Peers that slide and resize under the pointer read as a shuffle; the drop reorders them once. + it.each([ + ["p4", "p1", "a tile crosses rows"], + ["a1", "p2", "a card comes in"], + ["p1", "a1", "a pin leaves"], + ])("holds every pin in its cell while %s hovers %s (%s)", (active, over) => { + const result = gridPreview(active, over); + for (const key of ["p1", "p2", "p3", "p4"]) expect(result.get(key), key).toEqual(stationary); expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual(stationary); }); +}); - it("collapses the grid by a row when a pin leaves", () => { - const result = gridPreview("p1", "a1"); - expect(result.get("p2")).toEqual({ ...stationary, x: -88 }); - expect(result.get("p3")).toEqual({ ...stationary, x: -88 }); - expect(result.get("p4")).toEqual({ ...stationary, x: 176, y: -50, scaleX: 84 / 260 }); - expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(-50); +it("opens one grid row for a first pin", () => { + const items = [pinnedHeader, 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 }, }); + const args = layout(items, "a2", sidebarMarkerId("pinned-divider")); + expect(strategy({ ...args, index: 1 })?.y).toBe(46 + 8 + 1); + expect(strategy({ ...args, index: 2 })?.y).toBe(46 + 8 + 1); }); describe("lifted card clearance", () => { diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index 7bd8a49e67bf7..eab8a14d4b9ef 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -222,7 +222,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")) { @@ -246,7 +245,14 @@ export function createSidebarSortingStrategy(input: { leading: 0, trailing: 0, }; - const pinnedRows = Math.ceil(groups.pinned.length / pinnedGrid.columns); + // Pins hold their cells until the drop, so the grid keeps its resting rows; only an empty grid + // opens one for an incoming pin. Moving peers mid-drag read as a shuffle. + const restingPins = items.flatMap((item, index) => + item.kind === "thread" && item.section === "pinned" ? [index] : [], + ); + const pinnedRows = Math.ceil( + Math.max(restingPins.length, target.section === "pinned" ? 1 : 0) / pinnedGrid.columns, + ); // The grid hides when empty, so it adds no list gap either. const pinnedBlock = pinnedRows === 0 @@ -256,10 +262,7 @@ export function createSidebarSortingStrategy(input: { (pinnedRows - 1) * pinnedGrid.gap + pinnedGrid.trailing + 1; - const isPinnedRow = (item: SidebarListItem) => - item.kind === "thread" && item.section === "pinned"; - const flow = projected.filter((item) => !isPinnedRow(item)); - const heights = flow.map((item) => { + const heights = projected.map((item) => { const index = indices.get(sidebarListItemId(item)); const rect = index === undefined ? undefined : rects[index]; const fallback = @@ -291,27 +294,9 @@ export function createSidebarSortingStrategy(input: { ) : 0; const result = items.map(() => hidden); - const gridRect = rects[indices.get(sidebarMarkerId("pinned-header")) ?? 0] ?? rects[0]; - const placePinned = (gridTop: number) => { - const { columns, rowHeight, gap, leading } = pinnedGrid; - for (const [position, item] of groups.pinned.entries()) { - const index = indices.get(sidebarListItemId(item)); - const rect = index === undefined ? undefined : rects[index]; - if (index === undefined || !rect) continue; - const row = Math.floor(position / columns); - const inRow = Math.min(columns, groups.pinned.length - row * columns); - const width = (gridRect.width - (inRow - 1) * gap) / inRow; - const left = gridRect.left + (position - row * columns) * (width + gap); - result[index] = { - x: left - rect.left, - y: gridTop + leading + row * (rowHeight + gap) - rect.top, - scaleX: rect.width > 0 ? width / rect.width : 1, - scaleY: 1, - }; - } - }; + const firstPin = rects[restingPins[0] ?? -1]; let top = rects[0].top; - for (const [flowIndex, item] of flow.entries()) { + for (const [flowIndex, item] of projected.entries()) { if (isShelfHeader(item)) { top += shelfSpace; shelfSpace = 0; @@ -321,7 +306,8 @@ export function createSidebarSortingStrategy(input: { if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top }; top += heights[flowIndex]! + 1; if (item.kind === "marker" && item.marker === "pinned-header") { - placePinned(top); + const y = firstPin ? top + pinnedGrid.leading - firstPin.top : 0; + for (const pin of restingPins) result[pin] = { ...stationary, y }; top += pinnedBlock; } } diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index 85c16faaeb835..ec6af3f777fb6 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -1,9 +1,20 @@ 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"; +/** 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) => ({ + x: from.left + from.width / 2 - (to.left + to.width / 2), + 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" @@ -22,7 +33,11 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const running = new Map(); const entering = new Map(); const exiting = new Map(); - let released: Map | null = null; + let released: { + nodes: Map; + threads: Map; + } | null = null; + let settleGeneration = 0; const rows = () => Array.from(parent.children) @@ -123,15 +138,17 @@ export function createSidebarListMotion(parent: HTMLUListElement) { clearFades(); positions = null; released = null; + settleGeneration++; + parent.removeAttribute(SETTLING_ATTRIBUTE); }; - const move = (node: HTMLElement, offset: number, offsetX: number) => { + const move = (node: HTMLElement, offset: number, offsetX: number, timing = motionTiming) => { cancel(node); const entry = entering.get(node); if (entry) entry.travel = 0; - if (offset === 0 && offsetX === 0 && !entry) return; + if (offset === 0 && offsetX === 0 && !entry) return undefined; const animation = node.animate( [{ transform: `translate(${offsetX}px, ${offset}px)` }, { transform: "translate(0px, 0px)" }], - motionTiming, + timing, ); running.set(node, { animation, offset, offsetX }); animation.addEventListener( @@ -141,6 +158,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { }, { once: true }, ); + return animation; }; return { @@ -172,6 +190,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { fadeCount <= MAX_FADED_ROWS_PER_UPDATE; const movedDelta = new Map(); const movedDeltaX = new Map(); + const remounted = new Map(); const nextOrder = [...next.keys()]; const oldOrder = positions === null ? [] : [...positions.keys()]; const ridingDelta = ( @@ -201,6 +220,14 @@ export function createSidebarListMotion(parent: HTMLUListElement) { } for (const [node, position] of positions!) { if (next.has(node)) continue; + const key = threadKeyOf(node); + if (key !== undefined) { + remounted.set(key, { + ...position, + top: position.top + remainingOffset(node), + left: position.left + remainingOffsetX(node), + }); + } const delta = ridingDelta(oldOrder, oldOrder.indexOf(node), (n) => next.has(n)); fadeOut(node, position, delta === undefined ? rowTravel(position.height) : -delta); } @@ -219,7 +246,12 @@ 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); + move(node, delta.y, delta.x); + } 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( @@ -247,26 +279,48 @@ export function createSidebarListMotion(parent: HTMLUListElement) { } } if (released !== null) { + const landing: Animation[] = []; if (!reducedMotion?.matches) { for (const [node, position] of next) { - const origin = released.get(node); - if (origin !== undefined) - move(node, origin.top - position.top, origin.left - position.left); + 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); + const animation = move(node, delta.y, delta.x, settleTiming); + 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() { suspend(); const origin = parent.getBoundingClientRect(); - released = new Map( - rows().map((node) => { - const rect = node.getBoundingClientRect(); - return [node, { top: rect.top - origin.top, left: rect.left - origin.left }]; - }), - ); + 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 }; }, suspend, dispose() { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b5c3ba38966b9..9835c6a9b9040 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3304,6 +3304,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
      0 && "flex-1", diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 30d34cb11ac22..43ec9c6847442 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -74,6 +74,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { textDirection } from "~/textDirection"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { SIDEBAR_SORTING_SELECTOR } from "../Sidebar.motion"; import { animateSidebarLayoutChanges, hasUnseenCompletion, @@ -433,9 +434,17 @@ function SnoozeMenuButton(props: { type SortableThreadRowBag = Pick< ReturnType, - "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" + "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" | "isOver" >; +// 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"; @@ -459,7 +468,7 @@ export function SortableThreadRow( rowProps.thread.environmentId, AuthOrchestrationOperateScope, ); - const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({ id: sortableId, disabled: sortableDisabled || !canOperateThread @@ -475,6 +484,7 @@ export function SortableThreadRow( transform: contextDrag ? null : transform, transition: contextDrag && isDragging ? "transform 150ms ease-out" : transition, isDragging: isDragging && !contextDrag, + isOver: isOver && !isDragging && !contextDrag, }; // The sidebar arranges rows from quiet shells, which hold still while a // thread streams; reading the live shell here re-renders only this row. @@ -969,20 +979,21 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP "bg-sidebar bg-linear-to-b from-sidebar-row-active to-sidebar-row-active text-sidebar-foreground opacity-100 shadow-lg", ); const sortable = props.sortable; - const sortableStyle = sortable + const sortableRootProps = sortable ? { - transform: CSS.Translate.toString(sortable.transform), - transition: sortable.transition, - // A zero-height boundary also makes dnd-kit scale the source to - // zero. Only projected peers use scaleY as a visibility sentinel. - visibility: - !sortable.isDragging && sortable.transform?.scaleY === 0 - ? ("hidden" as const) - : undefined, + ref: sortable.setNodeRef, + style: { + transform: CSS.Translate.toString(sortable.transform), + transition: sortable.transition, + // A zero-height boundary also makes dnd-kit scale the source to + // zero. Only projected peers use scaleY as a visibility sentinel. + visibility: + !sortable.isDragging && sortable.transform?.scaleY === 0 + ? ("hidden" as const) + : undefined, + }, + ...sortable.listeners, } - : undefined; - const sortableRootProps = sortable - ? { ref: sortable.setNodeRef, style: sortableStyle, ...sortable.listeners } : {}; const destinationVerb = sortable?.isDragging ? props.dropVerb @@ -1199,20 +1210,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP : props.project ? deriveProjectIdentity(props.project.title).color : null; - // The drag projection reports a grid cell's new width as scaleX; resize instead of stretching. - const projectedWidth = - sortable?.transform && !sortable.isDragging && Math.abs(sortable.transform.scaleX - 1) > 0.01 - ? `${sortable.transform.scaleX * 100}%` - : undefined; return (
    • - + - + - + Date: Tue, 3 Nov 2026 04:07:57 +0200 Subject: [PATCH 05/13] fix(web): keep pinned thread transitions above neighboring rows Avoid duplicate exit clones when a thread changes row shape, keep the moving row opaque and elevated until landing, and keep wider cards within the sidebar. Co-Authored-By: GPT-6.1 Sol --- .../components/Sidebar.drag.browser.test.ts | 7 ++- apps/web/src/components/Sidebar.motion.ts | 61 +++++++++++++------ apps/web/src/components/Sidebar.tsx | 2 +- .../components/sidebar/SidebarThreadRow.tsx | 1 + apps/web/src/index.css | 12 ++++ 5 files changed, 61 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/Sidebar.drag.browser.test.ts b/apps/web/src/components/Sidebar.drag.browser.test.ts index e918aad903802..df930a222e968 100644 --- a/apps/web/src/components/Sidebar.drag.browser.test.ts +++ b/apps/web/src/components/Sidebar.drag.browser.test.ts @@ -39,6 +39,7 @@ describe("pinned grid layout contract", () => { 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); @@ -49,16 +50,18 @@ describe("pinned grid layout contract", () => { 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); - // Hover details stay held until the drop lands. + // Menu pinning and dropping both hold hover details until the row lands. const settling = () => mounted.list.hasAttribute("data-sidebar-settling"); - expect(settling()).toBe(path === "drop"); + expect(settling()).toBe(true); for (const animation of mounted.list.getAnimations({ subtree: true })) animation.finish(); await vi.waitFor(() => expect(settling()).toBe(false)); } finally { diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index ec6af3f777fb6..b8e4b46b6475c 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -2,6 +2,7 @@ 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"; /** 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); @@ -9,8 +10,8 @@ const rowTravel = (height: number) => Math.min(height, 40); const MAX_FADED_ROWS_PER_UPDATE = 40; type RowPosition = { top: number; left: number; width: number; height: number }; -const centerDelta = (from: RowPosition, to: RowPosition) => ({ - x: from.left + from.width / 2 - (to.left + to.width / 2), +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. @@ -36,6 +37,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { let released: { nodes: Map; threads: Map; + activeKey: string | undefined; } | null = null; let settleGeneration = 0; @@ -132,6 +134,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const cancel = (node: HTMLElement) => { running.get(node)?.animation.cancel(); running.delete(node); + node.removeAttribute(LEAD_ATTRIBUTE); }; const suspend = () => { for (const node of running.keys()) cancel(node); @@ -141,11 +144,19 @@ export function createSidebarListMotion(parent: HTMLUListElement) { settleGeneration++; parent.removeAttribute(SETTLING_ATTRIBUTE); }; - const move = (node: HTMLElement, offset: number, offsetX: number, timing = motionTiming) => { + 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 && offsetX === 0 && !entry) return undefined; + if (isLead) node.setAttribute(LEAD_ATTRIBUTE, ""); const animation = node.animate( [{ transform: `translate(${offsetX}px, ${offset}px)` }, { transform: "translate(0px, 0px)" }], timing, @@ -154,7 +165,10 @@ export function createSidebarListMotion(parent: HTMLUListElement) { 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); + } }, { once: true }, ); @@ -192,6 +206,8 @@ export function createSidebarListMotion(parent: HTMLUListElement) { 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[], @@ -221,12 +237,13 @@ export function createSidebarListMotion(parent: HTMLUListElement) { for (const [node, position] of positions!) { if (next.has(node)) continue; const key = threadKeyOf(node); - if (key !== undefined) { + 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); @@ -249,8 +266,9 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const key = threadKeyOf(node); const from = key === undefined ? undefined : remounted.get(key); if (from !== undefined && position.height > 0) { - const delta = centerDelta(from, position); - move(node, delta.y, delta.x); + 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; @@ -279,7 +297,6 @@ export function createSidebarListMotion(parent: HTMLUListElement) { } } if (released !== null) { - const landing: Animation[] = []; if (!reducedMotion?.matches) { for (const [node, position] of next) { const key = threadKeyOf(node); @@ -287,23 +304,29 @@ export function createSidebarListMotion(parent: HTMLUListElement) { released.nodes.get(node) ?? (key === undefined ? undefined : released.threads.get(key)); if (origin === undefined) continue; - const delta = centerDelta(origin, position); - const animation = move(node, delta.y, delta.x, settleTiming); + 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); - }); - } + } + 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(); const nodes = new Map(); @@ -320,7 +343,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const key = threadKeyOf(node); if (key !== undefined) threads.set(key, position); } - released = { nodes, threads }; + released = { nodes, threads, activeKey }; }, suspend, dispose() { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9835c6a9b9040..1e729e06b7a6b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1738,7 +1738,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve contextDragKeyRef.current = null; endThreadContextDrag(); if (started) { - listMotionRef.current?.release(); + listMotionRef.current?.release(dragState?.activeKey); setDragState(null); } }; diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 43ec9c6847442..5512973b92171 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -982,6 +982,7 @@ 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, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index e12b2b9ea3167..1d454b379ed19 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1091,6 +1091,18 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil .sidebar-pinned-grid:empty { display: none; } + /* Inline-size containers isolate stacking; raise the group as well as its travelling tile. */ + .sidebar-pinned-grid:has(> [data-sidebar-motion-lead], > [data-sidebar-drag-source]) { + z-index: 20; + } + [data-sidebar-motion-lead] { + position: relative; + z-index: 20; + } + [data-sidebar-motion-lead] [data-sidebar-row] { + background-color: var(--sidebar); + opacity: 1; + } .sidebar-pinned-grid > * { --sidebar-pinned-columns: 1; grid-column: span 6; From 42d6fd06e6abb63bfacc056cf58d1d21d3238d0b Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 3 Nov 2026 04:18:58 +0200 Subject: [PATCH 06/13] fix(web): keep widening pin peers within the sidebar Co-Authored-By: GPT-6.1 Sol --- apps/web/src/components/Sidebar.motion.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index b8e4b46b6475c..a46deb60ce77a 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -232,7 +232,11 @@ export function createSidebarListMotion(parent: HTMLUListElement) { ) continue; movedDelta.set(node, previous.top + remainingOffset(node) - position.top); - movedDeltaX.set(node, previous.left + remainingOffsetX(node) - position.left); + 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; From 4389871e1695c1a7122317289c1731e8fee8ed65 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 3 Nov 2026 04:26:33 +0200 Subject: [PATCH 07/13] style(web): match pinned tiles to the existing sidebar theme Use the standard thread row active, selected, and hover surfaces instead of project-coloured tile backgrounds or blue active outlines. Co-Authored-By: GPT-6.1 Sol --- .../components/sidebar/SidebarThreadRow.tsx | 28 ++----------------- apps/web/src/projectIconColors.ts | 8 ------ 2 files changed, 3 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 5512973b92171..7c6ff25dd15d0 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -103,8 +103,6 @@ import { import { pickSnoozePreset, resolveSnoozePresets, type SnoozePreset } from "../Sidebar.snooze"; import { useThreadShell } from "../../state/entities"; import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; -import { deriveProjectIdentity } from "../../projectIdentity"; -import { projectIconSwatchClassName } from "../../projectIconColors"; import { ThreadSearchMatchExcerpt } from "../ThreadSearchMatch"; import { makeWorkspaceFileDropHandlers } from "../chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; @@ -1204,13 +1202,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null; if (variant === "tile") { - const icon = props.project?.projectIcon; - const tint = - icon && "color" in icon - ? icon.color - : props.project - ? deriveProjectIdentity(props.project.title).color - : null; return (
    • {accessibleTitle} - {tint && !props.isActive && !isSelected ? ( - - ) : null}
      {props.project ? ( diff --git a/apps/web/src/projectIconColors.ts b/apps/web/src/projectIconColors.ts index 809a08c2da48c..0cf071b4debe4 100644 --- a/apps/web/src/projectIconColors.ts +++ b/apps/web/src/projectIconColors.ts @@ -123,11 +123,3 @@ const PROJECT_ICON_COLOR_CLASSES = Object.fromEntries( export function projectIconColorClassName(color: ProjectIconColor): string { return PROJECT_ICON_COLOR_CLASSES[color]; } - -const PROJECT_ICON_SWATCH_CLASSES = Object.fromEntries( - PROJECT_ICON_COLORS.map(({ value, swatchClassName }) => [value, swatchClassName]), -) as Record; - -export function projectIconSwatchClassName(color: ProjectIconColor): string { - return PROJECT_ICON_SWATCH_CLASSES[color]; -} From 37534bf380a697b9cff2f5acd2d131b2e0be6962 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 3 Nov 2026 04:48:38 +0200 Subject: [PATCH 08/13] feat(web): preview pinned tile reordering live Keep collision targets tied to stable grid slots while peers move into their preview positions. Resize the dragged tile to its destination without stretching its text, and show the existing pin action on wide tiles. Co-Authored-By: GPT-6.1 Sol --- apps/web/src/components/Sidebar.drag.test.ts | 83 ++++++++++++++++++- apps/web/src/components/Sidebar.drag.ts | 63 ++++++++++++++ apps/web/src/components/Sidebar.tsx | 42 +++++++++- .../components/sidebar/SidebarThreadRow.tsx | 30 +++++-- docs/user/thread-sidebar.md | 4 +- 5 files changed, 206 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index 50b818d25cb4d..e97eeb9653aad 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 = [ @@ -715,7 +768,15 @@ describe("sidebar drag projection", () => { }); describe("pinned grid projection", () => { - const grid = { columns: 3, rowHeight: 46, gap: 4, leading: 0, trailing: 8 }; + const grid = { + columns: 3, + rowHeight: 46, + gap: 4, + leading: 0, + trailing: 8, + width: 260, + labelHeight: 0, + }; const items = [ pinnedHeader, thread("p1", "pinned"), @@ -788,9 +849,15 @@ describe("pinned grid projection", () => { ); } - // Peers that slide and resize under the pointer read as a shuffle; the drop reorders them once. + 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([ - ["p4", "p1", "a tile crosses rows"], ["a1", "p2", "a card comes in"], ["p1", "a1", "a pin leaves"], ])("holds every pin in its cell while %s hovers %s (%s)", (active, over) => { @@ -806,7 +873,15 @@ it("opens one grid row for a first pin", () => { items, settledOrder: [], settledExpanded: true, - pinnedGrid: { columns: 3, rowHeight: 46, gap: 4, leading: 0, trailing: 8 }, + pinnedGrid: { + columns: 3, + rowHeight: 46, + gap: 4, + leading: 0, + trailing: 8, + width: 260, + labelHeight: 0, + }, }); const args = layout(items, "a2", sidebarMarkerId("pinned-divider")); expect(strategy({ ...args, index: 1 })?.y).toBe(46 + 8 + 1); diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index eab8a14d4b9ef..349f82302e015 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -34,23 +34,50 @@ 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) => { const pointer = args.pointerCoordinates; + let droppableRects = args.droppableRects; + const grid = options.pinnedGrid; + const header = args.droppableRects.get(sidebarMarkerId("pinned-header")); + if (grid && header && pins.some((item) => item.key === args.active.id)) { + 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, @@ -104,6 +131,8 @@ 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; @@ -111,10 +140,22 @@ export type SidebarPinnedGrid = { 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); @@ -125,6 +166,8 @@ export function measureSidebarPinnedGrid( : 1; const px = (value: string) => (Number.parseFloat(value) || 0) * scale; return { + width: grid.getBoundingClientRect().width, + labelHeight, columns: Number.isFinite(columns) && columns > 0 ? columns : 1, rowHeight: rowHeight * scale, gap: px(style.rowGap), @@ -240,6 +283,8 @@ export function createSidebarSortingStrategy(input: { section("settled"); const pinnedGrid = input.pinnedGrid ?? { columns: 1, + width: rects[0].width, + labelHeight: 0, rowHeight: pinnedHeight ?? cardHeight, gap: 1, leading: 0, @@ -308,6 +353,24 @@ export function createSidebarSortingStrategy(input: { 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 && active.section === "pinned" && target.section === "pinned") { + for (const [position, pin] of groups.pinned.entries()) { + const pinIndex = indices.get(pin.key); + if (pinIndex === undefined) 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; } } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1e729e06b7a6b..fd4c33e57d964 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -217,6 +217,7 @@ import { import { createSidebarCollisionDetection, measureSidebarPinnedGrid, + sidebarPinnedGridCell, type SidebarPinnedGrid, createSidebarSortingStrategy, restrictBelowSidebarLabel, @@ -1071,6 +1072,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve readonly targetSection: SidebarSection | null; readonly contextDrag: boolean; readonly pinnedGrid: SidebarPinnedGrid | undefined; + readonly pinnedTargetIndex: number | null; + readonly grabOffsetX: number; } | null>(null); const dragSensorRef = useRef(null); const contextDragKeyRef = useRef(null); @@ -1922,7 +1925,15 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve activeSection, targetSection: activeSection, contextDrag: false, - pinnedGrid: grid ? measureSidebarPinnedGrid(grid, scale) : undefined, + 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 - + (event.active.rect.current.initial?.left ?? event.activatorEvent.clientX) + : 0, occurredAt: new Date().toISOString(), activationY: event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, @@ -2021,10 +2032,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); @@ -2054,6 +2070,22 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const draggedThreadKey = dragState?.activeKey; const draggedFromSection = dragState?.activeSection; const dragActivationY = dragState?.activationY; + const pinnedDragPreview = (() => { + const grid = dragState?.pinnedGrid; + if ( + !grid || + dragState.activeSection !== "pinned" || + dragState.targetSection !== "pinned" || + dragState.pinnedTargetIndex === null + ) + return undefined; + const from = pinnedKeys.indexOf(dragState.activeKey); + if (from < 0 || dragState.pinnedTargetIndex < 0) return undefined; + const widthRatio = + sidebarPinnedGridCell(grid, dragState.pinnedTargetIndex, pinnedKeys.length).width / + sidebarPinnedGridCell(grid, from, pinnedKeys.length).width; + return { widthRatio, offsetX: dragState.grabOffsetX * (1 - widthRatio) }; + })(); const dndCollisionDetection = (() => { if (draggedThreadKey === undefined || draggedFromSection === undefined) return createSidebarCollisionDetection(() => true); @@ -2084,6 +2116,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve { items: sidebarListItems, activationY: dragActivationY ?? null, + pinnedGrid: dragState?.pinnedGrid, }, ); })(); @@ -3336,6 +3369,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve key={threadKey} sortableId={threadKey} contextDrag={isContextDrag} + pinnedDragPreview={ + threadKey === draggedThreadKey ? pinnedDragPreview : undefined + } sortableDisabled={ projectFolders !== null || renamingThreadKey === threadKey || diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 7c6ff25dd15d0..51b166184c584 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -432,7 +432,7 @@ function SnoozeMenuButton(props: { type SortableThreadRowBag = Pick< ReturnType, - "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" | "isOver" + "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" >; // A drag and its drop settle hold hover details, so a popup never covers where a row lands. @@ -466,7 +466,7 @@ export function SortableThreadRow( rowProps.thread.environmentId, AuthOrchestrationOperateScope, ); - const { listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({ + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: sortableId, disabled: sortableDisabled || !canOperateThread @@ -479,10 +479,17 @@ export function SortableThreadRow( const bag = { listeners, setNodeRef, - transform: contextDrag ? null : transform, + transform: contextDrag + ? null + : transform && isDragging && rowProps.pinnedDragPreview + ? { + ...transform, + x: transform.x + rowProps.pinnedDragPreview.offsetX, + scaleX: rowProps.pinnedDragPreview.widthRatio, + } + : transform, transition: contextDrag && isDragging ? "transform 150ms ease-out" : transition, isDragging: isDragging && !contextDrag, - isOver: isOver && !isDragging && !contextDrag, }; // The sidebar arranges rows from quiet shells, which hold still while a // thread streams; reading the live shell here re-renders only this row. @@ -555,6 +562,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; @@ -984,6 +992,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP style: { transform: CSS.Translate.toString(sortable.transform), transition: sortable.transition, + width: + variant === "tile" && + sortable.transform && + Math.abs(sortable.transform.scaleX - 1) > 0.001 + ? `${sortable.transform.scaleX * 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: @@ -1225,9 +1239,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className={cn( rowSurfaceClassName, "flex h-full flex-col justify-center gap-0.5 px-2", - // The dragged tile takes this cell on drop; peers shift only then. - sortable?.isOver && - "outline-2 -outline-offset-2 outline-dashed outline-sidebar-foreground/35", )} onClick={handleClick} onDoubleClick={handleDoubleClick} @@ -1247,6 +1258,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {props.projectDisplayName} ) : null} + {pinIndicator ? ( + + {pinIndicator} + + ) : null} {sortable?.isDragging ? ( dragDestination ) : jumpHintLabel ? ( diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 9ae51756a38a7..9762843216238 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -83,8 +83,8 @@ thread into the active list un-settles it. A snoozed thread can be dragged out o 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. List -rows slide aside to show where the thread will land, while pinned tiles hold still and outline -the tile whose spot it takes, then shift once you drop. When you cross into another section, +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 From e0b22b0875568f4edd179318eddba0dbeab1e350 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 3 Nov 2026 06:56:59 +0200 Subject: [PATCH 09/13] fix(web): fit pin previews to their destination cells Reserve an empty pin target before entry, preview incoming cards as tiles, and move neighboring pins into their destination cells without snapping their widths. Co-Authored-By: GPT-6.1 Sol --- apps/web/src/components/Sidebar.drag.test.ts | 37 +++++++++++++++---- apps/web/src/components/Sidebar.drag.ts | 21 +++++++---- apps/web/src/components/Sidebar.tsx | 24 +++++++----- .../components/sidebar/SidebarThreadRow.tsx | 17 +++++---- 4 files changed, 67 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index e97eeb9653aad..c106dfcbad4b7 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -860,15 +860,35 @@ describe("pinned grid projection", () => { it.each([ ["a1", "p2", "a card comes in"], ["p1", "a1", "a pin leaves"], - ])("holds every pin in its cell while %s hovers %s (%s)", (active, over) => { + ])("projects pinned cells while %s hovers %s (%s)", (active, over) => { const result = gridPreview(active, over); - for (const key of ["p1", "p2", "p3", "p4"]) expect(result.get(key), key).toEqual(stationary); + 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("opens one grid row for a first pin", () => { - const items = [pinnedHeader, divider, thread("a1", "active"), thread("a2", "active")]; +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: [], @@ -883,9 +903,12 @@ it("opens one grid row for a first pin", () => { labelHeight: 0, }, }); - const args = layout(items, "a2", sidebarMarkerId("pinned-divider")); - expect(strategy({ ...args, index: 1 })?.y).toBe(46 + 8 + 1); - expect(strategy({ ...args, index: 2 })?.y).toBe(46 + 8 + 1); + 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", () => { diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index 349f82302e015..a0f07b53eb218 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -50,7 +50,7 @@ export function createSidebarCollisionDetection( let droppableRects = args.droppableRects; const grid = options.pinnedGrid; const header = args.droppableRects.get(sidebarMarkerId("pinned-header")); - if (grid && header && pins.some((item) => item.key === args.active.id)) { + 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); @@ -166,7 +166,11 @@ export function measureSidebarPinnedGrid( : 1; const px = (value: string) => (Number.parseFloat(value) || 0) * scale; return { - width: grid.getBoundingClientRect().width, + 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, @@ -290,13 +294,16 @@ export function createSidebarSortingStrategy(input: { leading: 0, trailing: 0, }; - // Pins hold their cells until the drop, so the grid keeps its resting rows; only an empty grid - // opens one for an incoming pin. Moving peers mid-drag read as a shuffle. + // 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" ? 1 : 0) / pinnedGrid.columns, + 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 = @@ -353,10 +360,10 @@ export function createSidebarSortingStrategy(input: { 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 && active.section === "pinned" && target.section === "pinned") { + if (input.pinnedGrid && target.section === "pinned") { for (const [position, pin] of groups.pinned.entries()) { const pinIndex = indices.get(pin.key); - if (pinIndex === undefined) continue; + if (pinIndex === undefined || pinIndex === activeIndex) continue; const from = sidebarPinnedGridCell( input.pinnedGrid, restingPins.indexOf(pinIndex), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d0fcea72bf735..fe95d3012b992 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1074,6 +1074,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve readonly pinnedGrid: SidebarPinnedGrid | undefined; readonly pinnedTargetIndex: number | null; readonly grabOffsetX: number; + readonly sourceWidth: number; } | null>(null); const dragSensorRef = useRef(null); const contextDragKeyRef = useRef(null); @@ -1934,6 +1935,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ? event.activatorEvent.clientX - (event.active.rect.current.initial?.left ?? event.activatorEvent.clientX) : 0, + sourceWidth: event.active.rect.current.initial?.width ?? listRect?.width ?? 0, occurredAt: new Date().toISOString(), activationY: event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, @@ -2072,18 +2074,19 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const dragActivationY = dragState?.activationY; const pinnedDragPreview = (() => { const grid = dragState?.pinnedGrid; - if ( - !grid || - dragState.activeSection !== "pinned" || - dragState.targetSection !== "pinned" || - dragState.pinnedTargetIndex === null - ) + if (!grid || dragState.targetSection !== "pinned" || dragState.pinnedTargetIndex === null) return undefined; const from = pinnedKeys.indexOf(dragState.activeKey); - if (from < 0 || dragState.pinnedTargetIndex < 0) return undefined; + if (dragState.pinnedTargetIndex < 0) return undefined; + const sourceWidth = + from < 0 ? dragState.sourceWidth : sidebarPinnedGridCell(grid, from, pinnedKeys.length).width; + if (sourceWidth <= 0) return undefined; const widthRatio = - sidebarPinnedGridCell(grid, dragState.pinnedTargetIndex, pinnedKeys.length).width / - sidebarPinnedGridCell(grid, from, pinnedKeys.length).width; + sidebarPinnedGridCell( + grid, + dragState.pinnedTargetIndex, + pinnedKeys.length + (from < 0 ? 1 : 0), + ).width / sourceWidth; return { widthRatio, offsetX: dragState.grabOffsetX * (1 - widthRatio) }; })(); const dndCollisionDetection = (() => { @@ -3361,7 +3364,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ? folderRowsMultiline ? "folder-multiline" : "folder" - : section === "pinned" + : section === "pinned" || + (threadKey === draggedThreadKey && dragTargetSection === "pinned") ? "tile" : isCard ? "card" diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 88c069dc8584c..fb4079e74442b 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -991,13 +991,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP "data-sidebar-drag-source": sortable.isDragging ? "" : undefined, style: { transform: CSS.Translate.toString(sortable.transform), - transition: sortable.transition, - width: - variant === "tile" && - sortable.transform && - Math.abs(sortable.transform.scaleX - 1) > 0.001 - ? `${sortable.transform.scaleX * 100}%` - : undefined, + 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: @@ -1221,7 +1219,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP data-thread-item={threadKey} {...sortableRootProps} {...(fileDropHandlers ?? {})} - className={cn("@container/sidebar-tile list-none", sortable?.isDragging && "relative z-20")} + className={cn( + "@container/sidebar-tile list-none motion-reduce:transition-none!", + sortable?.isDragging && "relative z-20 h-[2.875rem]", + )} > Date: Tue, 3 Nov 2026 07:31:54 +0200 Subject: [PATCH 10/13] fix(web): keep unpin previews at their pickup width Only the explicit grid destination may resize an active pin. Ignore dnd-kit target scaling while dragging toward Active so the preview cannot oscillate between tile and row widths. Co-Authored-By: GPT-6.1 Sol --- apps/web/src/components/sidebar/SidebarThreadRow.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index fb4079e74442b..c8de6b7deed47 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -481,11 +481,11 @@ export function SortableThreadRow( setNodeRef, transform: contextDrag ? null - : transform && isDragging && rowProps.pinnedDragPreview + : transform && isDragging ? { ...transform, - x: transform.x + rowProps.pinnedDragPreview.offsetX, - scaleX: rowProps.pinnedDragPreview.widthRatio, + x: transform.x + (rowProps.pinnedDragPreview?.offsetX ?? 0), + scaleX: rowProps.pinnedDragPreview?.widthRatio ?? 1, } : transform, transition: contextDrag && isDragging ? "transform 150ms ease-out" : transition, From 58f9bfe4ff8fad752cb8d9ea6b8f7ecf9b1bb151 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 3 Nov 2026 07:46:49 +0200 Subject: [PATCH 11/13] fix(web): keep the released pin in the foreground Read the active thread from the live drag reference before cleanup, so a sensor created before drag state exists still gives the landing animation its lead identity. Co-Authored-By: GPT-6.1 Sol --- apps/web/src/components/Sidebar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fe95d3012b992..d360eac7d1aa1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1738,11 +1738,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(dragState?.activeKey); + listMotionRef.current?.release(activeKey); setDragState(null); } }; From 476b9c61e66e319849336b44a6d6d85aaabb8ff6 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 3 Nov 2026 08:54:16 +0200 Subject: [PATCH 12/13] fix(web): keep moving sidebar peers opaque Cover row text beneath finite reflow animations and clear the moving marker when the animation ends or is cancelled. Co-Authored-By: GPT-6.1 Sol --- apps/web/src/components/Sidebar.motion.ts | 4 ++++ apps/web/src/index.css | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index a46deb60ce77a..7302dbdf8e3b1 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -3,6 +3,7 @@ const motionTiming = { duration: 150, easing: "ease-out" }; 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); @@ -135,6 +136,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { 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); @@ -157,6 +159,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { if (entry) entry.travel = 0; if (offset === 0 && offsetX === 0 && !entry) return undefined; if (isLead) node.setAttribute(LEAD_ATTRIBUTE, ""); + node.setAttribute(MOVING_ATTRIBUTE, ""); const animation = node.animate( [{ transform: `translate(${offsetX}px, ${offset}px)` }, { transform: "translate(0px, 0px)" }], timing, @@ -168,6 +171,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { if (running.get(node)?.animation === animation) { running.delete(node); node.removeAttribute(LEAD_ATTRIBUTE); + node.removeAttribute(MOVING_ATTRIBUTE); } }, { once: true }, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 1d454b379ed19..bc9a7c1ff44e6 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1092,14 +1092,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil display: none; } /* Inline-size containers isolate stacking; raise the group as well as its travelling tile. */ - .sidebar-pinned-grid:has(> [data-sidebar-motion-lead], > [data-sidebar-drag-source]) { + .sidebar-pinned-grid:has(> [data-sidebar-motion-moving], > [data-sidebar-drag-source]) { z-index: 20; } [data-sidebar-motion-lead] { position: relative; z-index: 20; } - [data-sidebar-motion-lead] [data-sidebar-row] { + [data-sidebar-motion-moving] [data-sidebar-row] { background-color: var(--sidebar); opacity: 1; } From 85ed893fa0d8bfab4964317d8f918c6c86fccd49 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 3 Nov 2026 09:41:25 +0200 Subject: [PATCH 13/13] fix(web): align compact drag previews with their targets Measure the source before dnd-kit has populated its first rectangle, then constrain the destination-sized preview with its grab offset already applied. Keep displaced pins opaque while sorting. Co-Authored-By: GPT-6.1 Sol --- .../components/Sidebar.drag.browser.test.ts | 47 ++++++++++++++++++- apps/web/src/components/Sidebar.drag.ts | 35 ++++++++++++++ apps/web/src/components/Sidebar.tsx | 37 ++++++++------- .../components/sidebar/SidebarThreadRow.tsx | 1 - apps/web/src/index.css | 5 ++ 5 files changed, 107 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/Sidebar.drag.browser.test.ts b/apps/web/src/components/Sidebar.drag.browser.test.ts index df930a222e968..94bb08e87880b 100644 --- a/apps/web/src/components/Sidebar.drag.browser.test.ts +++ b/apps/web/src/components/Sidebar.drag.browser.test.ts @@ -2,7 +2,12 @@ import "../index.css"; import { describe, expect, it, vi } from "vite-plus/test"; import type { SortingStrategy } from "@dnd-kit/sortable"; -import { createSidebarSortingStrategy, measureSidebarPinnedGrid } from "./Sidebar.drag"; +import { + createSidebarSortingStrategy, + measureSidebarPinnedGrid, + restrictSidebarDrag, + resolveSidebarDragSourceRect, +} from "./Sidebar.drag"; import { sidebarListItemId, type SidebarListItem } from "./Sidebar.logic"; import { createSidebarListMotion } from "./Sidebar.motion"; @@ -21,6 +26,46 @@ function mountPinnedGrid(width: number, count: number) { } 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); diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index a0f07b53eb218..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]; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d360eac7d1aa1..b0dcd10d457ea 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, @@ -221,6 +221,8 @@ import { type SidebarPinnedGrid, createSidebarSortingStrategy, restrictBelowSidebarLabel, + restrictSidebarDrag, + resolveSidebarDragSourceRect, } from "./Sidebar.drag"; import { SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; @@ -1705,11 +1707,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve dragLabelOffsetRef.current, threadListRef.current?.getBoundingClientRect().top, ); - // Pinned tiles reorder across grid columns; list rows stay in their column. - const dndModifiers = - dragState?.activeSection === "pinned" - ? [restrictBelowPins, restrictToFirstScrollableAncestor] - : [restrictToVerticalAxis, restrictBelowPins, restrictToFirstScrollableAncestor]; const attachListMotionRef = (node: HTMLUListElement) => { threadListRef.current = node; const motion = createSidebarListMotion(node); @@ -1915,6 +1912,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const header = list?.querySelector('[data-testid="sidebar-pinned-header"]'); 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 = @@ -1933,10 +1934,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve pinnedTargetIndex: activeSection === "pinned" ? pinnedKeys.indexOf(activeKey) : null, grabOffsetX: event.activatorEvent instanceof PointerEvent - ? event.activatorEvent.clientX - - (event.active.rect.current.initial?.left ?? event.activatorEvent.clientX) + ? event.activatorEvent.clientX - (sourceRect?.left ?? event.activatorEvent.clientX) : 0, - sourceWidth: event.active.rect.current.initial?.width ?? listRect?.width ?? 0, + sourceWidth: sourceRect?.width ?? listRect?.width ?? 0, occurredAt: new Date().toISOString(), activationY: event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, @@ -2082,14 +2082,19 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const sourceWidth = from < 0 ? dragState.sourceWidth : sidebarPinnedGridCell(grid, from, pinnedKeys.length).width; if (sourceWidth <= 0) return undefined; - const widthRatio = - sidebarPinnedGridCell( - grid, - dragState.pinnedTargetIndex, - pinnedKeys.length + (from < 0 ? 1 : 0), - ).width / sourceWidth; - return { widthRatio, offsetX: dragState.grabOffsetX * (1 - widthRatio) }; + 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); diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index c8de6b7deed47..ea8a6bd95086b 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -484,7 +484,6 @@ export function SortableThreadRow( : transform && isDragging ? { ...transform, - x: transform.x + (rowProps.pinnedDragPreview?.offsetX ?? 0), scaleX: rowProps.pinnedDragPreview?.widthRatio ?? 1, } : transform, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index bc9a7c1ff44e6..08efc105cea96 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1103,6 +1103,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil 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;