From 1faa936043e909505c185ebc0aa4aac091221e23 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Mar 2026 11:53:19 -0800 Subject: [PATCH 01/11] Make chat timeline height estimates width-aware and test them - extract `estimateTimelineMessageHeight` into `timelineHeight.ts` - account for timeline width and explicit newlines when estimating wrapped lines - re-measure virtualized rows on width changes to fix attachment/message height sizing - add Vitest coverage for assistant/user wrapping and attachment row height rules --- apps/web/src/components/ChatView.tsx | 53 +++++++---- .../web/src/components/timelineHeight.test.ts | 88 +++++++++++++++++++ apps/web/src/components/timelineHeight.ts | 82 +++++++++++++++++ 3 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/timelineHeight.test.ts create mode 100644 apps/web/src/components/timelineHeight.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9c24d883b508..d7da2fb29bab 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -131,6 +131,7 @@ import { import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { clamp } from "effect/Number"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { estimateTimelineMessageHeight } from "./timelineHeight"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); @@ -3166,20 +3167,6 @@ type TimelineRow = } | { kind: "working"; id: string; createdAt: string | null }; -function estimateTimelineMessageHeight(message: TimelineMessage): number { - const textLength = message.text.length; - if (message.role === "assistant") { - const estimatedLines = Math.max(1, Math.ceil(textLength / 72)); - return 78 + Math.min(estimatedLines * 22, 820); - } - - const estimatedLines = Math.max(1, Math.ceil(textLength / 56)); - const attachmentCount = message.attachments?.length ?? 0; - const attachmentRows = Math.ceil(attachmentCount / 2); - const attachmentHeight = attachmentRows * 124; - return 96 + Math.min(estimatedLines * 22, 620) + attachmentHeight; -} - const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, @@ -3200,6 +3187,36 @@ const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, markdownCwd, }: MessagesTimelineProps) { + const timelineRootRef = useRef(null); + const [timelineWidthPx, setTimelineWidthPx] = useState(null); + + useLayoutEffect(() => { + const timelineRoot = timelineRootRef.current; + if (!timelineRoot) return; + + const updateWidth = (nextWidth: number) => { + setTimelineWidthPx((previousWidth) => { + if (previousWidth !== null && Math.abs(previousWidth - nextWidth) < 0.5) { + return previousWidth; + } + return nextWidth; + }); + }; + + updateWidth(timelineRoot.getBoundingClientRect().width); + + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + const [entry] = entries; + if (!entry) return; + updateWidth(entry.contentRect.width); + }); + observer.observe(timelineRoot); + return () => { + observer.disconnect(); + }; + }, []); + const rows = useMemo(() => { const nextRows: TimelineRow[] = []; @@ -3303,12 +3320,16 @@ const MessagesTimeline = memo(function MessagesTimeline({ if (!row) return 96; if (row.kind === "work") return 112; if (row.kind === "working") return 40; - return estimateTimelineMessageHeight(row.message); + return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); }, measureElement: measureVirtualElement, useAnimationFrameWithResizeObserver: true, overscan: 8, }); + useEffect(() => { + if (timelineWidthPx === null) return; + rowVirtualizer.measure(); + }, [rowVirtualizer, timelineWidthPx]); useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; @@ -3624,7 +3645,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ } return ( -
+
{virtualizedRowCount > 0 && (
{virtualRows.map((virtualRow: VirtualItem) => { diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts new file mode 100644 index 000000000000..df9d00aac5db --- /dev/null +++ b/apps/web/src/components/timelineHeight.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { estimateTimelineMessageHeight } from "./timelineHeight"; + +describe("estimateTimelineMessageHeight", () => { + it("uses assistant sizing rules for assistant messages", () => { + expect( + estimateTimelineMessageHeight({ + role: "assistant", + text: "a".repeat(144), + }), + ).toBe(122); + }); + + it("adds one attachment row for one or two user attachments", () => { + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "hello", + attachments: [{ id: "1" }], + }), + ).toBe(346); + + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "hello", + attachments: [{ id: "1" }, { id: "2" }], + }), + ).toBe(346); + }); + + it("adds a second attachment row for three or four user attachments", () => { + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "hello", + attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], + }), + ).toBe(574); + + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "hello", + attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], + }), + ).toBe(574); + }); + + it("does not cap long user message estimates", () => { + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "a".repeat(56 * 120), + }), + ).toBe(2736); + }); + + it("counts explicit newlines for user message estimates", () => { + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "first\nsecond\nthird", + }), + ).toBe(162); + }); + + it("uses narrower width to increase user line wrapping", () => { + const message = { + role: "user" as const, + text: "a".repeat(52), + }; + + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(140); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(118); + }); + + it("uses narrower width to increase assistant line wrapping", () => { + const message = { + role: "assistant" as const, + text: "a".repeat(200), + }; + + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(188); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(122); + }); +}); diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts new file mode 100644 index 000000000000..5bf9e1103c8a --- /dev/null +++ b/apps/web/src/components/timelineHeight.ts @@ -0,0 +1,82 @@ +const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; +const USER_CHARS_PER_LINE_FALLBACK = 56; +const LINE_HEIGHT_PX = 22; +const ASSISTANT_BASE_HEIGHT_PX = 78; +const USER_BASE_HEIGHT_PX = 96; +const ATTACHMENTS_PER_ROW = 2; +// Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. +const USER_ATTACHMENT_ROW_HEIGHT_PX = 228; +const USER_BUBBLE_WIDTH_RATIO = 0.8; +const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; +const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; +const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; +const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; +const MIN_USER_CHARS_PER_LINE = 16; +const MIN_ASSISTANT_CHARS_PER_LINE = 20; + +interface TimelineMessageHeightInput { + role: "user" | "assistant" | "system"; + text: string; + attachments?: ReadonlyArray<{ id: string }>; +} + +interface TimelineHeightEstimateLayout { + timelineWidthPx: number | null; +} + +function estimateWrappedLineCount(text: string, charsPerLine: number): number { + if (text.length === 0) return 1; + + // Avoid allocating via split for long logs; iterate once and count wrapped lines. + let lines = 0; + let currentLineLength = 0; + for (let index = 0; index < text.length; index += 1) { + if (text.charCodeAt(index) === 10) { + lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); + currentLineLength = 0; + continue; + } + currentLineLength += 1; + } + + lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); + return lines; +} + +function isFinitePositiveNumber(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function estimateCharsPerLineForUser(timelineWidthPx: number | null): number { + if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; + const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; + const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); + return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX)); +} + +function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number { + if (!isFinitePositiveNumber(timelineWidthPx)) return ASSISTANT_CHARS_PER_LINE_FALLBACK; + const textWidthPx = Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0); + return Math.max( + MIN_ASSISTANT_CHARS_PER_LINE, + Math.floor(textWidthPx / ASSISTANT_AVG_CHAR_WIDTH_PX), + ); +} + +export function estimateTimelineMessageHeight( + message: TimelineMessageHeightInput, + layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, +): number { + if (message.role !== "user") { + const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); + const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; + } + + const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); + const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + const attachmentCount = message.attachments?.length ?? 0; + const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); + const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; + return USER_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX + attachmentHeight; +} From 5914e34f786cd23adc886674c5919513a15ba287 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Mar 2026 12:18:54 -0800 Subject: [PATCH 02/11] Add Playwright timeline height parity tests to CI - add browser E2E tests validating timeline height estimator against rendered DOM - configure Playwright test runner and scripts in `apps/web` - run browser tests in CI with Playwright browser caching and install step --- .github/workflows/ci.yml | 18 ++- .gitignore | 4 +- .../timelineHeight.browser.e2e.ts | 148 ++++++++++++++++++ apps/web/package.json | 5 +- apps/web/playwright.config.ts | 12 ++ bun.lock | 9 ++ 6 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 apps/web/browser-tests/timelineHeight.browser.e2e.ts create mode 100644 apps/web/playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f300605cc474..787e07bce750 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: quality: - name: Lint, Typecheck, Test, Build + name: Lint, Typecheck, Test, Browser Test, Build runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout @@ -34,6 +34,14 @@ jobs: restore-keys: | ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}- + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-playwright- + - name: Install dependencies run: bun install --frozen-lockfile @@ -46,6 +54,14 @@ jobs: - name: Test run: bun run test + - name: Install browser test runtime + run: | + cd apps/web + bunx playwright install --with-deps chromium + + - name: Browser test + run: bun run --cwd apps/web test:browser + - name: Build desktop pipeline run: bun run build:desktop diff --git a/.gitignore b/.gitignore index 09bcb945a035..5c2ec0b4a0b4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ packages/*/dist build/ .logs/ release/ -.t3 \ No newline at end of file +.t3 +apps/web/.playwright +apps/web/playwright-report diff --git a/apps/web/browser-tests/timelineHeight.browser.e2e.ts b/apps/web/browser-tests/timelineHeight.browser.e2e.ts new file mode 100644 index 000000000000..185ca6ce0681 --- /dev/null +++ b/apps/web/browser-tests/timelineHeight.browser.e2e.ts @@ -0,0 +1,148 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { estimateTimelineMessageHeight } from "../src/components/timelineHeight"; + +interface HeightCase { + timelineWidthPx: number; + text: string; + attachmentCount: number; +} + +async function measureUserRowHeight(page: Page, testCase: HeightCase) { + const { timelineWidthPx, text, attachmentCount } = testCase; + await page.setContent(""); + + return page.evaluate(({ width, messageText, attachments }) => { + const timeline = document.createElement("div"); + timeline.style.width = `${width}px`; + timeline.style.maxWidth = `${width}px`; + + const row = document.createElement("div"); + row.style.paddingBottom = "16px"; + + const alignment = document.createElement("div"); + alignment.style.display = "flex"; + alignment.style.justifyContent = "flex-end"; + + const bubble = document.createElement("div"); + bubble.style.boxSizing = "border-box"; + bubble.style.maxWidth = "80%"; + bubble.style.padding = "12px 16px"; + bubble.style.border = "1px solid rgba(0, 0, 0, 0.12)"; + bubble.style.borderRadius = "16px"; + bubble.style.background = "rgba(0, 0, 0, 0.03)"; + + if (attachments > 0) { + const attachmentGrid = document.createElement("div"); + attachmentGrid.style.marginBottom = "8px"; + attachmentGrid.style.maxWidth = "420px"; + attachmentGrid.style.display = "grid"; + attachmentGrid.style.gridTemplateColumns = "repeat(2, minmax(0, 1fr))"; + attachmentGrid.style.gap = "8px"; + for (let index = 0; index < attachments; index += 1) { + const tile = document.createElement("div"); + tile.style.height = "220px"; + tile.style.border = "1px solid rgba(0, 0, 0, 0.12)"; + tile.style.borderRadius = "8px"; + tile.style.background = "rgba(0, 0, 0, 0.06)"; + attachmentGrid.append(tile); + } + bubble.append(attachmentGrid); + } + + if (messageText.length > 0) { + const pre = document.createElement("pre"); + pre.textContent = messageText; + pre.style.margin = "0"; + pre.style.whiteSpace = "pre-wrap"; + pre.style.overflowWrap = "anywhere"; + pre.style.fontFamily = + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace"; + pre.style.fontSize = "14px"; + pre.style.lineHeight = "22px"; + bubble.append(pre); + } + + const meta = document.createElement("div"); + meta.style.marginTop = "6px"; + meta.style.height = "16px"; + bubble.append(meta); + + alignment.append(bubble); + row.append(alignment); + timeline.append(row); + document.body.append(timeline); + return row.getBoundingClientRect().height; + }, { width: timelineWidthPx, messageText: text, attachments: attachmentCount }); +} + +function estimatedHeight(testCase: HeightCase): number { + return estimateTimelineMessageHeight( + { + role: "user", + text: testCase.text, + attachments: Array.from({ length: testCase.attachmentCount }, (_value, index) => ({ + id: String(index), + })), + }, + { timelineWidthPx: testCase.timelineWidthPx }, + ); +} + +test.describe("timeline height estimator parity", () => { + test("tracks long wrapped text growth at desktop width", async ({ page }) => { + const baselineCase: HeightCase = { timelineWidthPx: 960, text: "", attachmentCount: 0 }; + const longCase: HeightCase = { + timelineWidthPx: 960, + text: "x".repeat(1200), + attachmentCount: 0, + }; + + const baselineMeasured = await measureUserRowHeight(page, baselineCase); + const longMeasured = await measureUserRowHeight(page, longCase); + const measuredDelta = longMeasured - baselineMeasured; + + const estimatedDelta = estimatedHeight(longCase) - estimatedHeight(baselineCase); + expect(Math.abs(measuredDelta - estimatedDelta)).toBeLessThanOrEqual(22); + }); + + test("tracks additional wrapping when viewport narrows", async ({ page }) => { + const desktopCase: HeightCase = { + timelineWidthPx: 960, + text: "x".repeat(1000), + attachmentCount: 0, + }; + const mobileCase: HeightCase = { + timelineWidthPx: 360, + text: desktopCase.text, + attachmentCount: 0, + }; + + const desktopMeasured = await measureUserRowHeight(page, desktopCase); + const mobileMeasured = await measureUserRowHeight(page, mobileCase); + const measuredDelta = mobileMeasured - desktopMeasured; + + const estimatedDelta = estimatedHeight(mobileCase) - estimatedHeight(desktopCase); + expect(Math.abs(measuredDelta - estimatedDelta)).toBeLessThanOrEqual(44); + }); + + test("tracks attachment row growth", async ({ page }) => { + const withoutAttachmentsCase: HeightCase = { + timelineWidthPx: 960, + text: "hello", + attachmentCount: 0, + }; + const withAttachmentsCase: HeightCase = { + ...withoutAttachmentsCase, + attachmentCount: 3, + }; + + const withoutMeasured = await measureUserRowHeight(page, withoutAttachmentsCase); + const withMeasured = await measureUserRowHeight(page, withAttachmentsCase); + const measuredDelta = withMeasured - withoutMeasured; + + const estimatedDelta = + estimatedHeight(withAttachmentsCase) - estimatedHeight(withoutAttachmentsCase); + expect(Math.abs(measuredDelta - estimatedDelta)).toBeLessThanOrEqual(4); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 97985eb8535a..a9c4b5451d9f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,9 @@ "prepare": "effect-language-service patch", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run --passWithNoTests", + "test:browser": "playwright test -c playwright.config.ts", + "test:browser:install": "playwright install chromium" }, "dependencies": { "@base-ui/react": "^1.2.0", @@ -36,6 +38,7 @@ }, "devDependencies": { "@effect/language-service": "catalog:", + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", "@types/react": "^19.0.0", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 000000000000..a0d93c553cf9 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./browser-tests", + testMatch: "*.browser.e2e.ts", + reporter: "list", + fullyParallel: true, + outputDir: "./.playwright/test-results", + use: { + headless: true, + }, +}); diff --git a/bun.lock b/bun.lock index 2c717ea7dbf4..d5e9b1e534fe 100644 --- a/bun.lock +++ b/bun.lock @@ -87,6 +87,7 @@ }, "devDependencies": { "@effect/language-service": "catalog:", + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", "@types/react": "^19.0.0", @@ -467,6 +468,8 @@ "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@preact/signals-core": ["@preact/signals-core@1.13.0", "", {}, "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg=="], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], @@ -1249,6 +1252,10 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], @@ -1595,6 +1602,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], From 98b035a72bff89be4d620e5c08f17a80b4d32952 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Mar 2026 12:49:30 -0800 Subject: [PATCH 03/11] Migrate ChatView height browser tests to Vitest + Playwright - Replace Playwright e2e timeline-height test setup with Vitest browser config - Add `ChatView.browser.tsx` parity tests for text wrapping and attachment height estimation - Add row data attributes in `ChatView` to support in-browser measurement targeting --- .gitignore | 1 + .../timelineHeight.browser.e2e.ts | 148 ------ apps/web/package.json | 7 +- apps/web/playwright.config.ts | 12 - apps/web/src/components/ChatView.browser.tsx | 461 ++++++++++++++++++ apps/web/src/components/ChatView.tsx | 7 +- apps/web/vitest.browser.config.ts | 29 ++ bun.lock | 37 +- 8 files changed, 533 insertions(+), 169 deletions(-) delete mode 100644 apps/web/browser-tests/timelineHeight.browser.e2e.ts delete mode 100644 apps/web/playwright.config.ts create mode 100644 apps/web/src/components/ChatView.browser.tsx create mode 100644 apps/web/vitest.browser.config.ts diff --git a/.gitignore b/.gitignore index 5c2ec0b4a0b4..ac08d39161f1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ release/ .t3 apps/web/.playwright apps/web/playwright-report +apps/web/src/components/__screenshots__ diff --git a/apps/web/browser-tests/timelineHeight.browser.e2e.ts b/apps/web/browser-tests/timelineHeight.browser.e2e.ts deleted file mode 100644 index 185ca6ce0681..000000000000 --- a/apps/web/browser-tests/timelineHeight.browser.e2e.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; - -import { estimateTimelineMessageHeight } from "../src/components/timelineHeight"; - -interface HeightCase { - timelineWidthPx: number; - text: string; - attachmentCount: number; -} - -async function measureUserRowHeight(page: Page, testCase: HeightCase) { - const { timelineWidthPx, text, attachmentCount } = testCase; - await page.setContent(""); - - return page.evaluate(({ width, messageText, attachments }) => { - const timeline = document.createElement("div"); - timeline.style.width = `${width}px`; - timeline.style.maxWidth = `${width}px`; - - const row = document.createElement("div"); - row.style.paddingBottom = "16px"; - - const alignment = document.createElement("div"); - alignment.style.display = "flex"; - alignment.style.justifyContent = "flex-end"; - - const bubble = document.createElement("div"); - bubble.style.boxSizing = "border-box"; - bubble.style.maxWidth = "80%"; - bubble.style.padding = "12px 16px"; - bubble.style.border = "1px solid rgba(0, 0, 0, 0.12)"; - bubble.style.borderRadius = "16px"; - bubble.style.background = "rgba(0, 0, 0, 0.03)"; - - if (attachments > 0) { - const attachmentGrid = document.createElement("div"); - attachmentGrid.style.marginBottom = "8px"; - attachmentGrid.style.maxWidth = "420px"; - attachmentGrid.style.display = "grid"; - attachmentGrid.style.gridTemplateColumns = "repeat(2, minmax(0, 1fr))"; - attachmentGrid.style.gap = "8px"; - for (let index = 0; index < attachments; index += 1) { - const tile = document.createElement("div"); - tile.style.height = "220px"; - tile.style.border = "1px solid rgba(0, 0, 0, 0.12)"; - tile.style.borderRadius = "8px"; - tile.style.background = "rgba(0, 0, 0, 0.06)"; - attachmentGrid.append(tile); - } - bubble.append(attachmentGrid); - } - - if (messageText.length > 0) { - const pre = document.createElement("pre"); - pre.textContent = messageText; - pre.style.margin = "0"; - pre.style.whiteSpace = "pre-wrap"; - pre.style.overflowWrap = "anywhere"; - pre.style.fontFamily = - "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace"; - pre.style.fontSize = "14px"; - pre.style.lineHeight = "22px"; - bubble.append(pre); - } - - const meta = document.createElement("div"); - meta.style.marginTop = "6px"; - meta.style.height = "16px"; - bubble.append(meta); - - alignment.append(bubble); - row.append(alignment); - timeline.append(row); - document.body.append(timeline); - return row.getBoundingClientRect().height; - }, { width: timelineWidthPx, messageText: text, attachments: attachmentCount }); -} - -function estimatedHeight(testCase: HeightCase): number { - return estimateTimelineMessageHeight( - { - role: "user", - text: testCase.text, - attachments: Array.from({ length: testCase.attachmentCount }, (_value, index) => ({ - id: String(index), - })), - }, - { timelineWidthPx: testCase.timelineWidthPx }, - ); -} - -test.describe("timeline height estimator parity", () => { - test("tracks long wrapped text growth at desktop width", async ({ page }) => { - const baselineCase: HeightCase = { timelineWidthPx: 960, text: "", attachmentCount: 0 }; - const longCase: HeightCase = { - timelineWidthPx: 960, - text: "x".repeat(1200), - attachmentCount: 0, - }; - - const baselineMeasured = await measureUserRowHeight(page, baselineCase); - const longMeasured = await measureUserRowHeight(page, longCase); - const measuredDelta = longMeasured - baselineMeasured; - - const estimatedDelta = estimatedHeight(longCase) - estimatedHeight(baselineCase); - expect(Math.abs(measuredDelta - estimatedDelta)).toBeLessThanOrEqual(22); - }); - - test("tracks additional wrapping when viewport narrows", async ({ page }) => { - const desktopCase: HeightCase = { - timelineWidthPx: 960, - text: "x".repeat(1000), - attachmentCount: 0, - }; - const mobileCase: HeightCase = { - timelineWidthPx: 360, - text: desktopCase.text, - attachmentCount: 0, - }; - - const desktopMeasured = await measureUserRowHeight(page, desktopCase); - const mobileMeasured = await measureUserRowHeight(page, mobileCase); - const measuredDelta = mobileMeasured - desktopMeasured; - - const estimatedDelta = estimatedHeight(mobileCase) - estimatedHeight(desktopCase); - expect(Math.abs(measuredDelta - estimatedDelta)).toBeLessThanOrEqual(44); - }); - - test("tracks attachment row growth", async ({ page }) => { - const withoutAttachmentsCase: HeightCase = { - timelineWidthPx: 960, - text: "hello", - attachmentCount: 0, - }; - const withAttachmentsCase: HeightCase = { - ...withoutAttachmentsCase, - attachmentCount: 3, - }; - - const withoutMeasured = await measureUserRowHeight(page, withoutAttachmentsCase); - const withMeasured = await measureUserRowHeight(page, withAttachmentsCase); - const measuredDelta = withMeasured - withoutMeasured; - - const estimatedDelta = - estimatedHeight(withAttachmentsCase) - estimatedHeight(withoutAttachmentsCase); - expect(Math.abs(measuredDelta - estimatedDelta)).toBeLessThanOrEqual(4); - }); -}); diff --git a/apps/web/package.json b/apps/web/package.json index a9c4b5451d9f..7b23de3679d0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,8 +10,8 @@ "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", - "test:browser": "playwright test -c playwright.config.ts", - "test:browser:install": "playwright install chromium" + "test:browser": "vitest run --config vitest.browser.config.ts", + "test:browser:install": "playwright install --with-deps chromium" }, "dependencies": { "@base-ui/react": "^1.2.0", @@ -38,13 +38,14 @@ }, "devDependencies": { "@effect/language-service": "catalog:", - "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.1.4", + "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", + "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "catalog:", "vite": "^8.0.0-beta.12", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts deleted file mode 100644 index a0d93c553cf9..000000000000 --- a/apps/web/playwright.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from "@playwright/test"; - -export default defineConfig({ - testDir: "./browser-tests", - testMatch: "*.browser.e2e.ts", - reporter: "list", - fullyParallel: true, - outputDir: "./.playwright/test-results", - use: { - headless: true, - }, -}); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx new file mode 100644 index 000000000000..167f95f2f3d7 --- /dev/null +++ b/apps/web/src/components/ChatView.browser.tsx @@ -0,0 +1,461 @@ +import "../index.css"; + +import { type MessageId, type ThreadId } from "@t3tools/contracts"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ChatMessage, Thread } from "../types"; +import ChatView from "./ChatView"; +import { estimateTimelineMessageHeight } from "./timelineHeight"; + +const mocks = vi.hoisted(() => { + return { + navigate: vi.fn(), + markThreadVisited: vi.fn(), + setThreadError: vi.fn(), + setRuntimeMode: vi.fn(), + setThreadBranch: vi.fn(), + storeState: { + projects: [] as unknown[], + threads: [] as Thread[], + runtimeMode: "full-access", + markThreadVisited: vi.fn(), + setError: vi.fn(), + setRuntimeMode: vi.fn(), + setThreadBranch: vi.fn(), + }, + composerDraft: { + prompt: "", + images: [] as unknown[], + nonPersistedImageIds: [] as string[], + model: null, + effort: null, + }, + composerStore: { + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + setPrompt: vi.fn(), + setModel: vi.fn(), + setEffort: vi.fn(), + addImage: vi.fn(), + addImages: vi.fn(), + removeImage: vi.fn(), + clearPersistedAttachments: vi.fn(), + syncPersistedAttachments: vi.fn(), + clearComposerContent: vi.fn(), + clearDraftThread: vi.fn(), + setDraftThreadContext: vi.fn(), + }, + terminalStore: { + terminalStateByThreadId: {} as Record, + setTerminalOpen: vi.fn(), + setTerminalHeight: vi.fn(), + splitTerminal: vi.fn(), + newTerminal: vi.fn(), + setActiveTerminal: vi.fn(), + closeTerminal: vi.fn(), + }, + terminalState: { + terminalOpen: false, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + }, + }; +}); + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => mocks.navigate, + useSearch: (options?: { select?: (params: Record) => unknown }) => + options?.select ? options.select({}) : {}, +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual( + "@tanstack/react-query", + ); + return { + ...actual, + useQueryClient: () => ({}), + useMutation: () => ({ + mutateAsync: vi.fn(async () => ({})), + isPending: false, + }), + useQuery: () => ({ + data: undefined, + isLoading: false, + error: null, + }), + }; +}); + +vi.mock("../store", () => ({ + useStore: (selector: (store: typeof mocks.storeState) => unknown) => selector(mocks.storeState), +})); + +vi.mock("../composerDraftStore", () => ({ + useComposerThreadDraft: () => mocks.composerDraft, + useComposerDraftStore: (selector: (store: typeof mocks.composerStore) => unknown) => + selector(mocks.composerStore), +})); + +vi.mock("../terminalStateStore", () => ({ + useTerminalStateStore: (selector: (store: typeof mocks.terminalStore) => unknown) => + selector(mocks.terminalStore), + selectThreadTerminalState: () => mocks.terminalState, +})); + +vi.mock("../hooks/useTurnDiffSummaries", () => ({ + useTurnDiffSummaries: () => ({ + turnDiffSummaries: [], + inferredCheckpointTurnCountByTurnId: {}, + }), +})); + +vi.mock("../hooks/useTheme", () => ({ + useTheme: () => ({ resolvedTheme: "light" as const }), +})); + +vi.mock("../nativeApi", () => ({ + readNativeApi: () => null, + ensureNativeApi: () => { + throw new Error("Native API unavailable in browser test"); + }, +})); + +vi.mock("./BranchToolbar", () => ({ + default: () => null, +})); + +vi.mock("./GitActionsControl", () => ({ + default: () => null, +})); + +vi.mock("./ProjectScriptsControl", () => ({ + default: () => null, +})); + +vi.mock("./ThreadTerminalDrawer", () => ({ + default: () => null, +})); + +vi.mock("./ComposerPromptEditor", () => ({ + ComposerPromptEditor: () => null, +})); + +vi.mock("./ui/sidebar", () => ({ + SidebarTrigger: () => null, +})); + +const THREAD_ID = "thread-browser-test" as ThreadId; +const NOW_ISO = "2026-03-04T12:00:00.000Z"; +const BASE_TIME_MS = Date.parse(NOW_ISO); +const TALL_IMAGE_DATA_URI = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( + "", +)}`; + +interface RenderMeasureOptions { + timelineWidthPx: number; + messages: ChatMessage[]; + targetMessageId: MessageId; +} + +function createThread(messages: ChatMessage[]): Thread { + return { + id: THREAD_ID, + codexThreadId: null, + projectId: "project-1" as Thread["projectId"], + title: "Browser test thread", + model: "gpt-5", + session: null, + messages, + error: null, + createdAt: NOW_ISO, + latestTurn: null, + lastVisitedAt: NOW_ISO, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }; +} + +function isoAt(offsetSeconds: number): string { + return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); +} + +function createUserMessage({ + id, + text, + offsetSeconds, + attachments, +}: { + id: MessageId; + text: string; + offsetSeconds: number; + attachments?: ChatMessage["attachments"]; +}): ChatMessage { + return { + id, + role: "user", + text, + ...(attachments && attachments.length > 0 ? { attachments } : {}), + createdAt: isoAt(offsetSeconds), + completedAt: isoAt(offsetSeconds + 1), + streaming: false, + }; +} + +function createAssistantMessage({ + id, + text, + offsetSeconds, +}: { + id: MessageId; + text: string; + offsetSeconds: number; +}): ChatMessage { + return { + id, + role: "assistant", + text, + createdAt: isoAt(offsetSeconds), + completedAt: isoAt(offsetSeconds + 1), + streaming: false, + }; +} + +function createImageAttachments(count: number): NonNullable { + return Array.from({ length: count }, (_, index) => ({ + type: "image" as const, + id: `attachment-${index + 1}`, + name: `attachment-${index + 1}.svg`, + mimeType: "image/svg+xml", + sizeBytes: 128, + previewUrl: TALL_IMAGE_DATA_URI, + })); +} + +function createConversationWithTargetUser(options: { + targetMessageId: MessageId; + targetText: string; + targetAttachments?: ChatMessage["attachments"]; +}): ChatMessage[] { + const messages: ChatMessage[] = []; + for (let index = 0; index < 22; index += 1) { + const userId = (`msg-user-${index}` as MessageId); + const assistantId = (`msg-assistant-${index}` as MessageId); + const isTarget = index === 3; + messages.push( + createUserMessage({ + id: isTarget ? options.targetMessageId : userId, + text: isTarget ? options.targetText : `filler user message ${index}`, + offsetSeconds: messages.length * 3, + attachments: isTarget ? options.targetAttachments : undefined, + }), + ); + messages.push( + createAssistantMessage({ + id: assistantId, + text: `assistant filler ${index}`, + offsetSeconds: messages.length * 3, + }), + ); + } + return messages; +} + +async function nextFrame(): Promise { + await new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()); + }); +} + +async function waitForLayout(): Promise { + await nextFrame(); + await nextFrame(); + await nextFrame(); +} + +async function waitForImagesToLoad(scope: ParentNode): Promise { + const images = Array.from(scope.querySelectorAll("img")); + await Promise.all( + images.map( + (image) => + new Promise((resolve) => { + if (image.complete) { + resolve(); + return; + } + image.addEventListener("load", () => resolve(), { once: true }); + image.addEventListener("error", () => resolve(), { once: true }); + }), + ), + ); + await waitForLayout(); +} + +async function renderAndMeasureUserRow({ + timelineWidthPx, + messages, + targetMessageId, +}: RenderMeasureOptions): Promise<{ + measuredRowHeightPx: number; + timelineWidthMeasuredPx: number; + renderedInVirtualizedRegion: boolean; +}> { + const host = document.createElement("div"); + host.style.width = `${timelineWidthPx}px`; + host.style.height = "920px"; + host.style.display = "flex"; + host.style.overflow = "hidden"; + document.body.append(host); + + mocks.storeState.threads = [createThread(messages)]; + + const root: Root = createRoot(host); + root.render(); + await waitForLayout(); + + const scrollContainer = host.querySelector("div.overflow-y-auto.overscroll-y-contain"); + if (!(scrollContainer instanceof HTMLDivElement)) { + root.unmount(); + throw new Error("Unable to find ChatView message scroll container."); + } + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await waitForLayout(); + + const row = host.querySelector( + `[data-message-id="${targetMessageId}"][data-message-role="user"]`, + ); + if (!(row instanceof HTMLElement)) { + root.unmount(); + throw new Error("Unable to locate targeted user message row."); + } + await waitForImagesToLoad(row); + + const timelineRoot = row.closest("div.max-w-3xl"); + if (!(timelineRoot instanceof HTMLElement)) { + root.unmount(); + throw new Error("Unable to locate timeline root container."); + } + + const measuredRowHeightPx = row.getBoundingClientRect().height; + const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; + const renderedInVirtualizedRegion = row.closest("[data-index]") instanceof HTMLElement; + + root.unmount(); + host.remove(); + + return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; +} + +describe("ChatView timeline estimator parity", () => { + beforeEach(() => { + document.body.innerHTML = ""; + mocks.storeState.projects = []; + mocks.storeState.threads = []; + mocks.storeState.runtimeMode = "full-access"; + mocks.composerDraft.prompt = ""; + mocks.composerDraft.images = []; + mocks.composerDraft.nonPersistedImageIds = []; + mocks.composerDraft.model = null; + mocks.composerDraft.effort = null; + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("keeps long user message estimate close to actual rendered virtualized ChatView row height", async () => { + const userText = "x".repeat(3_200); + const targetMessageId = "msg-user-target-long" as MessageId; + const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = + await renderAndMeasureUserRow({ + timelineWidthPx: 960, + targetMessageId, + messages: createConversationWithTargetUser({ + targetMessageId, + targetText: userText, + }), + }); + + expect(renderedInVirtualizedRegion).toBe(true); + + const estimatedHeightPx = estimateTimelineMessageHeight( + { role: "user", text: userText, attachments: [] }, + { timelineWidthPx: timelineWidthMeasuredPx }, + ); + + expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual(44); + }); + + it("tracks additional rendered wrapping when ChatView width narrows", async () => { + const userText = "x".repeat(2_400); + const targetMessageId = "msg-user-target-wrap" as MessageId; + const messages = createConversationWithTargetUser({ + targetMessageId, + targetText: userText, + }); + const desktop = await renderAndMeasureUserRow({ + timelineWidthPx: 960, + targetMessageId, + messages, + }); + const mobile = await renderAndMeasureUserRow({ + timelineWidthPx: 360, + targetMessageId, + messages, + }); + + const estimatedDesktopPx = estimateTimelineMessageHeight( + { role: "user", text: userText, attachments: [] }, + { timelineWidthPx: desktop.timelineWidthMeasuredPx }, + ); + const estimatedMobilePx = estimateTimelineMessageHeight( + { role: "user", text: userText, attachments: [] }, + { timelineWidthPx: mobile.timelineWidthMeasuredPx }, + ); + + const measuredDeltaPx = mobile.measuredRowHeightPx - desktop.measuredRowHeightPx; + const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx; + expect(measuredDeltaPx).toBeGreaterThan(0); + expect(estimatedDeltaPx).toBeGreaterThan(0); + const ratio = estimatedDeltaPx / measuredDeltaPx; + expect(ratio).toBeGreaterThan(0.65); + expect(ratio).toBeLessThan(1.35); + }); + + it("keeps user attachment estimate close to actual rendered ChatView row height", async () => { + const targetMessageId = "msg-user-target-attachments" as MessageId; + const attachments = createImageAttachments(3); + const userText = "message with image attachments"; + const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = + await renderAndMeasureUserRow({ + timelineWidthPx: 960, + targetMessageId, + messages: createConversationWithTargetUser({ + targetMessageId, + targetText: userText, + targetAttachments: attachments, + }), + }); + + expect(renderedInVirtualizedRegion).toBe(true); + + const estimatedHeightPx = estimateTimelineMessageHeight( + { + role: "user", + text: userText, + attachments: attachments.map((attachment) => ({ id: attachment.id })), + }, + { timelineWidthPx: timelineWidthMeasuredPx }, + ); + + expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual(56); + }); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d7da2fb29bab..e9f81c44534e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3362,7 +3362,12 @@ const MessagesTimeline = memo(function MessagesTimeline({ const nonVirtualizedRows = rows.slice(virtualizedRowCount); const renderRowContent = (row: TimelineRow) => ( -
+
{row.kind === "work" && (() => { const groupId = row.id; diff --git a/apps/web/vitest.browser.config.ts b/apps/web/vitest.browser.config.ts new file mode 100644 index 000000000000..6083d6735e49 --- /dev/null +++ b/apps/web/vitest.browser.config.ts @@ -0,0 +1,29 @@ +import { fileURLToPath } from "node:url"; +import { playwright } from "@vitest/browser-playwright"; +import { defineConfig, mergeConfig } from "vitest/config"; + +import viteConfig from "./vite.config"; + +const srcPath = fileURLToPath(new URL("./src", import.meta.url)); + +export default mergeConfig( + viteConfig, + defineConfig({ + resolve: { + alias: { + "~": srcPath, + }, + }, + test: { + include: ["src/components/ChatView.browser.tsx"], + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: "chromium" }], + headless: true, + }, + testTimeout: 30_000, + hookTimeout: 30_000, + }, + }), +); diff --git a/bun.lock b/bun.lock index d5e9b1e534fe..5178098e6545 100644 --- a/bun.lock +++ b/bun.lock @@ -87,13 +87,14 @@ }, "devDependencies": { "@effect/language-service": "catalog:", - "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.1.4", + "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", + "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "catalog:", "vite": "^8.0.0-beta.12", @@ -468,7 +469,7 @@ "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="], - "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@preact/signals-core": ["@preact/signals-core@1.13.0", "", {}, "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg=="], @@ -702,6 +703,10 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], + "@vitest/browser": ["@vitest/browser@4.0.18", "", { "dependencies": { "@vitest/mocker": "4.0.18", "@vitest/utils": "4.0.18", "magic-string": "^0.30.21", "pixelmatch": "7.1.0", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.0.3", "ws": "^8.18.3" }, "peerDependencies": { "vitest": "4.0.18" } }, "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng=="], + + "@vitest/browser-playwright": ["@vitest/browser-playwright@4.0.18", "", { "dependencies": { "@vitest/browser": "4.0.18", "@vitest/mocker": "4.0.18", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "playwright": "*", "vitest": "4.0.18" } }, "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g=="], + "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], @@ -922,7 +927,7 @@ "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1194,6 +1199,8 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], @@ -1252,10 +1259,14 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pixelmatch": ["pixelmatch@7.1.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], @@ -1352,6 +1363,8 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1418,6 +1431,8 @@ "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -1594,6 +1609,8 @@ "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], @@ -1602,16 +1619,20 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.1", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.1", "@babel/helper-validator-identifier": "^8.0.0-rc.1" } }, "sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ=="], + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], @@ -1644,6 +1665,8 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "@tailwindcss/vite/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@types/cacheable-request/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@types/keyv/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -1654,6 +1677,10 @@ "@types/yauzl/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@vitejs/plugin-react/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], + + "vitest/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], } } From c167176d39cd592b3e1c00c370477ac5ddbaa9ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Mar 2026 13:00:27 -0800 Subject: [PATCH 04/11] Use real TanStack router in ChatView browser tests - Replace mocked router hooks with a memory router + RouterProvider - Render ChatView through a test route to exercise attachment height layout in realistic routing context --- apps/web/src/components/ChatView.browser.tsx | 36 +++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 167f95f2f3d7..58a0b3f8a5d9 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1,6 +1,14 @@ import "../index.css"; import { type MessageId, type ThreadId } from "@t3tools/contracts"; +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -10,7 +18,6 @@ import { estimateTimelineMessageHeight } from "./timelineHeight"; const mocks = vi.hoisted(() => { return { - navigate: vi.fn(), markThreadVisited: vi.fn(), setThreadError: vi.fn(), setRuntimeMode: vi.fn(), @@ -67,12 +74,6 @@ const mocks = vi.hoisted(() => { }; }); -vi.mock("@tanstack/react-router", () => ({ - useNavigate: () => mocks.navigate, - useSearch: (options?: { select?: (params: Record) => unknown }) => - options?.select ? options.select({}) : {}, -})); - vi.mock("@tanstack/react-query", async () => { const actual = await vi.importActual( "@tanstack/react-query", @@ -163,6 +164,24 @@ interface RenderMeasureOptions { targetMessageId: MessageId; } +function createTestRouter(threadId: ThreadId) { + const rootRoute = createRootRoute({ + component: () => , + }); + const threadRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/$threadId", + component: () => , + }); + return createRouter({ + routeTree: rootRoute.addChildren([threadRoute]), + history: createMemoryHistory({ + initialEntries: [`/${threadId}`], + }), + context: {}, + }); +} + function createThread(messages: ChatMessage[]): Thread { return { id: THREAD_ID, @@ -315,9 +334,10 @@ async function renderAndMeasureUserRow({ document.body.append(host); mocks.storeState.threads = [createThread(messages)]; + const router = createTestRouter(THREAD_ID); const root: Root = createRoot(host); - root.render(); + root.render(); await waitForLayout(); const scrollContainer = host.querySelector("div.overflow-y-auto.overscroll-y-contain"); From 97c88edf7bcfd8d1783d1130a0f98fb22e5527a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Mar 2026 21:49:00 -0800 Subject: [PATCH 05/11] Stabilize ChatView attachment height browser test with MSW - Run ChatView browser tests through the real app router/store stack - Add MSW worker setup and mocked WS/attachment responses for full-app rendering - Extract shared `getRouter` setup and add a timeline root data hook for reliable measurement --- apps/web/package.json | 1 + apps/web/public/mockServiceWorker.js | 349 ++++++++++ apps/web/src/components/ChatView.browser.tsx | 643 +++++++++++-------- apps/web/src/components/ChatView.tsx | 6 +- apps/web/src/main.tsx | 29 +- apps/web/src/router.ts | 34 + apps/web/src/routes/__root.tsx | 27 +- bun.lock | 1 + package.json | 9 +- 9 files changed, 776 insertions(+), 323 deletions(-) create mode 100644 apps/web/public/mockServiceWorker.js create mode 100644 apps/web/src/router.ts diff --git a/apps/web/package.json b/apps/web/package.json index 7b23de3679d0..b540fe35fa1b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,6 +45,7 @@ "@vitejs/plugin-react": "^5.1.4", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", + "msw": "^2.12.10", "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "catalog:", diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js new file mode 100644 index 000000000000..daa58d0f1205 --- /dev/null +++ b/apps/web/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.12.10' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 58a0b3f8a5d9..5af8c217b594 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1,279 +1,141 @@ import "../index.css"; -import { type MessageId, type ThreadId } from "@t3tools/contracts"; import { - Outlet, - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from "@tanstack/react-router"; + ORCHESTRATION_WS_METHODS, + type MessageId, + type OrchestrationReadModel, + type ProjectId, + type ProviderSessionId, + type ServerConfig, + type ThreadId, + type WsWelcomePayload, + WS_CHANNELS, + WS_METHODS, +} from "@t3tools/contracts"; +import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { HttpResponse, http, ws } from "msw"; +import { setupWorker } from "msw/browser"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import type { ChatMessage, Thread } from "../types"; -import ChatView from "./ChatView"; +import { getRouter } from "../router"; +import { useStore } from "../store"; import { estimateTimelineMessageHeight } from "./timelineHeight"; -const mocks = vi.hoisted(() => { - return { - markThreadVisited: vi.fn(), - setThreadError: vi.fn(), - setRuntimeMode: vi.fn(), - setThreadBranch: vi.fn(), - storeState: { - projects: [] as unknown[], - threads: [] as Thread[], - runtimeMode: "full-access", - markThreadVisited: vi.fn(), - setError: vi.fn(), - setRuntimeMode: vi.fn(), - setThreadBranch: vi.fn(), - }, - composerDraft: { - prompt: "", - images: [] as unknown[], - nonPersistedImageIds: [] as string[], - model: null, - effort: null, - }, - composerStore: { - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - setPrompt: vi.fn(), - setModel: vi.fn(), - setEffort: vi.fn(), - addImage: vi.fn(), - addImages: vi.fn(), - removeImage: vi.fn(), - clearPersistedAttachments: vi.fn(), - syncPersistedAttachments: vi.fn(), - clearComposerContent: vi.fn(), - clearDraftThread: vi.fn(), - setDraftThreadContext: vi.fn(), - }, - terminalStore: { - terminalStateByThreadId: {} as Record, - setTerminalOpen: vi.fn(), - setTerminalHeight: vi.fn(), - splitTerminal: vi.fn(), - newTerminal: vi.fn(), - setActiveTerminal: vi.fn(), - closeTerminal: vi.fn(), - }, - terminalState: { - terminalOpen: false, - terminalHeight: 280, - terminalIds: ["default"], - runningTerminalIds: [], - activeTerminalId: "default", - terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], - activeTerminalGroupId: "group-default", - }, - }; -}); - -vi.mock("@tanstack/react-query", async () => { - const actual = await vi.importActual( - "@tanstack/react-query", - ); - return { - ...actual, - useQueryClient: () => ({}), - useMutation: () => ({ - mutateAsync: vi.fn(async () => ({})), - isPending: false, - }), - useQuery: () => ({ - data: undefined, - isLoading: false, - error: null, - }), - }; -}); - -vi.mock("../store", () => ({ - useStore: (selector: (store: typeof mocks.storeState) => unknown) => selector(mocks.storeState), -})); - -vi.mock("../composerDraftStore", () => ({ - useComposerThreadDraft: () => mocks.composerDraft, - useComposerDraftStore: (selector: (store: typeof mocks.composerStore) => unknown) => - selector(mocks.composerStore), -})); - -vi.mock("../terminalStateStore", () => ({ - useTerminalStateStore: (selector: (store: typeof mocks.terminalStore) => unknown) => - selector(mocks.terminalStore), - selectThreadTerminalState: () => mocks.terminalState, -})); - -vi.mock("../hooks/useTurnDiffSummaries", () => ({ - useTurnDiffSummaries: () => ({ - turnDiffSummaries: [], - inferredCheckpointTurnCountByTurnId: {}, - }), -})); - -vi.mock("../hooks/useTheme", () => ({ - useTheme: () => ({ resolvedTheme: "light" as const }), -})); - -vi.mock("../nativeApi", () => ({ - readNativeApi: () => null, - ensureNativeApi: () => { - throw new Error("Native API unavailable in browser test"); - }, -})); - -vi.mock("./BranchToolbar", () => ({ - default: () => null, -})); - -vi.mock("./GitActionsControl", () => ({ - default: () => null, -})); - -vi.mock("./ProjectScriptsControl", () => ({ - default: () => null, -})); - -vi.mock("./ThreadTerminalDrawer", () => ({ - default: () => null, -})); - -vi.mock("./ComposerPromptEditor", () => ({ - ComposerPromptEditor: () => null, -})); - -vi.mock("./ui/sidebar", () => ({ - SidebarTrigger: () => null, -})); - const THREAD_ID = "thread-browser-test" as ThreadId; +const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); -const TALL_IMAGE_DATA_URI = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( - "", -)}`; +const ATTACHMENT_SVG = ""; -interface RenderMeasureOptions { - timelineWidthPx: number; - messages: ChatMessage[]; - targetMessageId: MessageId; +interface WsRequestEnvelope { + id: string; + body: { + _tag: string; + [key: string]: unknown; + }; } -function createTestRouter(threadId: ThreadId) { - const rootRoute = createRootRoute({ - component: () => , - }); - const threadRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/$threadId", - component: () => , - }); - return createRouter({ - routeTree: rootRoute.addChildren([threadRoute]), - history: createMemoryHistory({ - initialEntries: [`/${threadId}`], - }), - context: {}, - }); +interface TestFixture { + snapshot: OrchestrationReadModel; + serverConfig: ServerConfig; + welcome: WsWelcomePayload; } -function createThread(messages: ChatMessage[]): Thread { - return { - id: THREAD_ID, - codexThreadId: null, - projectId: "project-1" as Thread["projectId"], - title: "Browser test thread", - model: "gpt-5", - session: null, - messages, - error: null, - createdAt: NOW_ISO, - latestTurn: null, - lastVisitedAt: NOW_ISO, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - }; -} +let fixture: TestFixture; +const wsLink = ws.link(/ws(s)?:\/\/.*/); function isoAt(offsetSeconds: number): string { return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); } -function createUserMessage({ - id, - text, - offsetSeconds, - attachments, -}: { +function createBaseServerConfig(): ServerConfig { + return { + cwd: "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/repo/project", + keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", + keybindings: [], + issues: [], + providers: [ + { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: NOW_ISO, + }, + ], + availableEditors: [], + }; +} + +function createUserMessage(options: { id: MessageId; text: string; offsetSeconds: number; - attachments?: ChatMessage["attachments"]; -}): ChatMessage { + attachments?: Array<{ + type: "image"; + id: string; + name: string; + mimeType: string; + sizeBytes: number; + }>; +}) { return { - id, - role: "user", - text, - ...(attachments && attachments.length > 0 ? { attachments } : {}), - createdAt: isoAt(offsetSeconds), - completedAt: isoAt(offsetSeconds + 1), + id: options.id, + role: "user" as const, + text: options.text, + ...(options.attachments ? { attachments: options.attachments } : {}), + turnId: null, streaming: false, + createdAt: isoAt(options.offsetSeconds), + updatedAt: isoAt(options.offsetSeconds + 1), }; } -function createAssistantMessage({ - id, - text, - offsetSeconds, -}: { +function createAssistantMessage(options: { id: MessageId; text: string; offsetSeconds: number; -}): ChatMessage { +}) { return { - id, - role: "assistant", - text, - createdAt: isoAt(offsetSeconds), - completedAt: isoAt(offsetSeconds + 1), + id: options.id, + role: "assistant" as const, + text: options.text, + turnId: null, streaming: false, + createdAt: isoAt(options.offsetSeconds), + updatedAt: isoAt(options.offsetSeconds + 1), }; } -function createImageAttachments(count: number): NonNullable { - return Array.from({ length: count }, (_, index) => ({ - type: "image" as const, - id: `attachment-${index + 1}`, - name: `attachment-${index + 1}.svg`, - mimeType: "image/svg+xml", - sizeBytes: 128, - previewUrl: TALL_IMAGE_DATA_URI, - })); -} - -function createConversationWithTargetUser(options: { +function createSnapshotForTargetUser(options: { targetMessageId: MessageId; targetText: string; - targetAttachments?: ChatMessage["attachments"]; -}): ChatMessage[] { - const messages: ChatMessage[] = []; + targetAttachmentCount?: number; +}): OrchestrationReadModel { + const messages: Array = []; + for (let index = 0; index < 22; index += 1) { - const userId = (`msg-user-${index}` as MessageId); - const assistantId = (`msg-assistant-${index}` as MessageId); const isTarget = index === 3; + const userId = `msg-user-${index}` as MessageId; + const assistantId = `msg-assistant-${index}` as MessageId; + const attachments = + isTarget && (options.targetAttachmentCount ?? 0) > 0 + ? Array.from({ length: options.targetAttachmentCount ?? 0 }, (_, attachmentIndex) => ({ + type: "image" as const, + id: `attachment-${attachmentIndex + 1}`, + name: `attachment-${attachmentIndex + 1}.png`, + mimeType: "image/png", + sizeBytes: 128, + })) + : undefined; + messages.push( createUserMessage({ id: isTarget ? options.targetMessageId : userId, text: isTarget ? options.targetText : `filler user message ${index}`, offsetSeconds: messages.length * 3, - attachments: isTarget ? options.targetAttachments : undefined, + ...(attachments ? { attachments } : {}), }), ); messages.push( @@ -284,9 +146,149 @@ function createConversationWithTargetUser(options: { }), ); } - return messages; + + return { + snapshotSequence: 1, + projects: [ + { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "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/repo/project", + defaultModel: "gpt-5", + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + threads: [ + { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Browser test thread", + model: "gpt-5", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + messages, + activities: [], + checkpoints: [], + session: { + threadId: THREAD_ID, + status: "ready", + providerName: "codex", + providerSessionId: "session-1" as ProviderSessionId, + providerThreadId: null, + approvalPolicy: "on-failure", + sandboxMode: "workspace-write", + activeTurnId: null, + lastError: null, + updatedAt: NOW_ISO, + }, + }, + ], + updatedAt: NOW_ISO, + }; +} + +function buildFixture(snapshot: OrchestrationReadModel): TestFixture { + return { + snapshot, + serverConfig: createBaseServerConfig(), + welcome: { + cwd: "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/repo/project", + projectName: "Project", + bootstrapProjectId: PROJECT_ID, + bootstrapThreadId: THREAD_ID, + }, + }; +} + +function resolveWsRpc(tag: string): unknown { + if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { + return fixture.snapshot; + } + if (tag === WS_METHODS.serverGetConfig) { + return fixture.serverConfig; + } + if (tag === WS_METHODS.gitListBranches) { + return { + isRepo: true, + branches: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: null, + }, + ], + }; + } + if (tag === WS_METHODS.gitStatus) { + return { + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + } + if (tag === WS_METHODS.projectsSearchEntries) { + return { + entries: [], + truncated: false, + }; + } + return {}; } +const worker = setupWorker( + wsLink.addEventListener("connection", ({ client }) => { + client.send( + JSON.stringify({ + type: "push", + channel: WS_CHANNELS.serverWelcome, + data: fixture.welcome, + }), + ); + client.addEventListener("message", (event) => { + const rawData = event.data; + if (typeof rawData !== "string") return; + let request: WsRequestEnvelope; + try { + request = JSON.parse(rawData) as WsRequestEnvelope; + } catch { + return; + } + const method = request.body?._tag; + if (typeof method !== "string") return; + client.send( + JSON.stringify({ + id: request.id, + result: resolveWsRpc(method), + }), + ); + }); + }), + http.get("*/attachments/:attachmentId", () => + HttpResponse.text(ATTACHMENT_SVG, { + headers: { + "Content-Type": "image/svg+xml", + }, + }), + ), + http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), +); + async function nextFrame(): Promise { await new Promise((resolve) => { window.requestAnimationFrame(() => resolve()); @@ -299,8 +301,24 @@ async function waitForLayout(): Promise { await nextFrame(); } +async function waitForElement( + query: () => T | null, + errorMessage: string, +): Promise { + const timeoutAt = performance.now() + 8_000; + while (performance.now() < timeoutAt) { + const element = query(); + if (element) return element; + await nextFrame(); + } + throw new Error(errorMessage); +} + async function waitForImagesToLoad(scope: ParentNode): Promise { const images = Array.from(scope.querySelectorAll("img")); + if (images.length === 0) { + return; + } await Promise.all( images.map( (image) => @@ -317,74 +335,130 @@ async function waitForImagesToLoad(scope: ParentNode): Promise { await waitForLayout(); } -async function renderAndMeasureUserRow({ - timelineWidthPx, - messages, - targetMessageId, -}: RenderMeasureOptions): Promise<{ +async function renderAndMeasureUserRow(options: { + timelineWidthPx: number; + targetMessageId: MessageId; + snapshot: OrchestrationReadModel; +}): Promise<{ measuredRowHeightPx: number; timelineWidthMeasuredPx: number; renderedInVirtualizedRegion: boolean; }> { + fixture = buildFixture(options.snapshot); + const host = document.createElement("div"); - host.style.width = `${timelineWidthPx}px`; + host.style.width = `${options.timelineWidthPx}px`; host.style.height = "920px"; host.style.display = "flex"; host.style.overflow = "hidden"; document.body.append(host); - mocks.storeState.threads = [createThread(messages)]; - const router = createTestRouter(THREAD_ID); + const router = getRouter( + createMemoryHistory({ + initialEntries: [`/${THREAD_ID}`], + }), + ); const root: Root = createRoot(host); root.render(); - await waitForLayout(); - const scrollContainer = host.querySelector("div.overflow-y-auto.overscroll-y-contain"); - if (!(scrollContainer instanceof HTMLDivElement)) { - root.unmount(); - throw new Error("Unable to find ChatView message scroll container."); - } - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); + try { + await waitForLayout(); - const row = host.querySelector( - `[data-message-id="${targetMessageId}"][data-message-role="user"]`, - ); - if (!(row instanceof HTMLElement)) { - root.unmount(); - throw new Error("Unable to locate targeted user message row."); - } - await waitForImagesToLoad(row); + const scrollContainer = await waitForElement( + () => host.querySelector("div.overflow-y-auto.overscroll-y-contain"), + "Unable to find ChatView message scroll container.", + ); - const timelineRoot = row.closest("div.max-w-3xl"); - if (!(timelineRoot instanceof HTMLElement)) { + let row: HTMLElement | null = null; + const timeoutAt = performance.now() + 8_000; + while (performance.now() < timeoutAt) { + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await waitForLayout(); + row = host.querySelector( + `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`, + ); + if (row) { + break; + } + } + if (!row) { + throw new Error("Unable to locate targeted user message row."); + } + + await waitForImagesToLoad(row); + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await nextFrame(); + + const timelineRoot = + row.closest('[data-timeline-root="true"]') ?? + host.querySelector('[data-timeline-root="true"]'); + if (!(timelineRoot instanceof HTMLElement)) { + throw new Error("Unable to locate timeline root container."); + } + + const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; + let measuredRowHeightPx = 0; + let renderedInVirtualizedRegion = false; + const rowSelector = `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`; + const measureTimeoutAt = performance.now() + 4_000; + while (performance.now() < measureTimeoutAt) { + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await nextFrame(); + const measuredRow = host.querySelector(rowSelector); + if (!measuredRow) { + continue; + } + measuredRowHeightPx = measuredRow.getBoundingClientRect().height; + renderedInVirtualizedRegion = measuredRow.closest("[data-index]") instanceof HTMLElement; + if (measuredRowHeightPx > 0) { + break; + } + } + if (measuredRowHeightPx <= 0) { + throw new Error("Unable to measure targeted user row height."); + } + + return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; + } finally { root.unmount(); - throw new Error("Unable to locate timeline root container."); + host.remove(); } +} - const measuredRowHeightPx = row.getBoundingClientRect().height; - const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; - const renderedInVirtualizedRegion = row.closest("[data-index]") instanceof HTMLElement; - - root.unmount(); - host.remove(); +describe("ChatView timeline estimator parity (full app)", () => { + beforeAll(async () => { + fixture = buildFixture( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-bootstrap" as MessageId, + targetText: "bootstrap", + }), + ); + await worker.start({ + onUnhandledRequest: "bypass", + quiet: true, + serviceWorker: { + url: "/mockServiceWorker.js", + }, + }); + }); - return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; -} + afterAll(async () => { + await worker.stop(); + }); -describe("ChatView timeline estimator parity", () => { beforeEach(() => { + localStorage.clear(); document.body.innerHTML = ""; - mocks.storeState.projects = []; - mocks.storeState.threads = []; - mocks.storeState.runtimeMode = "full-access"; - mocks.composerDraft.prompt = ""; - mocks.composerDraft.images = []; - mocks.composerDraft.nonPersistedImageIds = []; - mocks.composerDraft.model = null; - mocks.composerDraft.effort = null; + useStore.setState({ + projects: [], + threads: [], + threadsHydrated: false, + runtimeMode: "full-access", + }); }); afterEach(() => { @@ -398,7 +472,7 @@ describe("ChatView timeline estimator parity", () => { await renderAndMeasureUserRow({ timelineWidthPx: 960, targetMessageId, - messages: createConversationWithTargetUser({ + snapshot: createSnapshotForTargetUser({ targetMessageId, targetText: userText, }), @@ -417,19 +491,19 @@ describe("ChatView timeline estimator parity", () => { it("tracks additional rendered wrapping when ChatView width narrows", async () => { const userText = "x".repeat(2_400); const targetMessageId = "msg-user-target-wrap" as MessageId; - const messages = createConversationWithTargetUser({ + const snapshot = createSnapshotForTargetUser({ targetMessageId, targetText: userText, }); const desktop = await renderAndMeasureUserRow({ timelineWidthPx: 960, targetMessageId, - messages, + snapshot, }); const mobile = await renderAndMeasureUserRow({ timelineWidthPx: 360, targetMessageId, - messages, + snapshot, }); const estimatedDesktopPx = estimateTimelineMessageHeight( @@ -452,16 +526,15 @@ describe("ChatView timeline estimator parity", () => { it("keeps user attachment estimate close to actual rendered ChatView row height", async () => { const targetMessageId = "msg-user-target-attachments" as MessageId; - const attachments = createImageAttachments(3); const userText = "message with image attachments"; const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = await renderAndMeasureUserRow({ timelineWidthPx: 960, targetMessageId, - messages: createConversationWithTargetUser({ + snapshot: createSnapshotForTargetUser({ targetMessageId, targetText: userText, - targetAttachments: attachments, + targetAttachmentCount: 3, }), }); @@ -471,7 +544,7 @@ describe("ChatView timeline estimator parity", () => { { role: "user", text: userText, - attachments: attachments.map((attachment) => ({ id: attachment.id })), + attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], }, { timelineWidthPx: timelineWidthMeasuredPx }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e9f81c44534e..3101b9403bb7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3650,7 +3650,11 @@ const MessagesTimeline = memo(function MessagesTimeline({ } return ( -
+
{virtualizedRowCount > 0 && (
{virtualRows.map((virtualRow: VirtualItem) => { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index b735484b2b87..9dd95b37d877 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,40 +1,17 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; -import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router"; -import { StoreProvider } from "./store"; +import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; import "@xterm/xterm/css/xterm.css"; import "./index.css"; -import { APP_DISPLAY_NAME } from "./branding"; import { isElectron } from "./env"; -import { routeTree } from "./routeTree.gen"; +import { getRouter } from "./router"; const history = isElectron ? createHashHistory() : createBrowserHistory(); -const queryClient = new QueryClient(); -document.title = APP_DISPLAY_NAME; - -const router = createRouter({ - routeTree, - history, - context: { - queryClient, - }, - Wrap: ({ children }) => ( - - {children} - - ), -}); - -declare module "@tanstack/react-router" { - interface Register { - router: typeof router; - } -} +const router = getRouter(history); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts new file mode 100644 index 000000000000..0192ee0c6cf8 --- /dev/null +++ b/apps/web/src/router.ts @@ -0,0 +1,34 @@ +import { createElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createRouter } from "@tanstack/react-router"; + +import { routeTree } from "./routeTree.gen"; +import { StoreProvider } from "./store"; + +type RouterHistory = NonNullable[0]["history"]>; + +export function getRouter(history: RouterHistory) { + const queryClient = new QueryClient(); + + return createRouter({ + routeTree, + history, + context: { + queryClient, + }, + Wrap: ({ children }) => + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(StoreProvider, null, children), + ), + }); +} + +export type AppRouter = ReturnType; + +declare module "@tanstack/react-router" { + interface Register { + router: AppRouter; + } +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4616543d552b..89037c218bbd 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -26,6 +26,9 @@ export const Route = createRootRouteWithContext<{ }>()({ component: RootRouteView, errorComponent: RootRouteErrorView, + head: () => ({ + meta: [{ name: "title", content: APP_DISPLAY_NAME }], + }), }); function RootRouteView() { @@ -152,9 +155,7 @@ function EventRouter() { latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); syncServerReadModel(snapshot); const activeThreadIds = new Set( - snapshot.threads - .filter((t) => t.deletedAt === null) - .map((t) => t.id), + snapshot.threads.filter((t) => t.deletedAt === null).map((t) => t.id), ); removeOrphanedTerminalStates(activeThreadIds); if (pending) { @@ -195,11 +196,13 @@ function EventRouter() { if (hasRunningSubprocess === null) { return; } - useTerminalStateStore.getState().setTerminalActivity( - ThreadId.makeUnsafe(event.threadId), - event.terminalId, - hasRunningSubprocess, - ); + useTerminalStateStore + .getState() + .setTerminalActivity( + ThreadId.makeUnsafe(event.threadId), + event.terminalId, + hasRunningSubprocess, + ); }); const unsubWelcome = onServerWelcome((payload) => { void (async () => { @@ -276,7 +279,13 @@ function EventRouter() { unsubWelcome(); unsubServerConfigUpdated(); }; - }, [navigate, queryClient, removeOrphanedTerminalStates, setProjectExpanded, syncServerReadModel]); + }, [ + navigate, + queryClient, + removeOrphanedTerminalStates, + setProjectExpanded, + syncServerReadModel, + ]); return null; } diff --git a/bun.lock b/bun.lock index 5178098e6545..312b75e9132e 100644 --- a/bun.lock +++ b/bun.lock @@ -94,6 +94,7 @@ "@vitejs/plugin-react": "^5.1.4", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", + "msw": "^2.12.10", "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "catalog:", diff --git a/package.json b/package.json index f79062d7f40d..907b6fde1e46 100644 --- a/package.json +++ b/package.json @@ -56,5 +56,10 @@ "bun": "^1.3.9", "node": "^24.13.1" }, - "packageManager": "bun@1.3.9" -} + "packageManager": "bun@1.3.9", + "msw": { + "workerDirectory": [ + "apps/web/public" + ] + } +} \ No newline at end of file From 9caf86c9b71b14a7df5e34649342023b88c77450 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Mar 2026 21:59:21 -0800 Subject: [PATCH 06/11] add back title --- apps/web/src/main.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9dd95b37d877..e4fe3eda5880 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,11 +8,14 @@ import "./index.css"; import { isElectron } from "./env"; import { getRouter } from "./router"; +import { APP_DISPLAY_NAME } from "./branding"; const history = isElectron ? createHashHistory() : createBrowserHistory(); const router = getRouter(history); +document.title = APP_DISPLAY_NAME; + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( From 673e44b8060b33947d878c92145d12a5f0e77a27 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Mar 2026 22:04:56 -0800 Subject: [PATCH 07/11] Use vitest-browser-react in ChatView browser tests - replace manual `createRoot` mounting with `render`/`unmount` from vitest-browser-react - add `vitest-browser-react` to web devDependencies and lockfile --- apps/web/package.json | 3 ++- apps/web/src/components/ChatView.browser.tsx | 17 +++++++---------- bun.lock | 3 +++ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index b540fe35fa1b..aa934cf5758f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,6 +50,7 @@ "tailwindcss": "^4.0.0", "typescript": "catalog:", "vite": "^8.0.0-beta.12", - "vitest": "catalog:" + "vitest": "catalog:", + "vitest-browser-react": "^2.0.5" } } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 5af8c217b594..ba8e8ff7817b 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -15,8 +15,8 @@ import { import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; -import { createRoot, type Root } from "react-dom/client"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { render } from "vitest-browser-react"; import { getRouter } from "../router"; import { useStore } from "../store"; @@ -92,11 +92,7 @@ function createUserMessage(options: { }; } -function createAssistantMessage(options: { - id: MessageId; - text: string; - offsetSeconds: number; -}) { +function createAssistantMessage(options: { id: MessageId; text: string; offsetSeconds: number }) { return { id: options.id, role: "assistant" as const, @@ -359,8 +355,9 @@ async function renderAndMeasureUserRow(options: { }), ); - const root: Root = createRoot(host); - root.render(); + const screen = await render(, { + container: host, + }); try { await waitForLayout(); @@ -372,7 +369,7 @@ async function renderAndMeasureUserRow(options: { let row: HTMLElement | null = null; const timeoutAt = performance.now() + 8_000; - while (performance.now() < timeoutAt) { + while (performance.now() < timeoutAt) { scrollContainer.scrollTop = 0; scrollContainer.dispatchEvent(new Event("scroll")); await waitForLayout(); @@ -424,7 +421,7 @@ async function renderAndMeasureUserRow(options: { return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; } finally { - root.unmount(); + await screen.unmount(); host.remove(); } } diff --git a/bun.lock b/bun.lock index 312b75e9132e..a5ed3cf6903c 100644 --- a/bun.lock +++ b/bun.lock @@ -100,6 +100,7 @@ "typescript": "catalog:", "vite": "^8.0.0-beta.12", "vitest": "catalog:", + "vitest-browser-react": "^2.0.5", }, }, "packages/contracts": { @@ -1506,6 +1507,8 @@ "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + "vitest-browser-react": ["vitest-browser-react@2.0.5", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YODQX8mHTJCyKNVYTWJrLEYrUtw+QfLl78owgvuE7C5ydgmGBq6v5s4jK2w6wdPhIZsN9PpV1rQbmAevWJjO9g=="], + "wait-on": ["wait-on@8.0.5", "", { "dependencies": { "axios": "^1.12.1", "joi": "^18.0.1", "lodash": "^4.17.21", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], From 8b55bf2aa3459e53304d343eff0798d579aa4827 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Mar 2026 22:31:44 -0800 Subject: [PATCH 08/11] Use vi.waitFor in ChatView browser measurement helpers - Replace manual timeout polling loops with `vi.waitFor` in test helpers - Improve reliability of locating and measuring virtualized user rows in attachment-height tests --- apps/web/src/components/ChatView.browser.tsx | 90 +++++++++++--------- 1 file changed, 48 insertions(+), 42 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index ba8e8ff7817b..ac11328596d8 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -15,7 +15,7 @@ import { import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { getRouter } from "../router"; @@ -301,13 +301,21 @@ async function waitForElement( query: () => T | null, errorMessage: string, ): Promise { - const timeoutAt = performance.now() + 8_000; - while (performance.now() < timeoutAt) { - const element = query(); - if (element) return element; - await nextFrame(); + let element: T | null = null; + await vi.waitFor( + () => { + element = query(); + expect(element, errorMessage).toBeTruthy(); + }, + { + timeout: 8_000, + interval: 16, + }, + ); + if (!element) { + throw new Error(errorMessage); } - throw new Error(errorMessage); + return element; } async function waitForImagesToLoad(scope: ParentNode): Promise { @@ -368,29 +376,29 @@ async function renderAndMeasureUserRow(options: { ); let row: HTMLElement | null = null; - const timeoutAt = performance.now() + 8_000; - while (performance.now() < timeoutAt) { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - row = host.querySelector( - `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`, - ); - if (row) { - break; - } - } - if (!row) { - throw new Error("Unable to locate targeted user message row."); - } + await vi.waitFor( + async () => { + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await waitForLayout(); + row = host.querySelector( + `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`, + ); + expect(row, "Unable to locate targeted user message row.").toBeTruthy(); + }, + { + timeout: 8_000, + interval: 16, + }, + ); - await waitForImagesToLoad(row); + await waitForImagesToLoad(row!); scrollContainer.scrollTop = 0; scrollContainer.dispatchEvent(new Event("scroll")); await nextFrame(); const timelineRoot = - row.closest('[data-timeline-root="true"]') ?? + row!.closest('[data-timeline-root="true"]') ?? host.querySelector('[data-timeline-root="true"]'); if (!(timelineRoot instanceof HTMLElement)) { throw new Error("Unable to locate timeline root container."); @@ -400,24 +408,22 @@ async function renderAndMeasureUserRow(options: { let measuredRowHeightPx = 0; let renderedInVirtualizedRegion = false; const rowSelector = `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`; - const measureTimeoutAt = performance.now() + 4_000; - while (performance.now() < measureTimeoutAt) { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await nextFrame(); - const measuredRow = host.querySelector(rowSelector); - if (!measuredRow) { - continue; - } - measuredRowHeightPx = measuredRow.getBoundingClientRect().height; - renderedInVirtualizedRegion = measuredRow.closest("[data-index]") instanceof HTMLElement; - if (measuredRowHeightPx > 0) { - break; - } - } - if (measuredRowHeightPx <= 0) { - throw new Error("Unable to measure targeted user row height."); - } + await vi.waitFor( + async () => { + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await nextFrame(); + const measuredRow = host.querySelector(rowSelector); + expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); + measuredRowHeightPx = measuredRow!.getBoundingClientRect().height; + renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement; + expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0); + }, + { + timeout: 4_000, + interval: 16, + }, + ); return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; } finally { From 8308649462db35324f76ac6d233a589b5b4efd7c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Mar 2026 00:13:45 -0800 Subject: [PATCH 09/11] Fix chat attachment height on narrow timeline resizes - recompute timeline width from the root element on ResizeObserver updates - rerun width effect when message/working state changes - reduce minimum user chars per line for narrow layouts and add regression test --- apps/web/src/components/ChatView.tsx | 8 +++----- apps/web/src/components/timelineHeight.test.ts | 10 ++++++++++ apps/web/src/components/timelineHeight.ts | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3101b9403bb7..ff8900d1d0d2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3206,16 +3206,14 @@ const MessagesTimeline = memo(function MessagesTimeline({ updateWidth(timelineRoot.getBoundingClientRect().width); if (typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver((entries) => { - const [entry] = entries; - if (!entry) return; - updateWidth(entry.contentRect.width); + const observer = new ResizeObserver(() => { + updateWidth(timelineRoot.getBoundingClientRect().width); }); observer.observe(timelineRoot); return () => { observer.disconnect(); }; - }, []); + }, [hasMessages, isWorking]); const rows = useMemo(() => { const nextRows: TimelineRow[] = []; diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index df9d00aac5db..613b20957162 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -76,6 +76,16 @@ describe("estimateTimelineMessageHeight", () => { expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(118); }); + it("does not clamp user wrapping too aggressively on very narrow layouts", () => { + const message = { + role: "user" as const, + text: "a".repeat(20), + }; + + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(184); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(118); + }); + it("uses narrower width to increase assistant line wrapping", () => { const message = { role: "assistant" as const, diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 5bf9e1103c8a..993736789a57 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -11,7 +11,7 @@ const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; -const MIN_USER_CHARS_PER_LINE = 16; +const MIN_USER_CHARS_PER_LINE = 4; const MIN_ASSISTANT_CHARS_PER_LINE = 20; interface TimelineMessageHeightInput { From a16486c23c4ceeb1ec100e088981be4bfae8d2df Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Mar 2026 01:44:20 -0800 Subject: [PATCH 10/11] Expand ChatView height parity tests across responsive viewports - Add viewport matrix coverage for long text and attachment row-height parity - Reuse a mount/measure test harness to validate resize behavior in one session - Ensure production CSS and viewport sizing are applied before measurements --- apps/web/src/components/ChatView.browser.tsx | 378 +++++++++++++------ 1 file changed, 267 insertions(+), 111 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index ac11328596d8..d3f3ede96632 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1,3 +1,4 @@ +// Production CSS is part of the behavior under test because row height depends on it. import "../index.css"; import { @@ -15,6 +16,7 @@ import { import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; +import { page } from "vitest/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -45,6 +47,45 @@ interface TestFixture { let fixture: TestFixture; const wsLink = ws.link(/ws(s)?:\/\/.*/); +interface ViewportSpec { + name: string; + width: number; + height: number; + textTolerancePx: number; + attachmentTolerancePx: number; +} + +const DEFAULT_VIEWPORT: ViewportSpec = { + name: "desktop", + width: 960, + height: 1_100, + textTolerancePx: 44, + attachmentTolerancePx: 56, +}; +const TEXT_VIEWPORT_MATRIX = [ + DEFAULT_VIEWPORT, + { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, + { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, + { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, +] as const satisfies readonly ViewportSpec[]; +const ATTACHMENT_VIEWPORT_MATRIX = [ + DEFAULT_VIEWPORT, + { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, + { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, +] as const satisfies readonly ViewportSpec[]; + +interface UserRowMeasurement { + measuredRowHeightPx: number; + timelineWidthMeasuredPx: number; + renderedInVirtualizedRegion: boolean; +} + +interface MountedChatView { + cleanup: () => Promise; + measureUserRow: (targetMessageId: MessageId) => Promise; + setViewport: (viewport: ViewportSpec) => Promise; +} + function isoAt(offsetSeconds: number): string { return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); } @@ -297,6 +338,26 @@ async function waitForLayout(): Promise { await nextFrame(); } +async function setViewport(viewport: ViewportSpec): Promise { + await page.viewport(viewport.width, viewport.height); + await waitForLayout(); +} + +async function waitForProductionStyles(): Promise { + await vi.waitFor( + () => { + expect(getComputedStyle(document.documentElement).getPropertyValue("--background").trim()).not.toBe( + "", + ); + expect(getComputedStyle(document.body).marginTop).toBe("0px"); + }, + { + timeout: 4_000, + interval: 16, + }, + ); +} + async function waitForElement( query: () => T | null, errorMessage: string, @@ -339,21 +400,84 @@ async function waitForImagesToLoad(scope: ParentNode): Promise { await waitForLayout(); } -async function renderAndMeasureUserRow(options: { - timelineWidthPx: number; +async function measureUserRow(options: { + host: HTMLElement; targetMessageId: MessageId; +}): Promise { + const { host, targetMessageId } = options; + const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="user"]`; + + const scrollContainer = await waitForElement( + () => host.querySelector("div.overflow-y-auto.overscroll-y-contain"), + "Unable to find ChatView message scroll container.", + ); + + let row: HTMLElement | null = null; + await vi.waitFor( + async () => { + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await waitForLayout(); + row = host.querySelector(rowSelector); + expect(row, "Unable to locate targeted user message row.").toBeTruthy(); + }, + { + timeout: 8_000, + interval: 16, + }, + ); + + await waitForImagesToLoad(row!); + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await nextFrame(); + + const timelineRoot = + row!.closest('[data-timeline-root="true"]') ?? + host.querySelector('[data-timeline-root="true"]'); + if (!(timelineRoot instanceof HTMLElement)) { + throw new Error("Unable to locate timeline root container."); + } + + let timelineWidthMeasuredPx = 0; + let measuredRowHeightPx = 0; + let renderedInVirtualizedRegion = false; + await vi.waitFor( + async () => { + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await nextFrame(); + const measuredRow = host.querySelector(rowSelector); + expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); + timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; + measuredRowHeightPx = measuredRow!.getBoundingClientRect().height; + renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement; + expect(timelineWidthMeasuredPx, "Unable to measure timeline width.").toBeGreaterThan(0); + expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0); + }, + { + timeout: 4_000, + interval: 16, + }, + ); + + return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; +} + +async function mountChatView(options: { + viewport: ViewportSpec; snapshot: OrchestrationReadModel; -}): Promise<{ - measuredRowHeightPx: number; - timelineWidthMeasuredPx: number; - renderedInVirtualizedRegion: boolean; -}> { +}): Promise { fixture = buildFixture(options.snapshot); + await setViewport(options.viewport); + await waitForProductionStyles(); const host = document.createElement("div"); - host.style.width = `${options.timelineWidthPx}px`; - host.style.height = "920px"; - host.style.display = "flex"; + host.style.position = "fixed"; + host.style.inset = "0"; + host.style.width = "100vw"; + host.style.height = "100vh"; + host.style.display = "grid"; host.style.overflow = "hidden"; document.body.append(host); @@ -367,68 +491,35 @@ async function renderAndMeasureUserRow(options: { container: host, }); - try { - await waitForLayout(); - - const scrollContainer = await waitForElement( - () => host.querySelector("div.overflow-y-auto.overscroll-y-contain"), - "Unable to find ChatView message scroll container.", - ); - - let row: HTMLElement | null = null; - await vi.waitFor( - async () => { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await waitForLayout(); - row = host.querySelector( - `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`, - ); - expect(row, "Unable to locate targeted user message row.").toBeTruthy(); - }, - { - timeout: 8_000, - interval: 16, - }, - ); - - await waitForImagesToLoad(row!); - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await nextFrame(); + await waitForLayout(); - const timelineRoot = - row!.closest('[data-timeline-root="true"]') ?? - host.querySelector('[data-timeline-root="true"]'); - if (!(timelineRoot instanceof HTMLElement)) { - throw new Error("Unable to locate timeline root container."); - } + return { + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }), + setViewport: async (viewport: ViewportSpec) => { + await setViewport(viewport); + await waitForProductionStyles(); + }, + }; +} - const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; - let measuredRowHeightPx = 0; - let renderedInVirtualizedRegion = false; - const rowSelector = `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`; - await vi.waitFor( - async () => { - scrollContainer.scrollTop = 0; - scrollContainer.dispatchEvent(new Event("scroll")); - await nextFrame(); - const measuredRow = host.querySelector(rowSelector); - expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); - measuredRowHeightPx = measuredRow!.getBoundingClientRect().height; - renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement; - expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0); - }, - { - timeout: 4_000, - interval: 16, - }, - ); +async function measureUserRowAtViewport(options: { + snapshot: OrchestrationReadModel; + targetMessageId: MessageId; + viewport: ViewportSpec; +}): Promise { + const mounted = await mountChatView({ + viewport: options.viewport, + snapshot: options.snapshot, + }); - return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; + try { + return await mounted.measureUserRow(options.targetMessageId); } finally { - await screen.unmount(); - host.remove(); + await mounted.cleanup(); } } @@ -453,7 +544,8 @@ describe("ChatView timeline estimator parity (full app)", () => { await worker.stop(); }); - beforeEach(() => { + beforeEach(async () => { + await setViewport(DEFAULT_VIEWPORT); localStorage.clear(); document.body.innerHTML = ""; useStore.setState({ @@ -468,57 +560,111 @@ describe("ChatView timeline estimator parity (full app)", () => { document.body.innerHTML = ""; }); - it("keeps long user message estimate close to actual rendered virtualized ChatView row height", async () => { - const userText = "x".repeat(3_200); - const targetMessageId = "msg-user-target-long" as MessageId; - const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = - await renderAndMeasureUserRow({ - timelineWidthPx: 960, - targetMessageId, + it.each(TEXT_VIEWPORT_MATRIX)( + "keeps long user message estimate close at the $name viewport", + async (viewport) => { + const userText = "x".repeat(3_200); + const targetMessageId = `msg-user-target-long-${viewport.name}` as MessageId; + const mounted = await mountChatView({ + viewport, snapshot: createSnapshotForTargetUser({ targetMessageId, targetText: userText, }), }); - expect(renderedInVirtualizedRegion).toBe(true); + try { + const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = + await mounted.measureUserRow(targetMessageId); - const estimatedHeightPx = estimateTimelineMessageHeight( - { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: timelineWidthMeasuredPx }, - ); + expect(renderedInVirtualizedRegion).toBe(true); + + const estimatedHeightPx = estimateTimelineMessageHeight( + { role: "user", text: userText, attachments: [] }, + { timelineWidthPx: timelineWidthMeasuredPx }, + ); + + expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( + viewport.textTolerancePx, + ); + } finally { + await mounted.cleanup(); + } + }, + ); + + it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => { + const userText = "x".repeat(3_200); + const targetMessageId = "msg-user-target-resize" as MessageId; + const mounted = await mountChatView({ + viewport: TEXT_VIEWPORT_MATRIX[0], + snapshot: createSnapshotForTargetUser({ + targetMessageId, + targetText: userText, + }), + }); - expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual(44); + try { + const measurements: Array = []; + + for (const viewport of TEXT_VIEWPORT_MATRIX) { + await mounted.setViewport(viewport); + const measurement = await mounted.measureUserRow(targetMessageId); + const estimatedHeightPx = estimateTimelineMessageHeight( + { role: "user", text: userText, attachments: [] }, + { timelineWidthPx: measurement.timelineWidthMeasuredPx }, + ); + + expect(measurement.renderedInVirtualizedRegion).toBe(true); + expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( + viewport.textTolerancePx, + ); + measurements.push({ ...measurement, viewport, estimatedHeightPx }); + } + + expect(new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx))).size).toBeGreaterThanOrEqual(3); + + const byMeasuredWidth = measurements.toSorted( + (left, right) => left.timelineWidthMeasuredPx - right.timelineWidthMeasuredPx, + ); + const narrowest = byMeasuredWidth[0]!; + const widest = byMeasuredWidth.at(-1)!; + expect(narrowest.timelineWidthMeasuredPx).toBeLessThan(widest.timelineWidthMeasuredPx); + expect(narrowest.measuredRowHeightPx).toBeGreaterThan(widest.measuredRowHeightPx); + expect(narrowest.estimatedHeightPx).toBeGreaterThan(widest.estimatedHeightPx); + } finally { + await mounted.cleanup(); + } }); - it("tracks additional rendered wrapping when ChatView width narrows", async () => { + it("tracks additional rendered wrapping when ChatView width narrows between desktop and mobile viewports", async () => { const userText = "x".repeat(2_400); const targetMessageId = "msg-user-target-wrap" as MessageId; const snapshot = createSnapshotForTargetUser({ targetMessageId, targetText: userText, }); - const desktop = await renderAndMeasureUserRow({ - timelineWidthPx: 960, - targetMessageId, + const desktopMeasurement = await measureUserRowAtViewport({ + viewport: TEXT_VIEWPORT_MATRIX[0], snapshot, - }); - const mobile = await renderAndMeasureUserRow({ - timelineWidthPx: 360, targetMessageId, + }); + const mobileMeasurement = await measureUserRowAtViewport({ + viewport: TEXT_VIEWPORT_MATRIX[2], snapshot, + targetMessageId, }); const estimatedDesktopPx = estimateTimelineMessageHeight( { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: desktop.timelineWidthMeasuredPx }, + { timelineWidthPx: desktopMeasurement.timelineWidthMeasuredPx }, ); const estimatedMobilePx = estimateTimelineMessageHeight( { role: "user", text: userText, attachments: [] }, - { timelineWidthPx: mobile.timelineWidthMeasuredPx }, + { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx }, ); - const measuredDeltaPx = mobile.measuredRowHeightPx - desktop.measuredRowHeightPx; + const measuredDeltaPx = mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx; const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx; expect(measuredDeltaPx).toBeGreaterThan(0); expect(estimatedDeltaPx).toBeGreaterThan(0); @@ -527,13 +673,13 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(ratio).toBeLessThan(1.35); }); - it("keeps user attachment estimate close to actual rendered ChatView row height", async () => { - const targetMessageId = "msg-user-target-attachments" as MessageId; - const userText = "message with image attachments"; - const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = - await renderAndMeasureUserRow({ - timelineWidthPx: 960, - targetMessageId, + it.each(ATTACHMENT_VIEWPORT_MATRIX)( + "keeps user attachment estimate close at the $name viewport", + async (viewport) => { + const targetMessageId = `msg-user-target-attachments-${viewport.name}` as MessageId; + const userText = "message with image attachments"; + const mounted = await mountChatView({ + viewport, snapshot: createSnapshotForTargetUser({ targetMessageId, targetText: userText, @@ -541,17 +687,27 @@ describe("ChatView timeline estimator parity (full app)", () => { }), }); - expect(renderedInVirtualizedRegion).toBe(true); - - const estimatedHeightPx = estimateTimelineMessageHeight( - { - role: "user", - text: userText, - attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], - }, - { timelineWidthPx: timelineWidthMeasuredPx }, - ); + try { + const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = + await mounted.measureUserRow(targetMessageId); + + expect(renderedInVirtualizedRegion).toBe(true); + + const estimatedHeightPx = estimateTimelineMessageHeight( + { + role: "user", + text: userText, + attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], + }, + { timelineWidthPx: timelineWidthMeasuredPx }, + ); - expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual(56); - }); + expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( + viewport.attachmentTolerancePx, + ); + } finally { + await mounted.cleanup(); + } + }, + ); }); From 93412ac9d75b7d61f53d7fc0cc4a9050a50473bc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Mar 2026 10:06:47 -0800 Subject: [PATCH 11/11] Handle system timeline messages with assistant height rules - Route `system` messages through assistant height estimation - Keep user attachment height logic scoped to user messages - Add regression test for system-message sizing behavior --- .../web/src/components/timelineHeight.test.ts | 9 +++++++++ apps/web/src/components/timelineHeight.ts | 20 +++++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 613b20957162..73a21cd08d23 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -12,6 +12,15 @@ describe("estimateTimelineMessageHeight", () => { ).toBe(122); }); + it("uses assistant sizing rules for system messages", () => { + expect( + estimateTimelineMessageHeight({ + role: "system", + text: "a".repeat(144), + }), + ).toBe(122); + }); + it("adds one attachment row for one or two user attachments", () => { expect( estimateTimelineMessageHeight({ diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 993736789a57..78a5f6539b39 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -67,16 +67,24 @@ export function estimateTimelineMessageHeight( message: TimelineMessageHeightInput, layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, ): number { - if (message.role !== "user") { + if (message.role === "assistant") { const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; } - const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); + if (message.role === "user") { + const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); + const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + const attachmentCount = message.attachments?.length ?? 0; + const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); + const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; + return USER_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX + attachmentHeight; + } + + // `system` messages are not rendered in the chat timeline, but keep a stable + // explicit branch in case they are present in timeline data. + const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); - const attachmentCount = message.attachments?.length ?? 0; - const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); - const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; - return USER_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX + attachmentHeight; + return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; }