diff --git a/.changeset/run-menu-scroll.md b/.changeset/run-menu-scroll.md new file mode 100644 index 00000000..693444f1 --- /dev/null +++ b/.changeset/run-menu-scroll.md @@ -0,0 +1,5 @@ +--- +"aicodeman": patch +--- + +The Run dropdown scrolls. It opens upward from the toolbar with no height limit, so with every CLI, the custom endpoint entries ("Claude Code (llama.cpp)" and so on), Terminal and the saved URLs it grew taller than the room above the toolbar: its top ran off-screen and the entries up there could not be reached, worst on a phone. It is now capped to the space between the header and the toolbar (`dvh`, with a `vh` fallback) and scrolls inside that, keeping the scroll from chaining to the page, and its sub-lists (history, saved URLs) are no longer squashed to nothing as the menu fills. diff --git a/config/test-suites.ts b/config/test-suites.ts index c79649b7..efe35733 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -42,6 +42,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/split-pane-orchestration.browser.test.ts', 'test/split-pane-auto-collapse.browser.test.ts', 'test/mobile-ime-preview.browser.test.ts', + 'test/run-mode-menu-scroll.browser.test.ts', ]; /** diff --git a/src/web/public/styles.css b/src/web/public/styles.css index f8498235..bd1e86de 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -5574,6 +5574,24 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { timestamp recent-session row still fits whole, which is what that list is for. */ max-width: 250px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3); + /* The menu grows UPWARD from the toolbar (`bottom: 100%`), so with every CLI, the custom + endpoint entries, Terminal and the saved URLs it can be taller than the room above the + toolbar: its top then sits off-screen with nothing to scroll, and the entries up there + cannot be reached (worst on a phone). Cap it to the space between the header and the + toolbar and scroll inside it. `dvh` so an on-screen keyboard or a collapsing browser bar + shrinks the cap with it; the `vh` line is the fallback for browsers without it. */ + max-height: calc(100vh - var(--header-height) - var(--toolbar-height) - 16px); + max-height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 16px); + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; +} +/* Children of a scrolling flex column must keep their natural height. Without this the ones + that have their own `overflow` (the history list, the saved-URL list, whose min-height is 0) + are squashed to nothing as the menu fills, instead of the menu scrolling. */ +.run-mode-menu > * { + flex-shrink: 0; } /* Above phone width the menu becomes a full-width drawer across the bottom of the diff --git a/test/run-mode-menu-scroll.browser.test.ts b/test/run-mode-menu-scroll.browser.test.ts new file mode 100644 index 00000000..bbb1854a --- /dev/null +++ b/test/run-mode-menu-scroll.browser.test.ts @@ -0,0 +1,130 @@ +/** + * Run dropdown on a phone, mixing the stock CLIs with custom-endpoint entries: it must fit the + * screen and scroll by touch, so the first and the last entry can both be reached. + * + * Before the fix the menu (anchored `bottom: 100%`) had no max-height and no overflow, so with + * enough entries its top ran off the top of the screen and could not be scrolled to. + * + * Browser-driven, so excluded from `npm run test:ci` (config/test-suites.ts). Run locally: + * npm run test:browser -- test/run-mode-menu-scroll.browser.test.ts + * + * Port: 3290 + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +const PORT = 3290; + +describe('Run dropdown on a phone', () => { + let server: WebServer; + let browser: Browser; + let page: Page; + + beforeAll(async () => { + server = new WebServer(PORT, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 390, height: 640 }, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 2, + }); + page = await context.newPage(); + await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); + }, 90000); + + afterAll(async () => { + if (browser) await browser.close(); + if (server) await server.stop(); + }, 60000); + + /** Open the menu with `extra` generated custom-endpoint entries, as _refreshCustomModelRunOptions builds them. */ + async function openWithEntries(extra: number) { + await page.evaluate((count) => { + const menu = document.getElementById('runModeMenu')!; + const box = document.getElementById('runModeCustomModels')!; + box.innerHTML = Array.from( + { length: count }, + (_, i) => + `` + ).join(''); + document.getElementById('runModeCustomModelSep')!.style.display = ''; + document.getElementById('runModeCustomModelHeader')!.style.display = ''; + menu.classList.add('active'); + }, extra); + } + + const geometry = () => + page.evaluate(() => { + const menu = document.getElementById('runModeMenu')!; + const r = menu.getBoundingClientRect(); + // The menu sits just above the Run button, which is inside the toolbar. + const anchor = menu.parentElement!.getBoundingClientRect(); + return { + top: r.top, + bottom: r.bottom, + anchorTop: anchor.top, + scrollTop: menu.scrollTop, + scrollHeight: menu.scrollHeight, + clientHeight: menu.clientHeight, + viewport: window.innerHeight, + overflowY: getComputedStyle(menu).overflowY, + }; + }); + + const visible = (selector: string) => + page.evaluate((sel) => { + const menu = document.getElementById('runModeMenu')!.getBoundingClientRect(); + const el = document.querySelector(sel)!.getBoundingClientRect(); + return el.top >= menu.top - 1 && el.bottom <= menu.bottom + 1 && el.top >= 0; + }, selector); + + it('stays on screen however many entries there are, and becomes scrollable', async () => { + await openWithEntries(30); + const g = await geometry(); + expect(g.overflowY).toBe('auto'); + expect(g.top).toBeGreaterThanOrEqual(0); // the top is not off the top of the screen + expect(g.bottom).toBeLessThanOrEqual(g.anchorTop); // above the Run button, not over it + expect(g.scrollHeight).toBeGreaterThan(g.clientHeight); // there is something to scroll to + }); + + it('a touch swipe scrolls it, and the last entry can be reached', async () => { + await openWithEntries(30); + expect(await visible('[data-test-entry="29"]')).toBe(false); // starts out of reach + // Real touch input (touchstart / touchmove / touchend), not scrollTo(): this is the path a + // finger takes, and the one a missing overflow or `touch-action` would block. + const cdp = await page.context().newCDPSession(page); + const g = await geometry(); + const y0 = (g.top + g.bottom) / 2 + 100; + for (let swipe = 0; swipe < 6; swipe += 1) { + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: 100, y: y0 }] }); + for (let step = 1; step <= 12; step += 1) { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x: 100, y: y0 - step * 20 }], // finger moves up: content scrolls down + }); + await page.waitForTimeout(16); + } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await page.waitForTimeout(300); + } + const after = await geometry(); + expect(after.scrollTop).toBeGreaterThan(0); + expect(await visible('[data-test-entry="29"]')).toBe(true); + // Scrolling the menu must not have scrolled the page behind it. + expect(await page.evaluate(() => window.scrollY)).toBe(0); + }); + + it('a short menu is not stretched or squashed (no scrollbar when it fits)', async () => { + await openWithEntries(0); + await page.evaluate(() => { + document.getElementById('runModeCustomModelSep')!.style.display = 'none'; + document.getElementById('runModeCustomModelHeader')!.style.display = 'none'; + }); + const g = await geometry(); + expect(g.scrollHeight).toBeLessThanOrEqual(g.clientHeight + 1); + }); +}); diff --git a/test/run-mode-menu-scroll.test.ts b/test/run-mode-menu-scroll.test.ts new file mode 100644 index 00000000..94b807b5 --- /dev/null +++ b/test/run-mode-menu-scroll.test.ts @@ -0,0 +1,44 @@ +/** + * The Run dropdown grows UPWARD from the toolbar (`bottom: 100%`). With every CLI, the custom + * endpoint entries ("Claude Code (llama.cpp)" ...), Terminal and the saved URLs it can be taller + * than the space above the toolbar, which put its top off-screen with nothing to scroll: on a + * phone the entries up there were unreachable. This pins the rule that makes it scroll, in the CI + * gate; the real touch scroll is test/run-mode-menu-scroll.browser.test.ts. + */ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const css = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf8'); + +/** The declaration block of the FIRST rule whose selector is exactly `selector`. */ +function rule(selector: string): string { + const start = css.search(new RegExp(`(^|\\n)${selector.replace(/[.>*]/g, '\\$&')}\\s*\\{`)); + expect(start, `no rule for ${selector}`).toBeGreaterThanOrEqual(0); + const open = css.indexOf('{', start); + return css.slice(open + 1, css.indexOf('}', open)); +} + +describe('Run dropdown scrolls when it is taller than the room above the toolbar', () => { + const menu = rule('.run-mode-menu'); + + it('scrolls vertically and keeps the scroll inside the menu', () => { + expect(menu).toMatch(/overflow-y:\s*auto/); + expect(menu).toMatch(/overscroll-behavior:\s*contain/); + expect(menu).toMatch(/touch-action:\s*pan-y/); + }); + + it('is capped to the space between the header and the toolbar, in dvh with a vh fallback', () => { + const caps = [...menu.matchAll(/max-height:\s*([^;]+);/g)].map((m) => m[1]); + expect(caps).toHaveLength(2); + expect(caps[0]).toContain('100vh'); + expect(caps[1]).toContain('100dvh'); + for (const cap of caps) { + expect(cap).toContain('var(--header-height)'); + expect(cap).toContain('var(--toolbar-height)'); + } + }); + + it('does not let scrolling children (history, saved URLs) be squashed to nothing', () => { + expect(rule('.run-mode-menu > *')).toMatch(/flex-shrink:\s*0/); + }); +});