feat(web): drag threads across sections with consistent motion#9731

merged4
sidebar/mobile-active-order
sidebar/drag-sections
openedSep 4, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago

Only pinned threads previously had direct drag reordering. Threads can now move between Pinned, Active, and Settled, wake by dragging out of Snoozed, and keep the chosen position within Pinned or Active. Section hints appear during the drag without adding idle headers or moving the list at pickup.

A single sortable list keeps the dragged row and insertion gap continuous across section boundaries. Ordinary thread actions retain their section-change animations, including the card/compact fade, while drag completion avoids a second layout transition. Reduced-motion preferences are respected.

Older servers retain pinned reordering without settlement support. Drop planning reserves hidden order keys, and marker IDs cannot collide with scoped thread IDs.

Verified with 207 focused sidebar tests, web typecheck, targeted lint, and an integrated browser pass covering Active reordering, pin/unpin, and Settle/Un-settle. Recordings use disposable seeded threads. Web and desktop share this sidebar; mobile controls are in the preceding PR.

Before: dragging an Active threadAfter: the row follows the insertion gap
Before: Active row stays in placeAfter: Active row reorders with a section hint
Before: idle sidebarAfter: idle sidebar, without Pinned or Active headings
Before: idle sidebarAfter: idle sidebar without drag hints

Active reorder recordings: before, after.

Pinning and unpinning by drag:

https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/1caa2d3fcb56a1d0/web-cross-sections-final.mp4

Restored Settle and Un-settle animations:

https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/faabc7dac953ecb1/web-section-actions.mp4

Depends on #9730.

Prepared with Codex.


Note

Medium Risk Large interaction surface touching pin/settle/snooze lifecycle and order-key persistence with optimistic concurrency, but logic is heavily unit-tested and gated on server capabilities.

Overview Cross-section drag replaces pinned-only reordering: one DndContext and sortable list spans Pinned, Active, Settled, and Snoozed (wake on drag out; no drop into snooze). Section headers, dividers, and empty placeholders are sortable markers so the gap and drop targets stay continuous.

New Sidebar.drag and Sidebar.logic plumbing resolves destinations (resolveSidebarDropTarget), plans server writes (planSidebarThreadDrop — pin, unpin, settle, active reorder), and previews row state (applySidebarThreadDrop). Custom collision detection handles invalid targets and empty Pins; a custom sorting strategy projects layout across section boundaries (collapsed settled shelf, route row visibility, etc.).

Motion and animation: @formkit/auto-animate is removed in favor of Sidebar.motion for post-action section moves (with reduced-motion and drag suspend). animateSidebarLayoutChanges avoids double animation after drop. Active list order now follows activeOrderKey via shared client-runtime sort helpers; drops call reorderActiveThread when the server exposes threadActiveReorder.

Optimistic optimisticDrop generalizes the old pinned-order hold across section moves and key writes until canonical state catches up. UI shows drag destination hints and boundary labels only while dragging.

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

Note

Add cross-section thread dragging with consistent motion to sidebar

  • Adds dragging threads between Pinned, Active, Snoozed, and Settled sections. The drop planner generates lifecycle commands, and an optimistic projector previews the thread in its destination before server confirmation.
  • Introduces createSidebarListMotion in Sidebar.motion.ts to animate retained rows with 150ms ease-out translations, fade entering and exiting rows, and honor reduced-motion preferences.
  • Replaces pinned-only DnD utilities with a unified sortable list in Sidebar.tsx spanning all sections, including structural markers and empty-section placeholders as drop targets.
  • Risk: sortThreadsForSidebar now uses the client-runtime Active order-key sorter instead of the previous timestamp-and-id comparator, and activeThreadAnchorTimestampMs is removed from the threadSort re-export surface.

Macroscope summarized 33e70c4.

Synced from GitHub

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire—13.6 KiB—15.1 KiB✅
CodexThread snapshot wire—7.0 KiB—7.3 KiB✅
CodexLive turn WebSocket wire—6.6 KiB—7.8 KiB✅
CodexLive turn WebSocket decoded—57.1 KiB—66.4 KiB✅
CodexLive turn messages—10—21✅
ClaudeTotal thread wire—13.6 KiB—15.1 KiB✅
ClaudeThread snapshot wire—7.0 KiB—7.3 KiB✅
ClaudeLive turn WebSocket wire—6.5 KiB—7.8 KiB✅
ClaudeLive turn WebSocket decoded—57.8 KiB—66.4 KiB✅
ClaudeLive turn messages—9—21✅

