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:

  1. Snoozed
  2. Pinned
  3. Settled
  4. 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:

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:

  1. An eligible section or row directly under the pointer
  2. A Pinned row boundary when the pointer is in Pinned
  3. 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:

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:

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:

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:

  1. Measure the old inner rect.
  2. Render and measure the new rect in a layout effect.
  3. Offset the new preview so its normalized anchor remains under the cursor.
  4. Apply the inverse translate and scale to the inner layer.
  5. 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:

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:

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

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:

Costs and accepted limits:

Non-goals

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:

  1. 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.
  2. 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.
  3. Preserve current idempotency when the destination already holds. Stamp fresh natural-order timestamps for new Settle and Snooze transitions.
  4. 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:

  1. Move SidebarThreadRow, its tooltip, and its row-local snooze control out of the roughly 3,900-line Sidebar.tsx into a sidebar component file.
  2. 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.
  3. Add a SidebarThreadBoard component that receives section arrays, row rendering data, and organization callbacks. Drafts remain above the board and are not draggable.
  4. Extend ScrollArea with an explicit viewport ref prop and pass it through SidebarContent.
  5. 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:

  1. Define discriminated section, draggable, droppable, eligibility, collision, and drop-intent types.
  2. Add the idle, dragging, awaiting-snooze-choice, and committing reducer.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. Keep SortableContext only around reorder-capable Pinned rows. Use current animatePinnedLayoutChanges behavior to avoid replay after commit.
  9. 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.
  10. 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:

  1. Use promise-returning command operations selected by the drop intent. Keep toast and forward-navigation policy in one coordinator.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

  1. Add an inert ThreadDragPreview for card and slim variants. Do not mount a second interactive SidebarThreadRow in the overlay.
  2. 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.
  3. 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.
  4. 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.
  5. Keep the dim source projection after pointer-up and use no DnD Kit drop animation for any category move.
  6. Add the scroll-anchor controller using the explicit viewport ref. Disable native overflow anchoring while active.
  7. 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.
  8. 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.
  9. 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.
  10. 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:

  1. 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.
  2. Change every Snooze success action to Wake. It dispatches thread.unsnooze and does not claim to restore the thread's former category.
  3. Confirm the same lifecycle semantics in mobile action sheets. Keep the existing Move up and Move down interaction for Pinned order.
  4. Keep legacy sidebar behavior unchanged.
  5. 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.
  6. After maintainers accept this ADR, add the durable decision and invariants under docs/internals/. Keep the implementation checklist out of the repository.
  7. 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:

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:

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:

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:

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:

  1. 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.
  2. The overlay uses the destination's card or slim treatment.
  3. The indicator does not alter list flow.
  4. Only the command selected by the matrix is sent for the dragged thread. Documented Pinned preparation uses thread.pin.reorder only for existing pins.
  5. Cross-section shell fields match the category table; same-section no-ops preserve the documented fields.
  6. A second connected client reaches the same section fields and exact Pinned key order.
  7. 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:

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:

Client and connection passes

Perform one integrated pass on each applicable path:

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:

  1. vp test run <lifecycle decider test files>
  2. vp test run <client-runtime command-map and pin-order test files>
  3. vp test run <web DnD logic and component test files>
  4. vp test run <mobile lifecycle action test files>
  5. 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:

The feature is ready only when: