diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index e1febcd68d533..144c8b73d3293 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, @@ -119,6 +120,63 @@ describe("sidebar collision detection", () => { }, ); + 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]!; + 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, + }); + 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 76f2c9bf3a3ec..d1eb43f85ba08 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -78,6 +78,28 @@ export function createSidebarCollisionDetection( } } } + // The clamped card stays below the toggle, so its center can select the divider. + 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 && + 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 669c805492bcc..c56cb1f4ba7f2 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -10,6 +10,7 @@ import { deleteSelectedThreadEntries, getFallbackThreadIdAfterDelete, hasUnseenCompletion, + isOptimisticDropReleased, resolveSidebarRowThreadId, belongsInSidebarWorkingShelf, isSidebarEnvironmentDisconnected, @@ -23,6 +24,7 @@ import { shouldRecedeSidebarThread, sortInboxThreadsByReturn, resolveSidebarDropTarget, + sidebarMarkerId, planSidebarThreadDrop, resolveSidebarReorderableThreadKeys, sortPinnedThreadsForSidebar, @@ -32,6 +34,7 @@ import { type SidebarListItem, type SidebarListMarker, type SidebarSection, + type SidebarOptimisticDrop, resolveSidebarDropVerb, groupSidebarThreadsByProject, resolveSidebarFolderDot, @@ -1108,6 +1111,186 @@ 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("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) => { + 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(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({ activeKey: "a2", diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 55cf694c2b79e..222d5b9301347 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -244,9 +244,97 @@ 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; +} + type SidebarDropTarget = { readonly section: "pinned" | "active" | "settled"; readonly pinnedOrder: readonly string[]; + readonly pinnedKeyOrder?: readonly string[]; readonly activeOrder: readonly string[]; }; @@ -254,6 +342,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); @@ -277,7 +366,34 @@ export function resolveSidebarDropTarget( } else if (currentSection === "pinned") pinnedOrder.push(item.key); else activeOrder.push(item.key); } - return { section, pinnedOrder, activeOrder }; + 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, + ...(pinnedKeyOrder === undefined ? {} : { pinnedKeyOrder }), + }; } type SidebarThreadDropPlan = @@ -471,13 +587,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); + // 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") { return assignments.length === 0 diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index deede26763fdd..64fbce1e3b620 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { attachSidebarTree, SIDEBAR_TREE_PROPS } from "../lib/sidebarTree"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost"; +import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; import { dropThreadContext, endThreadContextDrag, @@ -189,6 +190,8 @@ import { belongsInSidebarWorkingShelf, isSidebarEnvironmentDisconnected, isSidebarNestedLinkClick, + isOptimisticDropReleased, + type SidebarOptimisticDrop, isTrailingDoubleClick, orderItemsByPreferredIds, planSidebarThreadDrop, @@ -330,20 +333,10 @@ 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(); -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; @@ -475,6 +468,11 @@ function useSidebarThreads(input: { false, Schema.Boolean, ); + const [pinnedShelfExpanded, setPinnedShelfExpanded] = useLocalStorage( + PINNED_SHELF_EXPANDED_KEY, + true, + Schema.Boolean, + ); // Shared with the old per-project sidebar, so folders keep their open state. const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); @@ -535,6 +533,9 @@ function useSidebarThreads(input: { const visibleWorkingThreads = workingShelfExpanded ? workingThreads : routeThreadOnly(workingThreads, routeRowThreadKey); + const visiblePinnedThreads = pinnedShelfExpanded + ? pinnedThreads + : routeThreadOnly(pinnedThreads, routeRowThreadKey); const projectFolders = folderGroups === null ? null @@ -556,7 +557,7 @@ function useSidebarThreads(input: { }; }); const orderedThreads = [ - ...pinnedThreads, + ...visiblePinnedThreads, ...(projectFolders === null ? activeThreads : projectFolders.flatMap((folder) => folder.threads)), @@ -600,6 +601,9 @@ function useSidebarThreads(input: { toggleSnoozedShelf: () => setSnoozedShelfExpanded((value) => !value), workingShelfExpanded, toggleWorkingShelf: () => setWorkingShelfExpanded((value) => !value), + pinnedShelfExpanded, + togglePinnedShelf: () => setPinnedShelfExpanded((value) => !value), + visiblePinnedThreads, projectFolders, toggleProjectFolder: (group: SidebarProjectSnapshot) => { const keys = sidebarFolderExpansionKeys(group); @@ -613,7 +617,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), @@ -747,77 +751,6 @@ function olderSettledScopes( }); } -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 emits unpin/unsnooze too; hold projected fields and sort times until the whole move completes. - 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; -} - // Build maps in a hook so the compiler can prove they are frozen and cache them. function useSidebarLookups() { const projects = useProjects(); @@ -1328,6 +1261,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve toggleSnoozedShelf, workingShelfExpanded, toggleWorkingShelf, + pinnedShelfExpanded, + togglePinnedShelf, + visiblePinnedThreads, projectFolders, toggleProjectFolder, renderedSettledThreads, @@ -1849,13 +1785,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }); }, ); + const fullPinnedKeys = pinnedThreads.map(threadShellKey); // Release during render when server state catches up so canonical order appears in the same frame. if ( optimisticDrop !== null && isOptimisticDropReleased({ optimisticDrop, threads, - pinnedKeys, + pinnedKeys: fullPinnedKeys, activeKeys, now: snoozeNow, }) @@ -1942,7 +1879,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve 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"); @@ -2018,6 +1955,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve : { ...current, targetSection: target?.section ?? null }, ); }; + const collapsedPinnedOrder = pinnedShelfExpanded ? undefined : fullPinnedKeys; const sortableIds = sidebarListItems.map(sidebarListItemId); // Keep non-drag orders constant to preserve sorting strategy and row identity during streaming. const { settledOrder: draggedSettledOrder, activeOrder: draggedActiveOrder } = @@ -2051,7 +1989,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve 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({ @@ -2083,7 +2026,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve 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); @@ -2104,6 +2052,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }); 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" ? [ @@ -2116,12 +2066,28 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if ( !checkThreadOperations( [activeKey, ...assignments.map(({ id }) => id)].flatMap((key) => { - const thread = threadByKey.get(key); + const thread = operationThreadByKey.get(key); return thread ? [thread] : []; }), ) ) 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, @@ -2131,9 +2097,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve 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 () => { @@ -2200,7 +2166,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } 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( @@ -3467,12 +3433,45 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } switch (item.marker) { case "pinned-header": + // The sortable boundary reserves only drag-label height. + if (pinnedThreads.length > 0) { + items.push( +
  • + + {pinnedThreads.length} + + } + > + Pinned + +
  • , + ); + } items.push( , ); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index bed4ed73a40c5..932b5e4c96527 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -57,7 +57,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 @@ -82,8 +83,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. -Pinned and active boundary labels appear only while dragging, without moving the rows. The -other rows slide aside to show where the thread will land. When you cross into another section, +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