Files
Codeman/test/tab-overflow.test.ts
T
Codeman maintainer a80eda8e4c 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>
2026-08-10 03:06:23 +02:00

143 lines
5.5 KiB
TypeScript

import { readFileSync } from 'node:fs';
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;
computeTabScrollLeft: (input: ScrollInput) => number;
TAB_SCROLL_REVEAL_PX: number;
};
}
).CodemanTabOverflow;
}
describe('tab overflow layout policy', () => {
it('auto-wraps desktop tabs when their rendered width exceeds available tab space', () => {
const helper = loadTabOverflowHelper();
expect(
helper.shouldAutoWrapTabs({
deviceType: 'desktop',
manualTwoRows: false,
tabCount: 18,
scrollWidth: 1400,
clientWidth: 760,
})
).toBe(true);
});
it('does not auto-wrap when manual tall tabs are enabled or on mobile/tablet', () => {
const helper = loadTabOverflowHelper();
expect(
helper.shouldAutoWrapTabs({
deviceType: 'desktop',
manualTwoRows: true,
tabCount: 18,
scrollWidth: 1400,
clientWidth: 760,
})
).toBe(false);
expect(
helper.shouldAutoWrapTabs({
deviceType: 'mobile',
manualTwoRows: false,
tabCount: 18,
scrollWidth: 1400,
clientWidth: 320,
})
).toBe(false);
});
it('respects the boundary conditions (exact fit, +1 tolerance, and tabCount < 2)', () => {
const helper = loadTabOverflowHelper();
const base = { deviceType: 'desktop' as const, manualTwoRows: false, tabCount: 6 };
// Exact fit: no overflow, no wrap.
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 800, clientWidth: 800 })).toBe(false);
// Within the +1 sub-pixel tolerance: still no wrap.
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 801, clientWidth: 800 })).toBe(false);
// 2px over: wrap.
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 802, clientWidth: 800 })).toBe(true);
// A single overflowing tab must not wrap (need at least 2 to form a second row).
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);
});
});