diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 6dcbff1d5af..6d65c4e59a3 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -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 }) { + 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 ( - - {children} + + + {children} + ) } @@ -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 ( 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 } @@ -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 ? ( ) : ( ) } - const renderRows = (items: SessionInfo[]) => items.map(renderRow) - - const renderSessionList = (items: SessionInfo[]) => - dndActive ? ( - s.id)} strategy={verticalListSortingStrategy}> - {renderRows(items)} - - ) : ( - renderRows(items) - ) - - const renderNestedSessionList = (items: SessionInfo[]) => - dndActive ? ( - - s.id)} strategy={verticalListSortingStrategy}> - {renderRows(items)} - - - ) : ( - 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 ? ( ) : ( ) ) - inner = dndActive ? ( - - parentDndId(parent.id))} strategy={verticalListSortingStrategy}> - {parentNodes} - - + inner = onReorderParents ? ( + parent.id)} onReorder={onReorderParents} sensors={dndSensors}> + {parentNodes} + ) : ( parentNodes ) - bodyOwnsDndContext = false } else if (groups?.length) { - const groupNodes = groups.map(group => - dndActive ? ( - - ) : ( - - ) - ) - - inner = dndActive ? ( - - groupDndId(g.id))} strategy={verticalListSortingStrategy}> - {groupNodes} - - - ) : ( - groupNodes - ) - bodyOwnsDndContext = false + // Profile/source groups never reorder; render them flat with static rows. + inner = groups.map(group => ( + + )) } else if (flatVirtualized) { - inner = ( + const virtual = ( ) - } else { - inner = renderSessionList(sessions) - } - const body = bodyOwnsDndContext ? ( - - {inner} - - ) : ( - inner - ) + inner = + sessionsDraggable && onReorderSessions ? ( + s.id)} onReorder={onReorderSessions} sensors={dndSensors}> + {virtual} + + ) : ( + virtual + ) + } else if (sessionsDraggable && onReorderSessions) { + inner = ( + s.id)} onReorder={onReorderSessions} sensors={dndSensors}> + {sessions.map(session => renderRow(session, true))} + + ) + } 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 && ( - {body} + {inner} {footer} )} @@ -1553,15 +1466,17 @@ interface SortableWorkspaceProps { } function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) { - return + return } 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 // 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 ? ( ) : ( @@ -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.
- {sortableGroups ? ( - groupDndId(group.id))} - strategy={verticalListSortingStrategy} + {onReorderWorktree ? ( + group.id)} + onReorder={ids => onReorderWorktree(parent.id, ids)} + sensors={dndSensors} > {groupNodes} - + ) : ( 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 } function SortableSidebarWorkspaceParent(props: SortableWorkspaceParentProps) { - return + return } function SidebarCount({ children }: { children: React.ReactNode }) { diff --git a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx index b2c6eff9f1c..5f82b305962 100644 --- a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx +++ b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx @@ -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 = ({ workingSessionIdSet }) => { const scrollerRef = useRef(null) - const ids = useMemo(() => sessions.map(s => s.id), [sessions]) const virtualizer = useVirtualizer({ count: sessions.length, @@ -101,21 +100,16 @@ export const VirtualSessionList: FC = ({ ) }) - 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 (
{rows}
) - - return sortable ? ( - - {list} - - ) : ( - list - ) } interface VirtualSortableRowProps { diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index 27799435daf..46cdf0ede2a 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -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) } }