From 1eacdd6ff1dcce310ca1dfbd0b095d0dfe637699 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Mon, 26 Oct 2026 10:27:06 +0800 Subject: [PATCH] fix(web): contain image preview labels to prevent chat layout shifts (#2116) * fix(web): contain image preview labels to prevent chat layout shifts * test(web): run history layout regression in CI --- .github/workflows/test.yml | 2 +- e2e/history-navigation-layout.spec.ts | 83 +++++++++++++++++++++ web/e2e-fixtures/history-load-fixture.tsx | 90 +++++++++++++++-------- web/src/components/ImagePreview.tsx | 5 +- 4 files changed, 149 insertions(+), 31 deletions(-) create mode 100644 e2e/history-navigation-layout.spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 820d93a0..f292e5f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: # Required by cli/src/utils/windowsProcessTree.pwsh.test.ts — image # inspect alone does not pull; silent skip was fake B1 coverage (#1911). - run: docker pull mcr.microsoft.com/powershell:latest - - run: bun run test:e2e -- terminal-wrap-fidelity.spec.ts composer-copy.spec.ts session-list-scroll.spec.ts cold-initial-tail.spec.ts scrollbar-auto-hide.spec.ts attachment-removal.spec.ts + - run: bun run test:e2e -- terminal-wrap-fidelity.spec.ts composer-copy.spec.ts session-list-scroll.spec.ts cold-initial-tail.spec.ts scrollbar-auto-hide.spec.ts attachment-removal.spec.ts history-navigation-layout.spec.ts - run: bun run test # Serial runner-integration suite: starts real detached runner/session diff --git a/e2e/history-navigation-layout.spec.ts b/e2e/history-navigation-layout.spec.ts new file mode 100644 index 00000000..c54d552a --- /dev/null +++ b/e2e/history-navigation-layout.spec.ts @@ -0,0 +1,83 @@ +import { expect, test, type Page } from '@playwright/test' + +test.setTimeout(90000) + +async function geometry(page: Page) { + return page.evaluate(() => { + const root = document.getElementById('root')! + const chat = document.querySelector('.chat-scroll-y')! + const footer = document.querySelector('[data-testid="navigation-composer"]')! + const header = document.querySelector('[data-testid="navigation-header"]')! + return { + rootTop: root.scrollTop, + rootHeight: root.clientHeight, + rootScrollHeight: root.scrollHeight, + headerTop: header.getBoundingClientRect().top, + chatTop: chat.getBoundingClientRect().top, + chatScrollTop: chat.scrollTop, + composerBottom: footer.getBoundingClientRect().bottom, + viewportHeight: innerHeight, + } + }) +} + +for (const device of [ + { name: 'desktop', width: 1280, height: 800, isMobile: false, hasTouch: false }, + { name: 'mobile', width: 390, height: 780, isMobile: true, hasTouch: true }, +]) { + test.describe(device.name, () => { + test.use({ + viewport: { width: device.width, height: device.height }, + hasTouch: device.hasTouch, + // Firefox supports the narrow layout, but not mobile emulation. + isMobile: async ({ browserName }, use) => { + await use(device.isMobile && browserName !== 'firefox') + }, + }) + test('history selection keeps header and composer pinned to the app', async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', error => errors.push(error.message)) + await page.goto('/e2e-fixtures/history-load-fixture.html?navigationShell=1&navigationMedia=1') + await expect(page.getByText('Fixture message 1200', { exact: true })).toBeVisible() + await page.waitForTimeout(2000) + const before = await geometry(page) + expect(before.rootTop).toBe(0) + expect(before.rootScrollHeight).toBeLessThanOrEqual(before.rootHeight + 1) + await page.getByRole('button', { name: 'Open history', exact: true }).click() + await page.getByRole('searchbox', { name: 'Search outline items' }).fill('Fixture message 1010') + await page.getByRole('button', { name: /Fixture message 1010/ }).click() + await expect(page.getByRole('searchbox', { name: 'Search outline items' })).toHaveCount(0) + await page.waitForTimeout(1500) + const after = await geometry(page) + console.log(JSON.stringify({ device: device.name, before, after })) + expect(after.rootTop).toBe(0) + expect(after.rootScrollHeight).toBeLessThanOrEqual(after.rootHeight + 1) + expect(after.headerTop).toBeCloseTo(before.headerTop, 0) + expect(after.composerBottom).toBeCloseTo(after.viewportHeight, 0) + await expect(page.locator('[id="hapi-message-user-text:m-1010"]')).toBeInViewport() + // History pagination adds more image previews; no outer overflow. + await page.evaluate(() => window.__probe.loadMore()) + await expect(page.locator('[id="hapi-message-user-text:m-801"]')).toBeAttached() + await expect.poll(async () => (await geometry(page)).rootScrollHeight).toBeLessThanOrEqual(after.rootHeight + 1) + await page.getByRole('button', { name: 'Open history', exact: true }).click() + await page.getByRole('searchbox', { name: 'Search outline items' }).fill('Fixture message 810') + await page.getByRole('button', { name: /Fixture message 810/ }).click() + await expect(page.locator('[id="hapi-message-user-text:m-810"]')).toBeInViewport() + expect((await geometry(page)).rootTop).toBe(0) + // Exercise tail synchronization after older-history assertions so + // its latest-page refresh cannot replace their message window. + await page.getByRole('button', { name: 'Latest messages', exact: true }).click() + await expect(page.getByText('Fixture message 1200', { exact: true })).toBeInViewport() + expect((await geometry(page)).rootTop).toBe(0) + // Existing fullscreen preview must retain viewport-relative sizing. + const image = page.locator('[data-image-preview-trigger]').first() + await image.evaluate(element => element.scrollIntoView({ block: 'center' })) + await image.click() + await expect(page.getByRole('dialog', { name: /image-.*\.svg/ })).toBeVisible() + const dialogBox = await page.getByRole('dialog').boundingBox() + expect(dialogBox?.height).toBe(after.viewportHeight) + await page.getByRole('button', { name: 'Close', exact: true }).click() + expect(errors).toEqual([]) + }) + }) +} diff --git a/web/e2e-fixtures/history-load-fixture.tsx b/web/e2e-fixtures/history-load-fixture.tsx index fe229d1b..0b6b17fc 100644 --- a/web/e2e-fixtures/history-load-fixture.tsx +++ b/web/e2e-fixtures/history-load-fixture.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react' +import { useMemo, useRef, useState } from 'react' import ReactDOM from 'react-dom/client' import { AssistantRuntimeProvider } from '@assistant-ui/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' @@ -16,6 +16,7 @@ import { useHappyRuntime } from '../src/lib/assistant-runtime' import { HappyThread } from '../src/components/AssistantChat/HappyThread' import type { ChatBlock } from '../src/chat/types' import { getMessageWindowState } from '../src/lib/message-window-store' +import { buildConversationOutline } from '../src/chat/outline' // Drives the real message-window store + chat pipeline + HappyThread against a // fake paginated message API, so e2e tests can exercise older-history loading @@ -97,7 +98,11 @@ window.__probe = { // latest-tail refresh path can be tested independently from a cold window. // - ?holdLatest=1 — hold the latest response until `window.__probe.releaseLatest` // is called, making the cached first paint observable. +// - ?navigationShell=1 — add a pinned header/composer and outline controls. +// - ?navigationMedia=1 — include generated-image previews in older history. const fixtureParams = new URLSearchParams(window.location.search) +const navigationShell = fixtureParams.has('navigationShell') +const navigationMedia = fixtureParams.has('navigationMedia') const shortPages = fixtureParams.has('shortPages') const failBeforeCount = Number(fixtureParams.get('failBefore') ?? '0') const filteredOlder = fixtureParams.has('filteredOlder') @@ -120,7 +125,15 @@ const allMessages: DecryptedMessage[] = Array.from({ length: TOTAL_MESSAGES }, ( id: `m-${seq}`, seq, localId: null, - content: filtered + content: navigationMedia && seq % 50 === 0 && seq < TOTAL_MESSAGES + ? { + role: 'agent', + content: { + type: 'codex', + data: { type: 'generated-image', imageId: `img-${seq}`, fileName: `image-${seq}.svg`, mimeType: 'image/svg+xml' }, + }, + } + : filtered ? { role: 'agent', content: { type: 'output', data: { isMeta: true } } } : { role: 'user', content: { type: 'text', text: `Fixture message ${seq}` } }, createdAt: BASE_AT + seq, @@ -172,6 +185,7 @@ function pageFrom(messages: DecryptedMessage[], overrides: Partial ({ sessionSummaryInChat: false }), getMachines: async () => ({ machines: [] }), + getGeneratedImageBlob: async () => new Blob([''], { type: 'image/svg+xml' }), getMessages: async (_sessionId: string, query: { limit?: number beforeAt?: number | null @@ -270,6 +284,8 @@ const noopSend = () => {} const noopAbort = async () => {} function FixtureThread() { + const [outlineOpen, setOutlineOpen] = useState(false) + const [forceScrollToken, setForceScrollToken] = useState(0) const { messages, warning, @@ -322,33 +338,49 @@ function FixtureThread() { return ( -
- {}} - onViewModeChange={setViewMode} - isSyncingTail={isSyncingTail} - messagesWarning={warning} - hasMoreMessages={hasMore} - isLoadingMoreMessages={isLoadingMore} - onLoadMore={loadMore} - onCancelLoadMore={cancelLoadMore} - // This fixture drives HappyThread directly, bypassing the - // SessionChat block reduction that computes the real count. - unseenCount={0} - rawMessagesCount={messages.length} - normalizedMessagesCount={normalizedMessages.length} - messagesVersion={messagesVersion} - historyVersion={historyVersion} - forceScrollToken={0} - outlineOpen={false} - outlineItems={[]} - onOutlineOpenChange={() => {}} - /> +
+ {navigationShell ? ( + + ) : null} +
+ {navigationShell ? ( +
+ + +
+ ) : null} + {}} + onViewModeChange={setViewMode} + isSyncingTail={isSyncingTail} + messagesWarning={warning} + hasMoreMessages={hasMore} + isLoadingMoreMessages={isLoadingMore} + onLoadMore={loadMore} + onCancelLoadMore={cancelLoadMore} + // This fixture drives HappyThread directly, bypassing the + // SessionChat block reduction that computes the real count. + unseenCount={0} + rawMessagesCount={messages.length} + normalizedMessagesCount={normalizedMessages.length} + messagesVersion={messagesVersion} + historyVersion={historyVersion} + forceScrollToken={forceScrollToken} + outlineOpen={navigationShell && outlineOpen} + outlineItems={navigationShell ? buildConversationOutline(reconciled.blocks) : []} + onOutlineOpenChange={setOutlineOpen} + /> + {navigationShell ? ( +