fix(web): keep trajectory thumbnail status readable

Refs #4356
This commit is contained in:
sjw1231 committed 2026-09-17 15:44:43 +08:00
1 parent 641253e730
commit 70b4237c00
7 files changed
+135 -15

No files matched your search

+45 -2
View File
@@ -12,7 +12,7 @@ import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
@@ -24,6 +24,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/file-upload-r
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v3.jsonl', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url))
const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url))
const TRAJECTORY_STATUS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory-status.expected.md', import.meta.url))
const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url))
const DRAFT_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/draft.expected.md', import.meta.url))
const HISTORY_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/history.expected.md', import.meta.url))
@@ -385,11 +386,53 @@ describe('web e2e: generic file upload through the real assembly', () => {
expect(await inspectTrajectoryAttachments()).toBe(liveTrajectory)
})
it.skipIf(MODE === 'record')('keeps thumbnail loading and retry feedback inside the attachment row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-thumbnail-status'))
const releaseRead = Promise.withResolvers<undefined>()
const read = vi.spyOn(scaffold.ctx.attachments, 'readImage').mockImplementationOnce(async () => {
await releaseRead.promise
throw new Error('fixture image read failed')
})
try {
// Reload clears the browser image cache so the real image slot observes the delayed read.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await page.getByRole('row', { name: `USER, Images ×1 · Files ×1 · ${PROMPT}`, exact: true }).click()
await page.getByRole('tab', { name: 'Preview', exact: true }).click()
const panel = page.getByRole('tabpanel')
const thumbnail = panel.locator('[data-variant="thumbnail"]')
await thumbnail.waitFor()
await expect.poll(() => read.mock.calls.length).toBe(1)
const feedbackFits = () => thumbnail.evaluate(element => element.scrollHeight <= element.clientHeight
&& element.scrollWidth <= element.clientWidth)
expect(await feedbackFits()).toBe(true)
const loading = await captureStableAria(page, '[role="tabpanel"] [aria-label="Attachments"]', scaffold.workspaceCwd)
releaseRead.resolve(undefined)
const retry = panel.getByRole('button', { name: 'Image failed to load; click to retry', exact: true })
await retry.waitFor()
expect(await feedbackFits()).toBe(true)
const failed = await captureStableAria(page, '[role="tabpanel"] [aria-label="Attachments"]', scaffold.workspaceCwd)
await retry.focus()
await retry.press('Enter')
await panel.getByRole('img', { name: IMAGE_NAMES[0]!, exact: true }).waitFor()
expect(read).toHaveBeenCalledTimes(2)
await compareOrRefreshGolden(TRAJECTORY_STATUS_EXPECTED, [
'# Loading', loading, '# Failed', failed,
].join('\n\n'), MODE)
} finally {
releaseRead.resolve(undefined)
read.mockRestore()
}
})
it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md',
'session.v3.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md', 'trajectory-status.expected.md',
])
})
})
@@ -71,6 +71,8 @@
.frame[data-variant='thumbnail'],
.error[data-variant='thumbnail'] {
box-sizing: border-box;
display: grid;
place-items: center;
width: 48px;
height: 48px;
min-width: 48px;
@@ -84,6 +86,20 @@
object-fit: contain;
}
.spinner {
animation: spinner-rotate 0.9s linear infinite;
}
@keyframes spinner-rotate {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
}
}
.frame:focus-visible,
.error:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { IconLoadingOutline16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { ImageLightbox } from './ImageLightbox.tsx'
import type { ImageLightboxLabels } from './ImageLightbox.tsx'
import css from './MessageImage.module.css'
@@ -73,6 +74,7 @@ function dimensionsOf(image: MessageImageSpec): { readonly width: number; readon
* preview. A lone image renders at its `singleFit` size; an image among
* several renders as a fixed 64px square tile. The preview arm displays its
* local URL directly — no loader round-trip, no failure/retry surface.
* List thumbnails use status icons with localized tooltips and accessible names.
*
* @param props.image - the durable reference to load, or the local preview to display.
* @param props.load - session-authorized URL loader for the durable arm.
@@ -122,7 +124,21 @@ export function MessageImage({ image, load, variant, labels }: {
const src = preview?.url ?? loaded
const label = ('attachment' in image ? image.label : undefined)
?? preview?.name ?? attachment?.name ?? labels.image
if (error) return <button type="button" className={css.error} data-variant={variant} onClick={request}>{labels.loadFailed}</button>
const loadingThumbnail = variant === 'thumbnail' && src === null
if (error) return (
<button
type="button"
className={css.error}
data-variant={variant}
title={variant === 'thumbnail' ? labels.loadFailed : undefined}
aria-label={variant === 'thumbnail' ? labels.loadFailed : undefined}
onClick={request}
>
{variant === 'thumbnail'
? <span aria-hidden="true"><IconRefreshOutline16 /></span>
: labels.loadFailed}
</button>
)
return (
<>
<button
@@ -130,12 +146,17 @@ export function MessageImage({ image, load, variant, labels }: {
className={css.frame}
data-variant={variant}
style={fit === undefined ? undefined : { width: fit.width, height: fit.height }}
title={labels.open}
aria-label={labels.openNamed(label)}
title={loadingThumbnail ? labels.loading : labels.open}
aria-label={loadingThumbnail ? labels.loading : labels.openNamed(label)}
aria-busy={loadingThumbnail || undefined}
onClick={() => { if (src !== null) setOpen(true) }}
>
{src === null
? <span className={css.loading}>{labels.loading}</span>
? (
<span className={css.loading} aria-hidden={loadingThumbnail || undefined}>
{loadingThumbnail ? <IconLoadingOutline16 className={css.spinner} /> : labels.loading}
</span>
)
: <img src={src} alt={label} style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
@@ -2,7 +2,7 @@
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { EMPTY_CHAT_SNAPSHOT, type MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client'
import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -151,6 +151,33 @@ describe('MessageImage', () => {
expect(retry.getAttribute('data-variant')).toBe(variant)
})
it.each([
['Chinese', labels],
['English', { ...labels, loading: 'Loading image…', loadFailed: 'Image failed to load; click to retry' }],
] as const)('labels thumbnail status icons and retries a failed read in %s', async (_locale, statusLabels) => {
const pending = Promise.withResolvers<string>()
const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry')
const view = render(<MessageImage image={{ attachment }} load={load} variant="thumbnail" labels={statusLabels} />)
const loading = view.getByRole('button', { name: statusLabels.loading })
expect(loading.getAttribute('title')).toBe(statusLabels.loading)
expect(loading.getAttribute('aria-busy')).toBe('true')
expect(loading.textContent).toBe('')
expect(loading.querySelector('svg')).not.toBeNull()
await act(async () => { pending.reject(new Error('offline')) })
const retry = view.getByRole('button', { name: statusLabels.loadFailed })
expect(retry.getAttribute('title')).toBe(statusLabels.loadFailed)
expect(retry.textContent).toBe('')
expect(retry.querySelector('svg')).not.toBeNull()
fireEvent.click(retry)
await view.findByAltText(attachment.name)
const opener = view.getByRole('button', { name: statusLabels.openNamed(attachment.name) })
expect(opener.getAttribute('aria-busy')).toBeNull()
expect(opener.getAttribute('title')).toBe(statusLabels.open)
expect(load).toHaveBeenCalledTimes(2)
expect(load).toHaveBeenLastCalledWith(attachment)
})
it('uses the owner display name for an unnamed thumbnail and its original-image viewer', async () => {
const { name: _name, ...unnamed } = attachment
const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue('blob:thumbnail')
@@ -122,15 +122,13 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick<
| 'timeSeconds'
| 'startedAt'
> {
// An empty text block yields an empty preview; treat it as absent so an
// image-bearing record still labels its row instead of rendering blank.
const preview = previewContent(node.content)
const previewMarkdown = preview === '' ? undefined : preview
const images = imageBlockCount(node.content)
const files = fileBlockCount(node.content)
const attachmentSummary = [
images > 0
? t('layout.imageOnly', { count: images })
? t('layout.imageCount', { count: images })
: undefined,
files > 0 ? t('layout.fileAttachments', { count: files }) : undefined,
].filter((value): value is string => value !== undefined).join(' · ')
@@ -808,7 +806,7 @@ function summarizeAssistantActivity(
return t('layout.toolCallOnly')
}
const images = blocks.filter(block => block.kind === 'image').length
if (images > 0) return t('layout.imageOnly', { count: images })
if (images > 0) return t('layout.imageCount', { count: images })
return ''
}
@@ -1089,7 +1087,7 @@ function summarizeResult(
}
}
const images = imageBlockCount(node.content)
if (images > 0) return { result: t('layout.imageOnly', { count: images }) }
if (images > 0) return { result: t('layout.imageCount', { count: images }) }
return { result: t('record.noOutput') }
}
@@ -1116,7 +1114,7 @@ function detailResult(node: ToolResultNode, t: TrajectoryTranslate): string {
.join('\n')
if (text !== '') return text
const images = imageBlockCount(node.content)
if (images > 0) return t('layout.imageOnly', { count: images })
if (images > 0) return t('layout.imageCount', { count: images })
if (
node.content.length === 0
|| node.content.every(block =>
@@ -182,7 +182,7 @@ export const zh = {
'layout.toolCallOnly': '仅工具调用',
'attachment.list': '附件',
'attachment.imageName': '图片 {index}',
'layout.imageOnly': '图片 ×{count}',
'layout.imageCount': '图片 ×{count}',
'layout.fileAttachments': '文件 ×{count}',
'layout.initialSystemPrompt': '初始系统提示词',
'layout.systemPromptUpdated': '系统提示词已更新',
@@ -384,7 +384,7 @@ export const en: Record<TrajectoryKey, string> = {
'layout.toolCallOnly': 'Tool call only',
'attachment.list': 'Attachments',
'attachment.imageName': 'Image {index}',
'layout.imageOnly': 'Images ×{count}',
'layout.imageCount': 'Images ×{count}',
'layout.fileAttachments': 'Files ×{count}',
'layout.initialSystemPrompt': 'Initial System Prompt',
'layout.systemPromptUpdated': 'System Prompt Updated',
@@ -0,0 +1,15 @@
# Loading
- list "Attachments":
- listitem: poem.txt TXT · 16B
- listitem:
- button "Loading image…"
- text: reference-1.png image/png · 69B · 1 × 1
# Failed
- list "Attachments":
- listitem: poem.txt TXT · 16B
- listitem:
- button "Image failed to load; click to retry"
- text: reference-1.png image/png · 69B · 1 × 1