Skip to content

Session tab bar: touch-and-hold drag to reorder pinned tabs (smooth lift/slide/settle + edge auto-scroll) #49

Description

@ashwin-pc

Summary

Pinned session tabs in the bottom tab bar cannot be reordered. Add drag-to-reorder: touch-and-hold (300 ms) then drag on mobile, plain click-drag with mouse on desktop, with a smooth lift/slide/settle animation, haptics, and edge auto-scroll for long tab lists.

This has been fully implemented and manually validated on a phone in a downstream working copy; this issue captures the complete design, all the non-obvious pitfalls hit along the way, and the exact fixes, so it can be implemented upstream correctly the first time.

Files touched

  • src/sessions/sessionDrawer.ts — all gesture/reorder logic (new attachPinnedTabReorder(tab) helper + a guard in renderSessionBar())
  • src/styles/sessions.css — lift/settle styling, touch-action, selection suppression

Interaction spec

  1. Lift: touch/pen requires a 300 ms hold (holdDelayMs) with <10 px movement; moving earlier cancels the hold and lets the bar scroll normally. Mouse lifts after >6 px movement, no hold. On lift: setPointerCapture, add .dragging class, navigator.vibrate?.(10).
  2. Drag: the lifted tab tracks the pointer 1:1 via inline transform: translateX(dx) scale(1.06) (no transition). Other pinned tabs slide aside via transform: translateX(±draggedWidth) with a 0.2s cubic-bezier(0.2, 0, 0, 1) transition. navigator.vibrate?.(5) on each slot change.
  3. Drop: remove .dragging, add .settling, animate the tab to its target slot offset (~220 ms), then commit the new order to state.pinnedSessions, persist via persistSessionUiState({ pinnedSessions }), and re-render the bar cleanly.
  4. Cancel (pointercancel): snap back to the original slot, no commit.
  5. Reordering applies to pinned tabs only (.sessionBarTab.pinned); requires ≥2 pinned tabs; drags never start from the pin/unpin action buttons (closest(".sessionBarTabAction") guard).

Geometry model (key to smoothness)

Capture everything once at lift time and work in "lift-time" coordinates; never re-measure mid-drag:

  • rects = getBoundingClientRect() of all pinned tabs at lift; originalIndex, others (all pinned tabs minus dragged).
  • Slot detection: newIndex = count of others whose captured midpoint is left of the dragged tab's current center (rects[originalIndex].left + width/2 + dx).
  • Shift for another tab (DOM index k, position p in others): -draggedWidth if k > originalIndex && p < newIndex; +draggedWidth if k < originalIndex && p >= newIndex; else 0.
  • Drop target offset = signed sum of the widths of the tabs the dragged tab passed.
  • Clamp: minDx = rects[0].left - rects[originalIndex].left; maxDx = lastRect.right - draggedWidth - rects[originalIndex].left, so the tab can't be dragged past the first/last slot (or into the separator/temporary-tab area).

Edge auto-scroll (long tab lists)

The bar (.sessionBar) is overflow-x: auto; off-screen slots are unreachable without auto-scroll. While lifted, a requestAnimationFrame loop checks the last pointer X against 48 px edge zones (edgeZonePx); scroll velocity ramps linearly 0 → 14 px/frame (maxScrollPerFrame). Only start the loop if the bar actually overflows. Fold the scroll delta into the drag offset so the tab stays glued under the finger and slot math stays correct:

dx = (lastClientX - startX) + (bar.scrollLeft - scrollLeft0)   // then clamp to [minDx, maxDx]

Pitfalls (each of these was hit for real — do not skip)

  1. Bar re-renders kill the gesture. renderSessionBar() rebuilds the bar constantly (runtime sparks, unread dots, refreshes), destroying the held tab mid-gesture. Fix: while a press/drag is in flight, renderSessionBar() sets a queued flag and returns; flush after the gesture. Delay the flush ~250 ms after a plain tap so the click event still hits the old element before the rebuild.
  2. touch-action choice. touch-action: pan-x on .sessionBarTab.pinned + a non-passive touchmove listener that preventDefault()s once lifted. Verified on a real phone: pan-x allows normal bar scrolling and still lets the drag survive; auto gets the gesture stolen (pointercancel) immediately.
  3. Auto-scroll runs past the last tab. Translated elements inflate the scrollable overflow, so a live scrollWidth clamp chases phantom space as the tab moves right. Capture maxScrollLeft = scrollWidth - clientWidth at lift time, before any transforms, and clamp to that.
  4. The last swap at either end never triggers. With equal-width tabs, the drag clamp stops the dragged center exactly on the end tab's midpoint, and the strict < midpoint test can't pass. Special-case the clamp boundaries: dx >= maxDx - 0.5 → force last slot; dx <= minDx + 0.5 → force first slot.
  5. Release can happen off-tab. Before lift there is no pointer capture, so pointerup may fire elsewhere and leave the render-deferral flag stuck. Register one-shot pointerup/pointercancel listeners on window from pointerdown (capture retargets to the tab and still bubbles to window, so this works pre- and post-lift).
  6. Ghost click after drag. A click fires after pointerup; suppress tab-activation clicks for ~400 ms after a drop (suppressTabClickUntil).
  7. Long-press artifacts. preventDefault() on contextmenu while holding/dragging; CSS user-select: none; -webkit-user-select: none; -webkit-touch-callout: none on pinned tabs.
  8. CSS transitions. Add transform 0.2s cubic-bezier(0.2, 0, 0, 1) to the base .sessionBarTab transition list (drives neighbor slides and the settle). .dragging must set transition: none so the tab tracks the finger without lag; .dragging/.settling get elevated z-index and a lifted look (shadow, brighter background). .sessionBar.reordering { overflow-x: hidden; } blocks user scroll during the drag (programmatic scrollLeft still works).

Commit & persistence

On commit, rebuild state.pinnedSessions from the final visual order (map tab dataset.sessionId → existing entries; append any entries not in the DOM as a safety net), persist with persistSessionUiState({ pinnedSessions }), then do one clean renderSessionBar() — this clears all transforms by rebuilding, so no manual cleanup/flicker.

Reference prototype

A standalone mock with identical logic (plus an on-device event log and a touch-action mode switcher used to validate behavior on a real phone) lives at .pi/web/artifacts/tab-reorder-mock.html in the downstream working copy — useful for tuning holdDelayMs, scale, timings, edgeZonePx, maxScrollPerFrame.

Validation done downstream

  • npm run typecheck and npm run build clean
  • Unit tests 110/110; session/tab e2e specs pass on mobile/tablet/desktop viewports
  • Manual phone testing: hold-drag reorder, scroll-vs-drag disambiguation, edge auto-scroll to both ends, end-slot snapping, tap-to-activate unaffected

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestimplemented-downstreamImplemented in a downstream fork; track when upstream lands the same change

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions