From 71645c5db8d79bd2ae7d90884d1160c55ea8a56a Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 14 Oct 2026 19:06:03 -0500 Subject: [PATCH 1/7] feat(web): pinned threads collapse like the sidebar shelves Pinned threads sit above the inbox with no way to fold them away, so a long pin list pushes active threads down. Pinned now gets the same collapsible header as Working, Snoozed and Settled: expanded by default, "Pinned (N)" when collapsed, remembered per browser. The open thread stays visible while collapsed, like the shelves' route exception. Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 68 ++++++++++++++++++++++++++--- docs/user/thread-sidebar.md | 6 ++- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5771a1100a10f..9add5125a6545 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -307,6 +307,7 @@ const SETTLED_TAIL_PAGE_COUNT = 25; const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded"; +const PINNED_SHELF_EXPANDED_KEY = "t3code:sidebar:pinned-expanded"; // Working beta: when this client saw each thread leave the Working shelf. // Module scope keeps the inbox order across routes that unmount the sidebar. @@ -3084,16 +3085,37 @@ export default function Sidebar() { return routeThread === undefined ? EMPTY_THREADS : [routeThread]; }, [routeThreadKey, workingShelfExpanded, workingThreads]); + // Pinned collapses like the shelves, with the same route exception, but + // starts expanded so pins stay visible until someone folds them away. + const [pinnedShelfExpanded, setPinnedShelfExpanded] = useLocalStorage( + PINNED_SHELF_EXPANDED_KEY, + true, + Schema.Boolean, + ); + const togglePinnedShelf = useCallback( + () => setPinnedShelfExpanded((value) => !value), + [setPinnedShelfExpanded], + ); + const visiblePinnedThreads = useMemo(() => { + if (pinnedShelfExpanded) return pinnedThreads; + if (routeThreadKey === null) return EMPTY_THREADS; + const routeThread = pinnedThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + return routeThread === undefined ? EMPTY_THREADS : [routeThread]; + }, [pinnedShelfExpanded, pinnedThreads, routeThreadKey]); + const orderedThreads = useMemo( () => [ - ...pinnedThreads, + ...visiblePinnedThreads, ...activeThreads, ...visibleWorkingThreads, ...visibleSnoozedThreads, ...renderedSettledThreads, ], [ - pinnedThreads, + visiblePinnedThreads, activeThreads, visibleWorkingThreads, visibleSnoozedThreads, @@ -3654,12 +3676,14 @@ export default function Sidebar() { }, [attemptUnsettle, attemptUnsnooze, serverConfigs, settleThreads], ); + // The drop order holds the pinned rows on screen. A collapsed shelf hides + // its rows like a filter does, and their keys stay reserved in pinnedKeysById. const pinnedKeys = useMemo( () => - pinnedThreads.map((thread) => + visiblePinnedThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), - [pinnedThreads], + [visiblePinnedThreads], ); const activeKeys = useMemo( () => @@ -3830,7 +3854,7 @@ export default function Sidebar() { return []; } const items: SidebarListItem[] = [{ kind: "marker", marker: "pinned-header" }]; - const pinnedRows = rowsOf(pinnedThreads, "pinned"); + const pinnedRows = rowsOf(visiblePinnedThreads, "pinned"); items.push(...pinnedRows); items.push({ kind: "marker", marker: "pinned-divider" }); const activeRows = rowsOf(activeThreads, "active"); @@ -3851,10 +3875,11 @@ export default function Sidebar() { return items; }, [ activeThreads, - pinnedThreads, + pinnedThreads.length, renderedSettledThreads, settledThreads.length, snoozedThreads.length, + visiblePinnedThreads, visibleSnoozedThreads, visibleWorkingThreads, workingThreads.length, @@ -5450,12 +5475,41 @@ export default function Sidebar() { } switch (item.marker) { case "pinned-header": + // The toggle sits outside the sortable order, so the drag + // boundary below keeps its geometry. Its label would repeat + // the toggle's, so it shows only when nothing is pinned. + if (pinnedThreads.length > 0) { + items.push( +
  • + + {pinnedShelfExpanded + ? "Pinned" + : `Pinned (${pinnedThreads.length})`} + +
  • , + ); + } items.push( , ); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 2a9735f61c6da..0b450113cb1d3 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -45,7 +45,8 @@ and worktree while you stay in the new thread composer. This requires a Git proj ## Pin and reorder threads -Pin a thread from its menu to keep it above your active work. +Pin a thread from its menu to keep it above your active work. On web and desktop, click the +**Pinned** header to collapse the pinned section to a count; the thread you have open stays visible. On web and desktop, unpinning, settling, snoozing, and archiving a thread each show a notification with **Undo** for five seconds. Undo restores the thread's previous @@ -70,7 +71,8 @@ list to unpin it. Dragging a thread onto the **Settled** header settles it, and thread into the active list un-settles it. A snoozed thread can be dragged out of the snoozed shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation. -Pinned and active boundary labels appear only while dragging, without moving the rows. The +The active boundary label, and the pinned one when nothing is pinned, appear only while dragging, +without moving the rows. The other rows slide aside to show where the thread will land. When you cross into another section, 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 From e5e8e2488a364cd24b0ce7634e7f600132df9055 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:56:03 +0000 Subject: [PATCH 2/7] feat(web): rebuild collapsible pinned shelf on current sidebar --- apps/web/src/components/Sidebar.drag.test.ts | 59 +++++++++++++++++ apps/web/src/components/Sidebar.drag.ts | 24 +++++++ apps/web/src/components/Sidebar.logic.test.ts | 45 +++++++++++++ apps/web/src/components/Sidebar.logic.ts | 17 ++++- apps/web/src/components/Sidebar.tsx | 66 +++++++++++++++++-- docs/user/thread-sidebar.md | 4 +- 6 files changed, 204 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index 261bbfdcb5e84..392ca2f4e4009 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -7,6 +7,7 @@ import { restrictBelowSidebarLabel, } from "./Sidebar.drag"; import { + planSidebarThreadDrop, resolveSidebarDropTarget, sidebarListItemId, sidebarMarkerId, @@ -124,6 +125,64 @@ describe("sidebar collision detection", () => { expect(detector(collisionArgs())[0]?.id).toBe("blocked"); }); + it("prepends a drop on the collapsed toggle despite the clamped card overlapping the divider", () => { + const items = [pinnedHeader, thread("p2", "pinned"), divider, thread("source", "active")]; + const { rects, activeIndex } = layout(items, "source", sidebarMarkerId("pinned-divider")); + const sourceRect = rects[activeIndex]!; + const collisionRect = { ...sourceRect, top: 124, bottom: 206 }; + const headerNode = { + previousElementSibling: { + querySelector: () => ({ + getBoundingClientRect: () => ({ top: 68, bottom: 100, left: 0, right: 260 }), + }), + }, + } as unknown as HTMLElement; + const args = { + ...collisionArgs(), + active: { + id: "source", + data: { current: {} }, + rect: { current: { initial: sourceRect, translated: collisionRect } }, + }, + collisionRect, + pointerCoordinates: { x: 130, y: 84 }, + droppableRects: new Map(items.map((item, index) => [sidebarListItemId(item), rects[index]!])), + droppableContainers: items.map((item, index) => ({ + id: sidebarListItemId(item), + key: sidebarListItemId(item), + disabled: false, + data: { current: {} }, + node: { current: item === pinnedHeader ? headerNode : null }, + rect: { current: rects[index]! }, + })), + }; + expect(closestCenter(args)[0]?.id).toBe(sidebarMarkerId("pinned-divider")); + const detector = createSidebarCollisionDetection(() => true, { + items, + pinnedCollapsed: true, + }); + const overId = String(detector(args)[0]?.id); + expect(overId).toBe(sidebarMarkerId("pinned-header")); + const plan = planSidebarThreadDrop({ + activeKey: "source", + activeSection: "active", + target: resolveSidebarDropTarget(items, "source", overId, ["p1", "p2", "p3"])!, + pinnedOrder: ["p2"], + pinnedKeysById: new Map([ + ["p1", "gg"], + ["p2", "nn"], + ["p3", "tt"], + ]), + reorderableKeys: new Set(["source"]), + activeOrder: ["source"], + activeKeysById: new Map(), + }); + expect(plan.kind).toBe("pin"); + if (plan.kind !== "pin") return; + expect(plan.orderKey! < "gg").toBe(true); + expect(plan.extraAssignments).toEqual([]); + }); + it.each([ { sourceSection: "active", pins: 0 }, { sourceSection: "active", pins: 1 }, diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index 71fe8e6c2e019..fedc09aa356b3 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -33,6 +33,7 @@ export function createSidebarCollisionDetection( options: { items?: readonly SidebarListItem[]; activationY?: number | null; + pinnedCollapsed?: boolean; } = {}, ): CollisionDetection { const validity = new Map(); @@ -80,6 +81,29 @@ export function createSidebarCollisionDetection( } } } + // The clamped card stays below the toggle, so its center can select the divider. + const headerRect = + pointer && options.pinnedCollapsed + ? args.droppableContainers + .find((container) => container.id === sidebarMarkerId("pinned-header")) + ?.node.current?.previousElementSibling?.querySelector( + '[data-testid="sidebar-pinned-shelf-toggle"]', + ) + ?.getBoundingClientRect() + : undefined; + if ( + pointer && + headerRect && + pointer.x >= headerRect.left && + pointer.x <= headerRect.right && + pointer.y >= headerRect.top && + pointer.y <= headerRect.bottom + ) { + const header = collisions.find( + (collision) => collision.id === sidebarMarkerId("pinned-header"), + ); + if (header) collisions = [header, ...collisions.filter((collision) => collision !== header)]; + } const nearest = collisions[0]; if (!nearest || nearest.id === args.active.id) { return collisions; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 4883c690bb3df..9ef6c7865acb0 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -48,6 +48,7 @@ import { sortLogicalProjectsForSidebar, sortInboxThreadsByReturn, resolveSidebarDropTarget, + sidebarMarkerId, planSidebarThreadDrop, sortPinnedThreadsForSidebar, sortProjectsForSidebar, @@ -2340,6 +2341,50 @@ describe("Working shelf (beta)", () => { }, ); + it.each([null, "p2"])( + "prepends a collapsed-header drop before every pin with routed pin %s", + (routedPin) => { + const visiblePins = routedPin === null ? [] : [routedPin]; + const pinnedKeysById = new Map([ + ["p1", "gg"], + ["p2", "nn"], + ["p3", "tt"], + ]); + const target = resolveSidebarDropTarget( + [ + marker("pinned-header"), + ...visiblePins.map((key) => row(key, "pinned")), + marker("pinned-divider"), + row("a1", "active"), + ], + "a1", + sidebarMarkerId("pinned-header"), + ["p1", "p2", "p3"], + ); + expect(target).not.toBeNull(); + const plan = planSidebarThreadDrop({ + activeKey: "a1", + activeSection: "active", + target: target!, + pinnedOrder: visiblePins, + pinnedKeysById, + reorderableKeys: new Set(["a1", ...visiblePins]), + activeOrder: ["a1"], + activeKeysById: new Map(), + }); + expect(plan.kind).toBe("pin"); + if (plan.kind !== "pin") return; + expect(plan.extraAssignments).toEqual([]); + expect(plan.order).toEqual(["a1", ...visiblePins]); + const persistedKeys = new Map(pinnedKeysById); + persistedKeys.set("a1", plan.orderKey!); + expect([...persistedKeys].toSorted((a, b) => a[1].localeCompare(b[1]))).toEqual([ + ["a1", expect.any(String)], + ...pinnedKeysById, + ]); + }, + ); + it("arranges the rows it can write when another row's server cannot store an order", () => { const plan = planSidebarThreadDrop({ activeKey: "a2", diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f10c58fa0de16..b6912c2ea18b4 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -170,6 +170,7 @@ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): export type SidebarDropTarget = { readonly section: "pinned" | "active" | "settled"; readonly pinnedOrder: readonly string[]; + readonly pinnedKeyOrder?: readonly string[]; readonly activeOrder: readonly string[]; }; @@ -177,6 +178,7 @@ export function resolveSidebarDropTarget( items: readonly SidebarListItem[], activeKey: string, overId: string, + collapsedPinnedOrder?: readonly string[], ): SidebarDropTarget | null { const activeIndex = items.findIndex((item) => sidebarListItemId(item) === activeKey); const overIndex = items.findIndex((item) => sidebarListItemId(item) === overId); @@ -200,7 +202,16 @@ export function resolveSidebarDropTarget( } else if (currentSection === "pinned") pinnedOrder.push(item.key); else activeOrder.push(item.key); } - return { section, pinnedOrder, activeOrder }; + return { + section, + pinnedOrder, + activeOrder, + ...(overId === sidebarMarkerId("pinned-header") && + collapsedPinnedOrder !== undefined && + collapsedPinnedOrder[0] !== activeKey + ? { pinnedKeyOrder: [activeKey, ...collapsedPinnedOrder.filter((key) => key !== activeKey)] } + : {}), + }; } export type SidebarThreadDropPlan = @@ -347,13 +358,15 @@ export function planSidebarThreadDrop(input: { case "pinned": { const order = target.pinnedOrder; if ( + target.pinnedKeyOrder === undefined && activeSection === "pinned" && order.length === pinnedOrder.length && order.every((key, index) => key === pinnedOrder[index]) ) { return { kind: "none" }; } - const assignments = arrange(order, pinnedKeysById, reorderableKeys); + // A collapsed header means the start of the full run, including hidden pins. + const assignments = arrange(target.pinnedKeyOrder ?? order, pinnedKeysById, reorderableKeys); if (assignments === null) return { kind: "none" }; if (activeSection === "pinned") { return assignments.length === 0 diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 81eb57b37ddc3..541cb54789263 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -366,6 +366,7 @@ const SETTLED_TAIL_PAGE_COUNT = 25; const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded"; +const PINNED_SHELF_EXPANDED_KEY = "t3code:sidebar:pinned-expanded"; const inboxReturns = createInboxReturnTracker(); @@ -477,6 +478,11 @@ function useSidebarThreads(input: { false, Schema.Boolean, ); + const [pinnedShelfExpanded, setPinnedShelfExpanded] = useLocalStorage( + PINNED_SHELF_EXPANDED_KEY, + true, + Schema.Boolean, + ); // The settled tail restarts whenever the project scope changes. const [settledTail, setSettledTail] = useState({ scopeKey: projectScopeKey, @@ -521,8 +527,11 @@ function useSidebarThreads(input: { const visibleWorkingThreads = workingShelfExpanded ? workingThreads : routeThreadOnly(workingThreads, routeThreadKey); + const visiblePinnedThreads = pinnedShelfExpanded + ? pinnedThreads + : routeThreadOnly(pinnedThreads, routeThreadKey); const orderedThreads = [ - ...pinnedThreads, + ...visiblePinnedThreads, ...activeThreads, ...visibleWorkingThreads, ...visibleSnoozedThreads, @@ -562,6 +571,9 @@ function useSidebarThreads(input: { toggleSnoozedShelf: () => setSnoozedShelfExpanded((value) => !value), workingShelfExpanded, toggleWorkingShelf: () => setWorkingShelfExpanded((value) => !value), + pinnedShelfExpanded, + togglePinnedShelf: () => setPinnedShelfExpanded((value) => !value), + visiblePinnedThreads, renderedSettledThreads, visibleSnoozedThreads, visibleWorkingThreads, @@ -570,7 +582,7 @@ function useSidebarThreads(input: { settledThreadKeys: new Set(settledThreads.map(threadShellKey)), snoozedThreadKeys: new Set(snoozedThreads.map(threadShellKey)), sectionByThreadKey, - pinnedKeys: pinnedThreads.map(threadShellKey), + pinnedKeys: visiblePinnedThreads.map(threadShellKey), activeKeys: activeThreads.map(threadShellKey), pinnedKeysById: new Map( threads.map((thread) => [threadShellKey(thread), thread.pinOrderKey ?? null] as const), @@ -2985,6 +2997,9 @@ export default function Sidebar() { toggleSnoozedShelf, workingShelfExpanded, toggleWorkingShelf, + pinnedShelfExpanded, + togglePinnedShelf, + visiblePinnedThreads, renderedSettledThreads, visibleSnoozedThreads, visibleWorkingThreads, @@ -3543,7 +3558,7 @@ export default function Sidebar() { return []; } const items: SidebarListItem[] = [{ kind: "marker", marker: "pinned-header" }]; - const pinnedRows = rowsOf(pinnedThreads, "pinned"); + const pinnedRows = rowsOf(visiblePinnedThreads, "pinned"); items.push(...pinnedRows); items.push({ kind: "marker", marker: "pinned-divider" }); const activeRows = rowsOf(activeThreads, "active"); @@ -3614,6 +3629,7 @@ export default function Sidebar() { : { ...current, targetSection: target?.section ?? null }, ); }; + const collapsedPinnedOrder = pinnedShelfExpanded ? undefined : pinnedThreads.map(threadShellKey); const sortableIds = sidebarListItems.map(sidebarListItemId); // Outside a drag the orders are constants, so the strategy, and with it // every sortable row, keeps its identity while thread shells stream in. @@ -3648,7 +3664,12 @@ export default function Sidebar() { if (source === undefined) return createSidebarCollisionDetection(() => false); return createSidebarCollisionDetection( (id) => { - const target = resolveSidebarDropTarget(sidebarListItems, draggedThreadKey, id); + const target = resolveSidebarDropTarget( + sidebarListItems, + draggedThreadKey, + id, + collapsedPinnedOrder, + ); if (target === null) return false; return ( planSidebarThreadDrop({ @@ -3673,6 +3694,7 @@ export default function Sidebar() { { items: sidebarListItems, activationY: dragActivationY ?? null, + pinnedCollapsed: !pinnedShelfExpanded, }, ); })(); @@ -3682,7 +3704,12 @@ export default function Sidebar() { const target = event.over === null ? null - : resolveSidebarDropTarget(sidebarListItems, activeKey, String(event.over.id)); + : resolveSidebarDropTarget( + sidebarListItems, + activeKey, + String(event.over.id), + collapsedPinnedOrder, + ); const activeThread = threadByKey.get(activeKey); if (activeSection === undefined || target === null || activeThread === undefined) return; const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); @@ -4912,12 +4939,39 @@ export default function Sidebar() { } switch (item.marker) { case "pinned-header": + // The sortable boundary reserves only drag-label height. + if (pinnedThreads.length > 0) { + items.push( +
  • + + {pinnedShelfExpanded + ? "Pinned" + : `Pinned (${pinnedThreads.length})`} + +
  • , + ); + } items.push( , ); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 7c14f046ff5d7..d3b49108fb0ef 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -71,9 +71,7 @@ list to unpin it. Dragging a thread onto the **Settled** header settles it, and thread into the active list un-settles it. A snoozed thread can be dragged out of the snoozed shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation. -The active boundary label, and the pinned one when nothing is pinned, appear only while dragging, -without moving the rows. The -other rows slide aside to show where the thread will land. When you cross into another section, +The other rows slide aside to show where the thread will land. 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 164782d1069ecfdc3cccf17b48a78130cb084f55 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:57:45 +0000 Subject: [PATCH 3/7] fix(web): target expanded pinned header consistently --- apps/web/src/components/Sidebar.drag.test.ts | 3 +-- apps/web/src/components/Sidebar.drag.ts | 18 ++++++++---------- apps/web/src/components/Sidebar.tsx | 1 - 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index 392ca2f4e4009..24bb56fa9c9d4 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -125,7 +125,7 @@ describe("sidebar collision detection", () => { expect(detector(collisionArgs())[0]?.id).toBe("blocked"); }); - it("prepends a drop on the collapsed toggle despite the clamped card overlapping the divider", () => { + it("prepends a drop on the pinned toggle despite the clamped card overlapping the divider", () => { const items = [pinnedHeader, thread("p2", "pinned"), divider, thread("source", "active")]; const { rects, activeIndex } = layout(items, "source", sidebarMarkerId("pinned-divider")); const sourceRect = rects[activeIndex]!; @@ -159,7 +159,6 @@ describe("sidebar collision detection", () => { expect(closestCenter(args)[0]?.id).toBe(sidebarMarkerId("pinned-divider")); const detector = createSidebarCollisionDetection(() => true, { items, - pinnedCollapsed: true, }); const overId = String(detector(args)[0]?.id); expect(overId).toBe(sidebarMarkerId("pinned-header")); diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index fedc09aa356b3..535f8385980f6 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -33,7 +33,6 @@ export function createSidebarCollisionDetection( options: { items?: readonly SidebarListItem[]; activationY?: number | null; - pinnedCollapsed?: boolean; } = {}, ): CollisionDetection { const validity = new Map(); @@ -82,15 +81,14 @@ export function createSidebarCollisionDetection( } } // The clamped card stays below the toggle, so its center can select the divider. - const headerRect = - pointer && options.pinnedCollapsed - ? args.droppableContainers - .find((container) => container.id === sidebarMarkerId("pinned-header")) - ?.node.current?.previousElementSibling?.querySelector( - '[data-testid="sidebar-pinned-shelf-toggle"]', - ) - ?.getBoundingClientRect() - : undefined; + const headerRect = pointer + ? args.droppableContainers + .find((container) => container.id === sidebarMarkerId("pinned-header")) + ?.node.current?.previousElementSibling?.querySelector( + '[data-testid="sidebar-pinned-shelf-toggle"]', + ) + ?.getBoundingClientRect() + : undefined; if ( pointer && headerRect && diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 541cb54789263..730a35d73bd7d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3694,7 +3694,6 @@ export default function Sidebar() { { items: sidebarListItems, activationY: dragActivationY ?? null, - pinnedCollapsed: !pinnedShelfExpanded, }, ); })(); From f136cf4c21b41ea8a3637a06ed1a6a1e52f71008 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 03:12:25 +0000 Subject: [PATCH 4/7] fix(web): finish drops that rewrite hidden pinned keys --- apps/web/src/components/Sidebar.logic.test.ts | 47 ++++++++++ apps/web/src/components/Sidebar.logic.ts | 88 ++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 91 ++----------------- 3 files changed, 141 insertions(+), 85 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 9ef6c7865acb0..d3cee153f324b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,4 +1,5 @@ import { presentThreadShell } from "@t3tools/client-runtime/state/models"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import * as DateTime from "effect/DateTime"; import { deriveActiveWorkStartedAt } from "../session-logic.ts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -22,6 +23,7 @@ import { getSidebarThreadIdsToPrewarm, hasUnseenCompletion, isContextMenuPointerDown, + isOptimisticDropReleased, isSidebarSubagentThread, isSidebarThreadWorking, isTrailingDoubleClick, @@ -60,6 +62,7 @@ import { type SidebarListItem, type SidebarListMarker, type SidebarSection, + type SidebarOptimisticDrop, resolveSidebarDropVerb, } from "./Sidebar.logic"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; @@ -2385,6 +2388,50 @@ describe("Working shelf (beta)", () => { }, ); + it("releases a collapsed pinned drop only after the hidden key assignments land", () => { + const now = "2026-03-09T12:00:00.000Z"; + const threads = ["x", "h1", "h2"].map((id) => + makeThreadFixture({ + id: ThreadId.make(id), + environmentId: localEnvironmentId, + pinnedAt: now, + pinOrderKey: id === "x" ? "ff" : null, + }), + ); + const keys = threads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + const assignedKeys = new Map(keys.map((key, index) => [key, ["ff", "nn", "tt"][index]!])); + const optimisticDrop: SidebarOptimisticDrop = { + key: keys[0]!, + sourceSection: "active", + section: "pinned", + occurredAt: now, + clearsSnooze: true, + order: [keys[0]!], + keysAtDrop: new Map(keys.map((key) => [key, null])), + assignedKeys, + }; + const released = (canonicalThreads: readonly Thread[]) => + isOptimisticDropReleased({ + optimisticDrop, + threads: canonicalThreads, + pinnedKeys: [keys[0]!], + activeKeys: [], + now, + }); + expect(released(threads)).toBe(false); + const partiallyLanded = threads.map((thread, index) => + index === 1 ? { ...thread, pinOrderKey: assignedKeys.get(keys[index]!)! } : thread, + ); + expect(released(partiallyLanded)).toBe(false); + const allLanded = threads.map((thread, index) => ({ + ...thread, + pinOrderKey: assignedKeys.get(keys[index]!)!, + })); + expect(released(allLanded)).toBe(true); + }); + it("arranges the rows it can write when another row's server cannot store an order", () => { const plan = planSidebarThreadDrop({ activeKey: "a2", diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index b6912c2ea18b4..2b1e56211233d 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -2,6 +2,7 @@ import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/mod import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import * as React from "react"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { isAtomCommandInterrupted, type AtomCommandResult, @@ -167,6 +168,93 @@ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): return section; } +export type SidebarOptimisticDrop = { + readonly key: string; + readonly sourceSection: SidebarSection; + readonly section: "pinned" | "active" | "settled"; + readonly occurredAt: string; + readonly clearsSnooze: boolean; + readonly order: readonly string[] | null; + readonly keysAtDrop: ReadonlyMap; + readonly assignedKeys: ReadonlyMap; +}; + +export function isOptimisticDropReleased(input: { + readonly optimisticDrop: SidebarOptimisticDrop; + readonly threads: readonly SidebarThreadSummary[]; + readonly pinnedKeys: readonly string[]; + readonly activeKeys: readonly string[]; + readonly now: string; +}): boolean { + const { optimisticDrop, threads, pinnedKeys, activeKeys, now } = input; + const canonicalByKey = new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + thread, + ]), + ); + const thread = canonicalByKey.get(optimisticDrop.key); + if (thread === undefined || thread.archivedAt !== null) { + return true; + } + const canonicalSection = effectiveSnoozed(thread, { now }) + ? "snoozed" + : thread.settledOverride === "settled" + ? "settled" + : thread.pinnedAt != null + ? "pinned" + : "active"; + if ( + canonicalSection !== optimisticDrop.sourceSection && + canonicalSection !== optimisticDrop.section + ) { + return true; + } + if (optimisticDrop.order === null) { + // Settle also emits unpin/unsnooze events. Wait for the entire move + // before releasing the projected fields and sort timestamps. + if ( + canonicalSection === optimisticDrop.section && + thread.pinnedAt == null && + (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null) + ) { + return true; + } + return false; + } + if (canonicalSection !== optimisticDrop.section) return false; + if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return false; + const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys; + const canonicalDestination = destinationKeys.flatMap((key) => { + const canonical = canonicalByKey.get(key); + return canonical === undefined ? [] : [canonical]; + }); + const keyByThread = new Map( + canonicalDestination.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + (optimisticDrop.section === "pinned" ? thread.pinOrderKey : thread.activeOrderKey) ?? null, + ]), + ); + const heldOrder = optimisticDrop.order; + const heldKeys = new Set(heldOrder); + const membershipChanged = + destinationKeys.length !== heldOrder.length || + destinationKeys.some((key) => !heldKeys.has(key)); + const foreignKeyLanded = destinationKeys.some((threadKey) => { + const currentKey = keyByThread.get(threadKey) ?? null; + if (currentKey === (optimisticDrop.keysAtDrop.get(threadKey) ?? null)) return false; + return currentKey !== optimisticDrop.assignedKeys.get(threadKey); + }); + const allAssignmentsLanded = [...optimisticDrop.assignedKeys].every(([threadKey, orderKey]) => { + const canonical = canonicalByKey.get(threadKey); + return ( + (optimisticDrop.section === "pinned" ? canonical?.pinOrderKey : canonical?.activeOrderKey) === + orderKey + ); + }); + return membershipChanged || foreignKeyLanded || allAssignmentsLanded; +} + export type SidebarDropTarget = { readonly section: "pinned" | "active" | "settled"; readonly pinnedOrder: readonly string[]; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 730a35d73bd7d..d3328cbd87e13 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -199,6 +199,8 @@ import { firstValidTimestampMs, hasUnseenCompletion, isSidebarNestedLinkClick, + isOptimisticDropReleased, + type SidebarOptimisticDrop, isSidebarThreadWorking, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -370,17 +372,6 @@ const PINNED_SHELF_EXPANDED_KEY = "t3code:sidebar:pinned-expanded"; const inboxReturns = createInboxReturnTracker(); -type SidebarOptimisticDrop = { - readonly key: string; - readonly sourceSection: SidebarSection; - readonly section: "pinned" | "active" | "settled"; - readonly occurredAt: string; - readonly clearsSnooze: boolean; - readonly order: readonly string[] | null; - readonly keysAtDrop: ReadonlyMap; - readonly assignedKeys: ReadonlyMap; -}; - interface SidebarThreadPartitionInput { readonly threads: readonly EnvironmentThreadShell[]; readonly scopedProjectKeys: ReadonlySet | null; @@ -686,78 +677,6 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { }; } -function isOptimisticDropReleased(input: { - readonly optimisticDrop: SidebarOptimisticDrop; - readonly threads: readonly EnvironmentThreadShell[]; - readonly pinnedKeys: readonly string[]; - readonly activeKeys: readonly string[]; - readonly now: string; -}): boolean { - const { optimisticDrop, threads, pinnedKeys, activeKeys, now } = input; - const canonicalByKey = new Map( - threads.map((thread) => [ - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - thread, - ]), - ); - const thread = canonicalByKey.get(optimisticDrop.key); - if (thread === undefined || thread.archivedAt !== null) { - return true; - } - const canonicalSection = effectiveSnoozed(thread, { now }) - ? "snoozed" - : thread.settledOverride === "settled" - ? "settled" - : thread.pinnedAt != null - ? "pinned" - : "active"; - if ( - canonicalSection !== optimisticDrop.sourceSection && - canonicalSection !== optimisticDrop.section - ) { - return true; - } - if (optimisticDrop.order === null) { - // Settle also emits unpin/unsnooze events. Wait for the entire move - // before releasing the projected fields and sort timestamps. - if ( - canonicalSection === optimisticDrop.section && - thread.pinnedAt == null && - (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null) - ) { - return true; - } - return false; - } - if (canonicalSection !== optimisticDrop.section) return false; - if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return false; - const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys; - const canonicalDestination = destinationKeys.flatMap((key) => { - const canonical = canonicalByKey.get(key); - return canonical === undefined ? [] : [canonical]; - }); - const keyByThread = new Map( - canonicalDestination.map((thread) => [ - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - (optimisticDrop.section === "pinned" ? thread.pinOrderKey : thread.activeOrderKey) ?? null, - ]), - ); - const heldOrder = optimisticDrop.order; - const heldKeys = new Set(heldOrder); - const membershipChanged = - destinationKeys.length !== heldOrder.length || - destinationKeys.some((key) => !heldKeys.has(key)); - const foreignKeyLanded = destinationKeys.some((threadKey) => { - const currentKey = keyByThread.get(threadKey) ?? null; - if (currentKey === (optimisticDrop.keysAtDrop.get(threadKey) ?? null)) return false; - return currentKey !== optimisticDrop.assignedKeys.get(threadKey); - }); - const allAssignmentsLanded = [...optimisticDrop.assignedKeys].every( - ([threadKey, orderKey]) => keyByThread.get(threadKey) === orderKey, - ); - return membershipChanged || foreignKeyLanded || allAssignmentsLanded; -} - // Sidebar() is large enough that the compiler cannot prove its local maps stay // unmutated; built inside a hook they reach it frozen and cached. function useSidebarLookups() { @@ -3731,6 +3650,8 @@ export default function Sidebar() { }); if (plan.kind === "none") return; if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return; + // Rebalancing can assign keys to pins hidden by a collapsed shelf. + const operationThreadByKey = new Map(threads.map((thread) => [threadShellKey(thread), thread])); const assignments = plan.kind === "pin" ? [ @@ -3743,7 +3664,7 @@ export default function Sidebar() { if ( !checkThreadOperations( [activeKey, ...assignments.map(({ id }) => id)].flatMap((key) => { - const thread = threadByKey.get(key); + const thread = operationThreadByKey.get(key); return thread ? [thread] : []; }), ) @@ -3827,7 +3748,7 @@ export default function Sidebar() { } const keyWrites = plan.kind === "pin" ? plan.extraAssignments : plan.assignments; for (const assignment of keyWrites) { - const thread = threadByKey.get(assignment.id); + const thread = operationThreadByKey.get(assignment.id); if (thread === undefined) continue; if ( !(await run( From a71332e24077cb4105ae77ac10d35017ef2dd1d7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 03:55:07 +0000 Subject: [PATCH 5/7] fix(web): hold collapsed drops until hidden pin keys land --- apps/web/src/components/Sidebar.logic.test.ts | 125 ++++++++++++------ apps/web/src/components/Sidebar.tsx | 25 +++- 2 files changed, 104 insertions(+), 46 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d3cee153f324b..78b00d4af5b7e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2388,49 +2388,90 @@ describe("Working shelf (beta)", () => { }, ); - it("releases a collapsed pinned drop only after the hidden key assignments land", () => { - const now = "2026-03-09T12:00:00.000Z"; - const threads = ["x", "h1", "h2"].map((id) => - makeThreadFixture({ - id: ThreadId.make(id), - environmentId: localEnvironmentId, - pinnedAt: now, - pinOrderKey: id === "x" ? "ff" : null, - }), - ); - const keys = threads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - const assignedKeys = new Map(keys.map((key, index) => [key, ["ff", "nn", "tt"][index]!])); - const optimisticDrop: SidebarOptimisticDrop = { - key: keys[0]!, - sourceSection: "active", - section: "pinned", - occurredAt: now, - clearsSnooze: true, - order: [keys[0]!], - keysAtDrop: new Map(keys.map((key) => [key, null])), - assignedKeys, - }; - const released = (canonicalThreads: readonly Thread[]) => - isOptimisticDropReleased({ - optimisticDrop, - threads: canonicalThreads, - pinnedKeys: [keys[0]!], - activeKeys: [], - now, + it.each(["x", "h1"])( + "waits for every collapsed-header key assignment with routed thread %s", + (routedId) => { + const now = "2026-03-09T12:00:00.000Z"; + const threads = ["x", "h1", "h2"].map((id) => + makeThreadFixture({ + id: ThreadId.make(id), + environmentId: localEnvironmentId, + pinnedAt: id === "x" ? null : now, + pinOrderKey: null, + }), + ); + const keys = threads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + const visiblePins = threads.flatMap((thread, index) => + thread.pinnedAt !== null && thread.id === routedId ? [keys[index]!] : [], + ); + const keysAtDrop = new Map(keys.map((key) => [key, null])); + const target = resolveSidebarDropTarget( + [ + marker("pinned-header"), + ...visiblePins.map((key) => row(key, "pinned")), + marker("pinned-divider"), + row(keys[0]!, "active"), + ], + keys[0]!, + sidebarMarkerId("pinned-header"), + keys.slice(1), + ); + expect(target).not.toBeNull(); + const plan = planSidebarThreadDrop({ + activeKey: keys[0]!, + activeSection: "active", + target: target!, + pinnedOrder: visiblePins, + pinnedKeysById: keysAtDrop, + reorderableKeys: new Set(keys), + activeOrder: [keys[0]!], + activeKeysById: new Map(), }); - expect(released(threads)).toBe(false); - const partiallyLanded = threads.map((thread, index) => - index === 1 ? { ...thread, pinOrderKey: assignedKeys.get(keys[index]!)! } : thread, - ); - expect(released(partiallyLanded)).toBe(false); - const allLanded = threads.map((thread, index) => ({ - ...thread, - pinOrderKey: assignedKeys.get(keys[index]!)!, - })); - expect(released(allLanded)).toBe(true); - }); + expect(plan.kind).toBe("pin"); + if (plan.kind !== "pin") return; + expect(plan.extraAssignments.map(({ id }) => id)).toEqual(keys.slice(1)); + const assignedKeys = new Map([ + [keys[0]!, plan.orderKey!], + ...plan.extraAssignments.map(({ id, orderKey }) => [id, orderKey] as const), + ]); + const optimisticDrop: SidebarOptimisticDrop = { + key: keys[0]!, + sourceSection: "active", + section: "pinned", + occurredAt: now, + clearsSnooze: true, + order: target!.pinnedKeyOrder!, + keysAtDrop, + assignedKeys, + }; + const released = (canonicalThreads: readonly Thread[]) => + isOptimisticDropReleased({ + optimisticDrop, + threads: canonicalThreads, + pinnedKeys: canonicalThreads.flatMap((thread, index) => + thread.pinnedAt !== null ? [keys[index]!] : [], + ), + activeKeys: [], + now, + }); + expect(released(threads)).toBe(false); + const pinLanded = threads.map((thread, index) => + index === 0 ? { ...thread, pinnedAt: now, pinOrderKey: plan.orderKey! } : thread, + ); + expect(released(pinLanded)).toBe(false); + const partiallyLanded = pinLanded.map((thread, index) => + index === 1 ? { ...thread, pinOrderKey: assignedKeys.get(keys[index]!)! } : thread, + ); + expect(released(partiallyLanded)).toBe(false); + const allLanded = pinLanded.map((thread, index) => ({ + ...thread, + pinOrderKey: assignedKeys.get(keys[index]!)!, + })); + expect(released(allLanded)).toBe(true); + }, + ); it("arranges the rows it can write when another row's server cannot store an order", () => { const plan = planSidebarThreadDrop({ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d3328cbd87e13..aadb99c032b04 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3382,6 +3382,7 @@ export default function Sidebar() { }); }, ); + const fullPinnedKeys = pinnedThreads.map(threadShellKey); // Release the hold during render once the server state catches up, so the // canonical order shows in the same frame instead of after an effect pass. if ( @@ -3389,7 +3390,7 @@ export default function Sidebar() { isOptimisticDropReleased({ optimisticDrop, threads, - pinnedKeys, + pinnedKeys: fullPinnedKeys, activeKeys, now: snoozeNow, }) @@ -3548,7 +3549,7 @@ export default function Sidebar() { : { ...current, targetSection: target?.section ?? null }, ); }; - const collapsedPinnedOrder = pinnedShelfExpanded ? undefined : pinnedThreads.map(threadShellKey); + const collapsedPinnedOrder = pinnedShelfExpanded ? undefined : fullPinnedKeys; const sortableIds = sidebarListItems.map(sidebarListItemId); // Outside a drag the orders are constants, so the strategy, and with it // every sortable row, keeps its identity while thread shells stream in. @@ -3670,6 +3671,22 @@ export default function Sidebar() { ) ) return; + const assignedKeys = new Map(assignments.map(({ id, orderKey }) => [id, orderKey])); + let order = plan.kind === "settle" ? null : plan.order; + if (plan.kind === "pin" || plan.kind === "reorder-pinned") { + // Hold hidden pins in the order their planned keys will produce on expansion. + order = + target.pinnedKeyOrder ?? + sortPinnedThreadsForSidebar( + [ + activeThread, + ...pinnedThreads.filter((thread) => threadShellKey(thread) !== activeKey), + ].map((thread) => ({ + ...thread, + pinOrderKey: assignedKeys.get(threadShellKey(thread)) ?? thread.pinOrderKey, + })), + ).map(threadShellKey); + } const drop = { key: activeKey, sourceSection: activeSection, @@ -3679,9 +3696,9 @@ export default function Sidebar() { plan.kind === "pin" || plan.kind === "settle" || (plan.kind === "move-active" && plan.unsnooze), - order: plan.kind === "settle" ? null : plan.order, + order, keysAtDrop: target.section === "active" ? activeKeysById : pinnedKeysById, - assignedKeys: new Map(assignments.map(({ id, orderKey }) => [id, orderKey])), + assignedKeys, }; setOptimisticDrop(drop); void (async () => { From 800702632eeb135ead9d656f71913845b88681e2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 04:05:37 +0000 Subject: [PATCH 6/7] fix(web): preserve hidden pins beside collapsed row drops --- apps/web/src/components/Sidebar.logic.test.ts | 51 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 30 ++++++++--- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 78b00d4af5b7e..c4a430517533f 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2388,6 +2388,57 @@ describe("Working shelf (beta)", () => { }, ); + it("preserves hidden keyless pins when dropping below the routed pin in a collapsed shelf", () => { + const pins = [ + { id: "hidden-new", createdAt: "2026-03-09T12:00:00.000Z", pinOrderKey: null }, + { id: "routed", createdAt: "2026-03-09T11:00:00.000Z", pinOrderKey: null }, + { id: "hidden-old", createdAt: "2026-03-09T10:00:00.000Z", pinOrderKey: null }, + ]; + const dropped = { + id: "dropped", + createdAt: "2026-03-09T13:00:00.000Z", + pinOrderKey: null, + }; + const target = resolveSidebarDropTarget( + [ + marker("pinned-header"), + row("routed", "pinned"), + marker("pinned-divider"), + row("dropped", "active"), + ], + dropped.id, + sidebarMarkerId("pinned-divider"), + sortPinnedThreadsForSidebar(pins).map((thread) => thread.id), + ); + expect(target).not.toBeNull(); + const plan = planSidebarThreadDrop({ + activeKey: dropped.id, + activeSection: "active", + target: target!, + pinnedOrder: ["routed"], + pinnedKeysById: new Map(pins.map((thread) => [thread.id, thread.pinOrderKey])), + reorderableKeys: new Set([...pins, dropped].map((thread) => thread.id)), + activeOrder: [dropped.id], + activeKeysById: new Map(), + }); + expect(plan.kind).toBe("pin"); + if (plan.kind !== "pin") return; + const assignedKeys = new Map([ + [dropped.id, plan.orderKey!], + ...plan.extraAssignments.map(({ id, orderKey }) => [id, orderKey] as const), + ]); + const persisted = [...pins, dropped].map((thread) => ({ + ...thread, + pinOrderKey: assignedKeys.get(thread.id) ?? thread.pinOrderKey, + })); + expect(sortPinnedThreadsForSidebar(persisted).map((thread) => thread.id)).toEqual([ + "hidden-new", + "routed", + "dropped", + "hidden-old", + ]); + }); + it.each(["x", "h1"])( "waits for every collapsed-header key assignment with routed thread %s", (routedId) => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 2b1e56211233d..b1b3d792f0983 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -290,15 +290,33 @@ export function resolveSidebarDropTarget( } else if (currentSection === "pinned") pinnedOrder.push(item.key); else activeOrder.push(item.key); } + let pinnedKeyOrder: string[] | undefined; + if (section === "pinned" && collapsedPinnedOrder !== undefined && overId !== activeKey) { + const fullOrder = collapsedPinnedOrder.filter((key) => key !== activeKey); + const movedIndex = pinnedOrder.indexOf(activeKey); + const previousKey = pinnedOrder[movedIndex - 1]; + const nextKey = pinnedOrder[movedIndex + 1]; + const anchorKey = overId === nextKey ? nextKey : previousKey; + const anchorIndex = anchorKey === undefined ? -1 : fullOrder.indexOf(anchorKey); + const insertionIndex = + overId === sidebarMarkerId("pinned-header") + ? 0 + : anchorIndex !== -1 + ? anchorIndex + (anchorKey === previousKey ? 1 : 0) + : Math.max(0, collapsedPinnedOrder.indexOf(activeKey)); + fullOrder.splice(insertionIndex, 0, activeKey); + if ( + items[activeIndex]!.section !== "pinned" || + fullOrder.some((key, index) => key !== collapsedPinnedOrder[index]) + ) { + pinnedKeyOrder = fullOrder; + } + } return { section, pinnedOrder, activeOrder, - ...(overId === sidebarMarkerId("pinned-header") && - collapsedPinnedOrder !== undefined && - collapsedPinnedOrder[0] !== activeKey - ? { pinnedKeyOrder: [activeKey, ...collapsedPinnedOrder.filter((key) => key !== activeKey)] } - : {}), + ...(pinnedKeyOrder === undefined ? {} : { pinnedKeyOrder }), }; } @@ -453,7 +471,7 @@ export function planSidebarThreadDrop(input: { ) { return { kind: "none" }; } - // A collapsed header means the start of the full run, including hidden pins. + // Assign against the full collapsed run so hidden keyless pins keep their positions. const assignments = arrange(target.pinnedKeyOrder ?? order, pinnedKeysById, reorderableKeys); if (assignments === null) return { kind: "none" }; if (activeSection === "pinned") { From 1d4dec0d1bad7595670185613ee2f3e39f3291a3 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 28 Oct 2026 08:38:43 -0500 Subject: [PATCH 7/7] fix(web): pinned shelf header matches the other shelves Show the count as the header accessory like Working, Snoozed and Settled (#25778), and give the header role="treeitem" and data-sidebar-row so arrow keys reach it like the other shelf headers (#25877). --- apps/web/src/components/Sidebar.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3c68e00339e0b..c9e7e4ef9bf4d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3299,6 +3299,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve className="mx-0.5 h-8 list-none" > + {pinnedThreads.length} + + } > - {pinnedShelfExpanded - ? "Pinned" - : `Pinned (${pinnedThreads.length})`} + Pinned , );