mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #267 from Ark0N/fix/mobile-tab-scroll-257
fix(mobile): make every session tab reachable in the tab strip
This commit is contained in:
@@ -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 `<html>`. 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-<backend>` / `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.
|
||||
|
||||
+82
-8
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
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)', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user