fix(desktop): sidebar folder drags without layout shift, draggable Sessions tag folders

Merged
KI
2mo ago
+932-678

Summary

  • Dragging a folder in the sidebar no longer collapses every folder in the project. A folder drag sorts units (a header plus its rows, or an ungrouped row): other folders slide as blocks, the dragged folder dims in place as the drop slot, and the ghost stays under the cursor.
  • Sessions tag groups are real folders now: they drag as units, sessions can be dropped into and out of them, and folder order/collapse persist. Previously a session dragged out of its group silently snapped back.
  • With nothing pinned, picking up any row no longer mounts the Pinned section above the list. The drop-to-pin target floats at the top of the scroller with zero layout impact.

Why / Context

Reported: "dragging folders on sidebar is weird and the preview is not where the cursor is because the group collapses and move things around", then "sessions can't drag at all" and "sessions need to be able to drag groups/tags".

Root cause of the ghost offset: picking up a folder collapsed every folder in the project, so rows above the dragged header collapsed and the header moved under the pointer. dnd-kit's layout-shift compensation then scrolled the sidebar by that delta (measured: a 96px scroll jump at pickup); when the sidebar had no scroll room the DragOverlay latched the shifted position and the ghost rode ~160px off the cursor for the whole drag. The old scroll-height spacer only covered the scroll-clamp variant (and assumed 32px rows; they are 28px). The same class of shift happened for every workspace-row drag when nothing was pinned (Pinned header + drop zone mounting above the list: 76px).

Root cause of sessions snapping back: the Sessions lane only persisted order, never host tags, and its folder index helper returned empty for the project-less lane.

