Sidebar thread drag-and-drop
Status: Proposed
Date: 2026-08-21
Baseline: upstream/main at 8f7da3b99
Scope: Modern web sidebar, desktop through the shared web client, shared thread state, and mobile action parity
This proposal turns the modern sidebar into one drag-and-drop board with four visible sections: Pinned, Regular, Snooze, and Settled. Pinned stays manually ordered. The other lists keep their existing natural order. Each drop sends one existing lifecycle command for the dragged thread, strengthened so its full category transition commits in one transaction. Preparatory reorder commands run only when a Pinned insertion needs fresh key spacing. The client controls temporary layout, scroll position, and motion until every applicable receipt lands.
The wire contract stays unchanged, and the client and server changes ship together without a compatibility branch. Stored thread categories are assumed to be exclusive at rollout; this work adds no migration or repair path for older overlapping fields. The plan does not add a stored section column; existing thread fields remain the source of truth. It also does not add native drag-and-drop to mobile or thread drag-and-drop to the legacy sidebar.
Requested behavior at a glance
| # |
Requested behavior |
Proposed rule |
| 1 |
Drag every thread between categories |
Rows from every section are draggable with a fine pointer on a capable environment. Lifecycle guards can still disable Settled or Snooze as a destination. |
| 2 |
Settle and un-settle automatically |
Category-changing lifecycle commands become atomic transitions. Leaving Settled stamps an active override; entering Settled stamps settlement and clears pin and snooze. |
| 3 |
Pin and unpin automatically |
Pinned is exclusive. Entering it pins; leaving it clears pinnedAt and pinOrderKey. |
| 4 |
Preserve pinned order, use natural order elsewhere |
Pinned writes fractional order keys. Regular stays creation-descending. Settled stays settlement-time-descending. Snooze stays wake-time-ascending. |
| 5 |
Drop into Snooze, then choose a duration |
Dropping opens the existing standard context menu at the drop point. No command runs until a preset is selected. |
| 6 |
FLIP the overlay between destination views |
The inert overlay renders as a card over Pinned or Regular and as a slim row over Settled or Snooze. Its inner layer runs a one-shot FLIP transition. |
| 7 |
Keep the viewport and hovered item stable |
In-flow changes use explicit scroll correction. When clamping makes correction impossible, a temporary viewport-overlay target avoids moving the hovered item. |
| 8 |
Preserve the cursor's position on the card |
Drag start captures a normalized point inside the source rect. Overlay size changes offset the preview so that point remains under the cursor. |
| 9 |
Reveal an empty Snooze section without a jump |
An eligible empty Snooze target mounts at drag start. Scroll correction or a viewport-overlay rail keeps the source or current target at the same viewport Y coordinate. |
| 10 |
Do the same for Pinned |
Empty Pinned, Regular, and Settled destinations use the same rule. |
| 11 |
Preview manual order only |
A same-section Pinned drag reorders through transforms. Cross-section entry into Pinned uses a zero-flow line. Sorted lists do not reorder; the source row remains in place at reduced opacity until the relevant command receipt. |
| 12 |
Give cross-list indicators zero layout height |
Insertion lines are absolutely positioned. They do not add a list item, placeholder, margin, or scroll height. |
Part I: ADR
Context
The current classifier gives state precedence in this order:
- Snoozed
- Pinned
- Settled
- Regular, named
activeThreads in the current code
The rendered board uses a different visual order: Pinned, Regular, Snoozed, then Settled. The new board keeps that visual order.
Only Pinned has drag-and-drop. It owns a nested DndContext, uses useSortable, and writes fractional pinOrderKey values. Regular and Settled already have the natural sorting required here. Snoozed sorts by the next wake time. The outer thread list uses autoAnimate, and the actual scroll owner is hidden inside SidebarContent and ScrollArea.
The state model already has all persistent fields needed for the feature:
settledOverride and settledAt
snoozedUntil and snoozedAt
pinnedAt and pinOrderKey
The server can already emit several lifecycle events for one command. thread.settle clears pin and snooze, while thread.pin clears explicit settlement and snooze. The orchestration engine stores every event returned by the decider, projects the bundle, and writes its receipt in one SQLite transaction.
The existing command vocabulary already covers every category transition. The inconsistency is inside the commands. thread.unpin can reveal automatic settlement, and thread.snooze can preserve an underlying pin or settlement. The fix is to make each category-changing lifecycle command finish the transition it names. The client still sends one command per dragged thread, so no partial intermediate state can commit.
Decision
1. Make category-changing lifecycle commands atomic
Do not change the wire contract. Add no command, event, capability, payload shape, schema, or persistence field. Keep thread.pin, thread.unpin, thread.pin.reorder, thread.settle, thread.unsettle, thread.snooze, and thread.unsnooze. Improve the six category-changing command branches so one command moves a thread from one exclusive category to another. Keep thread.pin.reorder key-only because Pinned key preparation must not change a thread's category.
The client selects the command from the source and destination:
| Drag intent |
Existing command |
| Enter Pinned at a boundary |
thread.pin with orderKey |
| Pinned to Regular |
thread.unpin |
| Settled to Regular |
thread.unsettle with reason: "user" |
| Snooze to Regular |
thread.unsnooze with reason: "user" |
| Enter Settled |
thread.settle |
| Enter Snooze or change its wake time |
thread.snooze with snoozedUntil |
| Reorder within Pinned |
thread.pin.reorder |
Regular-to-Regular, Settled-to-Settled, same-position Pinned, and a Snooze menu dismissal are client no-ops. A selected Snooze preset always runs thread.snooze, including Snooze-to-Snooze rescheduling. The following postconditions apply when a category-changing command runs. thread.pin.reorder changes only pinOrderKey and updatedAt.
| Destination |
Final projected state |
Server checks |
| Regular |
pinnedAt = null, pinOrderKey = null, snooze fields null, settledOverride = "active", settledAt = null |
Thread exists and is not archived |
| Pinned |
Pin present, snooze fields null, settledOverride = "active", settledAt = null. A supplied key is exact; an omitted key preserves an existing key. |
Thread exists and is not archived |
| Settled |
Pin and snooze fields null, settledOverride = "settled", settledAt stamped |
Existing settle guards for running, starting, blocked, and queued work |
| Snooze |
Pin fields null, future snooze fields set, settledOverride = "active", settledAt = null |
Existing snooze guards for blocked and queued work; wake time is in the future |
thread.snooze means move the thread to Snooze. It clears pin and settlement, so Wake returns to Regular. The stronger command semantics apply to the existing row menu, context menu, bulk action, mobile action, and DnD. There is no second Snooze mode and no separate Move to Snooze... command. A Snooze success toast labels its follow-up action Wake, not Undo, because the client does not promise restoration.
Make thread.unpin, user thread.unsettle, and user thread.unsnooze converge on the complete Regular state. In particular, unpin and wake must stamp the active override so automatic settlement cannot immediately move the row to Settled. Activity-driven thread.unsettled and thread.unsnoozed events keep their current neutral-reset semantics because they do not come through these user commands.
Pinned entry keeps the existing optional thread.pin.orderKey; DnD supplies the key returned by planPinnedReorder. Same-section reorder and preparatory spacing continue to use the key-only thread.pin.reorder command.
Each category command emits its existing primary event plus only the existing cleanup events needed to reach the table's postcondition. Order the bundle so detail subscribers move directly from source to destination. Preserve the current idempotent timestamp behavior when the destination already holds. A new Settle transition stamps the current time so natural order stays correct.
Assume the updated client and server ship together. Add no version marker or compatibility path. The client checks only the existing capability for the command it is about to send. Pinned insertion also checks threadPinReorder for each assignment returned by the existing order planner.
The engine appends and projects the event bundle and receipt in one SQLite transaction, so there is no intermediate committed database state. After commit it publishes the individual domain events in order before resolving the command promise. Thread-detail subscribers therefore reduce every event. Shell conversion rereads committed projection state and emits final fields, even if transport coalescing produces more than one upsert. The board keeps the dragged row in its source section until receipt while other subscribers process the ordered bundle.
A same-section drop within Regular or Settled sends no command. Same-position Pinned drops are also no-ops. Snooze-to-Snooze uses thread.snooze to reschedule in the same transaction.
2. Use one board-level drag context
Replace the nested Pinned context with one DndContext around the non-search thread board. Namespace draggable, row-target, and section IDs, for example thread:${scopedThreadKey}, row:${section}:${scopedThreadKey}, and section:${section}. Keep a discriminated data payload on each. Reorder-capable Pinned rows use one useSortable ID as their combined draggable and row target. Natural rows combine a row droppable with useDraggable when at least one cross-section command is available; a static row can still be a target. A Pinned row that supports category moves but not reorder uses the same draggable-and-droppable wrapper. Pinned keeps a SortableContext only for its reorder-capable rows.
Use a custom collision resolver with these priorities:
- An eligible section or row directly under the pointer
- A Pinned row boundary when the pointer is in Pinned
- The closest eligible section for pointer fallback and edge auto-scroll
Classify the raw pointer hit before applying eligibility. The resolver returns only eligible targets to DnD Kit, while the coordinator may retain an explicit disabled hit and its reason for UI feedback. A disabled Pinned row boundary stops fallback instead of silently becoming the keyed-run tail. A running thread may target Pinned, Regular, or Snooze, but not Settled. A thread waiting on approval or user input may target Pinned or Regular, but not Settled or Snooze.
Eligibility follows the command map and checks only the existing capability used by that drop. Pinned requires threadPinning, plus threadPinReorder for its order assignment. Settled requires threadSettlement; Snooze requires threadSnooze; Regular requires the capability for its source-specific Unpin, Un-settle, or Wake command.
Search results are not organizational lists, so starting a thread drag is disabled while search is active. Project scoping stays supported. The rendered targets follow the scope, while Pinned key calculation uses the complete unscoped pin order.
3. Keep one explicit drag transaction
The client coordinator uses four phases with destination-specific branches:
idle -> dragging
dragging -> committing -> idle
dragging -> awaiting-snooze-choice -> committing -> idle
dragging -> idle cancel or invalid drop
awaiting-snooze-choice -> idle menu dismissal or failure
dragging stores the thread key, source section, source rect, normalized pointer anchor, source snapshot, and current target. A drag-local ref tracks the raw pointer without causing React updates. awaiting-snooze-choice snapshots that ref as the release point and keeps the dim source projection and temporary section rails while the menu is open. committing stores the selected lifecycle command family, expected category, and any Pinned preparation receipts. It keeps the same projection until preparation and the dragged thread's command succeed or fail.
Rules for the transaction:
- A board-local source projection stays mounted and dimmed. The overlay is a separate inert view.
DragOverlay exists only during the dragging phase. It unmounts on pointer-up; awaiting-snooze-choice and committing retain only the dim source row and temporary section rails.
- A sorted destination never changes DOM order while dragging.
- A same-section Pinned drag uses live sortable transforms only while the pointer remains over Pinned.
- After a valid same-section Pinned drop, replace the transforms with the proposed optimistic Pinned logical order and keep it through the reorder receipt and shell-sequence confirmation. Failure rolls back to canonical order through an anchored layout update, without replaying the sortable animation.
- The moving visual exists only in
DragOverlay. Sorted useDraggable rows never apply the draggable transform to their source projection. A same-section Pinned drag applies only the sortable transforms needed to preview its proposed order and resets them when the pointer leaves Pinned.
- On cancel, invalid drop, or menu dismissal, the source returns to full opacity and temporary empty sections disappear through an anchored layout update.
- On failure, clear the preview, render canonical state, and show one destination-specific error toast.
- While committing, keep a board-local transaction projection that renders the source snapshot in its old section. Record the owning environment's shell fields and
snapshotSequence; its upsert may arrive before the command receipt.
- On a successful receipt, compare the observed shell
snapshotSequence with the receipt sequence and the selected command's expected final fields. If the shell has caught up, remove the dim source projection and render canonical state immediately. A conflicting category at a later sequence means another writer won. If the shell is behind, render an optimistic destination projection until it reaches the receipt sequence, then release to canonical fields.
- Before the lifecycle command dispatch, deletion, archive, filtering, capability loss, or a foreign category change cancels the local intent. A foreign Pinned membership or key change also invalidates pending key preparation. After dispatch, the client cannot claim to cancel server work. It records later shell state, keeps the source projection through the receipt when the row still exists, then uses sequence-aware reconciliation to render the actual last writer.
Parking the currently open thread through Settled or Snooze keeps the existing forward-navigation behavior. Navigation runs only after a successful receipt and only if the user is still viewing that thread. Pinned and Regular moves do not navigate.
4. Preserve manual and natural order differently
Pinned remains the only manual list. Reuse planPinnedReorder and the existing fractional key format.
Same-section Pinned reorder behaves as it does today: rows transform into their proposed order before release, then the optimistic logical order holds their positions through confirmation. A cross-section insertion into Pinned shows an absolute insertion line, but no placeholder. Existing Pinned rows keep their relative order because inserting one new member does not require them to swap visually. Same-section Pinned reorders use sortable transforms; cross-section Pinned insertions use a zero-flow indicator.
Pointer position above or below a Pinned row selects the boundary before or after it. A section-level collision selects the head or tail. Build the desired order from the complete unscoped Pinned section so project filtering cannot change global Pinned order. Pass that order to the existing planPinnedReorder; do not add another key validator or repair algorithm.
Split the returned assignments by thread. Existing Pinned rows receive thread.pin.reorder; the dragged thread receives one thread.pin with its assigned key. Check threadPinReorder only on environments that own those writes. Send existing-row assignments first and stop if one fails, leaving the dragged thread in its source category. Keep the current planner's no-rollback behavior for any assignments that already landed.
Regular, Settled, and Snooze never preview manual order:
- Regular shows a zero-space line at the predicted final boundary from the unchanged
createdAt sort, never at an arbitrary pointer boundary.
- A newly Settled thread shows its line at the natural top because settlement stamps the current time.
- Snooze highlights the section before the preset is known. After selection, it shows a zero-space line at the predicted wake-time boundary while the source stays dimmed until receipt.
5. Reuse the standard Snooze menu
Dropping on Snooze ends pointer dragging without sending a command. Extract a small showSnoozePresetMenu presentation helper because the current code builds presets inline inside broader menus. The helper receives presets and client coordinates, calls localApi.contextMenu.show, and returns the selected preset or null; it never dispatches a thread command. Desktop receives its native menu. Web and remote browsers receive the existing DOM fallback.
Selecting a preset moves the coordinator to committing and sends thread.snooze for both cross-section entry and Snooze-to-Snooze rescheduling. The command completes the category transition in the same transaction. Escape, outside click, or a menu failure cancels with no mutation. The empty Snooze target stays mounted while the menu is open, which prevents the layout from reversing under the menu.
Give each pending Snooze menu an interaction epoch. A desktop native menu cannot be dismissed programmatically. If the source disappears, its environment disconnects, scope changes, or another drag supersedes it, invalidate the epoch and ignore a late menu result.
Track the active pointer's raw clientX and clientY in capture-phase pointer move and release listeners. Keep the latest point in a ref, not reducer state. Snapshot the pointer-up coordinates before DnD Kit handles the drop and pass that point to the Snooze menu. Do not derive it from the vertical-axis-constrained overlay transform. Remove the listeners on drop, cancel, sensor teardown, and unmount.
Reuse the existing preset builder for the drop menu, row popover, context menu, bulk action, and mobile action. Menu results carry the preset identity, not a deadline captured when the menu opened. Resolve the absolute snoozedUntil when the user selects the preset, then dispatch the same thread.snooze operation from every entry point. The server still rejects a deadline that is not in the future.
6. Separate pointer movement from overlay morphing
DragOverlay renders a non-interactive ThreadDragPreview:
- Pinned or Regular target: full card
- Settled or Snooze target: slim row
The preview follows the resolved eligible destination, not the raw DOM element under the pointer. If there is no eligible target, it returns to the source variant.
At drag start, read clientX and clientY from DragStartEvent.activatorEvent and synchronously measure the same element passed to the draggable setNodeRef before mounting any rails. Do not measure an inner visual row when a thin wrapper owns setNodeRef; DnD Kit and the overlay must use the same source geometry. Do not use a later pointer position or active.rect.current.initial, which may still be empty at activation. Convert that exact pointer-down point into normalized coordinates:
anchorX = (pointerX - sourceLeft) / sourceWidth
anchorY = (pointerY - sourceTop) / sourceHeight
Clamp both values to [0, 1] and fall back to the source center if the source rect has no usable size.
Set DragOverlay to adjustScale={false} and dropAnimation={null} for every drop, including same-section Pinned reorder. Its outer shell keeps the source width and height for the whole pointer drag. DnD Kit measures that shell from the initial source rect, so resizing it would corrupt collision geometry. The inner preview is absolutely positioned inside that shell and owns the visual variant and FLIP animation.
When a preview has width previewWidth and height previewHeight, position its inner layer at:
left = anchorX * sourceWidth - anchorX * previewWidth
top = anchorY * sourceHeight - anchorY * previewHeight
That keeps the captured point of the new card or slim row under the same point of the source-sized outer shell, and therefore under the cursor. When the variant changes, capture the inner layer's current visual rect before canceling an in-flight animation. Use that rect as the first frame:
- Measure the old inner rect.
- Render and measure the new rect in a layout effect.
- Offset the new preview so its normalized anchor remains under the cursor.
- Apply the inverse translate and scale to the inner layer.
- Animate once to identity with
transform-origin set from the normalized anchor.
Cancel the prior animation before a new one starts. The target state may update from onDragOver only when the target identity or preview variant changes. Run no per-frame React state updates and no continuous layout measurements. Use transform and opacity only. With reduced motion, skip the animation but keep the pointer compensation.
The overlay unmounts at pointer-up, but the dim source projection remains until receipt confirmation. A default return-to-source animation would therefore lie, even for same-section Pinned reorder. Sortable transforms already show the Pinned reorder while dragging.
7. Make scroll position part of every layout transaction
Expose the actual [data-slot="scroll-area-viewport"] element through ScrollArea and SidebarContent. The DnD coordinator must never infer it with a document-wide query.
Retain the AnimationController returned by autoAnimate. Disable it and set overflow-anchor: none on the viewport before the drag starts. Also disable DnD Kit's layoutShiftCompensation; its one-time ancestor scroll would fight the explicit correction when rails mount. Set DnD Kit's canScroll predicate to accept only viewportRef.current, so edge auto-scroll can never choose the document or another ancestor. Re-enable AutoAnimate after the transaction and call its optional destroy hook when the list node changes or unmounts. This leaves one owner for layout-induced scrolling.
Before any locally initiated drag-owned structural update, capture:
- The viewport's
scrollTop
- A stable anchor element and its viewport-relative top
- The current scroll range
Never use a row with a sortable transform as an anchor. getBoundingClientRect() includes the transform and makes the scroll correction wrong. Anchor priority is the current hovered row when it is persistent and untransformed; then its nearest persistent untransformed sibling; then the first fully visible untransformed row; then the dim source projection when it is untransformed; then a viewport-overlay rail. After React commits the layout, a layout effect measures the same element and applies:
nextScrollTop = previousScrollTop + newAnchorTop - oldAnchorTop
Clamp the result and write it before paint. Refresh the baseline after that controller-owned write, then ask DnD Kit to remeasure newly mounted droppables. Pointer-driven edge auto-scroll is an intentional scroll change. Mark that scroll separately from a correction write and refresh the baseline after it, so later correction does not fight it.
External shell updates cannot run through a client-side "before update" wrapper. While a transaction is active, retain the anchor's rect from the last committed layout and refresh it on user scroll, intentional edge auto-scroll, and target changes. A layout effect compares that stored rect with the new DOM after an external render and applies the same correction before paint. If a card-to-slim change, deletion, scope change, or rekey removes the anchor, select the next persistent untransformed candidate before falling back to the rail. Never correct against a detached element.
Observe the viewport, board, and active inner preview with ResizeObserver while the transaction is active. A real width or height change runs the same retained-rect correction, repositions viewport-overlay rails, and reapplies the normalized preview anchor. If the preview's size changed, cancel its current animation and run one FLIP from the captured visual rect. Ignore observer reports whose measured dimensions did not change.
After every local, external, or resize-driven layout correction during pointer dragging, ask DnD Kit to remeasure all board droppables, including targets that stayed mounted. Preserve the raw pointer point across that remeasurement and resolve collision again, so the item under the pointer remains the target. Newly mounted rails are one case of this rule, not the only case.
This controller covers:
- Empty destination reveal at drag start
- Temporary destination removal on cancel
- Shelf header appearance and disappearance
- Canonical source removal and destination insertion after a receipt
- Pinned divider appearance and disappearance
- External shell updates during a local drag
At drag start, use the dim source projection as the anchor only if it is not transformed. Mounting an empty destination above a source near the bottom raises scrollTop by the inserted height, so the content shifts upward without moving that row on screen. Mounting a destination below a source near the top expands toward the bottom. This gives empty sections the requested source-relative direction without guessing a fixed scroll offset.
Scroll compensation can fail whenever the desired correction clamps at either edge, including an underfilled viewport and a partially scrollable viewport already at its limit. If the predicted clamped result leaves more than 1 CSS pixel of anchor error, put the affected empty target in a clipped, non-scrolling overlay plane attached to the viewport shell. Do not place an absolutely positioned rail inside the scroll content because it can still enlarge scrollable overflow. The viewport-overlay rail must change neither scrollHeight nor scrollTop while the transaction is pending. Keep it through the Snooze menu and command wait. After the successful receipt and sequence-aware reconciliation complete, fold the destination into canonical flow with an inverse FLIP and use scroll correction when range permits. If clamping still prevents correction, the final layout movement is unavoidable, but it occurs only after hover and the pending transaction have ended.
Empty Pinned, Regular, Snooze, and Settled destinations mount only when a current drag can use them. Each missing section gets a temporary drag-only rail at its stable location in the board: Pinned above Regular, Regular below Pinned, Snooze between Regular and Settled, and Settled at the bottom. An existing collapsed Snooze or Settled header acts as its section-level droppable while its rows stay collapsed. Rails may have height. They render in flow when scroll correction can preserve the anchor and in the viewport-overlay plane when it cannot. The insertion indicator inside a populated list is always zero-flow. These are different elements.
8. Cover every client without adding mobile DnD
- Web and desktop share the modern sidebar implementation and receive pointer DnD.
- Public web, local web, direct remote, relay, and tunnel modes send the selected lifecycle command to the thread's owning environment.
- Mobile keeps its existing native action sheets and Move up or Move down controls.
- Provider adapters do not change. Thread organization is orchestration state and does not depend on Codex, Claude, Cursor, Grok, or OpenCode.
- The legacy sidebar keeps its project-order DnD and does not gain thread DnD.
Pointer DnD remains a single-thread action. It does not turn an existing multi-selection into a bulk drag. Every category transition remains available through keyboard-operable lifecycle actions for keyboard and touch users, with the same disabled reasons. Keyboard dragging and native mobile dragging are deferred.
Alternatives considered
Compose existing commands in the client
Rejected. Chaining old single-field behavior would let another client interleave a write, and a later command could fail after an earlier one committed. The existing decider already returns multi-event bundles from one command. Strengthen that boundary instead.
Add a generic placement command
Rejected. Pin, Unpin, Settle, Un-settle, Snooze, and Wake already name the user actions and have capability gates, client operations, error paths, and server guards. A generic command would duplicate that vocabulary and split category semantics across two command families.
Add a move mode to the lifecycle commands
Rejected. An optional move flag would give one command two meanings in the same build. Each lifecycle command should mean the same thing from DnD, menus, bulk actions, and mobile.
Make every list sortable
Rejected. Regular and Settled order communicate stable facts. Previewing a pointer insertion there would lie about the final location and make rows move on hover.
Insert a cross-section placeholder
Rejected. A placeholder changes scroll height and shifts collision targets. An absolute line shows the boundary without changing layout.
Keep outer AutoAnimate active during drag
Rejected. AutoAnimate and DnD Kit would both apply FLIP transforms to the same list. Scroll correction would measure layout positions while a second visual transform was still running.
Persist a section or manual order for every list
Rejected. The existing fields already derive the correct destination, and the natural lists should remain natural. A new stored section would create conflicting sources of truth.
Freeze the whole shell snapshot for the duration of a drag
Rejected. Remote work, approvals, deletion, and connection changes remain real during a drag. The coordinator should cancel or reconcile a stale intent, not hide newer state.
Consequences
Benefits:
- One existing lifecycle command names the dragged thread's final category and commits it atomically on the owning server.
- The wire contract and persistence model stay unchanged.
- Sorted lists stay visually stable, while Pinned keeps immediate manual feedback.
- Scroll and pointer invariants become testable with numeric bounds.
- DnD, existing menus, bulk actions, and mobile share the same category semantics.
Costs and accepted limits:
- The server decider, client runtime, web, and mobile action layer change.
- Snooze, Wake, Unpin, and Un-settle become exclusive category transitions for every caller.
- Pinned key materialization across environments cannot be atomic. The existing optimistic reconciliation and partial-write policy remains.
- Geometry and scroll behavior need real-browser verification. Node unit tests can prove the math and state machine, but not browser layout.
- Snoozed threads wake into Regular because Snooze clears prior pin and settlement state.
Non-goals
- Manual ordering for Regular, Snooze, or Settled
- Bulk dragging a multi-selection
- Native mobile DnD
- Keyboard DnD in the first implementation
- Thread DnD in the legacy sidebar
- Sidebar virtualization
- A new provider command or provider capability
- A generic thread-placement command
- A distributed transaction across environment servers
- Migration or repair of older overlapping category fields
Part II: implementation plan
Phase 1: strengthen the lifecycle command transactions
Make the current commands leave one exclusive category before wiring new UI.
Read .repos/effect-smol/LLMS.md before changing the Effect-based server path.
Work:
- Update the six category-changing command branches in
apps/server/src/orchestration/decider.ts to emit the atomic bundles in this ADR. Keep the current settle and snooze guards. Keep thread.pin.reorder unchanged and key-only.
- Make user Unpin, Un-settle, and Wake end in complete Regular state. Make Snooze end in complete Snooze state. Activity-driven un-settle and wake events keep their neutral reset behavior.
- Preserve current idempotency when the destination already holds. Stamp fresh natural-order timestamps for new Settle and Snooze transitions.
- Reuse the current operations in
packages/client-runtime/src/operations/commands.ts and packages/client-runtime/src/state/threadCommands.ts. They already use the per-environment, per-thread serial scheduler and existing capabilities.
Completion criterion: one category-changing lifecycle command receipt leaves every source and destination pair in the category matrix above. The command, event, capability, schema, and persistence contracts remain unchanged. Shell upserts contain final projected fields, and thread.pin.reorder changes no category field.
Phase 2: extract focused sidebar components
Do a behavior-preserving extraction before replacing DnD. Keep the refactor specific to thread rows and thread-board layout.
Work:
- Move
SidebarThreadRow, its tooltip, and its row-local snooze control out of the roughly 3,900-line Sidebar.tsx into a sidebar component file.
- Extract the section partition and natural-order calculation into pure sidebar logic. Keep the current capability gates, project scope, settled paging, and collapsed-shelf exceptions.
- Add a
SidebarThreadBoard component that receives section arrays, row rendering data, and organization callbacks. Drafts remain above the board and are not draggable.
- Extend
ScrollArea with an explicit viewport ref prop and pass it through SidebarContent.
- Keep the
AnimationController returned by autoAnimate. The ref callback calls destroy?.() on the previous controller when the node changes or unmounts, then stores the controller for the new node. The DnD coordinator calls disable() at drag start and keeps it disabled through canonical confirmation. After the final DnD-owned layout effect, it calls enable() on the live controller.
Completion criterion: apart from the lifecycle semantics landed in Phase 1, the rendered order, row variants, selection order, shortcuts, shelves, search, context menus, and current Pinned-only reorder are unchanged before cross-section DnD is connected.
Phase 3: add pure DnD planning and one board context
Keep DnD decisions outside JSX. A focused module such as Sidebar.dnd.logic.ts should own types and pure calculations, not React or DOM reads.
Work:
- Define discriminated section, draggable, droppable, eligibility, collision, and drop-intent types.
- Add the
idle, dragging, awaiting-snooze-choice, and committing reducer.
- Add the source-to-destination command map, no-op detection, existing capability checks, natural insertion prediction, and full-order Pinned boundary mapping. Reuse
planPinnedReorder and the existing display sorter.
- Replace the nested Pinned
DndContext with one board context. Preserve the 6px PointerSensor activation distance and vertical-axis restriction. Set autoScroll={{ layoutShiftCompensation: false, canScroll: (node) => node === viewportRef.current }} so edge auto-scroll accepts only the explicit sidebar viewport and never the document.
- Add thin DnD wrappers around the memoized visual row. Reorder-capable Pinned rows use
useSortable. Other rows register useDroppable row targets and add useDraggable when at least one cross-section command is available, combining their node refs. Do not apply useDraggable translation to sorted source rows; only the overlay moves. A row without any supported cross-section command stays static but may remain a destination target for another thread.
- Keep the pointer listeners on the row root so the card can be grabbed at any point with a fine pointer. Add a root
onPointerDownCapture gate that blocks sensor activation for touch, non-primary buttons, modifier-assisted selection, and interactive descendants such as buttons, links, inputs, rename controls, menu controls, and explicit [data-thread-selection-safe] controls. Do not match the row's own [data-thread-item] marker. Keep the 6px distance constraint for plain click and double-click behavior. Touch keeps native scrolling and uses the lifecycle actions.
- Track the active pointer ID and raw client point in capture-phase listeners. Update a ref on movement, snapshot the raw release point for a Snooze drop, and clean up listeners on every drag exit without a per-frame state update.
- Keep
SortableContext only around reorder-capable Pinned rows. Use current animatePinnedLayoutChanges behavior to avoid replay after commit.
- Render eligible drag-only rails at drag start for every missing section. Keep existing collapsed Snooze and Settled contents collapsed; register their headers as section-level droppables.
- Render insertion lines as absolutely positioned presentation elements with zero height.
Completion criterion: the field-aware source and destination matrix produces the expected no-op, rejection, or existing lifecycle command. Same-section Pinned reorders live. Cross-section Pinned insertion uses an indicator only. Sorted lists keep their source row and DOM order. No cross-section indicator changes scrollHeight.
Phase 4: connect commits, receipts, and the Snooze menu
Work:
- Use promise-returning command operations selected by the drop intent. Keep toast and forward-navigation policy in one coordinator.
- Hold the dim source snapshot in its old section through command completion. Record the owning shell's fields and
snapshotSequence when they arrive before the receipt. Reconcile each command against its receipt sequence and expected destination fields or Pinned key.
- For same-section Pinned reorder, replace the released sortable transforms with the optimistic logical order until receipt and shell-sequence confirmation. For cross-section insertion, check and send any existing-row
thread.pin.reorder assignments returned by planPinnedReorder, then send thread.pin once for the incoming thread. Abort before Pin if an assignment is unsupported or fails.
- Extract
showSnoozePresetMenu and the shared preset-item builder. The helper calls localApi.contextMenu.show, returns a preset identity or null, and performs no dispatch. Open it at the snapshotted raw pointer-up coordinates and keep the transaction pending until selection or cancellation.
- Share the preset builder with every Snooze control. Resolve the deadline at selection time and dispatch
thread.snooze for both cross-section entry and rescheduling.
- Before dispatch, cancel safely on route scope changes, search activation, source deletion, source archive, capability loss, environment disconnect, or a foreign category change on the same thread. After dispatch, keep receipt and shell reconciliation alive and release to the sequence-confirmed last writer.
Completion criterion: the source stays dimmed through any Pinned preparation and the dragged thread's command receipt. If shell sequence already passed the receipt, canonical state renders immediately. Otherwise an optimistic destination remains only until shell sequence catches up. The selected lifecycle command's expected fields confirm the local result; later conflicting fields release to the actual last writer. Cancel and failure leave the dragged thread in its canonical category. Snooze sends nothing before a preset click.
Phase 5: add overlay FLIP and scroll ownership
Work:
- Add an inert
ThreadDragPreview for card and slim variants. Do not mount a second interactive SidebarThreadRow in the overlay.
- In
onDragStart, capture activatorEvent.clientX/Y and the rect of the exact element registered through draggable setNodeRef before dispatching the state update that mounts rails. Normalize that point; do not depend on active.rect.current.initial or a differently sized inner row.
- Split the overlay into a source-sized outer pointer layer and an absolutely positioned inner FLIP layer. Set
adjustScale={false} and dropAnimation={null}. Offset the inner layer from the normalized pointer anchor before its one-shot Web Animations API card-to-slim or slim-to-card motion.
- Update application state from
onDragOver only when the target identity or preview variant changes. Cancel prior overlay animation on those changes and bypass visual animation under reduced motion.
- Keep the dim source projection after pointer-up and use no DnD Kit drop animation for any category move.
- Add the scroll-anchor controller using the explicit viewport ref. Disable native overflow anchoring while active.
- Route empty-target mount, cancel, receipt projection, divider changes, and active-session external updates through anchored layout commits. Remeasure every board droppable after each corrected layout commit while the pointer drag remains active.
- Pause outer AutoAnimate from drag start through canonical confirmation. Store its controller, call
destroy?.() when its node changes or unmounts, and enable the live controller only after the final DnD-owned layout effect.
- Add the clipped viewport-overlay rail for any layout whose clamped correction would leave more than 1 CSS pixel of anchor error. Keep that rail outside scroll content.
- Observe actual viewport, board, and inner-preview size changes. Run retained-rect scroll correction, rail repositioning, normalized preview anchoring, and one replacement FLIP only when measured dimensions changed. Remeasure every board droppable afterward.
Completion criterion: changing overlay variants moves the captured anchor by at most 1 CSS pixel. Structural changes move the chosen viewport anchor by at most 1 CSS pixel, except intentional pointer edge auto-scroll and a clamped post-receipt handoff after hover ends.
Phase 6: finish entry-point and client parity
Work:
- Keep every existing web lifecycle entry point on the current Pin, Unpin, Settle, Un-settle, Snooze, and Wake operations. Audit sidebar and chat-header menus, context and bulk actions, plus command-palette or keybinding entries where they exist. Keep keyboard-operable Move up and Move down actions for Pinned order.
- Change every Snooze success action to
Wake. It dispatches thread.unsnooze and does not claim to restore the thread's former category.
- Confirm the same lifecycle semantics in mobile action sheets. Keep the existing Move up and Move down interaction for Pinned order.
- Keep legacy sidebar behavior unchanged.
- Update
docs/user/thread-sidebar.md with cross-category dragging, Snooze drop behavior, exclusive Snooze and Wake semantics, natural versus manual ordering, and mobile alternatives.
- After maintainers accept this ADR, add the durable decision and invariants under
docs/internals/. Keep the implementation checklist out of the repository.
- Update
docs/internals/glossary.md only if this work introduces maintained vocabulary beyond the existing lifecycle terms.
Completion criterion: web, desktop, remote connections, and mobile lifecycle actions produce the same exclusive final category. User docs describe that behavior without source paths or implementation terms.
Part III: QA plan
Automated coverage
Server command behavior
Keep the existing command schemas and capabilities unchanged. Add focused decider tests for:
- Each of the six category-changing lifecycle commands across their normal source and destination categories
- Complete-destination idempotency
thread.pin.reorder changing only pinOrderKey and updatedAt
- User Unpin, Un-settle, and Wake ending in Regular; Snooze and Settle clearing the other category fields
- Fresh natural-order timestamps for new Settle and Snooze transitions
- Current running, starting, blocked, queued, archived, and future-wake guards
- One representative multi-event command committing its event bundle, projection, and receipt in one transaction without exposing an intermediate visible category
- Activity-driven un-settle and wake retaining their current neutral-reset behavior
Use the existing decider.pinned.test.ts, decider.settled.test.ts, and decider.snoozed.test.ts suites as the base. Add one small lifecycle-category matrix instead of repeating the setup in every file.
Client runtime and pure sidebar logic
Add focused tests for:
- The complete source and destination map to Pin, Unpin, Pin-reorder, Settle, Un-settle, Snooze, Wake, or no-op
- Exactly one lifecycle command for the dragged thread per accepted drop
- Existing per-command capability and
canSettle or canSnooze target rejection
- Same-section no-op handling, including an automatically Settled row
- Snooze-to-Snooze rescheduling using Snooze
- Pinned head, middle, and tail insertion through the existing
planPinnedReorder, including hidden project-scope neighbors
- Existing-row planner assignments using
thread.pin.reorder, the dragged thread using the only thread.pin, and Pin not running after an unsupported or failed preparation
- Foreign membership and key updates during preview
- Natural Regular and Settled indicator boundaries, plus Snooze's predicted boundary after preset selection
- State-machine cancellation from every non-idle phase
- Sequence-aware canonical confirmation before and after receipt, including an own shell update that arrives before command resolution and a newer conflicting write
- Activator-event pickup coordinates, measurement of the exact draggable
setNodeRef element before rail mount, and normalized card-to-slim or slim-to-card anchor math
- Raw pointer tracking and release-point snapshotting without a per-move reducer update
- Scroll correction delta, residual error after clamping, auto-scroll baseline refresh, resize-driven correction, and viewport-overlay fallback choice
- A droppable remeasure request after local, external, and resize-driven corrections, followed by collision resolution at the unchanged raw pointer point
Keep pure DnD tests beside the new DnD logic module. Extend the existing Sidebar.logic.test.ts pin-order coverage instead of creating a second fractional-index implementation.
Web component behavior
Add focused component tests for behavior that does not require real geometry:
- Empty eligible rails mount only during a drag transaction; cross-list indicators remain absolute and zero-height
- Sorted source rows stay dimmed in their original DOM list and never receive pointer-following transforms
- Same-section Pinned uses sortable transforms; cross-section Pinned insertion uses only the zero-flow indicator
- Draggable and droppable wrappers register the intended rows, and search or an unsupported command disables the relevant drag target
- Row-root activation works away from controls without breaking clicks, selection modifiers, rename, menus, or nested controls
- Drag start captures the activator point and exact draggable rect before rails mount
- A Snooze drop opens the standard menu at the raw release point without dispatching, then cleans its listeners and pending state on selection, dismissal, cancellation, or stale native completion
- Every Snooze entry point resolves the deadline at selection time; every success toast offers Wake through
thread.unsnooze without restoration wording
- Receipt and shell ordering keep the source projection until confirmation; success, failure, disconnect, and a foreign move clear all temporary state
- Same-section Pinned holds its optimistic order through confirmation and rolls back without replaying the sortable animation
- AutoAnimate and native overflow anchoring pause and restore on every exit path; reduced motion skips overlay animation
- DnD auto-scroll accepts only the explicit sidebar viewport
Node tests should not claim to prove geometry. They can test the controller inputs, DOM structure, and classes.
Mobile action behavior
Add focused tests for:
- Pin, Unpin, Settle, Un-settle, Snooze, and Wake availability for each category
- Snooze resolving its deadline at preset selection time and using the exclusive category semantics
- Snooze success offering Wake through
thread.unsnooze without restoration wording
- Pinned Move up and Move down behavior remaining key-only
- Cross-section DnD remaining absent from the native list
Manual transition matrix
Seed at least four threads per section in a current environment, plus a second current environment for normal remote ownership and merged Pinned order. Include an open thread, running, blocked, and queued threads, hidden project-scope pins, long lists, collapsed shelves, and an underfilled sidebar.
| Source |
Pinned target |
Regular target |
Settled target |
Snooze target |
| Pinned |
thread.pin.reorder, live preview |
thread.unpin, land by createdAt |
thread.settle, clear pin |
Menu, then thread.snooze, clear pin |
| Regular |
thread.pin at exact boundary |
No-op |
thread.settle at natural top |
Menu, then thread.snooze |
| Settled |
thread.pin, clear settlement |
thread.unsettle, land by createdAt |
No-op |
Menu, then thread.snooze, clear settlement |
| Snooze |
thread.pin, wake at exact boundary |
thread.unsnooze, land by createdAt |
thread.settle, wake and re-stamp |
Menu, then thread.snooze, remain exclusive Snooze |
For every non-no-op cell, verify:
- Before the receipt, the selected persistent untransformed anchor remains within 1 CSS pixel of its original viewport Y position and the source row has reduced opacity. Exclude intentional live Pinned sortable transforms. The only post-pointer exception is a clamped rail-to-canonical handoff after a successful receipt.
- The overlay uses the destination's card or slim treatment.
- The indicator does not alter list flow.
- Only the command selected by the matrix is sent for the dragged thread. Documented Pinned preparation uses
thread.pin.reorder only for existing pins.
- Cross-section shell fields match the category table; same-section no-ops preserve the documented fields.
- A second connected client reaches the same section fields and exact Pinned key order.
- Failure restores canonical UI and reports one error.
Dropping an automatically settled row back into Settled must not create an explicit settlement. Run Pin, Unpin, Settle, Un-settle, Snooze, and Wake from every applicable section and confirm their exclusive category semantics. Every Wake ends in Regular; every Snooze success action says Wake and dispatches thread.unsnooze.
Snooze to Settled must stamp a new settledAt and place the thread at the top of Settled.
For Pinned insertion, verify head, middle, and tail boundaries across hidden project-scope neighbors. If planPinnedReorder returns existing-row assignments, each one uses thread.pin.reorder, the incoming row uses one thread.pin, and an unsupported or failed assignment prevents Pin. Confirm that the indicator never advertises an unsupported boundary.
Motion and scroll passes
Run these in a real client with pointer grabs near the top, center, and bottom of both card and slim rows:
- Card to slim to card across all section boundaries
- Fast target changes that interrupt an in-flight overlay FLIP
- Reduced motion
- Empty Pinned, Regular, Snooze, and Settled reveal, cancel, and commit
- Source above and below each new empty section
scrollTop at zero, middle, and maximum
- Underfilled viewport with no initial scroll range
- Partially scrollable viewport exhausted at its top or bottom edge, where the desired correction would clamp
- Edge auto-scroll followed by a target reveal or remote shell update
- Sidebar edge auto-scroll leaves document scroll position unchanged
- Pinned divider creation and removal
- Collapsed Snooze and Settled shelves
- External pin, settle, snooze, archive, and delete while a local drag is active
- Window resize and sidebar width change during a drag
- Snooze drops near each viewport edge, confirming that the standard menu opens at the raw release point
- Matching shell state arriving before the lifecycle command receipt, receipt-before-shell, stale identical fields below the receipt sequence, and a newer conflicting write
- Same-section Pinned reorder with delayed success and failure receipts
- Lifecycle command rejection, Pinned preparation failure, menu dismissal, and a late native Snooze selection after cancellation
Use temporary manual browser instrumentation or a test-only probe, then remove it before shipping. Measure the chosen persistent untransformed anchor and overlay point. The acceptance bound is at most 1 CSS pixel of unintended movement between pre-layout and post-layout measurements. Intentional pointer edge auto-scroll is exempt, but the item under the pointer must remain the collision target after every local, external, or resize-driven remeasurement. Mounting a viewport-overlay rail must change both scrollHeight and scrollTop by zero pixels. A clamped rail-to-canonical in-flow handoff after a successful receipt is exempt because hover has ended.
At initial pickup and at the first, middle, and final frames of each FLIP, assert:
abs(innerRect.left + anchorX * innerRect.width - pointerClientX) <= 1
abs(innerRect.top + anchorY * innerRect.height - pointerClientY) <= 1
Include an initial pickup that mounts an empty rail above the source. This catches measuring the source after layout or using the pointer position from the activation threshold instead of the activator event.
Check the Performance panel during fast dragging. A pointer move that keeps the same target, preview variant, and scroll state must cause no application-owned coordinator update or memoized visual SidebarThreadRow commit. Thin useDraggable and useSortable wrappers may render when DnD Kit updates its context. Target, preview-variant, or intentional scroll changes may update the coordinator. Overlay motion must use transform and opacity without a continuous paint animation.
Input, navigation, and accessibility passes
Verify that drag activation does not break:
- Plain click, double-click rename, middle-click, modifier selection, range selection, and context menu
- Pin, Unpin, Settle, Un-settle, Snooze, Wake, and nested pull-request controls
- Keyboard access to every equivalent category transition through thread menus, command-palette entries, and keybindings where present
- Focus restoration after Snooze selection or cancellation
- Forward navigation after moving the open thread to Settled or Snooze
- No navigation after a failed or cancelled move
- Search, project scope changes, and sidebar close during a pending drag
- Touch and keyboard users using menus instead of pointer DnD
Client and connection passes
Perform one integrated pass on each applicable path:
- Local web
- Desktop
- Public web connected to a remote environment
- Multi-environment merged Pinned list
- Relay or tunnel connection with command latency
- Mobile action-sheet parity, without native DnD
- Legacy sidebar regression smoke test
No provider-specific matrix is needed. Run a smoke case with two providers to prove the row data does not affect organization, then rely on the provider-independent lifecycle commands.
Targeted verification commands
Use repository tooling and run only affected scopes:
vp test run <lifecycle decider test files>
vp test run <client-runtime command-map and pin-order test files>
vp test run <web DnD logic and component test files>
vp test run <mobile lifecycle action test files>
- Targeted typecheck and lint for server, client-runtime, web, and mobile packages changed by the implementation
Run sustained typecheck or build work through lowio. Do not run the repository-wide check or full test suite for this feature.
Browser or simulator verification requires explicit approval at implementation time. Use the repository's real-client workflows with seeded disposable state, not the live T3 home.
Evidence and exit criteria
Attach to the implementation PR:
- Before and after screenshots for populated and empty sections
- A short video showing card-to-slim FLIP, empty-section reveal, Pinned live reorder, and Snooze menu handoff
- The focused test commands and results
- The manual source and destination matrix, including failures
- A note confirming local, remote, desktop, and mobile-action applicability
The feature is ready only when:
- All twelve requested behaviors pass the traceability table.
- Every supported cross-section pair ends in the exact category fields; every same-section no-op preserves the documented fields.
- Connected clients show the same exact Pinned order.
- Sorted lists never preview-reorder.
- A cross-section indicator changes scroll height by zero pixels. Measure a populated destination with all drag rails already mounted, so an empty-section rail footprint cannot be mistaken for indicator height.
- Hovered-row and overlay-anchor drift stay within 1 CSS pixel outside intentional Pinned sortable transforms, intentional auto-scroll, and the documented clamped post-receipt handoff.
- Cancel, menu dismissal, disconnect, command failure, and concurrent remote change leave no stuck opacity, overlay, empty target, or scroll offset.
- Targeted tests, lint, and typechecks pass.
- User and internal documentation match the final behavior.