fix(mobile): make every session tab reachable in the tab strip (#257)

With five tabs open on a phone, the right-hand tabs were effectively
unreachable. Selecting a tab only toggled the .active class, so the strip
never moved, and every full rebuild (a task badge appearing, a session
created elsewhere) replaced the strip's innerHTML, which resets scrollLeft
to 0 and yanked a mid-swipe strip back to the first tab.

Three changes, which only work together:

* computeTabScrollLeft() (pure, constants.js) decides the scroll target from
  measured rects, and _scrollActiveTabIntoView() applies it on selection.
  Rect math on the strip's own scrollLeft rather than scrollIntoView(), which
  also scrolls ancestors: on a phone that is the document, under a fixed
  header and possibly an open keyboard.
* _fullRenderSessionTabs() saves and restores scrollLeft across the rebuild,
  and re-reveals the active tab only when it actually changed
  (_lastRenderedActiveTabId), so a background render never undoes a manual
  swipe.
* Mobile no longer hoists the active session to the front of the strip. That
  reordering ran on full renders only, so tab order flipped depending on
  which render path fired, and it renumbered the Alt+N badges. Scrolling the
  active tab into view replaces it.

Also sets overscroll-behavior-x: contain on the strip so a swipe that runs
past the last tab stays in the strip instead of becoming the browser's back
gesture.

Tests: scroll-target math in test/tab-overflow.test.ts (runs in CI), plus
five browser regressions in test/mobile/tabs.test.ts covering reveal-on-
select in both directions, scroll preservation across an ambient rebuild,
sessionOrder rendering on phones, and a real touch drag reaching the last
tab.

Closes #257

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-10 03:06:23 +02:00
parent c942bb5dfb
commit a80eda8e4c
6 changed files with 377 additions and 11 deletions
+43
View File
@@ -156,6 +156,47 @@ function shouldAutoWrapTabs(input) {
return scrollWidth > clientWidth + 1;
}
// Sliver of the neighbouring tab left visible when the strip scrolls a tab into
// view. Landing a tab flush against the edge reads as "this is the last one";
// the gap is what tells the user there is more strip to swipe to.
const TAB_SCROLL_REVEAL_PX = 16;
// Phone/tablet tab-strip scroll policy (issue #257). Those breakpoints scroll
// the strip horizontally (desktop wraps to a second row instead and never
// scrolls), so the active tab can sit entirely outside the visible slice with
// no way back except a swipe the user may not know is possible.
//
// Returns the scrollLeft that puts the tab inside the window, clamped to the
// scrollable range, and returns the CURRENT scrollLeft when the tab is already
// visible: callers compare and skip the write, so an already-correct strip is
// never nudged. Pure: the caller measures, this decides.
function computeTabScrollLeft(input) {
const scrollWidth = Number(input?.scrollWidth) || 0;
const clientWidth = Number(input?.clientWidth) || 0;
const maxScroll = Math.max(0, scrollWidth - clientWidth);
if (maxScroll === 0 || clientWidth <= 0) return 0;
const pad = input?.padding == null ? TAB_SCROLL_REVEAL_PX : Number(input.padding) || 0;
const tabLeft = Number(input?.tabLeft) || 0;
const tabWidth = Number(input?.tabWidth) || 0;
const tabRight = tabLeft + tabWidth;
const viewLeft = Math.min(Math.max(Number(input?.scrollLeft) || 0, 0), maxScroll);
const viewRight = viewLeft + clientWidth;
let target = viewLeft;
if (tabWidth + pad >= clientWidth) {
// Tab is as wide as the window (long session name on a narrow phone):
// there is no position that shows all of it plus padding, so align its
// start, since the name matters more than the trailing badges.
target = tabLeft;
} else if (tabLeft - pad < viewLeft) {
target = tabLeft - pad;
} else if (tabRight + pad > viewRight) {
target = tabRight + pad - clientWidth;
}
return Math.min(Math.max(Math.round(target), 0), maxScroll);
}
// COD-134 — Terminal WebSocket reconnect policy.
//
// Decide what to do after a terminal WebSocket closes, given the close `code`
@@ -261,6 +302,8 @@ if (typeof window !== 'undefined') {
window.shouldSkipWebGL = shouldSkipWebGL;
window.CodemanTabOverflow = {
shouldAutoWrapTabs,
computeTabScrollLeft,
TAB_SCROLL_REVEAL_PX,
};
window.CodemanWsReconnect = {
plan: planWsReconnect,