Files
Feiyu XiaandZrs000 5f01833f80 fix(client): align floating panels with the visual viewport (#6666)
* fix(ui-primitives): keep tall menus inside the mobile viewport

* fix(client): anchor model picker with Floating UI

Use Floating UI for fixed model-menu placement and automatic viewport updates. Preserve top-end alignment and defer automatic focus until placement is ready. Guard asynchronous results across updates and menu teardown.

* fix(client): preserve menu blur under pinned group titles

Keep the current provider title outside the native scroll viewport so
model rows cannot bleed through during fast scrolling. Preserve the
enclosing menu fill and backdrop filter, and update both model pickers,
lifecycle tests and ARIA goldens.

Validation: doc-sync, full build, final type checks, focused lint and
all 12 model-picker browser checks pass. The full unit run has 42,597
passing tests and four failures in unchanged gateway, third-party-
notices and Inspector tests. The Inspector case passes in isolation.

* fix(client): stabilize floating panels during browser zoom

* fix(client): keep portal menus aligned during Safari zoom

* fix(client): commit floating menu positions before paint

* fix(client): unify floating panel positioning

* fix(client): validate grouped menu headings

* fix(client): hide inactive pinned menu headings

* test(client): await floating menu layout across consumers

* test(ui-chat): await folded reply menu positioning

* test(client): align current consumers with asynchronous panel layout

* fix(client): prevent mobile model search focus zoom

* refactor(client): inject mobile device state from layout

* fix(client): keep anchored panel layout synchronous

* fix(client): address panel review coverage and documentation

* fix(client): keep viewport changes focused on iOS zoom

* fix(client): cover anchored panel lifecycle and scrollbar colors

* fix(client): preserve zoomed model menu scrolling and alignment

* fix(client): hide empty model menus and stabilize viewport pan tests

* test(web): use native wheel input for zoomed viewport panning

* fix(client): keep automation pickers scrollable when zoomed

* fix(client): allow horizontal gestures across clock columns

* fix(client): ellipsize overflowing floating-panel labels

* test(web): make clock overflow fixture independent of system fonts

---------

Co-authored-by: Zrs000 <[email protected]>
2026-10-30 11:33:46 +08:00

115 lines
5.4 KiB
TypeScript

/** Context details stay inside the viewport with and without Chat's statistics contribution. */
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { chromium, webkit } from 'playwright'
import { describe, expect, it } from 'vitest'
import { fixtureUserPrompts, launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode } from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
const NO_CHAT = fileURLToPath(new URL('./fixtures/context-meter-no-chat.patch.yml', import.meta.url))
describe.skipIf(webSnapshotMode() === 'record').each([
{ name: 'Chromium', engine: chromium },
{ name: 'WebKit', engine: webkit },
])('web e2e: context details placement ($name)', ({ engine }) => {
it.each([true, false])('keeps details visible while resizing the window (Chat statistics: %s)', async (withStats) => {
const fixture = await selectedSessionFixture(FIXTURE, false)
const scaffold = await launchWebScaffold({
replayFixture: fixture,
compareReplaySession: false,
paceMs: 5,
...withStats ? {} : { extraOverlayPath: NO_CHAT },
})
try {
const browser = await engine.launch()
try {
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const card = page.locator('[data-composer-card]')
expect(await card.evaluate(element =>
element.parentElement!.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom,
)).toBe(0)
const prompts = fixtureUserPrompts(await readFile(fixture, 'utf8'))
expect(prompts).toHaveLength(1)
const settled = scaffold.whenTurnSettled()
const input = page.locator('[data-composer-input]').first()
await input.fill(prompts[0]!)
await input.press('Enter')
await settled
const trigger = page.getByRole('button', { name: /% of context used$/ })
await trigger.waitFor()
expect(await trigger.textContent()).toMatch(/^\d+%$/)
expect(await page.getByRole('button', { name: /tok · Cache hit/ }).count()).toBe(withStats ? 1 : 0)
expect(await card.evaluate((element) => {
const dock = element.nextElementSibling!
return {
above: getComputedStyle(dock).paddingTop,
below: getComputedStyle(element.parentElement!).paddingBottom,
}
})).toEqual({ above: '4px', below: '4px' })
await trigger.click()
const panel = page.getByRole('dialog', { name: 'of context used', exact: true })
await panel.waitFor()
for (const width of [390, 800, 1280]) {
await page.setViewportSize({ width, height: 900 })
await page.locator('[data-sidebar-collapsed="true"]').waitFor({
state: width <= 800 ? 'attached' : 'detached',
})
await page.evaluate(async () => {
await Promise.all(document.getAnimations()
.filter(animation => animation.effect?.getComputedTiming().iterations !== Infinity)
.map(animation => animation.finished.catch(() => undefined)))
})
await expect.poll(async () => {
const rect = await panel.boundingBox()
return rect !== null && rect.x >= 12 && rect.x + rect.width <= width - 12
&& rect.y >= 12 && rect.y + rect.height <= 888
}).toBe(true)
}
await panel.getByText('System prompt', { exact: true }).click()
expect(await panel.isVisible()).toBe(true)
await page.keyboard.press('Escape')
await panel.waitFor({ state: 'hidden' })
await trigger.click()
await panel.waitFor()
await page.mouse.click(1260, 400)
await panel.waitFor({ state: 'hidden' })
const panels = [
{ trigger, name: 'of context used' },
...withStats ? [
{ trigger: page.locator('[data-composer-stat="activity"] button'), name: 'Session statistics' },
{ trigger: page.locator('[data-composer-stat="usage"] button'), name: 'Token usage' },
] : [],
]
for (const details of panels) {
await details.trigger.click()
const dialog = page.getByRole('dialog', { name: details.name, exact: true })
await dialog.waitFor()
for (const width of [1280, 800, 390]) {
await page.setViewportSize({ width, height: 900 })
await expect.poll(async () => {
const rect = await dialog.boundingBox()
const anchor = await details.trigger.boundingBox()
return rect !== null && anchor !== null
&& rect.x >= 11 && rect.x + rect.width <= width - 11
&& rect.y >= 11 && rect.y + rect.height <= 889
&& Math.abs(anchor.y - rect.y - rect.height - 8) <= 1
}).toBe(true)
}
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'hidden' })
}
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} finally {
await browser.close()
}
} finally {
await scaffold.close()
}
})
})