feat(desktop): drag-and-drop pinning in the v2 sidebar

Merged
KI
3mo ago
+1360-490

Links

  • ExecPlan (original pinning feature, this ships its deferred D6): apps/desktop/plans/done/20260725-1947-sidebar-workspace-pinning.md

Summary

  • Workspaces can now be dragged into the Pinned section (pin), reordered within it, and dragged back out (unpin, landing at the drop position). Sessions get the same treatment, and session rows became sortable as a side effect.
  • The sidebar's drag-and-drop moves from per-project DndContexts to one unified context (pinned / sessions / one container per project) — required because dnd-kit cannot drag across separate contexts.
  • Includes hardening found by adversarial CDP testing: a renderer crash fix (transfer oscillation → "Maximum update depth exceeded"), a data-corruption guard (a single drop could mass-pin swept-over rows under main-thread stalls), drag-time render-fan-out fixes, and hover-card suppression during drags.

Why / Context

Pinned-section drag reorder was explicitly deferred as decision D6 in the original pinning ExecPlan ("mechanical follow-up if wanted"). It turned out to be non-mechanical: cross-boundary dragging forces the unified-context restructure, which then surfaced the perf and stability work in the later commits.

How It Works

Containers. useSidebarDnd is now a sidebar-level multi-container engine. Every workspace row lives in exactly one container: pinned, sessions, or its project id. SortableContexts stay per-section; one DndContext (hosted by the new DashboardSidebarDndProvider) spans them all. Container-less targets (empty Pinned, empty Sessions, fully-pinned project) get a leaf SidebarDropZone.

Collision detection is kind-scoped (projects sort among projects, sections within their project, a workspace may only target Pinned or its home container) and uses dnd-kit's multi-container strategy for workspace drags: pointerWithin → rectIntersection → cached lastOverId, plus a one-frame freeze after each transfer. Bare closestCenter is what crashed: with Pinned at the top and the home project far below, "closest" flips on sub-pixel movement in the gap — and on the layout shift a transfer itself causes — producing a transfer oscillation that remounts the row until React aborts.

Persistence. Pinned order stays pinnedAt-ascending; reorderPinnedWorkspaces rewrites the sequence anchored at the smallest existing pin time so future pins still append last. It also enforces that one drop pins at most one new workspace (the dragged one) — a corrupted caller list can never mass-pin. Unpin drops reuse the existing tabOrder/section commit path. Drop-in-place is compared against the drag-start snapshot and writes nothing.

Race-proofing. Drag handlers read and write the item/container refs synchronously. Pointer events batch faster than renders under main-thread stalls; deriving containers from the last rendered state is how a drop once pinned three session rows that were never dragged.

Render fan-out rules (these are the drag-perf load-bearing invariants):

  • the shared drag context never carries per-pointer-move values (no over id, no predicted color — the predicted section accent renders on the DragOverlay ghost);
  • useDroppable lives only in leaf components (its internal context updates per move — hosting it in a section re-renders that section per move);
  • sortable wrappers memoize their row bodies so per-move wrapper re-renders don't re-run row query hooks;
  • hover cards are suppressed for the drag's duration via the hover provider's existing counted-suppression mechanism, now also making performOpen inert (hover-open side effects like the PR refresh key off hoveredId even when the card is hidden).

Manual QA (CDP-verified end-to-end, trusted input events, dev app)

All items below were driven with real Input.dispatchMouseEvent sequences and asserted against both screenshots and the persisted v2WorkspaceLocalState collection:

  • Drag a project worktree row into the empty Pinned section ("Drop to pin" zone appears mid-drag); pinned row renders with project avatar
  • Drag a session into Pinned below an existing pin
  • Reorder rows within Pinned (pinnedAt sequence rewritten, anchor preserved)
  • Drag a pinned row back into its home project at a specific position (lane renumbered 1..n, local main stays first)
  • Drag a pinned session back into Sessions at a position
  • Within-project reorder and project-header reorder (incl. with autoscroll) still work
  • Pin survives location.reload()
  • Crash scenario: real project order + long sweeps with autoscroll + boundary oscillation + mid-drag dwells — reproduced 2 × "Maximum update depth exceeded" before the fix, 0 after, across repeated runs
  • Corruption scenario: 4/4 boundary-wiggle drags with injected 120 ms main-thread stalls pin exactly the dragged row; sessions lane byte-identical
  • Hover cards: 0 opens during drags (28 poll samples incl. 900 ms dwells); hover works normally after drop
  • Perf: 80-move drag long-task time 445–600 ms stable at 97–102 fps (was 791→1173 ms degrading, worst 250 ms); CPU-profile idle 22.6% → 51.4% (dev build)
  • Reviewer spot-check on a large real sidebar with section groups (my dev fixture has no expanded section groups; section-drag code paths are unchanged and unit-covered, but a hands-on pass is worthwhile)

