diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 67b575e4b46..6cd7eee8274 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { CloudIcon, FolderPlusIcon, Globe2Icon, + GripVerticalIcon, SearchIcon, SettingsIcon, SquarePenIcon, @@ -26,10 +27,13 @@ import { DndContext, type DragCancelEvent, type CollisionDetection, + DragOverlay, + type Modifier, PointerSensor, type DragStartEvent, closestCorners, pointerWithin, + useDroppable, useSensor, useSensors, type DragEndEvent, @@ -77,7 +81,13 @@ import { import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useThreadRunningTerminalIds } from "../terminalSessionState"; import { useThreadDiscoveredPorts } from "../portDiscoveryState"; -import { useUiStateStore } from "../uiStateStore"; +import { newThreadGroupId, useUiStateStore } from "../uiStateStore"; +import { + buildGroupedThreadLayout, + type ThreadGroupSection, + threadKeyOf, +} from "../sidebarThreadGrouping"; +import SidebarThreadGroupRow, { groupHeaderDndId } from "./SidebarThreadGroupRow"; import { resolveShortcutCommand, shortcutLabelForCommand, @@ -215,6 +225,15 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map(); +// Stable empty array so per-project folder-order selectors don't churn renders. +const EMPTY_STRING_ARRAY: readonly string[] = []; +// Nudge the drag overlay down-right of the cursor so the row/folder being +// targeted underneath stays visible (the overlay no longer hides the drop spot). +const DRAG_OVERLAY_CURSOR_OFFSET: Modifier = ({ transform }) => ({ + ...transform, + x: transform.x + 16, + y: transform.y + 14, +}); const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", @@ -317,9 +336,17 @@ interface SidebarThreadRowProps { cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; + threadDragInProgressRef: React.RefObject; + /** When true the row sits inside a folder and is inset with a guide line. */ + indented?: boolean; } -const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { +// The heavy row content. Deliberately NOT a sortable: it is wrapped by the thin +// SidebarThreadRow below which owns useSortable, so this (expensive) subtree is +// not re-rendered on every drag-move frame. +const SidebarThreadRowContent = memo(function SidebarThreadRowContent( + props: SidebarThreadRowProps, +) { const { orderedProjectThreadKeys, isActive, @@ -343,6 +370,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP attemptArchiveThread, openPrLink, thread, + threadDragInProgressRef, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); @@ -405,26 +433,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const clearConfirmingArchive = useCallback(() => { setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); }, [setConfirmingArchiveThreadKey, threadKey]); - const handleMouseLeave = useCallback(() => { - clearConfirmingArchive(); - }, [clearConfirmingArchive]); - const handleBlurCapture = useCallback( - (event: React.FocusEvent) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; - } - clearConfirmingArchive(); - }); - }, - [clearConfirmingArchive], - ); const handleRowClick = useCallback( (event: React.MouseEvent) => { + // A drag in flight (and the synchronous trailing click dnd-kit may emit on + // drop) must not navigate/select. threadDragInProgressRef is cleared on the + // next animation frame after a drag ends, so a real click later still works. + if (threadDragInProgressRef.current) { + event.preventDefault(); + event.stopPropagation(); + return; + } handleThreadClick(event, threadRef, orderedProjectThreadKeys); }, - [handleThreadClick, orderedProjectThreadKeys, threadRef], + [handleThreadClick, orderedProjectThreadKeys, threadDragInProgressRef, threadRef], ); const handleOpenDiscoveredPort = useCallback( (event: React.MouseEvent) => { @@ -560,225 +581,293 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const rowButtonRender = useMemo(() =>
, []); return ( - - -
- {prStatus && ( - - - - - } - /> - {prStatus.tooltip} - - )} - {threadStatus && } - {renamingThreadKey === threadKey ? ( - + {prStatus && ( + + + + + } /> - ) : ( - - - {thread.title} - - } - /> - - {thread.title} - - - )} -
-
- {discoveredPorts.length > 0 && ( - - - } - > - - - - Open localhost:{discoveredPorts[0]?.port} - {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} - - - )} - {terminalStatus && ( - - - } - > - - - {terminalStatus.label} - - )} -
- {isConfirmingArchive ? ( - - ) : !isThreadRunning ? ( - appSettingsConfirmThreadArchive ? ( -
- -
- ) : ( + {prStatus.tooltip} + + )} + {threadStatus && } + {renamingThreadKey === threadKey ? ( + + ) : ( + + + {thread.title} + + } + /> + + {thread.title} + + + )} +
+
+ {discoveredPorts.length > 0 && ( + + + } + > + + + + Open localhost:{discoveredPorts[0]?.port} + {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} + + + )} + {terminalStatus && ( + + + } + > + + + {terminalStatus.label} + + )} +
+ {isConfirmingArchive ? ( + + ) : !isThreadRunning ? ( + appSettingsConfirmThreadArchive ? ( +
+ +
+ ) : ( + + + +
+ } + /> + Archive + + ) + ) : null} + + + {isRemoteThread && ( - -
+ } - /> - Archive + > + + + {threadEnvironmentLabel} - ) - ) : null} - - - {isRemoteThread && ( - - - } - > - - - {threadEnvironmentLabel} - - )} - {jumpLabel ? ( - - - } - > - {jumpLabel} - - {jumpLabel} - - ) : ( - + + } > - {formatRelativeTimeLabel( - thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, - )} - - )} - + {jumpLabel} + + {jumpLabel} + + ) : ( + + {formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + )} + + )} -
+
- + + + ); +}); + +// Thin sortable wrapper: owns useSortable + the draggable
  • , and the +// archive-confirm mouse/blur handlers. The expensive SidebarThreadRowContent +// inside is memoized and receives only stable props, so it is skipped while a +// drag is in progress (dnd-kit re-renders sortable nodes on every move). +const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + const { thread, indented = false, renamingThreadKey, setConfirmingArchiveThreadKey } = props; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const isRenaming = renamingThreadKey === threadKey; + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: threadKey, + disabled: isRenaming, + }); + const clearConfirmingArchive = useCallback(() => { + setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); + }, [setConfirmingArchiveThreadKey, threadKey]); + const handleMouseLeave = useCallback(() => { + clearConfirmingArchive(); + }, [clearConfirmingArchive]); + const handleBlurCapture = useCallback( + (event: React.FocusEvent) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } + clearConfirmingArchive(); + }); + }, + [clearConfirmingArchive], + ); + return ( + + ); }); +/** dnd id for the per-project "drop here to remove from folder" zone. */ +function ungroupedDropId(projectKey: string): string { + return `ungrouped:${projectKey}`; +} + +/** A drop target shown during a drag for moving a thread out of any folder. */ +function UngroupedDropZone({ projectKey }: { projectKey: string }) { + const { setNodeRef, isOver } = useDroppable({ id: ungroupedDropId(projectKey) }); + return ( + +
    + Remove from folder +
    +
    + ); +} + interface SidebarProjectThreadListProps { projectKey: string; projectExpanded: boolean; hasOverflowingThreads: boolean; hiddenThreadStatus: ThreadStatusPill | null; orderedProjectThreadKeys: readonly string[]; - renderedThreads: readonly SidebarThreadSummary[]; + pinnedCollapsedThread: SidebarThreadSummary | null; + sections: readonly ThreadGroupSection[]; + ungroupedRenderedThreads: readonly SidebarThreadSummary[]; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; @@ -817,6 +906,22 @@ interface SidebarProjectThreadListProps { openPrLink: (event: React.MouseEvent, prUrl: string) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; + // Folder header wiring. + renamingGroupId: string | null; + renamingGroupTitle: string; + setRenamingGroupTitle: (title: string) => void; + onToggleGroup: (groupId: string) => void; + onGroupContextMenu: (groupId: string, position: { x: number; y: number }) => void; + commitGroupRename: (groupId: string) => void; + cancelGroupRename: () => void; + // Thread/folder drag-and-drop wiring. + dndSensors: ReturnType; + dndCollisionDetection: CollisionDetection; + onThreadDragStart: (event: DragStartEvent) => void; + onThreadDragEnd: (event: DragEndEvent) => void; + onThreadDragCancel: (event: DragCancelEvent) => void; + activeDragLabel: string | null; + threadDragInProgressRef: React.RefObject; } const SidebarProjectThreadList = memo(function SidebarProjectThreadList( @@ -828,7 +933,9 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( hasOverflowingThreads, hiddenThreadStatus, orderedProjectThreadKeys, - renderedThreads, + pinnedCollapsedThread, + sections, + ungroupedRenderedThreads, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -856,92 +963,220 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( openPrLink, expandThreadListForProject, collapseThreadListForProject, + renamingGroupId, + renamingGroupTitle, + setRenamingGroupTitle, + onToggleGroup, + onGroupContextMenu, + commitGroupRename, + cancelGroupRename, + dndSensors, + dndCollisionDetection, + onThreadDragStart, + onThreadDragEnd, + onThreadDragCancel, + activeDragLabel, + threadDragInProgressRef, } = props; const showMoreButtonRender = useMemo(() =>