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); + }); +});