Skip to content

Add user-defined thread folders to the sidebar#3071

Open
TheIcarusWings wants to merge 2 commits into
pingdotgg:mainfrom
TheIcarusWings:t3code/sidebar-thread-folders
Open

Add user-defined thread folders to the sidebar#3071
TheIcarusWings wants to merge 2 commits into
pingdotgg:mainfrom
TheIcarusWings:t3code/sidebar-thread-folders

Conversation

@TheIcarusWings

@TheIcarusWings TheIcarusWings commented Jun 13, 2026

Copy link
Copy Markdown

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

  • Create / rename / delete folders from the project-header context menu, with inline rename.
  • Drag-and-drop threads into folders, reorder within a folder, and drag back out (a "Remove from folder" drop zone appears mid-drag). Multi-select moves are supported (drag/menu acts on the whole selection).
  • "Move to folder" submenus on the per-thread and multi-select context menus.
  • Collapsible folders with a count badge; manual ordering of folders (drag headers) and of threads within a folder.
  • Folder-member threads render inset with a vertical guide line for clear nesting.

Design decisions

  • Client-only persistence — folders, membership, order, and collapse live in 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.)
  • Within a single project — a folder belongs to one logical project and renders between the project header and its thread list.
  • A thread is in at most one folder (ordered threadKeys array is the single source of truth for both membership and within-folder order; a derived reverse index gives O(1) lookups).
  • Ungrouped threads stay sort-ordered below folders; pagination caps only the ungrouped list so a curated folder is never partially hidden behind "Show more".
  • syncThreadGroups garbage-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.tsx
Changed: apps/web/src/uiStateStore.ts (+ test), apps/web/src/components/Sidebar.tsx, apps/web/src/environments/runtime/service.ts

Testing

  • pnpm --filter @t3tools/web typecheck — clean
  • cd apps/web && pnpm exec vp test run --project unit1063 passed (incl. new reducer, persistence round-trip, and layout-helper tests)
  • pnpm exec vp lint / vp fmt — clean
  • Production build (pnpm --filter @t3tools/web build) — compiles
  • Manual + headless drive of the running app: create/rename folder, drag a thread in (count 0→1), persistence round-trip, zero console errors

🤖 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).

uiStateStore gains ThreadGroup state (membership order, folder order per project, collapse), persistence in localStorage, and syncThreadGroups to prune dead threads/projects on snapshot reconcile (wired from environments/runtime/service).

Sidebar renders folder sections via buildGroupedThreadLayout, new SidebarThreadGroupRow, per-project DndContext (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 expanded uiStateStore tests (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

  • Adds folder (thread group) creation, renaming, deletion, and reordering to the sidebar project thread list, persisted via useUiStateStore.
  • Threads can be moved into or out of folders via context menu ('Move to folder' / 'Remove from folder') or by drag-and-drop; multi-select is supported.
  • Folder headers are sortable via drag; thread rows render indented with a left border when inside a folder.
  • An UngroupedDropZone appears during drag to allow dropping threads back to the ungrouped area.
  • Folder state (order, membership, collapse) is saved to and restored from localStorage; a syncThreadGroups reducer prunes stale thread/project memberships on reconciliation.
  • Risk: DnD click suppression uses refs and a 6px drag distance threshold — fast clicks immediately after a drag may occasionally be swallowed.
📊 Macroscope summarized a14b59c. 5 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

TheIcarusWings and others added 2 commits June 13, 2026 15:08
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>
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: cc272ebf-0763-4057-9fa7-5bd6b22efdd3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jun 13, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Fix All in Cursor

❌ 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}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a14b59c. Configure here.

return [activeKey];
}
return orderedRenderedThreadKeys.filter((key) => selected.has(key));
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a14b59c. Configure here.

moveThreadsToGroup(draggedKeys, null);
} else {
moveThreadsToGroup(draggedKeys, overGroupId, overId);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a14b59c. Configure here.

const handleThreadDragCancel = useCallback((_event: DragCancelEvent) => {
threadDragInProgressRef.current = false;
setActiveDragLabel(null);
}, []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a14b59c. Configure here.

Comment on lines +120 to +132
{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>
)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

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.

🤖 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.

@macroscopeapp

macroscopeapp Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant