Skip to content

feat(web): drag sidebar threads between categories - #8001

Open
tarik02 wants to merge 25 commits into
pingdotgg:mainfrom
tarik02:feature/sidebar-dnd-upstream
Open

feat(web): drag sidebar threads between categories#8001
tarik02 wants to merge 25 commits into
pingdotgg:mainfrom
tarik02:feature/sidebar-dnd-upstream

Conversation

@tarik02

@tarik02 tarik02 commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

What Changed

  • Allow dragging threads between Pinned, Regular, Snoozed, and Settled on web and desktop.
  • Apply the destination state on drop. Pinned preserves the chosen position; other categories use their normal sort order. Dropping into Snoozed opens the existing duration menu.
  • Replace the pinned-only sorter with one flat dnd-kit board. dnd-kit handles sortable transforms, auto-scroll compensation, the drag overlay, and drop animation.
  • Keep the source slot and viewport stable while category geometry changes.
  • Use the existing lifecycle commands. Their deciders clear conflicting pinned, settled, and snoozed state atomically, without adding a command or event.
  • Keep mobile lifecycle menus consistent with the category rules.

Why

The sidebar previously allowed reordering only inside Pinned.

The diff is larger than a normal UI patch because the old pinned-only implementation lived inside Sidebar.tsx. This PR moves drag state, collision handling, pinned ordering, viewport anchoring, and board rendering into focused modules. The resulting design is documented in docs/internals/sidebar-thread-dnd.md.

UI Changes

Before: drag and drop only reordered threads within Pinned.

After:

recording-a377fc69-049a-472c-9a65-b0a7aa970521.mp4

The recording covers pinned reorder, moves between every category, the snooze menu, card-to-compact morphing, auto-scroll, and viewport stability.

Verification

  • Targeted lint passed.
  • @t3tools/web typecheck passed.
  • 128 focused sidebar and lifecycle tests passed.
  • Manually tested pinned reorder, pin/unpin, settle/unsettle, snooze selection and cancellation, collapsed categories, auto-scroll, and drop animations.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

Model: GPT-5.6-sol
Harness: Codex in T3 Code


Note

High Risk
Changes orchestration companion events for pin/snooze/settle/unpin so categories are exclusive, plus a large new drag-and-drop path that issues those lifecycle commands.

Overview
Users can drag web sidebar threads between Pinned, Regular, Snoozed, and Settled. A drop runs the matching lifecycle command (pin/unpin, settle/unsettle, snooze/wake). Pinned drops keep the chosen slot; other sections snap to their normal sort. Snooze still opens the duration picker.

The old pinned-only sorter is replaced by a single dnd-kit board (SidebarThreadBoard + useSidebarThreadDnd) with custom collision, overlay morphing, drop outlines, and viewport anchoring.

Server: pin, snooze, settle, unpin, unsettle, and wake now clear conflicting category state via planThreadCategoryCleanup. Pinning a neutral thread also stamps thread.unsettled. Snooze no longer preserves pin; Wake returns to Regular. Mobile menus and snooze toasts (“Wake” instead of “Undo”) follow the same exclusive-category rules.

Reviewed by Cursor Bugbot for commit d4975c7. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add drag-and-drop reordering of sidebar threads across categories on web

  • Introduces a flat DnD board model for the web sidebar with four sections (pinned, regular, snoozed, settled), custom collision detection, pointer-overlay morph animations, and anchored scroll stability via useSidebarDndLayout and useSidebarThreadDnd.
  • Replaces the previous per-row @dnd-kit pinned reordering logic in Sidebar.tsx with a unified SidebarThreadBoard component and SidebarThreadDndShell wrappers; removes animatePinnedLayoutChanges and SortablePinnedThreadRow.
  • Server decider.ts now emits companion cleanup events (thread.unpinned, thread.unsettled, thread.unsnoozed) via a shared planThreadCategoryCleanup helper when a thread moves between categories (pin/unpin/snooze/unsnooze/settle/unsettle).
  • Snoozed threads are now sorted by wake time via sortSnoozedThreadsForSidebar; snooze toast action label changes from "Undo" to "Wake" on web and mobile.
  • Mobile ThreadListV2Row removes the unsettle option from snoozed rows and always shows the pin menu item for settled slim rows.
  • Behavioral Change: decideOrchestrationCommand cases for thread.unsettle, thread.snooze, thread.unsnooze, and thread.unpin now emit additional companion events that previously were not emitted; tests in decider.pinned.test.ts and decider.snoozed.test.ts updated to expect sequences like ["thread.pinned", "thread.unsettled"].

