mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
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:
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user