Răsfoiți Sursa

fix(web): keep trajectory thumbnail status readable

Refs #4356
sjw1231 6 zile în urmă
părinte
comite
70b4237c00

+ 45 - 2
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<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',
     ])
   })
 })

+ 16 - 0
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);

+ 25 - 4
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 <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} />}

+ 28 - 1
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<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')

+ 4 - 6
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 =>

+ 2 - 2
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<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',

+ 15 - 0
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