Macroscope summarized d4975c7.

@tarik02 tarik02 changed the title Feature/sidebar dnd upstream feat(web): drag sidebar threads between categories Aug 23, 2026
@coderabbitai

coderabbitai Bot commented Aug 23, 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 Plus

Run ID: be011da6-bd26-49f9-b939-a04d3277a37d

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

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.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 23, 2026
Comment thread apps/web/src/hooks/useSidebarPinnedDnd.ts Outdated
threadId: command.threadId,
commandId: command.commandId,
occurredAt,
clear: ["pinned", "settled"],

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 orchestration/decider.ts:690

Snoozing a neutral thread emits thread.unsettled, leaving settledOverride as "active"; after waking, that override prevents the thread from auto-settling normally. planThreadCategoryCleanup treats every value other than "active" as settled, so this clear request must only emit thread.unsettled when the current override is actually "settled".

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/decider.ts around line 690:

Snoozing a neutral thread emits `thread.unsettled`, leaving `settledOverride` as `"active"`; after waking, that override prevents the thread from auto-settling normally. `planThreadCategoryCleanup` treats every value other than `"active"` as settled, so this `clear` request must only emit `thread.unsettled` when the current override is actually `"settled"`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaving this unchanged. An explicit snooze is modeled as a move to Snoozed, not as a deferred restoration marker. The decider intentionally emits thread.unsettled for a neutral thread so a later wake returns it to Regular, and decider.snoozed.test.ts asserts that event sequence. Restricting cleanup to the settled override would change the accepted lifecycle semantics.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

@macroscopeapp macroscopeapp 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.

UI consistency review of the new sidebar drag-and-drop surfaces. Two findings, both in newly added web UI code; everything else (primitive reuse, ScrollArea/SidebarContent viewportRef contract, Tailwind ownership, reduced-motion handling, drag-preview geometry vs. contain-intrinsic-size) looks consistent with the existing system.

Posted via Macroscope — UI Consistency

Comment thread apps/web/src/components/sidebar/SidebarThreadBoard.tsx Outdated
Comment thread apps/web/src/components/sidebar/SidebarThreadDnd.tsx Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 399a71d142

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +158 to +159
case "unsettle":
return capabilities?.threadSettlement === true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Gate Regular drops on category-exclusivity support

When the current web client connects to a server from before this commit, threadSettlement: true only means the server understands thread.unsettle; it does not mean that command also clears a pin. For example, dragging an auto-settled pinned thread to Regular passes this check, but the older decider leaves pinnedAt intact, so reconciliation places the row in Pinned instead of the requested destination. The existing command capabilities cannot distinguish the changed lifecycle semantics, so cross-category drops need a new capability/version gate or a compatibility command sequence.

AGENTS.md reference: AGENTS.md:L72-L74

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaving this unchanged. This PR intentionally makes the existing lifecycle commands perform their category cleanup atomically, and compatibility with servers that implement the earlier command semantics is outside its scope. Adding a protocol capability or a client-side compatibility sequence would add machinery for a version combination we do not support here.