How It Works

  • hooks/useSidebarDnd/sectionUnits.ts: createSectionUnitSortingStrategy (verticalListSortingStrategy lifted to units; every row of a unit gets the same translate) and closestUnitCenter (collision on the union rect of each unit, resolving to the unit's header). The flat item list and row heights stay exactly as they were at pickup, so nothing can shift the header. SectionDragSpacer and the instant-collapse escape hatch are deleted with the collapse they compensated for.
  • The Sessions lane is now a project-shaped lane: buildDashboardSidebarSessions returns { children, workspaces }, DashboardSidebarExpandedProjectContent takes containerId + projectId: string | null and renders both projects and Sessions, and useSidebarDnd treats "sessions" like a project container. Folder rows and host tag settings for the lane use SESSIONS_TAG_SCOPE (sessions:<tag> ids) via sectionScopeForProject / laneProjectIdForScope; deriveTagFolders derives session folders under that scope; the state hook's folder index / top-level items / ensureSectionRow / deleteSection map through it. DashboardSidebarSessionTagGroup is deleted.
  • dashboardSidebarSectionSchema.projectId widens from uuid to uuid | "sessions". Without this the folder-row insert threw a SchemaValidationError inside the drop handler that surfaced nowhere; the UI showed the drop as done and a reload reverted it.
  • DashboardSidebarPinnedSection with zero pins renders a sticky top-0 h-0 float holding "Drop to pin" (with -mb-3 to cancel the next section's first:mt-0 margin). A drag that started with pins keeps the in-flow section.

Manual QA Checklist

Driven in the dev app over CDP (trusted mouse events), each step checked after a page reload so only persisted state counts:

  • Folder drag: ghost top equals header top at pickup, no scroll change; displacement equals unit heights exactly; drop persists; Escape reverts every transform; collapsed folder drags as a header-only unit
  • Pre-fix vs fix in a tall viewport (no scroll room): ghost 162px above the pointer with 10/19 folder rows visible → pointer inside the ghost with 19/19 visible
  • Sessions: folder "automation" dragged above ungrouped sessions and back, as a block (folder row created, order persisted)
  • Sessions: session dragged out of the folder → ungrouped after reload, host tag removed; dragged back under the last member → host tag re-added
  • Sessions: folder collapse via chevron persists across reload; expand persists
  • Pinned: pickup with zero pins shifts the row 0px (was 76px); pin on the floating target persists; unpin by dragging back persists
  • Project folders: four reload-verified moves in the Superset project still land where expected
  • Sessions folder rename / color / delete from the folder context menu (unchanged code path, not re-exercised)

Testing

  • bun run typecheck (desktop)
  • ./scripts/lint.sh
  • bun run --cwd packages/i18n check
  • bun test for the sidebar, tag-folder utils, sidebar state hook, and section schema suites (202 tests); new tests: sectionUnits.test.ts, sessions builder tests, schema tests for the sessions scope, deriveTagFolders sessions case

Performance

Heavy-drag stress in the dev build (React dev mode) over CDP: 300 pointer moves sweeping the whole sidebar top to bottom and back through both autoscroll zones, plus 400-move ±3px jitter at a unit boundary. Instrumented with a longtask PerformanceObserver, rAF frame counter, HeapProfiler.collectGarbage before/after. Baseline = the same files from origin/main, alternated with this branch in the same app session (A/B/A/B) with nothing else running.

DragBaseline: long tasks / msThis PR: long tasks / msfps
Project row, mid-list sweep13 / 1055, 10 / 79010 / 868, 13 / 107072-78 both
Project folder, full sweep w/ autoscroll8 / 651, 5 / 4025 / 429, 4 / 36889-90 → 94-97
Session row, full sweep (crosses Pinned repeatedly)16 / 1791, 18 / 192017 / 1783, 17 / 184470-72 both
Project folder, boundary jitter4 / 4311 / 149106 → 116

No errors, no Maximum update depth recursion, ghost always removed, rAF latency after each run under 10ms. Folder drags got cheaper (nothing collapses/remounts at pickup); row drags are unchanged. The session-row full sweep is the heaviest case on both sides: each pass through the top zone transfers the row into and out of Pinned, and both sides retain ~25MB after GC from that run — pre-existing, noted as a follow-up.

The last commit caches unit rects per measurement in the unit strategy (dnd-kit calls it once per row per move with the same rects array).

Design Decisions

  • Collapse nothing at pickup instead of deferring the collapse or disabling dnd-kit's scroll compensation: those keep the folder snap-shut jank and the scroll-height hack; zero layout change removes the whole class of bug.
  • Sessions reuse the project lane rather than a parallel implementation: one list component, one DnD strategy, one persistence path; the only session-specific knowledge is the scope key.

Known Limitations

  • A row dropped directly under another folder's last member files into that folder (the row above decides membership), matching project folders.
  • Session folders have no Hide action; the hide preference is per project.

Follow-ups

  • Repeated Pinned↔home transfers during one drag re-render the whole sidebar and leave ~25MB retained after GC on main as well; worth a look at the transfer path separately.
  • The Sessions drop-to-unpin zone renders only when the section is expanded (or collapsed and empty), same as before; collapsed-with-rows keeps no zone.

https://claude.ai/code/session_01F1YS18jh8e6KHSUq5Fb3FK


Summary by cubic

Previously, dragging a sidebar folder collapsed its members and could leave the ghost offset from the cursor; now it keeps rows mounted and moves the folder as a unit, preventing layout shifts. Sessions tag groups previously could not be dragged and sessions dragged out snapped back; now they use the project folder DnD and persistence path, while an empty Pinned target stays out of flow.

Bug Fixes

  • Removes the old drag spacer and instant-collapse workaround.
  • Uses whole-unit collision bounds and translates each member row with its folder header.
  • Keeps the zero-height "Drop to pin" target sticky at the scroller top.
  • Caches unit rects once per pointer-move batch so folder drags don't recompute them per row.

New Features

  • Builds Sessions as project-shaped children so folders and ungrouped sessions share rendering, selection, shortcuts, and DnD.
  • Persists Sessions folder membership, order, and collapse state under SESSIONS_TAG_SCOPE; session drops update host tags.
  • Validates Sessions folder rows through the shared tag-folder scope helpers, while keeping folder hiding project-scoped.

Summary by CodeRabbit

  • New Features

    • Sessions can be organized into tag-based folders, consistent with project organization.
    • Keyboard shortcuts can reveal workspaces nested in collapsed Sessions folders.
    • Drag and drop reorders complete folders and their workspaces together.
  • Bug Fixes

    • Improved drag-and-drop behavior for empty folders and sections.
    • Dragged sections remain visible as clear placeholders.
    • Sessions folder ordering, collapse state, and deletion now behave consistently with project folders.
  • Changes

    • Sessions folders no longer offer a hide action.