mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user