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
- 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).
- 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.
- 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.
- Cancel (
pointercancel): snap back to the original slot, no commit.
- 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)
- 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.
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.
- 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.
- 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.
- 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).
- Ghost click after drag. A
click fires after pointerup; suppress tab-activation clicks for ~400 ms after a drop (suppressTabClickUntil).
- Long-press artifacts.
preventDefault() on contextmenu while holding/dragging; CSS user-select: none; -webkit-user-select: none; -webkit-touch-callout: none on pinned tabs.
- 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
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 (newattachPinnedTabReorder(tab)helper + a guard inrenderSessionBar())src/styles/sessions.css— lift/settle styling,touch-action, selection suppressionInteraction spec
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.draggingclass,navigator.vibrate?.(10).transform: translateX(dx) scale(1.06)(no transition). Other pinned tabs slide aside viatransform: translateX(±draggedWidth)with a0.2s cubic-bezier(0.2, 0, 0, 1)transition.navigator.vibrate?.(5)on each slot change..dragging, add.settling, animate the tab to its target slot offset (~220 ms), then commit the new order tostate.pinnedSessions, persist viapersistSessionUiState({ pinnedSessions }), and re-render the bar cleanly.pointercancel): snap back to the original slot, no commit..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).newIndex= count ofotherswhose captured midpoint is left of the dragged tab's current center (rects[originalIndex].left + width/2 + dx).k, positionpinothers):-draggedWidthifk > originalIndex && p < newIndex;+draggedWidthifk < originalIndex && p >= newIndex; else 0.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) isoverflow-x: auto; off-screen slots are unreachable without auto-scroll. While lifted, arequestAnimationFrameloop 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:Pitfalls (each of these was hit for real — do not skip)
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 aqueuedflag and returns; flush after the gesture. Delay the flush ~250 ms after a plain tap so theclickevent still hits the old element before the rebuild.touch-actionchoice.touch-action: pan-xon.sessionBarTab.pinned+ a non-passivetouchmovelistener thatpreventDefault()s once lifted. Verified on a real phone:pan-xallows normal bar scrolling and still lets the drag survive;autogets the gesture stolen (pointercancel) immediately.scrollWidthclamp chases phantom space as the tab moves right. CapturemaxScrollLeft = scrollWidth - clientWidthat lift time, before any transforms, and clamp to that.<midpoint test can't pass. Special-case the clamp boundaries:dx >= maxDx - 0.5→ force last slot;dx <= minDx + 0.5→ force first slot.pointerupmay fire elsewhere and leave the render-deferral flag stuck. Register one-shotpointerup/pointercancellisteners onwindowfrompointerdown(capture retargets to the tab and still bubbles to window, so this works pre- and post-lift).clickfires afterpointerup; suppress tab-activation clicks for ~400 ms after a drop (suppressTabClickUntil).preventDefault()oncontextmenuwhile holding/dragging; CSSuser-select: none; -webkit-user-select: none; -webkit-touch-callout: noneon pinned tabs.transform 0.2s cubic-bezier(0.2, 0, 0, 1)to the base.sessionBarTabtransition list (drives neighbor slides and the settle)..draggingmust settransition: noneso the tab tracks the finger without lag;.dragging/.settlingget elevatedz-indexand a lifted look (shadow, brighter background)..sessionBar.reordering { overflow-x: hidden; }blocks user scroll during the drag (programmaticscrollLeftstill works).Commit & persistence
On commit, rebuild
state.pinnedSessionsfrom the final visual order (map tabdataset.sessionId→ existing entries; append any entries not in the DOM as a safety net), persist withpersistSessionUiState({ pinnedSessions }), then do one cleanrenderSessionBar()— 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-actionmode switcher used to validate behavior on a real phone) lives at.pi/web/artifacts/tab-reorder-mock.htmlin the downstream working copy — useful for tuningholdDelayMs, scale, timings,edgeZonePx,maxScrollPerFrame.Validation done downstream
npm run typecheckandnpm run buildclean