diff --git a/apps/web/tests/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts index bedab5ea9d..c25211dd13 100644 --- a/apps/web/tests/file-upload-round.e2e.ts +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -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() + 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', ]) }) }) diff --git a/packages/client/ui-attachment/src/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css index 2ed6fbe507..f551605a4a 100644 --- a/packages/client/ui-attachment/src/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -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); diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 3efa5737fa..be35aa650c 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -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 + const loadingThumbnail = variant === 'thumbnail' && src === null + if (error) return ( + + ) return ( <> {open && src !== null && } diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index e0fec746aa..ee14237ad5 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -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() + const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry') + const view = render() + 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') diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 432ba0653d..e99b96a962 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -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 => diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index 80ddf8fe7d..2e891ff981 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -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 = { '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', diff --git a/snapshots/web/file-upload-round/trajectory-status.expected.md b/snapshots/web/file-upload-round/trajectory-status.expected.md new file mode 100644 index 0000000000..585455d2e2 --- /dev/null +++ b/snapshots/web/file-upload-round/trajectory-status.expected.md @@ -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