From 74de4c2da8ad2d0e9d742a43f9debefc7bb61fef Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 12 Aug 2026 19:13:30 -0700 Subject: [PATCH] [eric] chat: docked-slot sizing is exercised across 144 viewport/aspect cases and bound to the component CSS (ENG-278) --- .../pages/AgentChat/browserSlotSize.test.ts | 101 ++++++++++++++++++ .../app/pages/AgentChat/browserSlotSize.ts | 34 ++++++ 2 files changed, 135 insertions(+) create mode 100644 frontend/src/app/pages/AgentChat/browserSlotSize.test.ts create mode 100644 frontend/src/app/pages/AgentChat/browserSlotSize.ts diff --git a/frontend/src/app/pages/AgentChat/browserSlotSize.test.ts b/frontend/src/app/pages/AgentChat/browserSlotSize.test.ts new file mode 100644 index 00000000..60a43362 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/browserSlotSize.test.ts @@ -0,0 +1,101 @@ +// Run: node --test (via frontend/scripts/run-tests.mjs) +// +// ENG-278 condition 4, exercised across the whole input space instead of one screenshot. A single +// rendered sample proves the slot at one viewport and one page shape; this proves it at every +// viewport a laptop or monitor actually has, crossed with every page aspect from tall-phone to +// ultrawide, which is the claim the fix is actually making. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { browserSlotSize, SLOT_MAX_PX, SLOT_MAX_VH, SLOT_MIN_PX } from './browserSlotSize.ts'; + +// Real viewport heights: small laptop through 5K, plus the awkward ones in between. +const VIEWPORTS = [600, 700, 768, 800, 900, 1000, 1050, 1080, 1117, 1118, 1200, 1440, 1600, 1800, 2160, 2880]; +// Page shapes: tall portrait through ultrawide. +const ASPECTS: Array<[number, number]> = [ + [390, 844], [768, 1024], [1024, 768], [1280, 720], [1440, 900], + [1920, 1080], [2560, 1080], [3440, 1440], [1000, 1000], +]; + +test('the slot never exceeds a third of the viewport, at any size or page shape', () => { + let cases = 0; + for (const vh of VIEWPORTS) for (const [pw, ph] of ASPECTS) { + const s = browserSlotSize(pw, ph, vh); + // The old behaviour, kept here as the thing that must never come back. + const oldCap = Math.min(480, 0.52 * vh); + assert.ok(s.height <= Math.max(SLOT_MIN_PX, (SLOT_MAX_VH / 100) * vh) + 0.001, + `vh=${vh} ${pw}x${ph}: height ${s.height} exceeds ${SLOT_MAX_VH}vh`); + assert.ok(s.height <= SLOT_MAX_PX, `vh=${vh}: height ${s.height} exceeds ${SLOT_MAX_PX}px`); + if (vh >= 500) { + assert.ok(s.height < oldCap, `vh=${vh}: ${s.height} is not smaller than the old cap ${oldCap}`); + } + cases += 1; + } + assert.equal(cases, VIEWPORTS.length * ASPECTS.length, 'enumeration size drifted'); +}); + +test('the page aspect is preserved exactly, so the live overlay is never letterboxed', () => { + for (const vh of VIEWPORTS) for (const [pw, ph] of ASPECTS) { + const s = browserSlotSize(pw, ph, vh); + assert.ok(s.width !== null, 'a known page size must produce a width'); + const got = (s.width as number) / s.height; + assert.ok(Math.abs(got - pw / ph) < 1e-9, `vh=${vh} ${pw}x${ph}: aspect drifted to ${got}`); + } +}); + +test('a page that reports nothing goes full width at the smaller fallback cap', () => { + for (const vh of VIEWPORTS) { + const s = browserSlotSize(0, 0, vh); + assert.equal(s.fullWidth, true, `vh=${vh}: unknown size should be full width`); + assert.equal(s.width, null); + assert.ok(s.height <= 300, `vh=${vh}: fallback ${s.height} exceeds 300px`); + assert.ok(s.height <= Math.max(SLOT_MIN_PX, 0.28 * vh) + 0.001, `vh=${vh}: fallback exceeds 28vh`); + } +}); + +test('a tiny viewport still leaves a usable slot rather than collapsing to nothing', () => { + for (const vh of [200, 300, 400, 500]) { + const s = browserSlotSize(1280, 720, vh); + assert.ok(s.height >= SLOT_MIN_PX, `vh=${vh}: collapsed to ${s.height}, below the ${SLOT_MIN_PX}px floor`); + } +}); + +test('degenerate page sizes fall back instead of producing NaN or Infinity', () => { + for (const [pw, ph] of [[0, 0], [-1, 100], [100, -1], [0, 720], [1280, 0], [NaN, NaN]] as Array<[number, number]>) { + const s = browserSlotSize(pw, ph, 1080); + assert.ok(Number.isFinite(s.height), `${pw}x${ph} produced height ${s.height}`); + assert.ok(s.width === null || Number.isFinite(s.width), `${pw}x${ph} produced width ${s.width}`); + } +}); + +// The number the issue actually claims, pinned so it cannot drift silently. +test('the measured reduction against the old cap', () => { + const at1440 = browserSlotSize(1920, 1080, 1440); + assert.equal(at1440.height, 380, 'tall viewport should sit on the 380px cap'); + assert.equal(Math.min(480, 0.52 * 1440), 480, 'old cap at 1440 was 480px'); + const at900 = browserSlotSize(1920, 1080, 900); + assert.ok(Math.abs(at900.height - 306) < 0.5, `at 900px viewport expected ~306, got ${at900.height}`); + assert.ok(Math.abs(Math.min(480, 0.52 * 900) - 468) < 0.5, 'old cap at 900 was 468px'); +}); + +// The trap this guards: everything above tests an extracted MODEL of the sizing, while the component +// renders CSS min()/calc() strings. A model that has drifted from the code it describes passes its +// own tests perfectly. Bind the two, so changing one without the other fails here. +test('the component CSS still matches the constants this file tests', async () => { + const fs = await import('node:fs'); + const url = await import('node:url'); + const here = url.fileURLToPath(new URL('.', import.meta.url)); + // The test runs from .test-build, so resolve the source next to it by name. + const candidates = [ + here + 'AgentChat.tsx', + here.replace('/.test-build/', '/src/') + 'AgentChat.tsx', + ]; + const path = candidates.find((p) => fs.existsSync(p)); + assert.ok(path, `could not locate AgentChat.tsx from ${here}`); + const src = fs.readFileSync(path as string, 'utf8'); + assert.ok(src.includes(`min(${SLOT_MAX_PX}px, ${SLOT_MAX_VH}vh)`), + `AgentChat no longer uses min(${SLOT_MAX_PX}px, ${SLOT_MAX_VH}vh); the model here has drifted from the code`); + assert.ok(src.includes('min(300px, 28vh)'), + 'AgentChat no longer uses the min(300px, 28vh) fallback'); + assert.ok(!src.includes('52vh') && !src.includes('480px'), + 'the old oversized caps are back in AgentChat'); +}); diff --git a/frontend/src/app/pages/AgentChat/browserSlotSize.ts b/frontend/src/app/pages/AgentChat/browserSlotSize.ts new file mode 100644 index 00000000..df57bec2 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/browserSlotSize.ts @@ -0,0 +1,34 @@ +// How much of the chat a docked browser is allowed to take (ENG-278). +// +// Extracted from the inline sx so the sizing can be exercised across every viewport and page shape +// rather than whichever one a screenshot happened to catch. The rule it encodes: a docked page is a +// preview, not the view. It previously capped at 52vh / 480px, which is over half the screen, and +// because the transcript sits pinned to the bottom that half was always the half you were reading. + +/** Cap for a page that reported its dimensions; width follows so the aspect never breaks. */ +export const SLOT_MAX_PX = 380; +export const SLOT_MAX_VH = 34; +/** Cap for a page that reported nothing, so there is no aspect to preserve. */ +export const SLOT_FALLBACK_PX = 300; +export const SLOT_FALLBACK_VH = 28; +export const SLOT_MIN_PX = 140; + +export interface SlotSize { + /** Rendered height in CSS px at this viewport. */ + height: number; + /** Rendered width in CSS px, or null when the slot is full-width (no aspect known). */ + width: number | null; + fullWidth: boolean; +} + +/** Resolve what the slot actually renders at, given the page's size and the viewport height. */ +export function browserSlotSize(pageW: number, pageH: number, viewportH: number): SlotSize { + const known = pageW > 0 && pageH > 0; + if (!known) { + const h = Math.min(SLOT_FALLBACK_PX, (SLOT_FALLBACK_VH / 100) * viewportH); + return { height: Math.max(SLOT_MIN_PX, h), width: null, fullWidth: true }; + } + const capped = Math.min(SLOT_MAX_PX, (SLOT_MAX_VH / 100) * viewportH); + const height = Math.max(SLOT_MIN_PX, capped); + return { height, width: height * (pageW / pageH), fullWidth: false }; +}