diff --git a/config/test-suites.ts b/config/test-suites.ts index ffcfb2f8..3a16ed1a 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -62,6 +62,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/phone-press-feedback.browser.test.ts', 'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception 'test/local-model-ui.browser.test.ts', + 'test/home-breakpoint-swap.browser.test.ts', ]; /** diff --git a/src/web/public/app.js b/src/web/public/app.js index 3257792e..5b7c86c0 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1232,6 +1232,11 @@ class CodemanApp { // and then applies the sidebar variant on top — do not call both. this.applySessionListLayout(); this.applyTabOrientation(); + // The home screens' breakpoint listeners, once, before any home surface is + // shown: a fold or unfold must swap the surface whichever width the page + // loaded at (#702). Both self-gate on a home screen being up. + this._wireMobileOverviewBreakpoint?.(); + this._wireHomeSessionsBreakpoint?.(); this.initTabRailResize?.(); this.applyMonitorVisibility(); this.applyLineageLineSettings?.(); diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js index 7865c08c..9531c3fb 100644 --- a/src/web/public/home-sessions.js +++ b/src/web/public/home-sessions.js @@ -126,20 +126,31 @@ Object.assign(CodemanApp.prototype, { this._stopHomeSessionsClock(); }, - /** Re-render only when showing (called from the tab renderer's tail). */ + /** + * Re-render only when showing (called from the tab renderer's tail). Asked + * again when the debounce fires: `renderHomeSessions()` unhides the column, + * so a render queued just before a fold to phone width hid it (#702) would + * otherwise bring it back behind the phone overview. + */ _refreshHomeSessionsIfVisible() { if (!this.isHomeSessionsVisible()) return; - this._debouncedCall('homeSessions', () => this.renderHomeSessions(), 150); + this._debouncedCall( + 'homeSessions', + () => { + if (this.isHomeSessionsVisible() && this.shouldShowHomeSessions()) this.renderHomeSessions(); + }, + 150 + ); }, /** - * One delegated click listener for every row, plus a width listener so - * resizing the window while on the home screen adds or drops the column - * instead of leaving it overlapping the content it was sized to clear. + * One delegated click listener for every row. The width listener lives in + * `_wireHomeSessionsBreakpoint()`, installed at startup. */ _wireHomeSessions(el) { if (this._homeSessionsWired) return; this._homeSessionsWired = true; + this._wireHomeSessionsBreakpoint(); el.addEventListener('click', (event) => { const target = event.target?.closest?.('[data-hs-action]'); @@ -150,20 +161,29 @@ Object.assign(CodemanApp.prototype, { void this.openWebview?.(target.dataset.hsWebview); } }); + }, - if (window.matchMedia) { - const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`); - const onChange = () => { - // Only relevant while the welcome screen is up; entering a session - // re-decides through hideWelcome()/showWelcome() anyway. - if (this.activeSessionId) return; - const overlay = document.getElementById('welcomeOverlay'); - if (!overlay || !overlay.classList.contains('visible')) return; - this.showHomeSessions(); - }; - if (mq.addEventListener) mq.addEventListener('change', onChange); - else if (mq.addListener) mq.addListener(onChange); - } + /** + * Add or drop the column when the window crosses its width gate, so it never + * overlaps the content it was sized to clear. Installed ONCE at startup + * (`init()` in app.js) like the phone overview's breakpoint listener, so a + * page that loads on a phone and is unfolded to desktop width gets it too + * (#702). Idempotent. + */ + _wireHomeSessionsBreakpoint() { + if (this._homeSessionsBreakpointWired || !window.matchMedia) return; + this._homeSessionsBreakpointWired = true; + const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`); + const onChange = () => { + // Only relevant while the welcome screen is up; entering a session + // re-decides through hideWelcome()/showWelcome() anyway. + if (this.activeSessionId) return; + const overlay = document.getElementById('welcomeOverlay'); + if (!overlay || !overlay.classList.contains('visible')) return; + this.showHomeSessions(); + }; + if (mq.addEventListener) mq.addEventListener('change', onChange); + else if (mq.addListener) mq.addListener(onChange); }, // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index e1c41fdd..d6ea875a 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -385,13 +385,13 @@ Object.assign(CodemanApp.prototype, { }, /** - * One delegated click listener for every row, plus a breakpoint listener so - * rotating or unfolding while on the home screen swaps to the right surface - * instead of stranding a phone layout on a tablet-width viewport. + * One delegated click listener for every row. The breakpoint listener lives + * in `_wireMobileOverviewBreakpoint()`, installed at startup. */ _wireMobileOverview(el) { if (this._mobileOverviewWired) return; this._mobileOverviewWired = true; + this._wireMobileOverviewBreakpoint(); el.addEventListener('click', (event) => { const target = event.target && event.target.closest && event.target.closest('[data-mo-action]'); @@ -426,18 +426,34 @@ Object.assign(CodemanApp.prototype, { this.showWebviewModal(); } }); + }, - if (window.matchMedia) { - const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY); - const onChange = () => { - // Only relevant while a home surface is up; entering a session re-decides - // through hideWelcome()/showWelcome() anyway. - if (this.activeSessionId) return; - if (typeof this.showWelcome === 'function') this.showWelcome(); - }; - if (mq.addEventListener) mq.addEventListener('change', onChange); - else if (mq.addListener) mq.addListener(onChange); - } + /** + * Swap the home surface when the width crosses the phone breakpoint (rotate, + * fold, unfold), so a phone layout is never stranded on a tablet-width + * viewport and the welcome screen never on a phone-width one. + * + * Installed ONCE at startup (`init()` in app.js), not on the first show: a + * page that loads wide never shows the phone overview, so a listener wired + * from there was missing on exactly the fold that needed it (#702). Idempotent, + * so the call from `_wireMobileOverview()` adds nothing after startup. + */ + _wireMobileOverviewBreakpoint() { + if (this._mobileOverviewBreakpointWired || !window.matchMedia) return; + this._mobileOverviewBreakpointWired = true; + const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY); + const onChange = () => { + // Only relevant while a home surface is up; entering a session re-decides + // through hideWelcome()/showWelcome() anyway, and before the first + // showWelcome() there is nothing on screen to swap. + if (this.activeSessionId) return; + const overlay = document.getElementById('welcomeOverlay'); + const welcomeUp = !!overlay && overlay.classList.contains('visible'); + if (!welcomeUp && !this.isMobileOverviewVisible()) return; + if (typeof this.showWelcome === 'function') this.showWelcome(); + }; + if (mq.addEventListener) mq.addEventListener('change', onChange); + else if (mq.addListener) mq.addListener(onChange); }, _toggleMobileOverviewRunMenu() { diff --git a/test/home-breakpoint-listeners.test.ts b/test/home-breakpoint-listeners.test.ts new file mode 100644 index 00000000..7daccd0e --- /dev/null +++ b/test/home-breakpoint-listeners.test.ts @@ -0,0 +1,141 @@ +// Port: none (the real home-screen modules in a vm context with a fake +// matchMedia; no browser, no server). +// +// #702: folding a phone on the home screen must swap the home surface even when +// the page loaded wide. The phone overview's breakpoint listener used to be +// wired from its first show, which a wide-loaded page never reaches. Both home +// screens' listeners are now installed once at startup and self-gate on a home +// screen being up. The real resize walk lives in +// test/home-breakpoint-swap.browser.test.ts (browser suite, not in the gate); +// this file keeps the wiring itself under `npm test`. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); + +type Listener = () => void; + +function loadHomeApp(innerWidth = 1440) { + const listeners = new Map(); + const elements: Record = { + welcomeOverlay: { classList: new Set(), hidden: false }, + mobileOverview: { classList: new Set(), hidden: true }, + homeSessions: { hidden: true }, + }; + for (const el of Object.values(elements)) { + if (el.classList) { + const set = el.classList as Set; + el.classList = { + contains: (c: string) => set.has(c), + add: (c: string) => set.add(c), + remove: (c: string) => set.delete(c), + }; + } + } + const timers: Array<() => void> = []; + const window: any = { + innerWidth, + matchMedia: (query: string) => ({ + matches: false, + addEventListener: (_type: string, fn: Listener) => { + listeners.set(query, [...(listeners.get(query) || []), fn]); + }, + }), + }; + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + console, + window, + document: { + documentElement: { getAttribute: () => null }, + getElementById: (id: string) => elements[id] || null, + createElement: () => ({ dataset: {}, style: {}, appendChild() {}, setAttribute() {} }), + }, + MobileDetection: { getDeviceType: () => (window.innerWidth < 600 ? 'mobile' : 'desktop') }, + }); + for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) { + vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file }); + } + const app = new (CodemanApp as any)(); + app.loadAppSettingsFromStorage = () => ({}); + app.activeSessionId = null; + app.showWelcomeCalls = 0; + app.showWelcome = () => app.showWelcomeCalls++; + app.renderHomeSessionsCalls = 0; + app.renderHomeSessions = () => { + app.renderHomeSessionsCalls++; + elements.homeSessions.hidden = false; + }; + app._debouncedCall = (_key: string, fn: () => void) => timers.push(fn); + const fire = (query: string) => (listeners.get(query) || []).forEach((fn) => fn()); + const flushTimers = () => timers.splice(0).forEach((fn) => fn()); + return { app, window, elements, listeners, fire, flushTimers }; +} + +const PHONE_QUERY = '(max-width: 599px)'; +const RAIL_QUERY = '(min-width: 1180px)'; + +describe('home-screen breakpoint listeners (#702)', () => { + it('installs each listener once, with nothing shown yet', () => { + const { app, listeners } = loadHomeApp(); + app._wireMobileOverviewBreakpoint(); + app._wireHomeSessionsBreakpoint(); + // The lazy wiring from a first show must not add a second copy. + app._wireMobileOverviewBreakpoint(); + app._wireHomeSessionsBreakpoint(); + expect(listeners.get(PHONE_QUERY)).toHaveLength(1); + expect(listeners.get(RAIL_QUERY)).toHaveLength(1); + }); + + it('a fold on a wide-loaded welcome screen re-decides the surface', () => { + const { app, window, elements, fire } = loadHomeApp(1440); + app._wireMobileOverviewBreakpoint(); + elements.welcomeOverlay.classList.add('visible'); + window.innerWidth = 390; + fire(PHONE_QUERY); + expect(app.showWelcomeCalls).toBe(1); + }); + + it('an unfold on the phone overview re-decides the surface', () => { + const { app, window, elements, fire } = loadHomeApp(390); + app._wireMobileOverviewBreakpoint(); + elements.mobileOverview.classList.add('visible'); + window.innerWidth = 820; + fire(PHONE_QUERY); + expect(app.showWelcomeCalls).toBe(1); + }); + + it('stays out of the way inside a session and before any home screen is up', () => { + const { app, elements, fire } = loadHomeApp(); + app._wireMobileOverviewBreakpoint(); + fire(PHONE_QUERY); // page still loading: no home surface yet + elements.welcomeOverlay.classList.add('visible'); + app.activeSessionId = 'abc'; + fire(PHONE_QUERY); + expect(app.showWelcomeCalls).toBe(0); + }); + + it('a rail render queued before a fold does not bring the hidden rail back', () => { + const { app, window, elements, flushTimers } = loadHomeApp(1440); + elements.welcomeOverlay.classList.add('visible'); + elements.homeSessions.hidden = false; + app._refreshHomeSessionsIfVisible(); // queued while the rail is still up + window.innerWidth = 390; // the fold + elements.homeSessions.hidden = true; // showWelcome()'s phone path hid it + flushTimers(); + expect(app.renderHomeSessionsCalls).toBe(0); + expect(elements.homeSessions.hidden).toBe(true); + }); + + it('is wired from init(), not from the first show', () => { + const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); + const start = appJs.indexOf('\n init() {'); + const end = appJs.indexOf('\n }\n', start); + const init = appJs.slice(start, end); + expect(init).toContain('this._wireMobileOverviewBreakpoint?.()'); + expect(init).toContain('this._wireHomeSessionsBreakpoint?.()'); + }); +}); diff --git a/test/home-breakpoint-swap.browser.test.ts b/test/home-breakpoint-swap.browser.test.ts new file mode 100644 index 00000000..dd5fab1c --- /dev/null +++ b/test/home-breakpoint-swap.browser.test.ts @@ -0,0 +1,151 @@ +/** + * @fileoverview Folding or unfolding a phone on the home screen swaps the home + * surface in place, whichever width the page loaded at (#702). + * + * Both breakpoint listeners (the phone overview's `(max-width: 599px)` and the + * desktop rail's `(min-width: 1180px)`) used to be wired from the first show of + * their own surface. A page that loaded wide never showed the phone overview, + * so folding it to phone width left the welcome screen up until something + * called `showWelcome()` again. They are now installed once at startup. + * + * Real server, real Chromium, one page per direction, resized across 599/600 + * and 767/768 in both directions with no navigation, plus a height-only resize + * (the on-screen keyboard) that must not re-decide the surface. + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +type Surface = { welcome: boolean; overview: boolean; rail: boolean }; + +describe('home surface follows a fold or unfold without a reload', () => { + let server: WebServer; + let browser: Browser; + let workDir: string; + + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + // One session, so the desktop rail has a row to show (it hides when empty). + workDir = mkdtempSync(join(tmpdir(), 'home-breakpoint-')); + const res = await fetch(`http://localhost:${server.boundPort}/api/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workingDir: workDir }), + }); + expect(res.ok).toBe(true); + browser = await chromium.launch({ headless: true }); + }, 90000); + + afterAll(async () => { + if (browser) await browser.close(); + if (server) await server.stop(); + if (workDir) rmSync(workDir, { recursive: true, force: true }); + }, 60000); + + async function open(width: number, height: number): Promise<[BrowserContext, Page]> { + const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 }); + const page = await context.newPage(); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); + // The load selects the one session; Home is where a fold has to swap surfaces. + await page.waitForFunction(() => (window as any).app?.sessions?.size > 0, null, { timeout: 30000 }); + await page.evaluate(() => (window as any).app.goHome()); + await page.waitForFunction( + () => + document.getElementById('welcomeOverlay')?.classList.contains('visible') || + document.getElementById('mobileOverview')?.classList.contains('visible'), + null, + { timeout: 30000 } + ); + return [context, page]; + } + + const surface = (page: Page): Promise => + page.evaluate(() => { + const overview = document.getElementById('mobileOverview')!; + const rail = document.getElementById('homeSessions')!; + return { + welcome: document.getElementById('welcomeOverlay')!.classList.contains('visible'), + overview: !overview.hidden && overview.classList.contains('visible'), + rail: !rail.hidden, + }; + }); + + /** Resize, then wait until the surface settles on what that width should show. */ + async function resizeTo(page: Page, width: number, height: number, expected: Surface) { + await page.setViewportSize({ width, height }); + await expect + .poll(() => surface(page), { timeout: 5000, message: `surface at ${width}x${height}` }) + .toEqual(expected); + } + + const PHONE: Surface = { welcome: false, overview: true, rail: false }; + const TABLET: Surface = { welcome: true, overview: false, rail: false }; + const DESKTOP: Surface = { welcome: true, overview: false, rail: true }; + + it('loaded wide: folding to phone width shows the phone overview, unfolding brings the welcome back', async () => { + const [context, page] = await open(1440, 900); + try { + expect(await surface(page)).toEqual(DESKTOP); + await resizeTo(page, 768, 900, TABLET); + await resizeTo(page, 767, 900, TABLET); + await resizeTo(page, 600, 900, TABLET); + await resizeTo(page, 599, 900, PHONE); + await resizeTo(page, 390, 844, PHONE); + await resizeTo(page, 600, 900, TABLET); + await resizeTo(page, 767, 900, TABLET); + await resizeTo(page, 768, 900, TABLET); + await resizeTo(page, 1440, 900, DESKTOP); + // A straight fold, the way a real device changes posture, in one step. + await resizeTo(page, 390, 844, PHONE); + } finally { + await context.close(); + } + }, 60000); + + it('loaded narrow: unfolding to desktop width shows the welcome and the rail, folding brings the overview back', async () => { + const [context, page] = await open(390, 844); + try { + expect(await surface(page)).toEqual(PHONE); + await resizeTo(page, 599, 844, PHONE); + await resizeTo(page, 600, 844, TABLET); + await resizeTo(page, 767, 844, TABLET); + await resizeTo(page, 768, 844, TABLET); + await resizeTo(page, 1440, 900, DESKTOP); + await resizeTo(page, 768, 900, TABLET); + await resizeTo(page, 767, 900, TABLET); + await resizeTo(page, 600, 900, TABLET); + await resizeTo(page, 599, 900, PHONE); + } finally { + await context.close(); + } + }, 60000); + + it('a height-only resize (the on-screen keyboard) does not re-decide the home surface', async () => { + const [context, page] = await open(1440, 900); + try { + await resizeTo(page, 390, 844, PHONE); + await page.evaluate(() => { + const app = (window as any).app; + app.__showWelcomeCalls = 0; + const original = app.showWelcome; + app.showWelcome = function (...args: unknown[]) { + app.__showWelcomeCalls++; + return original.apply(this, args); + }; + }); + await page.setViewportSize({ width: 390, height: 480 }); + await page.waitForTimeout(400); + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(400); + expect(await surface(page)).toEqual(PHONE); + expect(await page.evaluate(() => (window as any).app.__showWelcomeCalls)).toBe(0); + } finally { + await context.close(); + } + }, 60000); +});