Baseline: unavailable · PR result: e908032 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 113.9 KiB
  • Claude decoded thread snapshot: 114.6 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR adds cross-section drag-and-drop with persistent reordering and lifecycle mutations, while replacing the sidebar's drag and animation architecture. Because it changes existing ordering behavior and can issue multiple state-changing server commands, the scope and runtime impact require human review.

Not approved because:

  • Monthly spending limit reached (workspace setting). Approvability relies on correctness review in order to determine eligibility

Review your spending limits in Billing settings. You can add or adjust custom eligibility rules. Learn more.

CcursorBotreviewed · 2 inline threadsSep 4, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix is ON, but a cloud agent failed to start.

Reviewed by Cursor Bugbot for commit d2c3f461c5bfbee80ff37ef951516be5e66b88e6. Configure here.

apps/web/src/components/Sidebar.tsxline 3052Outdated · d2c3f46Open thread
……@@ -2735,77 +2977,122 @@ export default function Sidebar() {
3049+ canonicalSection !== optimisticDrop.section
3050+ ) {
3051+ setOptimisticDrop(null);
3052+ return;
CcursorBoton line 3052Sep 4, 2026

Optimistic drop clears on intermediate events

Medium Severity

The optimistic-drop hold is dropped as soon as canonical state leaves both the source and destination sections. A settle from Pinned or Snoozed emits unpin/unsnooze first, so the row can snap into Active (or Settled) before the rest of the command lands, then jump again — and a second drag can start against that half-applied state.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit d2c3f461c5bfbee80ff37ef951516be5e66b88e6. Configure here.

Verified against d2c3f461c5bfbee80ff37ef951516be5e66b88e6: the server emits thread.settled first, followed by unpin/unsnooze companion events (decider.ts:524).

A pinned thread therefore classifies directly as Settled; the hold remains until pinnedAt is cleared. A snoozed thread stays in Snoozed until unsnooze cleanup, then classifies as Settled. The classification and completion checks cover both paths, so this sequence never passes through Active. No code change needed.

apps/web/src/components/Sidebar.tsxline 3198Outdated · d2c3f46Open thread
……@@ -2847,71 +3134,324 @@ export default function Sidebar() {
3195+ if (settledRows.length === 0) {
3196+ items.push({ kind: "marker", marker: "settled-placeholder" });
3197+ }
3198+ items.push(...settledRows);
CcursorBoton line 3198Sep 4, 2026

Empty Settled section shown when idle

Medium Severity

The Settled header is always mounted whenever any thread exists, including when settledThreads is empty. Idle sidebars now show a Settled heading and a 36px placeholder that previously appeared only with settled rows, or during a drag as a drop hint.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit d2c3f461c5bfbee80ff37ef951516be5e66b88e6. Configure here.

This is intentional in d2c3f461c5bfbee80ff37ef951516be5e66b88e6: the empty Settled destination keeps its measured geometry so pickup does not shift the list. Settled remains an available shelf and drop destination.

The placeholder's label and border are hidden at idle; its showHint condition becomes true only during an eligible drag. Pinned and Active use zero-height boundary markers with conditional labels, so those boundaries do not appear at idle. No code change needed.

apps/web/src/components/Sidebar.logic.tsline 95Outdated · d68f19bOpen thread
……@@ -77,12 +77,256 @@ export function useRetainedValue<T>(key: string | null, value: T | null): T | nu
92+
93+/** Sortable ids: thread rows use their scoped key; structural items use a
94+ prefix that cannot collide with `<environmentId>:<threadId>`. */
95+const SIDEBAR_MARKER_PREFIX = "marker:";
MmacroscopeappBoton line 95Sep 5, 2026

🟡 Medium components/Sidebar.logic.ts:95

SIDEBAR_MARKER_PREFIX does not make marker IDs distinct from scoped thread keys: an environment marker with thread ID pinned-header produces the same ID, marker:pinned-header. resolveSidebarDropTarget then resolves the dragged thread as the marker, rejects the drop, and dnd-kit receives duplicate sortable IDs. Use a collision-free encoding for both thread and marker sortable IDs.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/Sidebar.logic.ts around line 95:
2
3`SIDEBAR_MARKER_PREFIX` does not make marker IDs distinct from scoped thread keys: an environment `marker` with thread ID `pinned-header` produces the same ID, `marker:pinned-header`. `resolveSidebarDropTarget` then resolves the dragged thread as the marker, rejects the drop, and dnd-kit receives duplicate sortable IDs. Use a collision-free encoding for both thread and marker sortable IDs.

Fixed in 33e70c44a31. Marker IDs now use a colon-free namespace. Every scoped thread key contains the environment/thread colon separator, so the two sets are disjoint even for environment marker and thread pinned-header. The regression test drags that exact scoped key and verifies unique sortable IDs and the resulting order.

apps/web/src/components/Sidebar.tsxlines 2490–2494Outdated · d68f19bOpen thread
……@@ -2269,17 +2473,46 @@ export default function Sidebar() {
2491+ supportsSettlement &&
2492+ capabilities?.threadPinning === true &&
2493+ capabilities.threadPinReorder === true
2494+ ) {
MmacroscopeappBoton lines 2490–2494Sep 5, 2026

🟠 High components/Sidebar.tsx:2490

Pinned rows become non-draggable on servers that support pinning and threadPinReorder but not threadSettlement, so users lose the existing pin-reorder functionality. Remove supportsSettlement from this drag eligibility check.

suggestion
1 if (
2 capabilities?.threadPinning === true &&
3 capabilities.threadPinReorder === true
4 ) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/Sidebar.tsx around lines 2490-2494:
2
3Pinned rows become non-draggable on servers that support pinning and `threadPinReorder` but not `threadSettlement`, so users lose the existing pin-reorder functionality. Remove `supportsSettlement` from this drag eligibility check.

Fixed in 33e70c44a31. Pickup now requires pinning and pin-reorder support without requiring settlement. Both preview and commit planning reject settlement when the source server lacks that capability. The regression test verifies pin reordering remains available while settlement is rejected.

apps/web/src/components/Sidebar.logic.tsline 237Outdated · d68f19bOpen thread
……@@ -77,12 +77,256 @@ export function useRetainedValue<T>(key: string | null, value: T | null): T | nu
234+ ) {
235+ return { kind: "none" };
236+ }
237+ const assignments = planPinnedReorder({
MmacroscopeappBoton line 237Sep 5, 2026

🟡 Medium components/Sidebar.logic.ts:237

planSidebarThreadDrop can assign an activeOrderKey or pin orderKey already held by a hidden snoozed thread, so waking that thread creates duplicate keys and the persisted order no longer matches the drop. The caller populates activeKeysById and pinnedKeysById from visible rows only; include all threads retaining Active or pinned keys as ordering anchors, or reject generated keys that exist in the complete set.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/Sidebar.logic.ts around line 237:
2
3`planSidebarThreadDrop` can assign an `activeOrderKey` or pin `orderKey` already held by a hidden snoozed thread, so waking that thread creates duplicate keys and the persisted order no longer matches the drop. The caller populates `activeKeysById` and `pinnedKeysById` from visible rows only; include all threads retaining Active or pinned keys as ordering anchors, or reject generated keys that exist in the complete set.

Fixed across the stack. The shared planner reserves keys held by rows outside the displayed order for both midpoint insertion and keyless materialization. Web supplies every thread key, and both mobile entry points do the same. Hidden rows receive no writes. Focused regressions cover Active and pinned lists.

Confirmed: hidden keys are reserved during generation and hidden rows are not assigned. The focused regressions cover the reported collision.

Synced from GitHub

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

Synced from GitHub

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

Added 4 commits

  • feat(web): drag threads across sections with consistent motion0746ca1
  • fix(web): retain fades when thread rows change size463aeb6
  • fix(web): preserve sidebar drag compatibility and hidden slots95043fc
  • fix(web): retain current pull request state after rebasee908032