feat(desktop): make sidebar groups reorderable anywhere in the project list
Summary
- Sidebar groups (sections) can now be dragged anywhere in their project's list — above, between, and below ungrouped workspaces — instead of being stuck at the bottom. Group membership is now explicit instead of positional.
- Fixes a pre-existing drag bug from #6377 where rows displaced by any sidebar drag (including plain workspace drags on main) visually vanished mid-drag.
- Fixes
Delete group/Ungroupcontext-menu actions teleporting rows to the top of the project.
Why / Context
Groups were pinned to the bottom by three interlocking behaviors: section drags could only sort against other section headers, the drop handler rebuilt the list as "ungrouped first, then sections", and membership was positional — any ungrouped row rendered below a header was silently absorbed into that group, making "group above an ungrouped row" unrepresentable.
How It Works
SidebarDndItems.membership(flat workspace id → flat section id) carries explicit membership through the drag;groupInfo, commit parsing, and the ghost's predicted accent read the map instead of scanning position.- Section drag-end collapses the flat list into top-level units (header + members move as one) and
arrayMoves among units, ungrouped rows included. A dropped workspace joins the group of the row that ends up directly above it (membershipFromNeighbors); in-place drops restore the pre-drag membership so they write nothing. buildDashboardSidebarProjectsno longer absorbs trailing ungrouped rows — interleaved orders round-trip through the existing tabOrder persistence.- Drag-feel fixes (all rooted in the #6377 collapse wrapper + the layout shift a section pickup causes):
- The dnd translate now sits outside each row's
overflow-hiddencollapse wrapper (moved intoSortableWorkspaceItem), so displaced rows slide instead of clipping out of view. - Section pickups collapse members instantly, use a live-DOM pointer collision, hide the in-list header (the
DragOverlayghost is the drag representation), and render aSectionDragSpacerthat keeps the scroller's scrollHeight constant — without it, the pickup shrink clamps scrollTop near the bottom and dnd-kit's cached rects/overlay transform desync (drops landed ~member-count rows off; ghost drifted 96px from the cursor, both measured).
- The dnd translate now sits outside each row's
deleteSectionreplaces the deleted group's own slot with its members;moveWorkspaceToSection(null)lands the row directly below its former group.
Manual QA (all driven end-to-end over CDP in the dev app, real trusted input, unstaged layout)
- Group dragged above / between / below ungrouped rows, both directions — drop lands exactly at the pointer slot (verified numerically: ghost == pointer, gap adjacent, landed index exact)
- Group ↔ group reorder; collapsed-group drag; empty-group lifecycle (drain by drag, drag empty group, drop a row back in)
- Workspace into / out of a group by drag (accent + membership persist);
localmain row always stays on top - Pin a grouped row by drag, reorder, unpin back into its group by drag and via context menu (returns to the same slot;
sectionIdretained while pinned) - Context menus: Ungroup lands below its group; Delete group keeps members in place; New group from workspace appends at bottom
- In-place drop writes nothing (byte-identical collections before/after)
- Full reload reproduces the exact interleaved order; sidebar state survives across reloads after every scenario
- Pinned + Sessions lanes re-verified after the row-wrapper restructure (render, reorder, cross-lane drags)
- Stress battery on final code: 30 rapid random drags (8–18 ms steps), boundary oscillation with ~1 s dwells, full-sidebar autoscroll sweeps into the Pinned zone, 12 drags under induced 120 ms main-thread jank, Escape-cancels — integrity held (14 rows, no orphaned memberships), zero console errors, exact post-stress reload
- Frame-by-frame visual review of slow drags: rows part around the ghost, no vanishing rows, no double header, WYSIWYG drop
Testing
bun run typecheckbun run lintbun test src/renderer/routes/_authenticated(371 pass; includes a new builder test for interleaved layouts)
Design Decisions
- Explicit membership map instead of positional parsing: positional absorption is what made groups-at-bottom load-bearing; the map lets ungrouped rows live below a group while keeping the drag-into-group gesture positional at drop time only.
- Live-DOM collision for section drags instead of dnd-kit's rect store: the pickup reflow invalidates cached rects and the scroll compensation double-counts; comparing the raw pointer against untransformed live rects is immune to both (transformed rects would oscillate — the known sidebar-DnD crash class).
- Spacer instead of animated collapse: keeping scrollHeight constant during the drag removes the scroll clamp entirely, which is what kept the overlay glued to the cursor; the collapse animation was cosmetic and its mid-animation re-measure was itself a source of stale geometry.
Known Limitations
- Dropping a row at the very end of a project joins the last group (row-above rule); use
Ungroupor drop below another ungrouped row to leave it ungrouped — inherent to flat lists without end-of-group markers. SectionDragSpacerassumes the 32 px sidebar row height; if row height changes it degrades to the old clamp behavior (cosmetic), not a failure.- Keyboard-sensor sorting shares the fixed handlers but wasn't exercised separately.
Follow-ups
- Legacy profiles that relied on positional absorption will render those trailing rows as ungrouped (their stored
sectionIdwas always null) — this now shows the persisted truth, but counts in group headers may differ from what those users saw before.
Summary by cubic
Make sidebar groups reorderable anywhere in a project's list by replacing positional membership with an explicit map. This removes the “groups at bottom” constraint, fixes rows vanishing mid‑drag, prevents context‑action teleports, improves multiselect, and avoids stray clicks after canceled drags.
-
Section drags reorder top‑level units (header + members) against ungrouped rows; grouped rows hide during drag, the in‑list header hides,
SectionDragSpacerkeeps scroll height constant, and collisions use dnd‑kit’sclosestCenter. -
Workspace drops join the group of the row directly above the landing slot; in‑place drops restore pre‑drag membership; the ghost’s accent predicts using the same rule.
-
The sidebar builder no longer absorbs trailing ungrouped rows; interleaved orders round‑trip via existing
tabOrderpersistence. -
Context menus: Ungroup lands directly below the former group; Delete group replaces the group’s slot with its members; no‑op when already ungrouped in the same project.
-
Multiselect: the bulk‑actions toolbar is sticky; bulk Ungroup runs back‑to‑front to preserve visual order; collapsing a group prunes hidden members; stale ids drop and additive clicks re‑anchor if the previous anchor is gone.
-
Drag feel: displaced rows no longer clip out of view (transform sits outside the collapse wrapper); mouse activation distance is 5 px; Escape‑canceled drags swallow the next click to prevent navigation.
-
Rollout: profiles that relied on positional absorption will now show those rows as ungrouped; header counts may differ. Limitation: dropping at the very end joins the last group; use Ungroup or drop below an ungrouped row.
Summary by CodeRabbit
-
New Features
- Improved sidebar drag-and-drop for sections and workspaces.
- Sections move with their grouped workspaces while preserving layout.
- Added smoother collapsing, spacing, and visual feedback during drag operations.
- Bulk actions remain visible while scrolling.
-
Bug Fixes
- Ungrouped workspaces remain correctly ordered among sections.
- Ungrouping workspaces or deleting sections better preserves sidebar order.
- Improved workspace transfers, stale grouping recovery, and bulk ungrouping.
- Workspace selection now excludes hidden or unavailable items.
- Prevented accidental navigation after canceled drag operations.