diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 67b575e4b4..0b93be24b3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -26,10 +26,12 @@ import { DndContext, type DragCancelEvent, type CollisionDetection, + DragOverlay, PointerSensor, type DragStartEvent, closestCorners, pointerWithin, + useDroppable, useSensor, useSensors, type DragEndEvent, @@ -77,7 +79,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 +223,8 @@ 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[] = []; const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", @@ -317,6 +327,10 @@ interface SidebarThreadRowProps { cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; + threadDragInProgressRef: React.RefObject; + suppressThreadClickAfterDragRef: 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) { @@ -343,9 +357,23 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP attemptArchiveThread, openPrLink, thread, + threadDragInProgressRef, + suppressThreadClickAfterDragRef, + indented = false, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); + const isRenamingThisRow = renamingThreadKey === threadKey; + const { + attributes: dragAttributes, + listeners: dragListeners, + setNodeRef: setDragNodeRef, + transform: dragTransform, + transition: dragTransition, + isDragging, + } = useSortable({ id: threadKey, disabled: isRenamingThisRow }); + // Suppress drag listeners while renaming so typing never starts a drag. + const rowDragHandleProps = isRenamingThisRow ? {} : { ...dragAttributes, ...dragListeners }; const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const runningTerminalIds = useThreadRunningTerminalIds({ @@ -422,9 +450,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ); const handleRowClick = useCallback( (event: React.MouseEvent) => { + // Mirror the project drag-vs-click guards: a drag in flight, or the + // trailing click dnd-kit emits after a drop, must not navigate/select. + if (threadDragInProgressRef.current) { + event.preventDefault(); + event.stopPropagation(); + return; + } + if (suppressThreadClickAfterDragRef.current) { + suppressThreadClickAfterDragRef.current = false; + event.preventDefault(); + event.stopPropagation(); + return; + } handleThreadClick(event, threadRef, orderedProjectThreadKeys); }, - [handleThreadClick, orderedProjectThreadKeys, threadRef], + [ + handleThreadClick, + orderedProjectThreadKeys, + suppressThreadClickAfterDragRef, + threadDragInProgressRef, + threadRef, + ], ); const handleOpenDiscoveredPort = useCallback( (event: React.MouseEvent) => { @@ -561,7 +608,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP return (
{prStatus && ( @@ -772,13 +824,36 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ); }); +/** 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 +892,23 @@ 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; + suppressThreadClickAfterDragRef: React.RefObject; } const SidebarProjectThreadList = memo(function SidebarProjectThreadList( @@ -828,7 +920,9 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( hasOverflowingThreads, hiddenThreadStatus, orderedProjectThreadKeys, - renderedThreads, + pinnedCollapsedThread, + sections, + ungroupedRenderedThreads, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -856,92 +950,222 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( openPrLink, expandThreadListForProject, collapseThreadListForProject, + renamingGroupId, + renamingGroupTitle, + setRenamingGroupTitle, + onToggleGroup, + onGroupContextMenu, + commitGroupRename, + cancelGroupRename, + dndSensors, + dndCollisionDetection, + onThreadDragStart, + onThreadDragEnd, + onThreadDragCancel, + activeDragLabel, + threadDragInProgressRef, + suppressThreadClickAfterDragRef, } = props; const showMoreButtonRender = useMemo(() =>