Comment on lines +857 to +862
if (
transaction === null ||
(transaction.phase !== "dragging" &&
transaction.phase !== "dropping" &&
transaction.phase !== "awaiting-snooze-choice")
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear projected entries when the sidebar scope changes

When a remote lifecycle command is in committing or reconciling, this guard excludes both phases from scope invalidation. If the user switches project scope while waiting for the command receipt or shell snapshot, transaction.entries continues rendering the previous project's board under the new scope until reconciliation completes; after a disconnect following a successful receipt, that stale board can remain indefinitely. The scope check should also clear the projected transaction during these pending phases.

AGENTS.md reference: AGENTS.md:L74-L74

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9d856a3b6. Scope changes and search now clear projected entries in every transaction phase. beginReconciliation also verifies that the committing transaction is still current, so an async command completion after the view changes cannot restore the old board.

Comment thread apps/server/src/orchestration/decider.ts
@macroscopeapp

macroscopeapp Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Skipped

Macroscope did not run approvability analysis for this PR. Macroscope could not determine whether this PR modifies its approvability configuration, so the PR was not approved automatically. A PR that may change the rules that govern approval is never approved automatically.

Not approved because:

  • 2 blocking correctness issues found at or above your repo's Minimum Blocking Severity

finishTransaction();
return;
}
for (const assignment of pinnedPlan.assignments) {

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 hooks/useSidebarThreadDnd.ts:360

Pinned-order updates are left partially persisted when any rebalance command fails. handlePinnedReorder and the preparation loop in commitLifecycleDrop execute multiple reorderPinnedThread/pinThread commands sequentially, but failure only clears the optimistic transaction; it never restores commands that already succeeded, so the canonical pinOrderKey values expose a corrupted order (including an insertion preparation without the source pin). These operations need rollback/compensation for previously successful assignments before the optimistic state is cleared.

Also found in 1 other location(s)

apps/web/src/hooks/useSidebarPinnedDnd.ts:148

handlePinnedReorder executes rebalance assignments sequentially and aborts on the first failure without restoring assignments that already succeeded. planPinnedReorder can return assignments for the entire pinned section when a neighboring thread is keyless or keys are corrupt, so a failure after one or more successful commands leaves the persisted pinOrderKey values only partially rewritten; clearing the optimistic state then exposes an incorrect pinned order.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/hooks/useSidebarThreadDnd.ts around line 360:

Pinned-order updates are left partially persisted when any rebalance command fails. `handlePinnedReorder` and the preparation loop in `commitLifecycleDrop` execute multiple `reorderPinnedThread`/`pinThread` commands sequentially, but failure only clears the optimistic transaction; it never restores commands that already succeeded, so the canonical `pinOrderKey` values expose a corrupted order (including an insertion preparation without the source pin). These operations need rollback/compensation for previously successful assignments before the optimistic state is cleared.

Also found in 1 other location(s):
- apps/web/src/hooks/useSidebarPinnedDnd.ts:148 -- `handlePinnedReorder` executes rebalance assignments sequentially and aborts on the first failure without restoring assignments that already succeeded. `planPinnedReorder` can return assignments for the entire pinned section when a neighboring thread is keyless or keys are corrupt, so a failure after one or more successful commands leaves the persisted `pinOrderKey` values only partially rewritten; clearing the optimistic state then exposes an incorrect pinned order.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaving this unchanged. These assignments can span environments, so compensation would be another fallible multi-command sequence that can race with remote state. Each successful key assignment is independently valid, partial materialization still produces a deterministic order, and a later reorder rematerializes any missing assignments. This matches the existing no-rollback design for pinned key materialization.

Comment thread apps/web/src/hooks/useSidebarPinnedDnd.ts Outdated

@macroscopeapp macroscopeapp 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.

One finding on the new sidebar drag board: the empty-section drop rail is missing for Snoozed, so an empty Snoozed section presents differently from the other three sections during a drag.

Posted via Macroscope — UI Consistency

Comment on lines +216 to +226
content =
snoozedThreadCount > 0 || railVisible ? (
<SidebarThreadShelfHeader
section="snoozed"
count={snoozedThreadCount}
expanded={props.snoozedShelf.expanded}
dropActive={railVisible || dnd.transaction?.target?.section === "snoozed"}
setDroppableNodeRef={bag.setDroppableNodeRef}
onToggle={props.snoozedShelf.onToggle}
/>
) : 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.

Empty Snoozed is the only section that does not get the dashed drop rail. Pinned, Regular, and Settled fall back to EmptySectionRail when their section is empty and a drop is possible, but Snoozed renders SidebarThreadShelfHeader with count={0} instead (and folds railVisible into dropActive), so dragging a snoozeable thread over an empty shelf shows a bare "Snoozed" header with no isOver highlight and a much smaller hit area — while docs/user/thread-sidebar.md in this PR says empty Pinned, Regular, Snoozed, and Settled all appear as drop targets. Consider mirroring the Settled branch.

Suggested change
content =
snoozedThreadCount > 0 || railVisible ? (
<SidebarThreadShelfHeader
section="snoozed"
count={snoozedThreadCount}
expanded={props.snoozedShelf.expanded}
dropActive={railVisible || dnd.transaction?.target?.section === "snoozed"}
setDroppableNodeRef={bag.setDroppableNodeRef}
onToggle={props.snoozedShelf.onToggle}
/>
) : null;
content =
snoozedThreadCount > 0 ? (
<SidebarThreadShelfHeader
section="snoozed"
count={snoozedThreadCount}
expanded={props.snoozedShelf.expanded}
dropActive={dnd.transaction?.target?.section === "snoozed"}
setDroppableNodeRef={bag.setDroppableNodeRef}
onToggle={props.snoozedShelf.onToggle}
/>
) : railVisible ? (
<EmptySectionRail
section="snoozed"
label="Snoozed"
isOver={bag.isOver}
setDroppableNodeRef={bag.setDroppableNodeRef}
/>
) : null;

Posted via Macroscope — UI Consistency

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaving this unchanged. The empty Snoozed header is intentionally the drop target; the product behavior here is to reveal the normal category header instead of a synthetic dashed rail. The user doc promises that the empty section appears as a target, not that every empty target shares the same presentation. Category ownership is also based on the shelf boundary collision logic rather than only the header element's pointer hit box.

Comment thread apps/web/src/hooks/useSidebarPinnedDnd.ts
Comment thread apps/web/src/components/sidebar/SidebarThreadBoard.tsx

@macroscopeapp macroscopeapp 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.

One finding on the latest commit's removal of the empty Regular drop rail. The previously reported empty-Snoozed rail inconsistency (SidebarThreadBoard.tsx, snoozed boundary) is unchanged and still stands — not re-commented.

Posted via Macroscope — UI Consistency

Comment thread apps/web/src/components/sidebar/SidebarThreadBoard.tsx Outdated

@macroscopeapp macroscopeapp 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.

UI consistency review of the sidebar drag-and-drop rework. The two items I raised on the previous head were addressed (aria-expanded now tracks the real shelf state via a separate dropActive presentation flag, and the grab cursor now lands on the [data-thread-row] surface). One earlier finding is still open (empty Snoozed renders a shelf header instead of the dashed EmptySectionRail the other three sections get — see the existing thread on SidebarThreadBoard.tsx), plus one new note below on coverage for the newly extracted pure drag logic.

Posted via Macroscope — UI Consistency

}

