From a80eda8e4c46bc43871167c80cf733a93178c7ea Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 03:06:23 +0200 Subject: [PATCH] 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) --- CLAUDE.md | 2 + src/web/public/app.js | 90 ++++++++++++++++++-- src/web/public/constants.js | 43 ++++++++++ src/web/public/mobile.css | 7 +- test/mobile/tabs.test.ts | 165 ++++++++++++++++++++++++++++++++++++ test/tab-overflow.test.ts | 81 +++++++++++++++++- 6 files changed, 377 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e6792be6f..1ec84f4c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,6 +252,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`. +**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders. ⚠️ 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. Scroll-into-view replaces it; do not reintroduce it. + **Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere). **Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview. diff --git a/src/web/public/app.js b/src/web/public/app.js index f475cb674..9b633c204 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3461,6 +3461,54 @@ class CodemanApp { tab.classList.remove('active'); } } + // #257: selection used to stop at the class toggle. On phones/tablets the + // strip scrolls horizontally, so a tab selected from the palette, a swipe, + // Alt+N or a push notification could stay parked off-screen. + this._scrollActiveTabIntoView(sessionId); + } + + /** + * Scroll the tab strip so the given (default: active) tab is visible. + * + * Only phones/tablets scroll the strip (desktop wraps to a second row), and + * the pure policy no-ops whenever there is nothing to scroll, so this is a + * cheap call on every device. + * + * Deliberately NOT scrollIntoView(): that also scrolls every scrollable + * ANCESTOR, which on a phone is the document itself. With the header fixed + * and the keyboard possibly open, a vertical nudge there shifts the whole + * app. Rect math + scrollLeft touches exactly one scroller. + */ + _scrollActiveTabIntoView(sessionId, behavior = 'smooth') { + const container = this.$('sessionTabs'); + if (!container) return; + const tab = + (sessionId && container.querySelector(`.session-tab[data-id="${sessionId}"]`)) || + container.querySelector('.session-tab.active'); + if (!tab) return; + + const policy = window.CodemanTabOverflow?.computeTabScrollLeft; + if (!policy) return; + const containerRect = container.getBoundingClientRect(); + const tabRect = tab.getBoundingClientRect(); + const target = policy({ + scrollLeft: container.scrollLeft, + clientWidth: container.clientWidth, + scrollWidth: container.scrollWidth, + // Offsets are relative to the SCROLL CONTENT, not the offsetParent: the + // tabs' offsetParent is the positioned header, so offsetLeft would carry + // the brand column's width into the math. + tabLeft: tabRect.left - containerRect.left + container.scrollLeft, + tabWidth: tabRect.width, + }); + if (Math.abs(target - container.scrollLeft) < 1) return; + + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; + if (typeof container.scrollTo === 'function') { + container.scrollTo({ left: target, behavior: reduceMotion ? 'auto' : behavior }); + } else { + container.scrollLeft = target; + } } _setTerminalLoadState(sessionId, selectGen, phase) { @@ -3678,6 +3726,11 @@ class CodemanApp { this._fullRenderSessionTabs(); } + // Keep the reveal-on-change bookkeeping honest when only the incremental + // branch ran: _updateActiveTabImmediate has already scrolled the new active + // tab into view, so the next full rebuild must not treat it as a change. + this._lastRenderedActiveTabId = this.activeSessionId; + this.updateTabOverflowMode(); // After the wrap measurement: the `unroll` style starts tabs at max-width 0, // so measuring mid-animation would decide the wrap on collapsed widths. @@ -3749,15 +3802,25 @@ class CodemanApp { document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove()); this.cancelHideSubagentDropdown(); - // Build tabs HTML using array for better string concatenation performance - // Iterate in sessionOrder to respect user's custom tab arrangement - // On mobile: put active session first (only one tab visible anyway) + // #257: replacing innerHTML below resets scrollLeft to 0. On phones the + // strip scrolls, and ambient rebuilds (a task badge appearing, a session + // created elsewhere) fire often enough that a user swiping toward the + // right-hand tabs kept getting yanked back to the first one. Remember + // where the strip was; the browser clamps the restore to the new content. + const prevScrollLeft = container.scrollLeft; + const prevActiveTabId = this._lastRenderedActiveTabId; + const isFirstRender = !container.querySelector('.session-tab'); + + // Build tabs HTML using array for better string concatenation performance. + // Iterate in sessionOrder to respect the user's custom tab arrangement, on + // EVERY device: mobile used to hoist the active session to the front, from + // when only one tab fit on screen. With five tabs it made the strip jump + // under the user's finger (and renumbered the Alt+N badges) on every full + // rebuild, while the incremental path left the order alone, so the order + // depended on which render path happened to run. Scrolling the active tab + // into view replaces it. const parts = []; - let tabOrder = this.sessionOrder; - if (MobileDetection.getDeviceType() === 'mobile' && this.activeSessionId) { - // Reorder to put active tab first - tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)]; - } + const tabOrder = this.sessionOrder; let _tabIdx = 0; for (const id of tabOrder) { const session = this.sessions.get(id); @@ -3826,6 +3889,17 @@ class CodemanApp { container.innerHTML = parts.join(''); + // Put the strip back where the user left it, then reveal the active tab + // only when it CHANGED (or on the first paint). Restoring unconditionally + // and revealing conditionally is what lets someone browse the far end of + // the strip while a background rebuild fires, without the active tab ever + // being stranded off-screen after a switch. + container.scrollLeft = prevScrollLeft; + this._lastRenderedActiveTabId = this.activeSessionId; + if (isFirstRender || prevActiveTabId !== this.activeSessionId) { + this._scrollActiveTabIntoView(this.activeSessionId, isFirstRender ? 'auto' : 'smooth'); + } + // Set up drag-and-drop handlers for tab reordering this.setupTabDragHandlers(); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index d411ce9a9..2c3f8d64f 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -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, diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index c3cf70874..90b8e271a 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -115,13 +115,17 @@ html.mobile-init .file-browser-panel { } /* Compact session tabs — .tabs-two-rows override needed to match - specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */ + specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0). + overscroll-behavior-x keeps a swipe that runs past the last tab inside the + strip: chained to the page it becomes the browser's back gesture, which is + exactly the swipe someone makes reaching for the rightmost tabs (#257). */ .session-tabs, .session-tabs.tabs-two-rows { flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; scrollbar-width: none; max-height: 52px; gap: 3px; @@ -643,6 +647,7 @@ html.mobile-init .file-browser-panel { overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; scrollbar-width: none; max-height: 36px; gap: 2px; diff --git a/test/mobile/tabs.test.ts b/test/mobile/tabs.test.ts index 82aba528c..33533090f 100644 --- a/test/mobile/tabs.test.ts +++ b/test/mobile/tabs.test.ts @@ -183,6 +183,171 @@ describe('Tab Navigation', () => { }); }); + // ─── Tab Strip Scrolling (issue #257) ──────────────────────────────────── + + describe('Tab Strip Scrolling', () => { + /** + * Seed `count` real sessions and render the strip through the production + * code path (_fullRenderSessionTabs), so the tabs carry the real markup, + * widths and CSS rather than hand-built stand-ins. + */ + async function seedTabs(page: Page, count: number, activeIndex = 0): Promise { + await page.evaluate(`(function (n, activeIndex) { + app.sessions.clear(); + app.sessionOrder = []; + for (let i = 1; i <= n; i++) { + const id = 'scroll-sess-' + i; + app.sessions.set(id, { id, name: 'w' + i + '-project', status: 'idle', mode: 'claude', workingDir: '/tmp/p' + i }); + app.sessionOrder.push(id); + } + app.activeSessionId = app.sessionOrder[activeIndex]; + app._lastRenderedActiveTabId = null; + app._fullRenderSessionTabs(); + })(${count}, ${activeIndex})`); + await page.waitForTimeout(200); + } + + async function stripState(page: Page, sessionId: string) { + return page.evaluate(`(function (id) { + const c = document.getElementById('sessionTabs'); + const tab = c.querySelector('.session-tab[data-id="' + id + '"]'); + const cRect = c.getBoundingClientRect(); + const tRect = tab ? tab.getBoundingClientRect() : null; + return { + scrollLeft: Math.round(c.scrollLeft), + maxScroll: Math.round(c.scrollWidth - c.clientWidth), + order: [...c.querySelectorAll('.session-tab[data-id]')].map((t) => t.dataset.id), + visible: tRect ? tRect.left >= cRect.left - 1 && tRect.right <= cRect.right + 1 : false, + }; + })('${sessionId}')`) as Promise<{ scrollLeft: number; maxScroll: number; order: string[]; visible: boolean }>; + } + + it('reveals a rightmost tab that selection would otherwise leave off-screen', async () => { + const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'); + try { + await page.waitForTimeout(WAIT.PAGE_SETTLE); + await seedTabs(page, 5); + + const before = await stripState(page, 'scroll-sess-5'); + // Precondition: the strip really does overflow and the last tab is hidden. + expect(before.maxScroll).toBeGreaterThan(0); + expect(before.visible).toBe(false); + + // The selection path selectSession() uses (class toggle, no rebuild). + await page.evaluate(`(function () { + app.activeSessionId = 'scroll-sess-5'; + app._updateActiveTabImmediate('scroll-sess-5'); + })()`); + await page.waitForTimeout(600); // smooth scroll + + const after = await stripState(page, 'scroll-sess-5'); + expect(after.visible).toBe(true); + expect(after.scrollLeft).toBeGreaterThan(before.scrollLeft); + } finally { + await context.close(); + } + }); + + it('scrolls back to reveal a leftmost tab', async () => { + const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'); + try { + await page.waitForTimeout(WAIT.PAGE_SETTLE); + await seedTabs(page, 5); + await page.evaluate(`document.getElementById('sessionTabs').scrollLeft = 9999`); + + await page.evaluate(`(function () { + app.activeSessionId = 'scroll-sess-1'; + app._updateActiveTabImmediate('scroll-sess-1'); + })()`); + await page.waitForTimeout(600); + + const after = await stripState(page, 'scroll-sess-1'); + expect(after.visible).toBe(true); + expect(after.scrollLeft).toBe(0); + } finally { + await context.close(); + } + }); + + it('keeps the scroll position across an ambient full re-render', async () => { + const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'); + try { + await page.waitForTimeout(WAIT.PAGE_SETTLE); + await seedTabs(page, 5); + + // User swipes to the end of the strip, then a background rebuild fires + // (a task badge appearing forces the full-render path). + await page.evaluate(`document.getElementById('sessionTabs').scrollLeft = 9999`); + const scrolled = await stripState(page, 'scroll-sess-5'); + expect(scrolled.scrollLeft).toBeGreaterThan(0); + + await page.evaluate(`(function () { + app.sessions.get('scroll-sess-2').taskStats = { running: 2, total: 3 }; + app._fullRenderSessionTabs(); + })()`); + await page.waitForTimeout(200); + + const after = await stripState(page, 'scroll-sess-5'); + expect(after.scrollLeft).toBe(scrolled.scrollLeft); + } finally { + await context.close(); + } + }); + + it('renders tabs in sessionOrder on phones instead of hoisting the active one', async () => { + const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'); + try { + await page.waitForTimeout(WAIT.PAGE_SETTLE); + await seedTabs(page, 5, 3); // 4th tab active + + const state = await stripState(page, 'scroll-sess-4'); + expect(state.order).toEqual([ + 'scroll-sess-1', + 'scroll-sess-2', + 'scroll-sess-3', + 'scroll-sess-4', + 'scroll-sess-5', + ]); + // ...and the active tab is still brought into view by the render. + expect(state.visible).toBe(true); + } finally { + await context.close(); + } + }); + + it('reaches the last tab with a horizontal touch drag', async () => { + const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium'); + try { + await page.waitForTimeout(WAIT.PAGE_SETTLE); + await seedTabs(page, 5); + + const cdp = await context.newCDPSession(page); + const box = await page.locator(SELECTORS.TABS_CONTAINER).boundingBox(); + if (!box) throw new Error('tab strip not found'); + const y = box.y + box.height / 2; + const startX = box.x + box.width * 0.85; + const endX = box.x + box.width * 0.1; + + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: startX, y }] }); + for (let i = 1; i <= 10; i++) { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x: startX + ((endX - startX) * i) / 10, y }], + }); + await page.waitForTimeout(16); + } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await page.waitForTimeout(400); + + const after = await stripState(page, 'scroll-sess-5'); + expect(after.scrollLeft).toBeGreaterThan(0); + expect(after.visible).toBe(true); + } finally { + await context.close(); + } + }); + }); + // ─── Swipe Navigation (CDP - Chromium) ─────────────────────────────────── describe('Swipe Navigation (CDP - Chromium)', () => { diff --git a/test/tab-overflow.test.ts b/test/tab-overflow.test.ts index 0ed7895a2..65473cfec 100644 --- a/test/tab-overflow.test.ts +++ b/test/tab-overflow.test.ts @@ -3,12 +3,28 @@ import { resolve } from 'node:path'; import vm from 'node:vm'; import { describe, expect, it } from 'vitest'; +type ScrollInput = { + scrollLeft?: number; + clientWidth?: number; + scrollWidth?: number; + tabLeft?: number; + tabWidth?: number; + padding?: number; +}; + function loadTabOverflowHelper() { const context = vm.createContext({ window: {}, globalThis: {} }); const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); vm.runInContext(source, context, { filename: 'constants.js' }); - return (context.window as { CodemanTabOverflow: { shouldAutoWrapTabs: (input: unknown) => boolean } }) - .CodemanTabOverflow; + return ( + context.window as { + CodemanTabOverflow: { + shouldAutoWrapTabs: (input: unknown) => boolean; + computeTabScrollLeft: (input: ScrollInput) => number; + TAB_SCROLL_REVEAL_PX: number; + }; + } + ).CodemanTabOverflow; } describe('tab overflow layout policy', () => { @@ -63,3 +79,64 @@ describe('tab overflow layout policy', () => { expect(helper.shouldAutoWrapTabs({ ...base, tabCount: 1, scrollWidth: 1400, clientWidth: 760 })).toBe(false); }); }); + +// Issue #257: the phone tab strip scrolls horizontally, so the active tab can +// sit entirely outside the visible slice. These pin the scroll target math that +// _scrollActiveTabIntoView() feeds with measured rects. +describe('mobile tab strip scroll-into-view policy', () => { + // A 5-tab phone strip: 335px visible of 558px of tabs. + const strip = { clientWidth: 335, scrollWidth: 558 }; + const pad = 16; + + it('scrolls right to reveal a tab past the right edge, leaving the reveal sliver', () => { + const helper = loadTabOverflowHelper(); + // Last tab: 458..558, strip parked at 0. + const target = helper.computeTabScrollLeft({ ...strip, scrollLeft: 0, tabLeft: 458, tabWidth: 100 }); + // 558 + 16 - 335 = 239, clamped to the 223px maximum. + expect(target).toBe(223); + // The revealed tab is now inside the window. + expect(458).toBeGreaterThanOrEqual(target); + expect(558).toBeLessThanOrEqual(target + strip.clientWidth); + }); + + it('scrolls left to reveal a tab before the left edge', () => { + const helper = loadTabOverflowHelper(); + // First tab: 0..150, strip scrolled to the end. + expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 223, tabLeft: 0, tabWidth: 150 })).toBe(0); + // A middle tab partially cut off on the left: reveal it with the sliver. + expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 223, tabLeft: 200, tabWidth: 100 })).toBe(200 - pad); + }); + + it('leaves an already-visible tab alone (callers skip the write)', () => { + const helper = loadTabOverflowHelper(); + expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 100, tabLeft: 152, tabWidth: 100 })).toBe(100); + }); + + it('never scrolls a strip that fits, and never leaves the scrollable range', () => { + const helper = loadTabOverflowHelper(); + // Everything fits: nothing to scroll, whatever the tab geometry says. + expect( + helper.computeTabScrollLeft({ clientWidth: 900, scrollWidth: 400, scrollLeft: 0, tabLeft: 300, tabWidth: 100 }) + ).toBe(0); + // Clamped at both ends. + const low = helper.computeTabScrollLeft({ ...strip, scrollLeft: 40, tabLeft: 4, tabWidth: 100 }); + expect(low).toBe(0); + const high = helper.computeTabScrollLeft({ ...strip, scrollLeft: 0, tabLeft: 500, tabWidth: 58 }); + expect(high).toBeLessThanOrEqual(strip.scrollWidth - strip.clientWidth); + }); + + it('aligns the start of a tab too wide to fit the window', () => { + const helper = loadTabOverflowHelper(); + // 330px tab in a 335px window: no position shows it plus padding. + expect( + helper.computeTabScrollLeft({ clientWidth: 335, scrollWidth: 900, scrollLeft: 0, tabLeft: 400, tabWidth: 330 }) + ).toBe(400); + }); + + it('tolerates missing measurements instead of producing NaN', () => { + const helper = loadTabOverflowHelper(); + expect(helper.computeTabScrollLeft({})).toBe(0); + expect(helper.computeTabScrollLeft(undefined as unknown as ScrollInput)).toBe(0); + expect(helper.TAB_SCROLL_REVEAL_PX).toBe(pad); + }); +});