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
This commit is contained in:
Ananovo
2026-10-26 10:27:06 +08:00
committed by GitHub
parent c64051eba6
commit 1eacdd6ff1
4 changed files with 149 additions and 31 deletions
+1 -1
View File
@@ -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
+83
View File
@@ -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<HTMLElement>('.chat-scroll-y')!
const footer = document.querySelector<HTMLElement>('[data-testid="navigation-composer"]')!
const header = document.querySelector<HTMLElement>('[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([])
})
})
}
+61 -29
View File
@@ -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<MessagesRespo
const fakeApi = {
getHubSettings: async () => ({ sessionSummaryInChat: false }),
getMachines: async () => ({ machines: [] }),
getGeneratedImageBlob: async () => new Blob(['<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160"><rect width="240" height="160" fill="lightblue"/></svg>'], { 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 (
<AssistantRuntimeProvider runtime={runtime}>
<div className="flex h-screen min-h-0 flex-col">
<HappyThread
api={fakeApi}
session={fakeSession}
sessionId={SESSION_ID}
metadata={null}
disabled={false}
onRefresh={() => {}}
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={() => {}}
/>
<div className={navigationShell ? 'flex h-full min-h-0' : 'flex h-screen min-h-0 flex-col'}>
{navigationShell ? (
<aside className="hidden w-64 shrink-0 sm:block">Session sidebar</aside>
) : null}
<div className="flex h-full min-h-0 flex-1 flex-col">
{navigationShell ? (
<header data-testid="navigation-header" className="flex h-[76px] shrink-0 items-center p-3">
<button type="button" onClick={() => setOutlineOpen(true)}>Open history</button>
<button type="button" onClick={() => setForceScrollToken(value => value + 1)}>Latest messages</button>
</header>
) : null}
<HappyThread
api={fakeApi}
session={fakeSession}
sessionId={SESSION_ID}
metadata={null}
disabled={false}
onRefresh={() => {}}
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 ? (
<footer data-testid="navigation-composer" className={`h-[150px] shrink-0 p-3 ${outlineOpen ? 'max-sm:hidden' : ''}`}>
<textarea aria-label="Fixture composer" className="w-full resize-none" />
</footer>
) : null}
</div>
</div>
</AssistantRuntimeProvider>
)
+4 -1
View File
@@ -282,7 +282,10 @@ export function ImagePreview(props: {
data-image-preview-file-name={props.fileName}
data-image-preview-label={props.label}
data-image-preview-gallery={props.galleryId ?? ''}
className={props.buttonClassName ?? 'group flex min-h-[18rem] w-full items-center justify-center overflow-auto rounded-md border border-[var(--app-border)] bg-[var(--app-code-bg)] p-3 text-left'}
// Contain the absolute sr-only filename inside this trigger.
// Otherwise it can escape the chat viewport and make the app
// root scroll when history navigation calls scrollIntoView.
className={`relative ${props.buttonClassName ?? 'group flex min-h-[18rem] w-full items-center justify-center overflow-auto rounded-md border border-[var(--app-border)] bg-[var(--app-code-bg)] p-3 text-left'}`}
title="Click to zoom"
>
<img