From 95bbc98481c7fa70da41c7a0e6184cda2c2069ec Mon Sep 17 00:00:00 2001 From: TheIcarusWings <10465470+TheIcarusWings@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:08:34 +0100 Subject: [PATCH 1/4] feat(sidebar): add user-defined thread folders Add collapsible, per-project folders to organize sidebar threads, with drag-and-drop (including multi-select moves), inline rename, and manual ordering of folders and of threads within a folder. State is client-only in useUiStateStore (localStorage); a thread belongs to at most one folder; ungrouped threads render below folders and remain sort-ordered. - uiStateStore: ThreadGroup model + reducers (create/rename/delete/move/ reorder/toggle), derived threadKey->groupId index, persistence round-trip, and syncThreadGroups orphan GC against the live snapshot. - sidebarThreadGrouping.ts: pure buildGroupedThreadLayout helper (+ tests). - SidebarThreadGroupRow.tsx: collapsible folder header that is both a sortable item and a drop target, with inline rename. - Sidebar.tsx: per-project DnD context, sortable thread rows with click-vs-drag guards, folder section rendering, pagination of the ungrouped list only, and context-menu CRUD (project header, multi-select, per-thread "Move to folder"). - service.ts: garbage-collect folder state when threads/projects disappear. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/Sidebar.tsx | 707 +++++++++++++++--- .../src/components/SidebarThreadGroupRow.tsx | 141 ++++ apps/web/src/environments/runtime/service.ts | 17 + apps/web/src/sidebarThreadGrouping.test.ts | 116 +++ apps/web/src/sidebarThreadGrouping.ts | 72 ++ apps/web/src/uiStateStore.test.ts | 172 +++++ apps/web/src/uiStateStore.ts | 451 ++++++++++- 7 files changed, 1569 insertions(+), 107 deletions(-) create mode 100644 apps/web/src/components/SidebarThreadGroupRow.tsx create mode 100644 apps/web/src/sidebarThreadGrouping.test.ts create mode 100644 apps/web/src/sidebarThreadGrouping.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 67b575e4b46..90c50f0214d 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,8 @@ interface SidebarThreadRowProps { cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; + threadDragInProgressRef: React.RefObject; + suppressThreadClickAfterDragRef: React.RefObject; } const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -343,9 +355,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP attemptArchiveThread, openPrLink, thread, + threadDragInProgressRef, + suppressThreadClickAfterDragRef, } = 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 +447,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 +605,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP return (
{prStatus && ( @@ -772,13 +819,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 +887,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 +915,9 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( hasOverflowingThreads, hiddenThreadStatus, orderedProjectThreadKeys, - renderedThreads, + pinnedCollapsedThread, + sections, + ungroupedRenderedThreads, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -856,92 +945,221 @@ 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(() =>