refactor(desktop): collapse sidebar drag-reorder into one generic ReorderableList

Every reorderable surface (repos, worktrees, sessions, pins) now drops in a
single ReorderableList that owns its own DndContext, so a drag only ever
collides with that list's own items — nesting "just works" without leaking
into the lists around or inside it. This replaces the shared DndContext +
id-prefix dispatch (parent:/group:) whose closestCenter collisions resolved
to a different-typed droppable and silently no-op'd worktree/repo drags.

- Delete groupDndId/parentDndId/parse* helpers and the monolithic
  handleAgentDragEnd/handlePinnedDragEnd; each list persists its new id order
  via a direct typed write (reorderParents/reorderWorktree/reorderSessions/
  reorderPinned).
- Sessions inside repos/worktrees are date-ordered and static (no drag),
  matching the "never reorder on new messages" rule.
- Add setPinnedSessionOrder; drop now-unused reorderPinnedSession.
This commit is contained in:
Brooklyn Nicholson 2026-06-12 18:59:54 -05:00
parent dd12a5403d
commit 1a3cd3d436
3 changed files with 130 additions and 219 deletions

View file

@ -58,8 +58,8 @@ import {
$sidebarWorkspaceOrderIds,
$sidebarWorkspaceParentOrderIds,
pinSession,
reorderPinnedSession,
SESSION_SEARCH_FOCUS_EVENT,
setPinnedSessionOrder,
setSidebarAgentsGrouped,
setSidebarCronOpen,
setSidebarPinsOpen,
@ -135,8 +135,6 @@ const WORKSPACE_PAGE = 5
// ALL-profiles view: show only the latest N per profile up front to keep the
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const GROUP_DND_ID_PREFIX = 'group:'
// Two modes via the `compact` height variant (styles.css):
// tall → each section is shrink-0, capped, its own scroller; Sessions is flex-1.
// compact → COMPACT_FLAT drops the caps so the whole stack scrolls as one.
@ -150,42 +148,47 @@ const SCROLL_Y = 'overflow-y-auto overflow-x-hidden overscroll-contain'
// A non-session group's scroll body: own scroller when tall, flattened when compact.
const GROUP_BODY = cn(SCROLL_Y, COMPACT_FLAT)
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
const parseGroupDndId = (id: string) =>
id.startsWith(GROUP_DND_ID_PREFIX) ? id.slice(GROUP_DND_ID_PREFIX.length) : null
// Worktree-tree parents (repo roots) reorder in their own dnd lane, distinct
// from the worktree groups (group:) and session rows nested inside them.
const PARENT_DND_ID_PREFIX = 'parent:'
const parentDndId = (id: string) => `${PARENT_DND_ID_PREFIX}${id}`
const parseParentDndId = (id: string) =>
id.startsWith(PARENT_DND_ID_PREFIX) ? id.slice(PARENT_DND_ID_PREFIX.length) : null
// Sidebar reordering is a strictly vertical list. The dragged item's transform
// is rendered Y-only in useSortableBindings (no x, no scale); this just stops
// dnd-kit's auto-scroll from dragging the rail — or the window — sideways when
// the pointer nears an edge, killing the horizontal "drag to valhalla".
const reorderAutoScroll = { threshold: { x: 0, y: 0.2 } }
function ReorderContext({
// One self-contained, nesting-safe reorderable list. It owns its DndContext, so a
// drag only ever collides with THIS list's own items — drop it at any depth (repos,
// worktrees, sessions) and reordering "just works" without leaking into the lists
// around or inside it. Pair each item with useSortableBindings(id); the list reports
// the new id order and the caller persists it. This is the single generic primitive
// behind every reorderable surface in the sidebar.
function ReorderableList({
children,
ids,
onReorder,
sensors
}: {
children: React.ReactNode
onReorder?: (event: DragEndEvent) => void
ids: string[]
onReorder: (ids: string[]) => void
sensors?: ReturnType<typeof useSensors>
}) {
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
}
const from = ids.indexOf(String(active.id))
const to = ids.indexOf(String(over.id))
if (from >= 0 && to >= 0) {
onReorder(arrayMove(ids, from, to))
}
}
return (
<DndContext
autoScroll={reorderAutoScroll}
collisionDetection={closestCenter}
onDragEnd={onReorder}
sensors={sensors}
>
{children}
<DndContext autoScroll={reorderAutoScroll} collisionDetection={closestCenter} onDragEnd={handleDragEnd} sensors={sensors}>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{children}
</SortableContext>
</DndContext>
)
}
@ -747,90 +750,29 @@ export function ChatSidebar({
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
}
// Each reorderable list reports its OWN new id order; persisting is a direct,
// typed write — no id-prefix sniffing to figure out which level moved.
const reorderSessions = (ids: string[]) => setSidebarSessionOrderIds(ids)
const newIndex = pinnedSessions.findIndex(s => s.id === String(over.id))
const reorderParents = (ids: string[]) => setSidebarWorkspaceParentOrderIds(ids)
if (newIndex < 0) {
return
}
// Worktrees persist as one flat list (orderByIds applies it per parent), so a
// single parent's new worktree order is spliced back over its slice.
const reorderWorktree = (parentId: string, ids: string[]) =>
setSidebarWorkspaceOrderIds(
(agentTree ?? []).flatMap(parent => (parent.id === parentId ? ids : parent.groups.map(group => group.id)))
)
// Sortable ids are live session ids; the pinned store is keyed by durable
// (lineage-root) ids, so translate before reordering.
const dragged = sessionByAnyId.get(String(active.id))
reorderPinnedSession(dragged ? sessionPinId(dragged) : String(active.id), newIndex)
}
// Sortable rows carry live session ids; the pinned store is keyed by durable
// (lineage-root) ids, so translate before persisting the new order.
const reorderPinned = (ids: string[]) =>
setPinnedSessionOrder(
ids.map(id => {
const session = sessionByAnyId.get(id)
const handleAgentDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
}
const activeId = String(active.id)
const overId = String(over.id)
// Parent (repo) reorder.
const activeParent = parseParentDndId(activeId)
const overParent = parseParentDndId(overId)
if (activeParent || overParent) {
const parents = agentTree ?? []
const oldIdx = parents.findIndex(parent => parent.id === activeParent)
const newIdx = parents.findIndex(parent => parent.id === overParent)
if (oldIdx < 0 || newIdx < 0) {
return
}
setSidebarWorkspaceParentOrderIds(arrayMove(parents, oldIdx, newIdx).map(parent => parent.id))
return
}
// Worktree reorder — only within the parent that owns the dragged group. The
// persisted order is a single flat list; orderByIds applies it per parent.
const activeGroup = parseGroupDndId(activeId)
const overGroup = parseGroupDndId(overId)
if (activeGroup || overGroup) {
const parents = agentTree ?? []
const owner = parents.find(parent => parent.groups.some(group => group.id === activeGroup))
if (!owner || !owner.groups.some(group => group.id === overGroup)) {
return
}
const oldIdx = owner.groups.findIndex(group => group.id === activeGroup)
const newIdx = owner.groups.findIndex(group => group.id === overGroup)
if (oldIdx < 0 || newIdx < 0) {
return
}
const reordered = arrayMove(owner.groups, oldIdx, newIdx).map(group => group.id)
const nextFlat = parents.flatMap(parent =>
parent.id === owner.id ? reordered : parent.groups.map(group => group.id)
)
setSidebarWorkspaceOrderIds(nextFlat)
return
}
// Session reorder (only the ungrouped flat recents list).
const oldIdx = agentSessions.findIndex(s => s.id === activeId)
const newIdx = agentSessions.findIndex(s => s.id === overId)
if (oldIdx < 0 || newIdx < 0) {
return
}
setSidebarSessionOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id))
}
return session ? sessionPinId(session) : id
})
)
return (
<Sidebar
@ -961,7 +903,7 @@ export function ChatSidebar({
label={s.pinned}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
onReorderSessions={reorderPinned}
onResumeSession={onResumeSession}
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
onTogglePin={unpinSession}
@ -1038,7 +980,9 @@ export function ChatSidebar({
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
onReorderParents={showAllProfiles ? undefined : reorderParents}
onReorderSessions={showAllProfiles ? undefined : reorderSessions}
onReorderWorktree={showAllProfiles ? undefined : reorderWorktree}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
onTogglePin={pinSession}
@ -1225,7 +1169,12 @@ interface SidebarSessionsSectionProps {
labelMeta?: React.ReactNode
labelIcon?: React.ReactNode
sortable?: boolean
onReorder?: (event: DragEndEvent) => void
// Per-level reorder callbacks. Each is optional; a list is draggable iff its
// callback is supplied. The flat session list, the repo parents, and a parent's
// worktrees each own an independent ReorderableList, so nothing collides.
onReorderSessions?: (ids: string[]) => void
onReorderParents?: (ids: string[]) => void
onReorderWorktree?: (parentId: string, ids: string[]) => void
dndSensors?: ReturnType<typeof useSensors>
}
@ -1253,15 +1202,19 @@ function SidebarSessionsSection({
labelMeta,
labelIcon,
sortable = false,
onReorder,
onReorderSessions,
onReorderParents,
onReorderWorktree,
dndSensors
}: SidebarSessionsSectionProps) {
const hasTreeSessions = Boolean(tree?.some(parent => parent.sessionCount > 0))
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
const showEmptyState = forceEmptyState || (!hasGroupedSessions && !hasTreeSessions && sessions.length === 0)
const dndActive = sortable && !!onReorder
// The flat recents/pinned list is the only place sessions reorder by hand;
// grouped/tree views always sort by creation date and never drag.
const sessionsDraggable = sortable && !!onReorderSessions
const renderRow = (session: SessionInfo) => {
const renderRow = (session: SessionInfo, draggable: boolean) => {
const rowProps = {
isPinned: pinned,
isSelected: session.id === activeSessionId,
@ -1273,105 +1226,58 @@ function SidebarSessionsSection({
session
}
return sortable ? (
return draggable ? (
<SortableSidebarSessionRow key={session.id} {...rowProps} />
) : (
<SidebarSessionRow key={session.id} {...rowProps} />
)
}
const renderRows = (items: SessionInfo[]) => items.map(renderRow)
const renderSessionList = (items: SessionInfo[]) =>
dndActive ? (
<SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}>
{renderRows(items)}
</SortableContext>
) : (
renderRows(items)
)
const renderNestedSessionList = (items: SessionInfo[]) =>
dndActive ? (
<ReorderContext onReorder={onReorder} sensors={dndSensors}>
<SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}>
{renderRows(items)}
</SortableContext>
</ReorderContext>
) : (
renderRows(items)
)
// Sessions inside repos/worktrees are date-ordered and static.
const renderRows = (items: SessionInfo[]) => items.map(session => renderRow(session, false))
const flatVirtualized =
!showEmptyState && !groups?.length && !tree?.length && sessions.length >= VIRTUALIZE_THRESHOLD
let inner: React.ReactNode
let bodyOwnsDndContext = dndActive && !showEmptyState
if (showEmptyState) {
inner = emptyState
bodyOwnsDndContext = false
} else if (tree?.length) {
const parentNodes = tree.map(parent =>
dndActive ? (
onReorderParents ? (
<SortableSidebarWorkspaceParent
dndSensors={dndSensors}
key={parent.id}
onNewSession={onNewSessionInWorkspace}
onReorderWorktree={onReorderWorktree}
parent={parent}
renderRows={renderSessionList}
sortableGroups
renderRows={renderRows}
/>
) : (
<SidebarWorkspaceParent
key={parent.id}
onNewSession={onNewSessionInWorkspace}
parent={parent}
renderRows={renderSessionList}
renderRows={renderRows}
/>
)
)
inner = dndActive ? (
<ReorderContext onReorder={onReorder} sensors={dndSensors}>
<SortableContext items={tree.map(parent => parentDndId(parent.id))} strategy={verticalListSortingStrategy}>
{parentNodes}
</SortableContext>
</ReorderContext>
inner = onReorderParents ? (
<ReorderableList ids={tree.map(parent => parent.id)} onReorder={onReorderParents} sensors={dndSensors}>
{parentNodes}
</ReorderableList>
) : (
parentNodes
)
bodyOwnsDndContext = false
} else if (groups?.length) {
const groupNodes = groups.map(group =>
dndActive ? (
<SortableSidebarWorkspaceGroup
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
renderRows={renderNestedSessionList}
/>
) : (
<SidebarWorkspaceGroup
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
renderRows={renderSessionList}
/>
)
)
inner = dndActive ? (
<ReorderContext onReorder={onReorder} sensors={dndSensors}>
<SortableContext items={groups.map(g => groupDndId(g.id))} strategy={verticalListSortingStrategy}>
{groupNodes}
</SortableContext>
</ReorderContext>
) : (
groupNodes
)
bodyOwnsDndContext = false
// Profile/source groups never reorder; render them flat with static rows.
inner = groups.map(group => (
<SidebarWorkspaceGroup group={group} key={group.id} onNewSession={onNewSessionInWorkspace} renderRows={renderRows} />
))
} else if (flatVirtualized) {
inner = (
const virtual = (
<VirtualSessionList
activeSessionId={activeSessionId}
className={contentClassName}
@ -1381,21 +1287,28 @@ function SidebarSessionsSection({
onTogglePin={onTogglePin}
pinned={pinned}
sessions={sessions}
sortable={sortable}
sortable={sessionsDraggable}
workingSessionIdSet={workingSessionIdSet}
/>
)
} else {
inner = renderSessionList(sessions)
}
const body = bodyOwnsDndContext ? (
<ReorderContext onReorder={onReorder} sensors={dndSensors}>
{inner}
</ReorderContext>
) : (
inner
)
inner =
sessionsDraggable && onReorderSessions ? (
<ReorderableList ids={sessions.map(s => s.id)} onReorder={onReorderSessions} sensors={dndSensors}>
{virtual}
</ReorderableList>
) : (
virtual
)
} else if (sessionsDraggable && onReorderSessions) {
inner = (
<ReorderableList ids={sessions.map(s => s.id)} onReorder={onReorderSessions} sensors={dndSensors}>
{sessions.map(session => renderRow(session, true))}
</ReorderableList>
)
} else {
inner = renderRows(sessions)
}
// The virtualizer owns its own scroller, so suppress the wrapper's overflow
// to avoid a double scroll container.
@ -1413,7 +1326,7 @@ function SidebarSessionsSection({
/>
{open && (
<SidebarGroupContent className={resolvedContentClassName}>
{body}
{inner}
{footer}
</SidebarGroupContent>
)}
@ -1553,15 +1466,17 @@ interface SortableWorkspaceProps {
}
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(groupDndId(props.group.id))} />
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(props.group.id)} />
}
interface SidebarWorkspaceParentProps extends React.ComponentProps<'div'> {
parent: SidebarWorkspaceTree
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
// Whether the worktrees inside this parent reorder (wired to a SortableContext).
sortableGroups?: boolean
// When set, this parent's worktrees reorder inside their OWN ReorderableList, so a
// worktree drag only ever collides with its siblings — never the repos around it.
onReorderWorktree?: (parentId: string, ids: string[]) => void
dndSensors?: ReturnType<typeof useSensors>
// Whether this parent itself is draggable (set by useSortableBindings).
reorderable?: boolean
dragging?: boolean
@ -1574,7 +1489,8 @@ function SidebarWorkspaceParent({
parent,
renderRows,
onNewSession,
sortableGroups = false,
onReorderWorktree,
dndSensors,
reorderable = false,
dragging = false,
dragHandleProps,
@ -1597,7 +1513,7 @@ function SidebarWorkspaceParent({
const hiddenCount = soleWorktree ? Math.max(0, soleWorktree.sessions.length - visibleSessions.length) : 0
const groupNodes = parent.groups.map(group =>
sortableGroups ? (
onReorderWorktree ? (
<SortableSidebarWorkspaceGroup group={group} key={group.id} onNewSession={onNewSession} renderRows={renderRows} />
) : (
<SidebarWorkspaceGroup group={group} key={group.id} onNewSession={onNewSession} renderRows={renderRows} />
@ -1648,13 +1564,14 @@ function SidebarWorkspaceParent({
// Indent the worktrees under their repo; keep the column pinned to the
// rail so long branch labels truncate instead of shoving controls off.
<div className="grid grid-cols-[minmax(0,1fr)] gap-px pl-2.5">
{sortableGroups ? (
<SortableContext
items={parent.groups.map(group => groupDndId(group.id))}
strategy={verticalListSortingStrategy}
{onReorderWorktree ? (
<ReorderableList
ids={parent.groups.map(group => group.id)}
onReorder={ids => onReorderWorktree(parent.id, ids)}
sensors={dndSensors}
>
{groupNodes}
</SortableContext>
</ReorderableList>
) : (
groupNodes
)}
@ -1668,11 +1585,12 @@ interface SortableWorkspaceParentProps {
parent: SidebarWorkspaceTree
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
sortableGroups?: boolean
onReorderWorktree?: (parentId: string, ids: string[]) => void
dndSensors?: ReturnType<typeof useSensors>
}
function SortableSidebarWorkspaceParent(props: SortableWorkspaceParentProps) {
return <SidebarWorkspaceParent {...props} {...useSortableBindings(parentDndId(props.parent.id))} />
return <SidebarWorkspaceParent {...props} {...useSortableBindings(props.parent.id)} />
}
function SidebarCount({ children }: { children: React.ReactNode }) {

View file

@ -1,7 +1,7 @@
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useVirtualizer } from '@tanstack/react-virtual'
import { type FC, useCallback, useMemo, useRef } from 'react'
import { type FC, useCallback, useRef } from 'react'
import type { SessionInfo } from '@/hermes'
import { cn } from '@/lib/utils'
@ -48,7 +48,6 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
workingSessionIdSet
}) => {
const scrollerRef = useRef<HTMLDivElement | null>(null)
const ids = useMemo(() => sessions.map(s => s.id), [sessions])
const virtualizer = useVirtualizer({
count: sessions.length,
@ -101,21 +100,16 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
)
})
const list = (
// When sortable, the caller wraps this in a ReorderableList that owns the
// DndContext + SortableContext (keyed on the same ids); the virtualized rows
// just consume that context via useSortable.
return (
<div className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
{rows}
</div>
</div>
)
return sortable ? (
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{list}
</SortableContext>
) : (
list
)
}
interface VirtualSortableRowProps {

View file

@ -204,16 +204,15 @@ export function unpinSession(sessionId: string) {
}
}
export function reorderPinnedSession(sessionId: string, targetIndex: number) {
// Replace the whole pinned order at once (drag-reorder hands back the new order
// rather than a single move). Keep only ids that are actually pinned so a stale
// row can't smuggle an unpinned id into the store.
export function setPinnedSessionOrder(ids: string[]) {
const prev = $pinnedSessionIds.get()
const pinned = new Set(prev)
const next = ids.filter(id => pinned.has(id))
if (!prev.includes(sessionId)) {
return
}
const next = insertUniqueId(prev, sessionId, targetIndex)
if (!arraysEqual(prev, next)) {
if (next.length === prev.length && !arraysEqual(prev, next)) {
$pinnedSessionIds.set(next)
}
}