/** The lifecycle command that realizes a drop between two sidebar sections. */
export function resolveSidebarDndAction(input: {

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.

The new pure drag logic ships without tests: resolveSidebarDndAction (source/destination -> lifecycle command), resolveSidebarDndPreviewVariant, and the Sidebar.dnd.board.ts helpers (moveSidebarDndBoardThread, findSortedSidebarDndDropTarget) all decide which command a drop issues and where the row lands, and this PR also drops the animatePinnedLayoutChanges block from Sidebar.logic.test.ts and adds sortSnoozedThreadsForSidebar with no peer test. Every sibling in Sidebar.logic.ts (sortThreadsForSidebar, sortSettledThreadsForSidebar, sortPinnedThreadsForSidebar, planPinnedReorder, orderItemsByPreferredIds) has its own describe block, so these are the only sidebar lifecycle decisions without coverage.

Consider a focused Sidebar.dnd.logic.test.ts (plus a sortSnoozedThreadsForSidebar case alongside the existing sorter suites) covering each source/destination pair and the board move/target helpers — they are dependency-free and cheap to pin down.

Posted via Macroscope — UI Consistency

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 8d0b2e6. Sidebar.dnd.logic.test.ts now covers all 16 source/destination actions, preview variants, board movement, empty-section placement, and sorted targets. Sidebar.logic.test.ts now also covers snoozed wake-time ordering and stable ties.

@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 using high effort and found 1 potential issue.

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 1f62d2b. Configure here.

Comment thread apps/web/src/components/sidebar/SidebarThreadBoard.tsx Outdated

@macroscopeapp macroscopeapp 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.

Reviewed the sidebar drag-and-drop UI for shared-primitive usage, Tailwind ownership, and interaction/accessibility preservation. The Regular boundary droppable and the missing pure-logic tests flagged earlier are addressed. One accessibility regression remains in the new board structure.

Posted via Macroscope — UI Consistency

Comment thread apps/web/src/components/sidebar/SidebarThreadBoard.tsx

@macroscopeapp macroscopeapp 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.

One finding: the sidebar un-settle affordance loses its shared Tooltip, reverting a recently merged fix and diverging from the sibling icon buttons in the same row.

Posted via Macroscope — UI Consistency

Comment thread apps/web/src/components/Sidebar.tsx Outdated
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:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant