// Web e2e scenario: with the feedback note editor open, the assistant IconActions // row stays one intact line (no wrapping, nothing pushed out), and the note // editor floats above the transcript in a popover that escapes the conversation // column's overflow clip and stays inside the viewport. // // The hazard this pins: a slot-contributed note editor (260px textarea plus // Save and Cancel) cannot fit the shared IconActions row at ANY viewport, and an // inline expansion made the row wider than the column — full-screen desktop // included — so the branch action and the clock were pushed out of view by later // flex items. The fix is to not mount the editor in the row at all: it is a // popover portaled to document.body and fixed-positioned from the note trigger's // rect, so the row keeps its single 28px line of icons and the trigger, and the // panel cannot be cropped by the column's overflow because it lives outside it. // // The sweep records, per viewport, whether the open editor keeps the actions row // on one line with zero overflow, whether the panel is outside the column (proof // it escapes the clip), whether the panel stays inside the viewport (proof the // clamp works), and whether it sits by its trigger. All relations, no absolute // pixels: the column width follows the viewport, the sidebar, and the platform's // scrollbar, so a golden carrying pixels would document the platform, not the // behavior. // // Zero model calls: a settled transcript is cold-seeded, so nothing streams. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-feedback-layout', import.meta.url)) /** * Committed golden of the popover relations at every stop. Booleans and counts * only, never absolute coordinates. */ const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') const MODE = webSnapshotMode() /** Borrowed read-only: this scenario needs any settled assistant message to rate. */ const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url)) const SEED_ID = 'message-feedback-layout-e2e' /** Viewport widths from full-screen desktop down to a narrow window. */ const WIDTHS = [1680, 1280, 1024, 900, 700, 600] /** One viewport stop: how the row reads with the note editor closed and open, plus the popover's own relations. */ export interface PopoverMetrics { /** Viewport width the stop was measured at. */ width: number /** The row's scrollable overflow with the note editor closed (natural row width). */ rowOverflowClosed: number /** The row's scrollable overflow with the note editor open; must equal the closed value. */ rowOverflowOpen: number /** Flex lines the row occupies with the note editor open; the editor must not reflow it. */ rowLines: number /** Row items whose right edge escapes the column, editor closed. */ itemsOutsideColumnClosed: number /** Row items whose right edge escapes the column, editor open; must equal the closed value. */ itemsOutsideColumnOpen: number /** True when the portaled panel is NOT inside the column (escapes its overflow clip). */ panelOutsideColumn: boolean /** True when the panel lies fully inside the viewport (the clamp holds). */ panelWithinViewport: boolean /** Horizontal separation between the panel's left edge and the note trigger's, in px. */ panelToTriggerGap: number } /** * Measure the feedback row (and the open popover, when present) at the current * viewport. The same reader serves the closed and open readings so the two * sides differ only by whether the editor is open. * @param page - the page under test. * @param width - the viewport width already applied, recorded with the reading. * @param editorOpen - true to also read the popover's relations; throws if it is absent. * @returns the stop's relations. */ function measurePopover(page: Page, width: number, editorOpen: boolean): Promise { return page.evaluate(({ viewportWidth, open }) => { const rated = document.querySelector('button[aria-label="Remove rating"]') if (rated === null) throw new Error('no rated feedback control in the DOM') const row = rated.parentElement?.closest('div[class*="actions"]') ?? null if (row === null) throw new Error('the IconActions row is not an ancestor of the feedback control') const trigger = row.querySelector('button[aria-haspopup="dialog"]') if (trigger === null) throw new Error('the note trigger is not in the row') /** * The real flex items of the row. A slot contributor (the feedback strip) * arrives as a `display: contents` wrapper (the `assistant-actions` slot * renders inside a transparent `data-slot` div), which reports an all-zero * rect; a zero box would be miscounted as a phantom flex line. The actual * items are the boxes inside it. * @param element - the row whose items to read. * @returns the real flex-item boxes, in flex/DOM order. */ const flexItemBoxes = (element: HTMLElement): DOMRect[] => { const boxes: DOMRect[] = [] for (const child of Array.from(element.children)) { const el = child as HTMLElement const rect = el.getBoundingClientRect() if (el.style.display === 'contents') { boxes.push(...flexItemBoxes(el)) } else if (rect.height > 0 && rect.width > 0) { boxes.push(rect) } } return boxes } /** * Group items into flex lines by overlapping vertical extent. * @param boxes - the row items' boxes, in DOM order. * @returns the number of distinct lines. */ const countFlexLines = (boxes: DOMRect[]): number => { const centres: number[] = [] for (const box of boxes) { const centre = box.top + box.height / 2 if (!centres.some(known => Math.abs(known - centre) <= box.height / 2)) centres.push(centre) } return centres.length } const column = row.closest('[data-conversation-scroll]') const columnRight = (column?.getBoundingClientRect().left ?? 0) + (column?.clientWidth ?? 0) const itemRects = flexItemBoxes(row) // A half-pixel tolerance: subpixel layout puts a contained edge a fraction // over the boundary on some device scale factors. const itemsOutsideColumn = itemRects.filter(box => box.right > columnRight + 0.5).length // The editor is a portal, so the row measures identically whether the // editor is open or not; the closed/open fields differ by call so the sweep // can assert a zero delta on them. const overflow = row.scrollWidth - row.clientWidth let builder: { panelOutsideColumn: boolean panelWithinViewport: boolean panelToTriggerGap: number } if (!open) { builder = { panelOutsideColumn: true, panelWithinViewport: true, panelToTriggerGap: 0 } } else { const panel = document.body.querySelector('[role="dialog"]') if (panel === null) throw new Error('the note popover is not open') const panelBox = panel.getBoundingClientRect() const triggerBox = trigger.getBoundingClientRect() const vw = window.innerWidth const vh = window.innerHeight builder = { // The panel portals out of the column, so the clip cannot reach it. panelOutsideColumn: column === null ? true : !column.contains(panel), panelWithinViewport: panelBox.left >= -0.5 && panelBox.right <= vw + 0.5 && panelBox.top >= -0.5 && panelBox.bottom <= vh + 0.5, // The panel is fixed from the trigger's left, so a zero gap says it is // anchored; a clamp can only widen it. panelToTriggerGap: Math.abs(panelBox.left - triggerBox.left), } } return { width: viewportWidth, rowOverflowClosed: overflow, rowOverflowOpen: overflow, rowLines: countFlexLines(itemRects), itemsOutsideColumnClosed: itemsOutsideColumn, itemsOutsideColumnOpen: itemsOutsideColumn, ...builder, } }, { viewportWidth: width, open: editorOpen }) } /** * Render the golden body: one line per stop, relations and counts only. The * row-overflow and outside-column readings are deltas (open minus closed) so * the golden records that opening the editor leaves the row untouched, not an * absolute count that many unrelated controls could move. * @param stops - the measured stops, in sweep order. * @returns the golden body, without a trailing newline. */ function renderGeometry(stops: PopoverMetrics[]): string { return [ '# Assistant actions row with the feedback note popover open', '', '| viewport | row overflow delta | row lines | items-outside delta ' + '| panel outside the column | panel within the viewport | panel-to-trigger gap |', '| --- | --- | --- | --- | --- | --- | --- |', ...stops.map(stop => `| ${String(stop.width)}px | ${String(stop.rowOverflowOpen - stop.rowOverflowClosed)}px ` + `| ${String(stop.rowLines)} | ${String(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed)} ` + `| ${String(stop.panelOutsideColumn)} | ${String(stop.panelWithinViewport)} ` + `| ${String(stop.panelToTriggerGap)}px |`), ].join('\n') } describe('web e2e: the feedback note editor floats above the column', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { scaffold = await launchWebScaffold({}) await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser, 900) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 180_000) afterAll(async () => { await browser?.close() await scaffold?.close() }) /** * Open the seeded transcript. The first treeitem is the collapsible group * row; the session itself is the row beneath it. * @returns nothing. */ async function openSeededSession(): Promise { const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click() const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 15_000 }) await sessionRow.click() } /** * Resize to a viewport and read the row once its width stops moving. The * frame eases its column tracks, so reading straight after a resize can * report the previous viewport's relation. * @param width - viewport width to settle at. * @param editorOpen - whether the note editor is currently open; reads the popover relations when so. * @returns the row's (and popover's) readings at that width. */ const settleAt = async (width: number, editorOpen: boolean): Promise => { await page.setViewportSize({ width, height: 900 }) let previous = -1 await expect.poll(async () => { const current = await page.evaluate(() => document.querySelector('[data-conversation-scroll]')?.clientWidth ?? -1) const settled = current === previous previous = current return settled }, { timeout: 10_000 }).toBe(true) // The popover is JS-positioned from the trigger rect and re-places on // resize/scroll, so once the column width stops moving we nudge it to the // final layout; otherwise the panel can sit at a transient position from // mid-resize and the anchor reading would be off. await page.evaluate(() => window.dispatchEvent(new Event('resize'))) return measurePopover(page, width, editorOpen) } /** * Rate a message, then for every stop read the row once with the note editor * closed and once with it open, handing the SAME measured readings to both * assertions so the golden and the assertions describe one measurement * rather than two runs that could disagree. * @returns the stops in {@link WIDTHS} order. */ let swept: Promise | undefined const sweep = (): Promise => { swept ??= (async () => { await openSeededSession() await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) // The controller defers its list read to the first hover or focus, so the // strip has to be touched before it can be rated. const like = page.getByRole('button', { name: 'Good response' }).first() await like.waitFor({ timeout: 30_000 }) await like.scrollIntoViewIfNeeded() await like.hover() await like.click() await page.getByRole('button', { name: 'Remove rating' }).first() .waitFor({ timeout: 15_000 }) const noteTrigger = page.getByRole('button', { name: 'Add a note' }).first() const stops: PopoverMetrics[] = [] for (const width of WIDTHS) { // Reset to the closed baseline at each stop before opening. if (await noteTrigger.getAttribute('aria-expanded') === 'true') await noteTrigger.click() const closed = await settleAt(width, false) await page.getByRole('button', { name: 'Add a note' }).first().click() await page.getByRole('dialog').waitFor({ timeout: 10_000 }) const open = await settleAt(width, true) stops.push({ width, rowOverflowClosed: closed.rowOverflowClosed, rowOverflowOpen: open.rowOverflowOpen, rowLines: open.rowLines, itemsOutsideColumnClosed: closed.itemsOutsideColumnClosed, itemsOutsideColumnOpen: open.itemsOutsideColumnOpen, panelOutsideColumn: open.panelOutsideColumn, panelWithinViewport: open.panelWithinViewport, panelToTriggerGap: open.panelToTriggerGap, }) } return stops })() return swept } it('keeps the actions row untouched by the note popover, which stays in the viewport', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout')) const stops = await sweep() for (const stop of stops) { // The popover lives outside the row, so opening it must not change the // row at all. This is the vacuity guard of the whole redesign: an inline // editor would widen or reflow the row, pushing the delta off zero. expect(stop.rowOverflowOpen - stop.rowOverflowClosed, `viewport ${String(stop.width)}`).toBe(0) expect(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed, `viewport ${String(stop.width)}`).toBe(0) // The row is one 28px line; the editor never forces a reflow. expect(stop.rowLines, `viewport ${String(stop.width)}`).toBe(1) // The panel escapes the column's overflow clip by living outside it. expect(stop.panelOutsideColumn, `viewport ${String(stop.width)}`).toBe(true) // The placement clamps the panel inside the viewport at every width. expect(stop.panelWithinViewport, `viewport ${String(stop.width)}`).toBe(true) // The panel stays anchored to its trigger rather than drifting off. expect(stop.panelToTriggerGap, `viewport ${String(stop.width)}`).toBeLessThanOrEqual(4) } expect(tripwire.pageErrors).toEqual([]) }, 180_000) it('matches the committed geometry golden', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout-golden')) await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE) }, 180_000) it('kept the console clean', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) })