Testing

  • bun run typecheck
  • bun run lint (exits 0)
  • bun test for DashboardSidebar + useDashboardSidebarState (74 pass)
  • CDP verification as above; adversarial-drag methodology added to AGENTS.md (CDP rules 7–8)

Design Decisions

  • Rewrite pinnedAt vs adding an order field: reusing pinnedAt as the ordering key keeps the schema untouched (no migration, no new persisted key) and preserves the "future pins append last" property by anchoring the rewrite below Date.now().
  • pointerWithin-first vs closestCenter: pointer containment can't flip in the gap between far-apart containers and removes action-at-a-distance transfers; rectIntersection covers keyboard drags; the lastOverId cache keeps over stable across disallowed territory.
  • Predicted accent on the overlay ghost instead of the in-list row: the in-list variant needs per-move context churn through every row; the ghost follows the pointer anyway.

Known Limitations

  • A project's local main workspace is drag-disabled (pre-existing rule so mains stay first); it can still be pinned via the context menu.
  • Cross-container inserts land where the live make-room preview shows, which with pointer-based collision can read one slot off from the pointer's exact pixel — standard dnd-kit behavior; within-section reorder afterwards is unaffected.
  • Collapsed-rail (icon) mode intentionally has no pin drag targets; pinning there remains context-menu only.

Follow-ups

  • Branch sidebar-render-perf (in progress, forked from this one): hover-open PR-refresh cool-down, collapsing ~80 per-row query subscriptions into one sidebar-level subscription, replacing per-row framer-motion wrappers.
  • Deferred from the original plan and still open: shortcut-map entries for pinned rows (D7), hover pin button (D8).

Risks / Rollout

  • Risk: regressions in existing sidebar reordering (projects/sections/workspaces) since all drags now share one context; mitigated by the CDP battery + unchanged persistence paths + 74 unit tests.
  • Rollout: ships with the desktop app, no flags, no schema/data changes.
  • Rollback: revert the three commits; pin state is plain pinnedAt values that the previous build already understands.

Summary by cubic

Adds unified drag-and-drop in the v2 sidebar so you can drag workspaces/sessions into Pinned, reorder them, and drag them back out. Fixes a drag crash and tightens persistence to avoid accidental mass pinning.

  • New Features

    • One DndContext spans Pinned, Sessions, and project lists; cross-boundary drags now work.
    • Reorder Pinned; drag from projects/Sessions to pin, and drag out to unpin at a specific position.
    • Empty containers show a drop zone (“Drop to pin” / “Drop to unpin”) so first inserts land correctly.
    • Pinned order stays pinnedAt-ascending; future pins still append. Drag overlay shows the predicted section accent. Sortable row bodies are memoized.
  • Bug Fixes

    • Stopped transfer oscillation crash by switching workspace collision to pointerWithin → rectIntersection → cached last hit, with a one-frame freeze after transfers; the freeze is now re-armed at the transfer site.
    • Race-proofed drops: handlers read/write container and item refs synchronously, commitDragItems is the only writer, and a single drop can pin at most one new workspace.
    • Hover cards are suppressed during drags and always released; suppression is unwound with try/finally and delayed opens re-check suppression before showing.
    • Reduced drag-time renders by keeping droppable only in leaf drop zones.

Summary by CodeRabbit

  • New Features
    • Added unified drag-and-drop sorting for projects, workspaces, pinned items, and sessions.
    • Added drop zones for pinning, unpinning, and moving items between sidebar sections.
    • Preserved workspace accent colors in drag previews.
  • Bug Fixes
    • Improved drag behavior during repeated moves, cancellations, and invalid drops.
    • Prevented hover cards from opening while dragging.
    • Reduced unnecessary sidebar updates during pointer movement.