Add user-defined thread folders to the sidebar#3071
Conversation
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 <noreply@anthropic.com>
Folder-member rows now render inset with a left vertical guide so the nesting under a folder header is visually obvious; ungrouped and collapsed-pinned threads stay flush. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 4 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a14b59c. Configure here.
| onThreadDragCancel={handleThreadDragCancel} | ||
| activeDragLabel={activeDragLabel} | ||
| threadDragInProgressRef={threadDragInProgressRef} | ||
| suppressThreadClickAfterDragRef={suppressThreadClickAfterDragRef} |
There was a problem hiding this comment.
Jump shortcuts ignore folder layout
Medium Severity
visibleSidebarThreadKeys still builds a flat, sort-ordered, preview-capped thread list per project. Per-project rendering now uses buildGroupedThreadLayout (folders first, pagination only on ungrouped rows). Thread-jump shortcuts, hint labels, orderedSidebarThreadKeys, and prewarm subscriptions therefore no longer match what the sidebar actually shows.
Reviewed by Cursor Bugbot for commit a14b59c. Configure here.
| return [activeKey]; | ||
| } | ||
| return orderedRenderedThreadKeys.filter((key) => selected.has(key)); | ||
| }, |
There was a problem hiding this comment.
Drag ignores off-screen selection
Medium Severity
resolveDraggedThreadKeys only includes selected keys that appear in orderedRenderedThreadKeys. The multi-select context menu’s “Move to folder” uses the full selection. Dragging after a multi-select can move fewer threads than the menu would for the same selection (e.g. threads in collapsed folders or behind “Show more”).
Additional Locations (1)
Reviewed by Cursor Bugbot for commit a14b59c. Configure here.
| moveThreadsToGroup(draggedKeys, null); | ||
| } else { | ||
| moveThreadsToGroup(draggedKeys, overGroupId, overId); | ||
| } |
There was a problem hiding this comment.
Same-target drop reorders folder
Low Severity
handleThreadDragEnd never returns early when activeId equals overId. For a thread in a folder, dropping on itself runs moveThreadsToGroup with beforeThreadKey set to a key already removed from the list, so the thread is appended to the end of that folder instead of leaving order unchanged.
Reviewed by Cursor Bugbot for commit a14b59c. Configure here.
| const handleThreadDragCancel = useCallback((_event: DragCancelEvent) => { | ||
| threadDragInProgressRef.current = false; | ||
| setActiveDragLabel(null); | ||
| }, []); |
There was a problem hiding this comment.
Cancelled drag blocks next click
Low Severity
handleThreadDragStart sets suppressThreadClickAfterDragRef to true, but handleThreadDragCancel only clears threadDragInProgressRef and activeDragLabel. After a cancelled drag, the next row click is swallowed by the suppress guard and does not navigate.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit a14b59c. Configure here.
| {isRenaming ? ( | ||
| <input | ||
| ref={handleRenameRef} | ||
| className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-[11px] outline-none" | ||
| value={renamingTitle} | ||
| onChange={(event) => setRenamingTitle(event.target.value)} | ||
| onKeyDown={handleRenameKeyDown} | ||
| onBlur={() => commitRename(group.id)} | ||
| onClick={(event) => event.stopPropagation()} | ||
| /> | ||
| ) : ( | ||
| <span className="min-w-0 flex-1 truncate">{group.name}</span> | ||
| )} |
There was a problem hiding this comment.
🟡 Medium components/SidebarThreadGroupRow.tsx:120
Pressing Escape to cancel renaming calls cancelRename(), but the input's onBlur handler unconditionally calls commitRename(group.id) when the input loses focus. This causes the rename to be committed even when the user explicitly canceled it, defeating the Escape key functionality.
{isRenaming ? (
<input
ref={handleRenameRef}
className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-[11px] outline-none"
value={renamingTitle}
onChange={(event) => setRenamingTitle(event.target.value)}
onKeyDown={handleRenameKeyDown}
- onBlur={() => commitRename(group.id)}
+ onBlur={() => {
+ if (!event.defaultPrevented) {
+ commitRename(group.id);
+ }
+ }}
onClick={(event) => event.stopPropagation()}
/>
) : (Also found in 1 other location(s)
apps/web/src/components/Sidebar.tsx:2421
handleThreadDragCanceldoes not resetsuppressThreadClickAfterDragRef.currentandsuppressGroupClickAfterDragRef.current. When a user cancels a drag operation (e.g., pressing Escape), these flags remaintruefromhandleThreadDragStart, causing the next click on any thread row or folder header to be incorrectly suppressed. The flags are only reset inside their respective click handlers (handleRowClickinSidebarThreadRowandhandleToggleGroup), but after a cancelled drag, no synthetic click event fires to trigger this reset.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/SidebarThreadGroupRow.tsx around lines 120-132:
Pressing Escape to cancel renaming calls `cancelRename()`, but the input's `onBlur` handler unconditionally calls `commitRename(group.id)` when the input loses focus. This causes the rename to be committed even when the user explicitly canceled it, defeating the Escape key functionality.
Evidence trail:
SidebarThreadGroupRow.tsx:85-87 (Escape calls cancelRename with no guard), SidebarThreadGroupRow.tsx:127 (onBlur unconditionally calls commitRename), Sidebar.tsx:2345-2351 (commitGroupRename performs renameThreadGroupAction), Sidebar.tsx:2353-2355 (cancelGroupRename just sets state), Sidebar.tsx:525-541 (thread row rename has renamingCommittedRef guard pattern that the group row is missing)
Also found in 1 other location(s):
- apps/web/src/components/Sidebar.tsx:2421 -- `handleThreadDragCancel` does not reset `suppressThreadClickAfterDragRef.current` and `suppressGroupClickAfterDragRef.current`. When a user cancels a drag operation (e.g., pressing Escape), these flags remain `true` from `handleThreadDragStart`, causing the next click on any thread row or folder header to be incorrectly suppressed. The flags are only reset inside their respective click handlers (`handleRowClick` in `SidebarThreadRow` and `handleToggleGroup`), but after a cancelled drag, no synthetic click event fires to trigger this reset.
ApprovabilityVerdict: Needs human review 1 blocking correctness issue found. This PR introduces a substantial new feature (thread folders with drag-and-drop organization) involving new UI components, state management, and persistence. Additionally, there are multiple unresolved review comments identifying bugs in drag behavior, keyboard shortcuts, and rename cancellation that should be addressed before merge. You can customize Macroscope's approvability policy. Learn more. |


Summary
Adds user-defined folders to organize threads within a project in the sidebar. You can create named folders (e.g. "PRs in review", "experiments"), drag threads into them, reorder threads and folders, and collapse/expand folders — all persisted locally.
What's included
Design decisions
useUiStateStore(localStorage), the same place project order/expand state already live. No server/contracts/decider/projector/DB changes. (Trade-off: does not sync across machines; can be lifted to the server later behind the same UI.)threadKeysarray is the single source of truth for both membership and within-folder order; a derived reverse index gives O(1) lookups).syncThreadGroupsgarbage-collects membership for threads/projects that disappear from the live snapshot.Known v1 limitation
Switching the project grouping mode can change a project's logical key and detach its folders. This is non-destructive — affected threads fall back to ungrouped, and membership (keyed by stable thread key) is never lost.
Files
New:
apps/web/src/sidebarThreadGrouping.ts(+ test),apps/web/src/components/SidebarThreadGroupRow.tsxChanged:
apps/web/src/uiStateStore.ts(+ test),apps/web/src/components/Sidebar.tsx,apps/web/src/environments/runtime/service.tsTesting
pnpm --filter @t3tools/web typecheck— cleancd apps/web && pnpm exec vp test run --project unit— 1063 passed (incl. new reducer, persistence round-trip, and layout-helper tests)pnpm exec vp lint/vp fmt— cleanpnpm --filter @t3tools/web build) — compiles🤖 Generated with Claude Code
Note
Medium Risk
Large Sidebar interaction surface (DND, selection, pagination) with persisted local state; no server/auth changes, but regressions in navigation or thread ordering are plausible.
Overview
Adds client-only thread folders per project in the sidebar: create/rename/delete folders, collapse/expand with counts, and organize threads via drag-and-drop or Move to folder context menus (including multi-select).
uiStateStoregainsThreadGroupstate (membership order, folder order per project, collapse), persistence in localStorage, andsyncThreadGroupsto prune dead threads/projects on snapshot reconcile (wired fromenvironments/runtime/service).Sidebarrenders folder sections viabuildGroupedThreadLayout, newSidebarThreadGroupRow, per-projectDndContext(reorder folders/threads, drop onto headers or Remove from folder), drag-vs-click guards, and inset styling for folder members. Show more caps only the ungrouped list so full folders stay visible.New pure helper
sidebarThreadGrouping(+ unit tests) and expandeduiStateStoretests (reducers + persistence round-trip).Reviewed by Cursor Bugbot for commit a14b59c. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add user-defined thread folders to the sidebar
useUiStateStore.UngroupedDropZoneappears during drag to allow dropping threads back to the ungrouped area.syncThreadGroupsreducer prunes stale thread/project memberships on reconciliation.📊 Macroscope summarized a14b59c. 5 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted
🗂️ Filtered Issues
No issues evaluated.