Просмотр исходного кода

Merge pull request #4408 from deepseek-harness/fix/trajectory-attachments

fix(web): unify trajectory attachment presentation
Jiawei Song 1 неделя назад
Родитель
Сommit
04bab1cbd2
31 измененных файлов с 785 добавлено и 187 удалено
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml
  2. 55 0
      .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md
  3. 55 0
      .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml
  5. 4 0
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
  6. 4 0
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md
  7. 146 13
      apps/web/tests/file-upload-round.e2e.ts
  8. 2 2
      packages/client/ui-attachment/README.i18n.yaml
  9. 2 0
      packages/client/ui-attachment/README.md
  10. 2 0
      packages/client/ui-attachment/README.zh.md
  11. 38 0
      packages/client/ui-attachment/src/MessageImage.module.css
  12. 38 11
      packages/client/ui-attachment/src/MessageImage.tsx
  13. 2 1
      packages/client/ui-attachment/src/client/MessageImages.tsx
  14. 49 4
      packages/client/ui-attachment/tests/message-image.client.spec.tsx
  15. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  16. 2 0
      packages/client/ui-conversation/README.md
  17. 2 0
      packages/client/ui-conversation/README.zh.md
  18. 7 1
      packages/client/ui-conversation/src/client/contract/slots.ts
  19. 2 2
      packages/client/ui-trajectory/README.i18n.yaml
  20. 3 1
      packages/client/ui-trajectory/README.md
  21. 3 1
      packages/client/ui-trajectory/README.zh.md
  22. 79 2
      packages/client/ui-trajectory/src/client/TrajectoryTable.module.css
  23. 99 52
      packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
  24. 15 11
      packages/client/ui-trajectory/src/client/layout.ts
  25. 6 2
      packages/client/ui-trajectory/src/client/locales.ts
  26. 3 1
      packages/client/ui-trajectory/src/client/trajectory-record.ts
  27. 23 10
      packages/client/ui-trajectory/tests/layout.client.spec.tsx
  28. 56 42
      packages/client/ui-trajectory/tests/table.client.spec.tsx
  29. 25 25
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  30. 15 0
      snapshots/web/file-upload-round/trajectory-status.expected.md
  31. 38 2
      snapshots/web/file-upload-round/trajectory.expected.md

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md
+2026-09-15-trajectory-attachment-presentation.md: 03d737d67e626497bbcb957911b1578e0dadd5fb
+2026-09-15-trajectory-attachment-presentation.zh.md: 55c63bf3d97de2e3e102e92730ce4d2637547d6b

+ 55 - 0
.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md

@@ -0,0 +1,55 @@
+# Agent Note: Consistent Trajectory attachment presentation
+
+Status: implemented
+
+English | [中文](2026-09-15-trajectory-attachment-presentation.zh.md)
+
+## Problem
+
+A user message can contain text, images, and ordinary files. Trajectory omitted image counts beside text, left ordinary files out of Preview, and mixed file JSON with rendered images in Raw. Readers could not identify the complete attachment set consistently across the ledger and inspector (Issue #4356).
+
+## Decision
+
+Trajectory presents every attachment occurrence while keeping visual inspection separate from inspection of recorded fields.
+
+| Location | Behavior |
+|---|---|
+| Ledger | Separate nonzero image and ordinary-file counts accompany the text excerpt, including messages without text. Each record stays on one compact line. |
+| Summary and Preview | Message text precedes one ordered attachment list. Both tabs use the same renderer; Summary retains its compact spacing and scroll container. |
+| Attachment list | Equal-width compact rows share spacing and corners. A file-type icon or contained image thumbnail accompanies the recorded name and available metadata. Repeated references retain their occurrence order. |
+| Image interaction | The complete image fits inside a 48px square thumbnail without cropping. Activation opens the existing image viewer, with keyboard access. Loading and retry icons fit the thumbnail and expose localized tooltips and accessible names. |
+| Raw | Image and file blocks use initially collapsed metadata disclosures with complete recorded fields. Original content-block order and unrendered text remain intact; generated display labels do not enter raw fields. |
+
+Recorded filenames take precedence over localized numbered image labels. Metadata comes from recorded fields or the filename extension; zero-byte sizes remain visible. Visually truncated names retain their complete accessible text and tooltip. Messages without text still expose all attachments inside the inspector's scroll area.
+
+### Ownership
+
+[ui-trajectory](../../../../packages/client/ui-trajectory/README.md) owns counts, ordered attachment projection, and inspector composition. Its view data carries typed image and file references separately from the serialized content used by Raw, so display code does not recover file metadata by parsing JSON.
+
+[ui-attachment](../../../../packages/client/ui-attachment/README.md) owns image loading and the viewer through the existing image slot. [ui-conversation](../../../../packages/client/ui-conversation/README.md) owns the per-session image cache: Chat and Trajectory share one authorized read and browser URL per attachment. Thumbnail sizing and optional display labels affect presentation only; loading still receives the original reference. Chat and tool-result gallery sizing retain their existing behavior.
+
+This decision uses existing durable attachment references without changing upload, storage, model requests, or the Session format. Ordinary files expose metadata without a content viewer or download action. Product labels use typed locale dictionaries, and styles use the existing semantic tokens.
+
+The [generic file upload decision](../feature/2026-08-26-generic-file-upload.md) owns upload, storage, admission, and ordered message attachments; this record owns Trajectory presentation. The [unreadable attachment quarantine proposal](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) concerns model-request recovery independently of thumbnail loading feedback.
+
+## Alternatives considered
+
+**Keep large inline images as the default inspector presentation.** This makes one image immediately readable, but a tall image displaces attachment names and message text in the narrow inspector. Compact rows with explicit image activation provide a consistent overview of mixed attachments.
+
+**Use two-column attachment cards.** A grid can make thumbnails more prominent on wide panels, but it leaves less room for long filenames and requires a different narrow-panel arrangement. A single equal-width list accommodates both panel widths.
+
+**Keep file JSON and image previews together in Raw.** This assigns different meanings to the same tab for two attachment kinds. Metadata disclosures let readers inspect recorded fields while Preview owns visual image presentation.
+
+## Consequences
+
+The shared attachment renderer keeps names, order, metadata, and image actions consistent between Summary and Preview. Grouping attachments after message text makes the complete set easy to scan; Raw preserves interleaving when readers need the original block sequence. Repeated references remain separate rows even when image loading shares a cache entry.
+
+Compact thumbnails require opening the viewer to read detailed diagrams. Missing metadata stays absent, and image-read failures retain a retry control without hiding ordinary files. Changes to the shared image slot also affect Chat and tool results, so their gallery behavior and cache reuse remain regression requirements.
+
+## Verification
+
+[Projection tests](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx) cover mixed, attachment-only, empty-text, and repeated occurrences. [Inspector tests](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx) cover both locales, unnamed images, long filenames, zero-byte files, ordered lists, and complete Raw fields. [Image tests](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx) cover thumbnail labels, loading, retry, and the existing viewer.
+
+The recorded [mixed-upload browser scenario](../../../../apps/web/tests/file-upload-round.e2e.ts) compares Summary and Preview, verifies keyboard activation and focus restoration, checks collapsed and expanded Raw content, and repeats inspection after reload. Its [Trajectory expectation](../../../../snapshots/web/file-upload-round/trajectory.expected.md) accompanies the unchanged Session fixture. The [assembled image-cache test](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts) also checks reuse between Chat and Trajectory.
+
+Layout acceptance covers narrow and wide panels, both themes and locales, complete thumbnail containment, and reachable final attachments. The [GIF workflow](../../../skills/record-browser-gif/SKILL.md) requires review evidence from a clean committed tree and one real-server, real-model session showing mixed submission, ledger counts, Summary, Preview image activation, and expanded Raw metadata. Issue #4356 and its linked PR track executed checks and demonstration evidence.

+ 55 - 0
.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md

@@ -0,0 +1,55 @@
+# Agent Note: 统一 Trajectory 附件展示
+
+Status: implemented
+
+[English](2026-09-15-trajectory-attachment-presentation.md) | 中文
+
+## 问题
+
+用户消息可以包含文本、图片和普通文件。Trajectory 曾在文本旁省略图片数量,在预览中遗漏普通文件,并在原始内容中混排文件 JSON 与渲染后的图片。读者无法在列表和详情面板中一致地确认完整附件集合(Issue #4356)。
+
+## 决定
+
+Trajectory 展示每次附件出现的记录,并分别提供视觉预览与已记录字段检查。
+
+| 位置 | 行为 |
+|---|---|
+| 列表 | 文本摘要旁分别显示非零的图片数量与普通文件数量,无正文的消息也显示计数。每条记录保持为紧凑单行。 |
+| 概述与预览 | 消息正文之后展示一个有序附件列表。两个标签页使用相同的渲染器;概述保留紧凑间距和滚动容器。 |
+| 附件列表 | 等宽的紧凑行使用一致的间距和圆角。文件类型图标或完整缩放的图片缩略图旁展示已记录的名称与可用元数据。重复引用保留各次出现的顺序。 |
+| 图片交互 | 图片完整缩放到 48px 方形缩略图内,不裁剪。激活时打开现有图片查看器,保留键盘访问。加载与重试图标适配缩略图尺寸,并提供本地化的工具提示和可访问名称。 |
+| 原始内容 | 图片块和文件块使用默认收起的元数据折叠区,包含完整记录字段。原始内容块顺序与未渲染文本保持完整;生成的显示标签不进入原始字段。 |
+
+已记录文件名优先于本地化的编号图片标签。元数据来自记录字段或文件扩展名,零字节大小仍然可见。视觉上截断的名称保留完整的无障碍文本和工具提示。无正文的消息仍在详情面板滚动区域内展示全部附件。
+
+### 职责
+
+[ui-trajectory](../../../../packages/client/ui-trajectory/README.zh.md)负责计数、有序附件投影和详情面板组合。其视图数据将带类型的图片与文件引用和原始内容使用的序列化文本分别保存,展示代码因此无需解析 JSON 来恢复文件元数据。
+
+[ui-attachment](../../../../packages/client/ui-attachment/README.zh.md)通过现有图片 slot 负责图片加载与查看器。[ui-conversation](../../../../packages/client/ui-conversation/README.zh.md)拥有会话级图片缓存:Chat 与 Trajectory 对每个附件共享一次授权读取和一个浏览器 URL。缩略图尺寸和可选显示标签只影响展示,加载仍接收原始引用。Chat 和工具结果图库保留现有的尺寸行为。
+
+本决定使用现有持久附件引用,不改变上传、存储、模型请求或会话格式。普通文件展示元数据,不提供内容查看器或下载操作。产品标签使用带类型的本地化字典,样式使用现有语义 token。
+
+[通用文件上传决定](../feature/2026-08-26-generic-file-upload.zh.md)负责上传、存储、准入和有序消息附件;本记录负责 Trajectory 展示。[不可读附件隔离提案](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)处理模型请求恢复,与缩略图加载反馈相互独立。
+
+## 考虑过的替代方案
+
+**默认在详情面板中内联展示大图。** 这样可以立即阅读单张图片,但高图片会在窄面板中挤走附件名称和消息正文。紧凑行加显式图片激活入口能一致地概览混合附件。
+
+**使用双列附件卡片。** 网格能在宽面板上突出缩略图,但会压缩长文件名空间,并要求窄面板采用不同排布。单列等宽列表适用于两种面板宽度。
+
+**在原始内容中混排文件 JSON 和图片预览。** 这让同一个标签页对两种附件具有不同含义。元数据折叠区用于检查已记录字段,预览负责图片的视觉展示。
+
+## 影响
+
+共享附件渲染器使概述与预览中的名称、顺序、元数据和图片操作保持一致。将附件集中在消息正文之后便于浏览完整集合;需要原始块顺序时,原始内容保留交错关系。即使图片加载共享缓存项,重复引用仍各占一行。
+
+紧凑缩略图需要打开查看器才能阅读详细图表。缺失的元数据保持缺失,图片读取失败保留重试控件,并且不隐藏普通文件。共享图片 slot 的修改也会影响 Chat 和工具结果,因此其图库行为和缓存复用仍需回归验证。
+
+## 验证
+
+[投影测试](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx)覆盖混合附件、纯附件、空文本和重复引用。[详情面板测试](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx)覆盖两种语言、无名称图片、长文件名、零字节文件、有序列表和完整的原始字段。[图片测试](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx)覆盖缩略图标签、加载、重试和现有查看器。
+
+已录制的[混合上传浏览器场景](../../../../apps/web/tests/file-upload-round.e2e.ts)比较概述与预览,验证键盘激活和焦点恢复,检查原始内容收起与展开状态,并在刷新后重复检查。其 [Trajectory 预期输出](../../../../snapshots/web/file-upload-round/trajectory.expected.md)配合未改动的会话 fixture(测试前置数据)使用。[组装应用图片缓存测试](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts)也检查 Chat 与 Trajectory 之间的复用。
+
+布局验收覆盖窄版与宽版面板、两种主题和语言、缩略图完整缩放及末尾附件可达性。[GIF 工作流](../../../skills/record-browser-gif/SKILL.md)要求评审证据基于干净的已提交代码树,在同一个真实服务、真实模型会话中展示混合提交、列表计数、概述、预览中的图片激活和展开的原始元数据。Issue #4356 及其关联 PR(Pull Request)记录已执行的检查和演示证据。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
-2026-08-26-generic-file-upload.md: 6d0d2901b14c44930b4342292d317ebe03705730
-2026-08-26-generic-file-upload.zh.md: 2d78811c24ba1a801d43f21f4e63db5646b0431b
+2026-08-26-generic-file-upload.md: ddaf92a396ade59d65a81300531be9bce2aeeaba
+2026-08-26-generic-file-upload.zh.md: 27a73536ec3632f40ee49a59d932406e77b5591d

+ 4 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md

@@ -48,3 +48,7 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow-
 ## Testing
 
 Unit and integration coverage pins verbatim and streamed storage, name sanitization, a declared 2.19 GiB request passing through the streaming bridge without aggregate buffering, raw and RPC wire admission, Blob and transferable `ReadableStream` carriers, bounded background-upload concurrency, progress and cancellation, cross-Session upload residency, command file-receipt submission without a second byte read, native and PTC projection under read-only and workspace-write permissions, upload staging, ordered queue and steer submission, queue-to-steer conversion, pending echo retirement and failure recovery, active plan and goal intake, `/plan` and `/goal` mixed attachments, subagent refusal, composer and Chat attachment layouts, and Trajectory file summaries. The keyless `file-upload-round` snapshot records the browser upload, model file read, and rendered answer.
+
+## Related
+
+The [Trajectory attachment presentation decision](../bug-fix/2026-09-15-trajectory-attachment-presentation.md) owns ledger counts and inspector attachment layouts.

+ 4 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md

@@ -48,3 +48,7 @@ Status: implemented
 ## 测试
 
 单元与集成覆盖原样与流式存储、文件名清洗、声明为 2.19 GiB 的请求通过流式 bridge 且不发生总量缓冲、原始字节与 RPC wire 准入、Blob 与可转移 `ReadableStream` 载体、后台上传并发上限、进度与取消、跨 Session 上传常驻、命令通过文件凭证提交且不再次读取字节、read-only 与 workspace-write 权限下的 native 和 PTC 投影、上传暂存、有序 queue 与 steer 提交、队列转 steer、pending 回显退休与失败恢复、active plan 和 goal 的附件入口、`/plan` 与 `/goal` 混合附件、子代理拒绝、composer 与 Chat 附件布局,以及 Trajectory 文件摘要。keyless `file-upload-round` 快照记录浏览器上传、模型读取文件与答案呈现。
+
+## 相关记录
+
+[Trajectory 附件展示决定](../bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)负责列表计数与详情面板附件布局。

+ 146 - 13
apps/web/tests/file-upload-round.e2e.ts

@@ -12,10 +12,10 @@ 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 {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -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))
@@ -108,6 +109,93 @@ describe('web e2e: generic file upload through the real assembly', () => {
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
   const sessionEvents: SessionEvent[] = []
+  let liveTrajectory: string | undefined
+
+  /** Inspect one durable mixed message through the real image slot and all message tabs. */
+  async function inspectTrajectoryAttachments(): Promise<string> {
+    const userMessage = sessionEvents.find(event => event.type === 'user/message' && event.data.source.kind === 'user')
+    if (userMessage?.type !== 'user/message') throw new Error('the replayed turn recorded no user message')
+    const content = userMessage.data.content
+    const imageName = IMAGE_NAMES[0]!
+    await page.getByRole('tab', { name: 'Chat', exact: true }).click()
+    const chatImage = page.getByRole('img', { name: imageName, exact: true })
+    await expect.poll(() => chatImage.getAttribute('src'), { timeout: 10_000 }).toMatch(/^blob:/)
+    const chatImageUrl = await chatImage.getAttribute('src')
+
+    await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+    await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
+    const row = page.getByRole('row', { name: `USER, Images ×1 · Files ×1 · ${PROMPT}`, exact: true })
+    await row.waitFor({ timeout: 10_000 })
+    const ledger = await captureStableAria(page, '[data-trajectory-row-key][aria-label*="Files ×1"]', scaffold.workspaceCwd)
+    await row.click()
+
+    const panel = page.getByRole('tabpanel')
+    const list = panel.getByRole('list', { name: 'Attachments', exact: true })
+    let summaryAttachments: string | undefined
+    let preview: string | undefined
+    for (const tab of ['Summary', 'Preview']) {
+      await page.getByRole('tab', { name: tab, exact: true }).click()
+      await list.waitFor()
+      expect(await panel.getByText(PROMPT, { exact: true }).count()).toBe(1)
+      const rows = list.getByRole('listitem')
+      expect(await rows.count()).toBe(2)
+      expect(await rows.nth(0).getByTitle(FILE_NAME, { exact: true }).count()).toBe(1)
+      expect(await rows.nth(1).getByTitle(imageName, { exact: true }).count()).toBe(1)
+      expect(await rows.nth(0).textContent()).toContain('TXT · 16B')
+      expect(await rows.nth(1).textContent()).toContain('image/png · 69B · 1 × 1')
+      const thumbnail = list.getByRole('img', { name: imageName, exact: true })
+      await expect.poll(() => thumbnail.getAttribute('src'), { timeout: 10_000 }).toBe(chatImageUrl)
+      expect(await thumbnail.evaluate(element => getComputedStyle(element).objectFit)).toBe('contain')
+
+      const attachments = await captureStableAria(page, '[role="tabpanel"] [aria-label="Attachments"]', scaffold.workspaceCwd)
+      if (tab === 'Summary') summaryAttachments = attachments
+      else {
+        expect(attachments).toBe(summaryAttachments)
+        preview = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd)
+      }
+      const opener = list.getByRole('button', { name: `${imageName}, click to view original`, exact: true })
+      await opener.focus()
+      await opener.press('Enter')
+      const dialog = page.getByRole('dialog', { name: 'Original image preview', exact: true })
+      await dialog.waitFor()
+      expect(await dialog.getByRole('img', { name: imageName }).getAttribute('src')).toBe(chatImageUrl)
+      await page.keyboard.press('Escape')
+      await dialog.waitFor({ state: 'hidden' })
+      expect(await opener.evaluate(element => element === document.activeElement)).toBe(true)
+    }
+
+    await page.getByRole('tab', { name: 'Raw', exact: true }).click()
+    const disclosures = panel.locator('details')
+    expect(await disclosures.count()).toBe(2)
+    expect(await panel.getByRole('img').count()).toBe(0)
+    for (const disclosure of await disclosures.all()) {
+      expect(await disclosure.getAttribute('open')).toBeNull()
+      expect(await disclosure.locator('pre').isVisible()).toBe(false)
+    }
+    const rawCollapsed = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd)
+    const blocks = panel.locator('details, section')
+    expect(await blocks.count()).toBe(content.length)
+    for (const [index, block] of content.entries()) {
+      const renderedBlock = blocks.nth(index)
+      if (block.type === 'text') {
+        expect(await renderedBlock.locator('pre').textContent()).toBe(block.text)
+      } else {
+        expect(['image', 'file']).toContain(block.type)
+        await renderedBlock.locator('summary').click()
+        expect(await renderedBlock.locator('pre').isVisible()).toBe(true)
+        const raw: unknown = JSON.parse((await renderedBlock.locator('pre').textContent())!)
+        expect(raw).toEqual(block)
+      }
+    }
+    const rawExpanded = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd)
+    return [
+      '# Ledger', ledger,
+      '# Summary attachments', summaryAttachments,
+      '# Preview', preview,
+      '# Raw (collapsed)', rawCollapsed,
+      '# Raw (expanded)', rawExpanded,
+    ].join('\n\n')
+  }
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({
@@ -283,23 +371,68 @@ describe('web e2e: generic file upload through the real assembly', () => {
     await compareOrRefreshGolden(HISTORY_EXPECTED, renderHistoryAttachmentGeometry(geometry), MODE)
   })
 
-  it.skipIf(MODE === 'record')('marks the durable file in Trajectory without copying the Chat card', async () => {
-    await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
-    await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
-    await page.getByRole('row', { name: /Files ×1/ }).waitFor({ timeout: 10_000 })
-    const snapshot = await captureStableAria(
-      page,
-      '[data-trajectory-row-key][aria-label*="Files ×1"]',
-      scaffold.workspaceCwd,
-    )
-    await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
+  it.skipIf(MODE === 'record')('shows both attachment kinds throughout the Trajectory inspector', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-trajectory'))
+    liveTrajectory = await inspectTrajectoryAttachments()
+    await compareOrRefreshGolden(TRAJECTORY_EXPECTED, liveTrajectory, MODE)
+  })
+
+  it.skipIf(MODE === 'record')('restores the same Trajectory attachments from persisted history after reload', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-trajectory-reload'))
+    expect(liveTrajectory).toBeDefined()
+    const warningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    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',
     ])
   })
 })

+ 2 - 2
packages/client/ui-attachment/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md
-README.md: 3c69b387914c631f50d783c4a35089ab8db1668f
-README.zh.md: c21b2acdbeb7ced6e9a8733a861c5324121ad194
+README.md: 0765068c73c8ca3cf79b8b23a54629669625ca2e
+README.zh.md: 5976acfda5b605dd85ba3d43e18a83af4d9d58e0

+ 2 - 0
packages/client/ui-attachment/README.md

@@ -35,6 +35,8 @@ Images and generic files retain pick order in one non-wrapping horizontal rail.
 
 In Chat, one user message presents files and images in a right-aligned wrapping flow that preserves source order. A lone image without another attachment renders at 240px on its longer edge (aspect clamped to [0.25, 4], never upscaled); when the message has more than one attachment, each image is a fixed 64px square beside 240×64px file cards. A loaded image opens the document-level lightbox on click; a failed load shows a retry control instead. The lightbox closes on Escape, a mask press, or its close control, and restores focus to its opener.
 
+Trajectory attachment rows request 48px square thumbnails that contain the complete image without cropping. Loading and retry icons keep the same box, with localized tooltips and accessible names; the image opens the same lightbox. A slot owner may supply a presentation-only image label for the thumbnail and lightbox without changing the durable reference or the cache lookup.
+
 ### Drop overlay
 
 While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject.

+ 2 - 0
packages/client/ui-attachment/README.zh.md

@@ -35,6 +35,8 @@ kind: "package-reference"
 
 Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行的排列中,并保持来源顺序。消息仅有一张图片且没有其他附件时,图片按长边 240px 渲染(宽高比钳制在 [0.25, 4],从不放大);消息有多个附件时,每张图片显示为固定 64px 方块,与 240×64px 文件卡同排。加载完成的图片单击打开文档级灯箱;加载失败则显示重试控件。灯箱按 Escape、按下遮罩或点关闭按钮关闭,并把焦点还给打开者。
 
+Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪。加载与重试图标保持相同尺寸,提供本地化的工具提示和可访问名称;图片打开同一个灯箱。插槽持有方可以为缩略图与灯箱提供仅用于展示的图片名称,而不改变持久化引用或缓存查询。
+
 ### 拖放遮罩
 
 文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态——是否接受由持有方的文档级监听器决定。

+ 38 - 0
packages/client/ui-attachment/src/MessageImage.module.css

@@ -67,3 +67,41 @@
   overflow: hidden;
   border-radius: 16px;
 }
+
+.frame[data-variant='thumbnail'],
+.error[data-variant='thumbnail'] {
+  box-sizing: border-box;
+  display: grid;
+  place-items: center;
+  width: 48px;
+  height: 48px;
+  min-width: 48px;
+  min-height: 48px;
+  padding: 2px;
+  overflow: hidden;
+  border-radius: 8px;
+}
+
+.frame[data-variant='thumbnail'] img {
+  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);
+  outline-offset: 2px;
+}

+ 38 - 11
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'
@@ -11,7 +12,11 @@ export type ImageLoader = ((attachment: ImageAttachmentRef) => Promise<string>)
 
 /** One gallery entry: a durable admitted reference, or a submission echo's local preview. */
 export type MessageImageSpec =
-  | { readonly attachment: ImageAttachmentRef }
+  | {
+    readonly attachment: ImageAttachmentRef
+    /** Presentation-only name for the thumbnail and lightbox; loading uses the original reference. */
+    readonly label?: string
+  }
   | {
     readonly preview: {
       readonly url: string
@@ -69,17 +74,18 @@ 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.
- * @param props.variant - `single` for a message's lone image, `tile` otherwise.
+ * @param props.variant - lone image, cropped gallery tile, or uncropped list thumbnail.
  * @param props.labels - resolved strings (tooltip, loading, retry, lightbox).
  * @returns the bounded thumbnail button, or the retry control on failure.
  */
 export function MessageImage({ image, load, variant, labels }: {
   image: MessageImageSpec
   load: ImageLoader
-  variant: 'single' | 'tile'
+  variant: 'single' | 'tile' | 'thumbnail'
   labels: MessageImageLabels
 }) {
   const preview = 'preview' in image ? image.preview : undefined
@@ -116,8 +122,23 @@ export function MessageImage({ image, load, variant, labels }: {
   }, [attachment, load, attempt])
 
   const src = preview?.url ?? loaded
-  const label = (preview?.name ?? attachment?.name) ?? labels.image
-  if (error) return <button type="button" className={css.error} data-variant={variant} onClick={request}>{labels.loadFailed}</button>
+  const label = ('attachment' in image ? image.label : undefined)
+    ?? preview?.name ?? attachment?.name ?? labels.image
+  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
@@ -125,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} />}
@@ -139,16 +165,17 @@ export function MessageImage({ image, load, variant, labels }: {
 }
 
 /** Wrapping image group shared by user and assistant history: a lone image
- * renders large unless its owning mixed-attachment row requests compact tiles. */
-export function ImageGallery({ images, load, align, compact = false, labels }: {
+ * renders large unless its owner requests compact tiles or contained list thumbnails. */
+export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels }: {
   images: readonly MessageImageSpec[]
   load: ImageLoader
   align: 'start' | 'end'
   compact?: boolean
+  thumbnail?: boolean
   labels: MessageImageLabels
 }) {
   if (images.length === 0) return null
-  const variant = compact || images.length > 1 ? 'tile' : 'single'
+  const variant = thumbnail ? 'thumbnail' : compact || images.length > 1 ? 'tile' : 'single'
   return (
     <div className={css.gallery} data-align={align}>
       {images.map((image, index) => (

+ 2 - 1
packages/client/ui-attachment/src/client/MessageImages.tsx

@@ -3,13 +3,14 @@ import { ImageGallery } from '../MessageImage.tsx'
 import { messageImageLabels } from './labels.ts'
 
 /** Historical message-image slot entry. */
-export function MessageImages({ images, loadImage, align, compact = false, t }: MessageImagesProps) {
+export function MessageImages({ images, loadImage, align, compact = false, thumbnail = false, t }: MessageImagesProps) {
   return (
     <ImageGallery
       images={images}
       load={loadImage}
       align={align}
       compact={compact}
+      thumbnail={thumbnail}
       labels={messageImageLabels(t)}
     />
   )

+ 49 - 4
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'
@@ -144,11 +144,56 @@ describe('MessageImage', () => {
     expect(frame.getAttribute('style')).toBeNull()
   })
 
-  it('keeps the tile variant on the failed-load retry control', async () => {
+  it.each(['tile', 'thumbnail'] as const)('keeps the %s variant on the failed-load retry control', async (variant) => {
     const load = vi.fn().mockRejectedValue(new Error('offline'))
-    const view = render(<MessageImage image={{ attachment }} load={load} variant="tile" labels={labels} />)
+    const view = render(<MessageImage image={{ attachment }} load={load} variant={variant} labels={labels} />)
     const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
-    expect(retry.getAttribute('data-variant')).toBe('tile')
+    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')
+    const view = render(<ImageGallery images={[{ attachment: unnamed, label: 'Image 2' }]} load={load} align="start" thumbnail labels={labels} />)
+    fireEvent.click(await view.findByRole('button', { name: labels.loadFailed }))
+    const image = await view.findByAltText('Image 2')
+    expect(image.closest('button')?.getAttribute('data-variant')).toBe('thumbnail')
+    const opener = view.getByRole('button', { name: 'Image 2,点击查看原图' })
+    opener.focus()
+    fireEvent.click(opener)
+    expect(view.getByRole('dialog')).toBeTruthy()
+    fireEvent.keyDown(window, { key: 'Escape' })
+    expect(view.queryByRole('dialog')).toBeNull()
+    expect(document.activeElement).toBe(opener)
+    expect(load).toHaveBeenLastCalledWith(unnamed)
+    expect(unnamed).not.toHaveProperty('name')
   })
 
   it('ignores a load settling after unmount', async () => {

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 23690a0c71f9a3b81639fa763c6a095e1bf93011
-README.zh.md: 251fa9c6513773cfca3e092742a045b0ac57030c
+README.md: c611304e5a359ef5556b232ea9c3236a95f56897
+README.zh.md: fad2daa506414d731936f3a60ed7278f92eaae94

+ 2 - 0
packages/client/ui-conversation/README.md

@@ -36,6 +36,8 @@ Target packages declaration-merge their snapshot and Location data maps, then re
 <a id="shell-and-standard-props"></a>
 ## Shell and standard props
 
+The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. [ui-attachment](../ui-attachment/README.md) owns rendering and the lightbox.
+
 The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available.
 
 The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.

+ 2 - 0
packages/client/ui-conversation/README.zh.md

@@ -36,6 +36,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
 <a id="shell-and-standard-props"></a>
 ## Shell 与标准 props
 
+共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。[ui-attachment](../ui-attachment/README.zh.md) 负责渲染与灯箱。
+
 上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。
 
 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。

+ 7 - 1
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -77,7 +77,11 @@ export interface ComposerAttachmentsOwnerProps {
  * local preview of a submission echo whose admission is still in flight.
  */
 export type MessageImageSource =
-  | { readonly attachment: ImageAttachmentRef }
+  | {
+    readonly attachment: ImageAttachmentRef
+    /** Presentation-only name for the thumbnail and lightbox; loading uses the original reference. */
+    readonly label?: string
+  }
   | {
     readonly preview: {
       /** Browser-owned preview URL (lifecycle stays with the submitter). */
@@ -105,6 +109,8 @@ export interface MessageImagesOwnerProps {
   align: 'start' | 'end'
   /** Force every image into the compact message-attachment tile size. */
   compact?: boolean
+  /** Fixed, uncropped thumbnail for an attachment list row. */
+  thumbnail?: boolean
 }
 
 /** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */

+ 2 - 2
packages/client/ui-trajectory/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
-README.md: 02cbf402a702a4a51e9e3a122e3df13c4eb4e772
-README.zh.md: 00971c0710e1620b97fc51ae03486a9114629a49
+README.md: b58466f0c38efef89ff3aa2b5d6da49b6c85ac86
+README.zh.md: ab9ecbfd26478c30c9078a25f92bd121245b7bf9

+ 3 - 1
packages/client/ui-trajectory/README.md

@@ -31,7 +31,9 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit
 
 Calls are identified by their recorded tool name. For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Copying the original arguments retains their recorded JSON whitespace. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. See the [PTC inspection decision](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md) for replay constraints.
 
-Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn.
+Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows both nonzero image and ordinary-file counts beside its text, including attachment-only records. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn.
+
+Summary and Preview share one ordered attachment list after the message text, preserving repeated references. Each row shows a contained image thumbnail or file-type icon, the recorded filename (a localized numbered label for unnamed images), and recorded size, type, and image dimensions where available. Zero-byte files retain their size, and truncated filenames expose the full name in a tooltip. Images open the existing lightbox. Raw keeps content-block order and unrendered text, with images and files in initially collapsed disclosures containing their complete recorded fields.
 
 Thinking in the inspector uses compact Markdown at the inspector's fixed 13px size and 20px line height, independent of the content-size setting. Headings add bold weight without increasing size or line height. Assistant output keeps its regular Markdown typography.
 

+ 3 - 1
packages/client/ui-trajectory/README.zh.md

@@ -31,7 +31,9 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间
 
 调用按已记录的工具名识别。对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。复制原始参数时保留记录中 JSON 的空白。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。回放约束见 [PTC 检查决策](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。
 
-选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。
+选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁同时显示非零的图片与普通文件数量,仅含附件的记录也显示这些数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。
+
+概述与预览共用正文之后的有序附件列表,保留重复引用。每行展示完整缩放的图片缩略图或文件类型图标、已记录的文件名(未命名图片使用本地化的编号名称),以及可用的已记录大小、类型和图片尺寸。零字节文件保留大小显示,截断的文件名通过提示显示完整名称。图片打开现有灯箱。原始内容保留内容块顺序和未渲染文本,图片与文件使用默认折叠的展开项,包含完整的已记录字段。
 
 检查器中的思考内容使用紧凑 Markdown,沿用检查器固定的 13px 字号和 20px 行高,不受内容字号设置影响。标题只加粗,不放大字号或行高。助手输出保留常规 Markdown 排版。
 

+ 79 - 2
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css

@@ -1710,11 +1710,88 @@
   gap: 8px;
 }
 
-.messageImagesPreview {
-  margin: 6px 14px 8px;
+.attachments {
+  display: flex;
+  flex-direction: column;
+  margin: 8px 14px 14px;
+  padding: 0;
+  gap: 8px;
+  list-style: none;
+}
+
+.attachmentsPreview {
+  margin-block: 6px 8px;
   gap: 6px;
 }
 
+.attachmentRow {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+  padding: 8px;
+  gap: 10px;
+  border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin);
+  border-radius: 12px;
+}
+
+.attachmentIcon {
+  display: grid;
+  flex: 0 0 48px;
+  width: 48px;
+  height: 48px;
+  place-items: center;
+}
+
+.attachmentInfo {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  min-width: 0;
+  gap: 3px;
+}
+
+.attachmentName {
+  overflow: hidden;
+  color: var(--dsw-alias-label-primary);
+  font-size: 12px;
+  line-height: 18px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.attachmentMetadata {
+  overflow: hidden;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 11px;
+  line-height: 16px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.attachmentDisclosure {
+  margin-block: 8px;
+  border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin);
+  border-radius: 8px;
+}
+
+.attachmentDisclosure > summary {
+  padding: 8px;
+  cursor: pointer;
+}
+
+.attachmentDisclosure > summary .attachmentName {
+  display: block;
+}
+
+.attachmentDisclosure > summary:focus-visible {
+  outline: 2px solid var(--dsw-alias-state-business-primary);
+  outline-offset: 2px;
+}
+
+.attachmentDisclosure > pre {
+  padding: 0 8px 8px;
+}
+
 .assistantToolCalls {
   box-sizing: border-box;
   max-width: 100%;

+ 99 - 52
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -5,6 +5,9 @@ import type { CSSProperties, ReactNode } from 'react'
 import { useVirtualizer } from '@tanstack/react-virtual'
 import {
   CodeBlock,
+  FileTypeIcon,
+  fileExtension,
+  fileSizeText,
   IconCheckOutline16,
   IconChevronRightOutline14,
   IconCodeOutline16,
@@ -20,7 +23,6 @@ import {
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { JsonTreeLabels, JsonTreeProps, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
 import { structuredPatch } from 'diff'
-import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {
   AssistantRequestConfig, ConversationPromptSnapshot, RenderMessageImages,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -1157,75 +1159,120 @@ function MarkdownFragment({
 function SourceBlocks({
   blocks,
   onOpenCall,
-  renderImages,
   t,
 }: {
   blocks: readonly TrajectorySourceBlock[]
   onOpenCall: (callId: string) => void
-  renderImages: RenderMessageImages
   t: TrajectoryTranslate
 }) {
+  const attachments = new Map(recordAttachments(blocks, t).map(entry => [entry.index, entry]))
   return (
     <div className={css.sourceBlocks}>
-      {blocks.map((block, index) => (
-        <section className={css.sourceBlock} key={index}>
-          {block.callId !== undefined
-            ? (
-              <button
-                type="button"
-                className={css.sourceBlockJumpTarget}
-                aria-label={t('block.openSummary', { index: index + 1 })}
-                title={t('block.openSummaryTitle')}
-                onClick={() => {
-                  if (block.callId !== undefined) onOpenCall(block.callId)
-                }}
-              >
-                <span className={css.sourceBlockLabel}>
-                  {t('block.label', { index: index + 1, type: block.type })}
-                </span>
-                <IconChevronRightOutline14 className={css.sourceBlockJumpIcon} size={12} />
-              </button>
-            )
-            : (
-              <div className={css.sourceBlockHeader}>
-                <span className={css.sourceBlockLabel}>
-                  {t('block.label', { index: index + 1, type: block.type })}
-                </span>
-              </div>
-            )}
-          {/* The Raw view keeps model block order and granularity: one
-              gallery per image block, unlike the aggregated record gallery. */}
-          {block.attachment !== undefined
-            ? renderImages({ images: [{ attachment: block.attachment }], align: 'start' })
-            : <pre className={css.sourceBlockContent}>{block.content}</pre>}
-        </section>
-      ))}
+      {blocks.map((block, index) => {
+        const attachment = attachments.get(index)
+        return attachment !== undefined ? (
+          <details className={css.attachmentDisclosure} key={index}>
+            <summary>
+              <span className={css.sourceBlockLabel}>
+                {t('block.label', { index: index + 1, type: block.type })}
+              </span>
+              <span className={css.attachmentName} title={attachment.name}>
+                {attachment.name}
+              </span>
+            </summary>
+            <pre className={css.sourceBlockContent}>{block.content}</pre>
+          </details>
+        )
+          : (
+            <section className={css.sourceBlock} key={index}>
+              {block.callId !== undefined
+                ? (
+                  <button
+                    type="button"
+                    className={css.sourceBlockJumpTarget}
+                    aria-label={t('block.openSummary', { index: index + 1 })}
+                    title={t('block.openSummaryTitle')}
+                    onClick={() => {
+                      if (block.callId !== undefined) onOpenCall(block.callId)
+                    }}
+                  >
+                    <span className={css.sourceBlockLabel}>
+                      {t('block.label', { index: index + 1, type: block.type })}
+                    </span>
+                    <IconChevronRightOutline14 className={css.sourceBlockJumpIcon} size={12} />
+                  </button>
+                )
+                : (
+                  <div className={css.sourceBlockHeader}>
+                    <span className={css.sourceBlockLabel}>
+                      {t('block.label', { index: index + 1, type: block.type })}
+                    </span>
+                  </div>
+                )}
+              <pre className={css.sourceBlockContent}>{block.content}</pre>
+            </section>
+          )
+      })}
     </div>
   )
 }
 
-function recordImages(
+function recordAttachments(
   blocks: readonly TrajectorySourceBlock[] | undefined,
-): { readonly attachment: ImageAttachmentRef }[] {
-  return (blocks ?? []).flatMap(block =>
-    block.attachment !== undefined ? [{ attachment: block.attachment }] : [])
+  t: TrajectoryTranslate,
+) {
+  let imageIndex = 0
+  return (blocks ?? []).flatMap((block, index) => {
+    const ref = block.attachment ?? block.file
+    if (ref === undefined) return []
+    if (block.attachment !== undefined) imageIndex += 1
+    return [{
+      block,
+      index,
+      name: ref.name ?? t('attachment.imageName', { index: imageIndex }),
+      metadata: [
+        block.attachment?.mediaType ?? fileExtension(ref.name ?? '').toUpperCase(),
+        fileSizeText(ref.bytes),
+        ...(block.attachment === undefined ? [] : [`${block.attachment.width} × ${block.attachment.height}`]),
+      ].filter(Boolean).join(' · '),
+    }]
+  })
 }
 
-function MessageImages({
+function RecordAttachments({
   blocks,
   preview,
   renderImages,
+  t,
 }: {
   blocks: readonly TrajectorySourceBlock[] | undefined
   preview: boolean
   renderImages: RenderMessageImages
+  t: TrajectoryTranslate
 }) {
-  const images = recordImages(blocks)
-  if (images.length === 0) return null
+  const attachments = recordAttachments(blocks, t)
+  if (attachments.length === 0) return null
   return (
-    <div className={preview ? `${css.messageImages} ${css.messageImagesPreview}` : css.messageImages}>
-      {renderImages({ images, align: 'start' })}
-    </div>
+    <ul
+      className={preview ? `${css.attachments} ${css.attachmentsPreview}` : css.attachments}
+      aria-label={t('attachment.list')}
+    >
+      {attachments.map(({ block, index, name, metadata }) => (
+        <li className={css.attachmentRow} key={index}>
+          {block.attachment !== undefined
+            ? renderImages({
+              images: [{ attachment: block.attachment, label: name }],
+              align: 'start',
+              thumbnail: true,
+            })
+            : <span className={css.attachmentIcon}><FileTypeIcon path={name} /></span>}
+          <div className={css.attachmentInfo}>
+            <span className={css.attachmentName} title={name}>{name}</span>
+            <span className={css.attachmentMetadata}>{metadata}</span>
+          </div>
+        </li>
+      ))}
+    </ul>
   )
 }
 
@@ -1488,7 +1535,6 @@ function MarkdownRecordContent({
       <SourceBlocks
         blocks={record.cell.sourceBlocks}
         onOpenCall={onOpenCall}
-        renderImages={renderImages}
         t={t}
       />
     )
@@ -1544,25 +1590,26 @@ function MarkdownRecordContent({
           onOpenCall={onOpenCall}
           t={t}
         />
-        <MessageImages
+        <RecordAttachments
           blocks={record.cell.sourceBlocks}
           preview={preview}
           renderImages={renderImages}
+          t={t}
         />
       </div>
     )
   }
   const source = markdownSource(record)
-  const hasImages = record.cell.sourceBlocks?.some(block => block.attachment !== undefined) === true
+  const hasAttachments = record.cell.sourceBlocks?.some(block => block.attachment !== undefined || block.file !== undefined) === true
   const hasToolCalls = record.cell.kind === 'message'
     && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true
-  if (!source && !hasImages && !hasToolCalls) {
+  if (!source && !hasAttachments && !hasToolCalls) {
     const emptyLabel = isToolCallOnly(record.cell, t)
       ? t('record.toolCallOnly')
       : record.cell.text || t('record.noContent')
     return <p className={css.noPayload}>{emptyLabel}</p>
   }
-  if (!rendered || (!hasImages && !hasToolCalls)) {
+  if (!rendered || (!hasAttachments && !hasToolCalls)) {
     return <MarkdownFragment text={source ?? ''} rendered={rendered} preview={preview} t={t} />
   }
   return (
@@ -1576,7 +1623,7 @@ function MarkdownRecordContent({
           t={t}
         />
       )}
-      <MessageImages blocks={record.cell.sourceBlocks} preview={preview} renderImages={renderImages} />
+      <RecordAttachments blocks={record.cell.sourceBlocks} preview={preview} renderImages={renderImages} t={t} />
     </div>
   )
 }

+ 15 - 11
packages/client/ui-trajectory/src/client/layout.ts

@@ -12,7 +12,7 @@ import type {
   ToolCallBlock,
   ToolResultNode,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {
   TrajectoryCellProps,
   TrajectorySourceBlock,
@@ -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 = [
-    previewMarkdown === undefined && images > 0
-      ? t('layout.imageOnly', { count: images })
+    images > 0
+      ? 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 ''
 }
 
@@ -829,7 +827,7 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
       callId: block.callId,
       toolName: block.name,
     }
-    case 'image': return { type: 'image', content: '', attachment: block.attachment }
+    case 'image': return sourceBlock({ type: 'image', attachment: block.attachment })
     case 'other': return sourceBlock(block.block)
   }
 }
@@ -844,14 +842,20 @@ function sourceBlock(value: unknown): TrajectorySourceBlock {
     return { type: type === 'reasoning' ? 'thinking' : type, content: block.text }
   }
   if (
-    type === 'image'
+    (type === 'image' || type === 'file')
     && typeof block.attachment === 'object' && block.attachment !== null
     && typeof (block.attachment as Record<string, unknown>).attachmentId === 'string'
   ) {
     // Session-log content is validated into core ContentBlocks by the
     // Conversation node assembly; the `attachmentId` guard only keeps
     // wire-shaped 'other' blocks with an unrelated `attachment` member out.
-    return { type, content: '', attachment: block.attachment as ImageAttachmentRef }
+    return {
+      type,
+      content: stringifySourceValue(value),
+      ...(type === 'image'
+        ? { attachment: block.attachment as ImageAttachmentRef }
+        : { file: block.attachment as FileAttachmentRef }),
+    }
   }
   return { type, content: stringifySourceValue(value) }
 }
@@ -1083,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') }
 }
 
@@ -1110,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 =>

+ 6 - 2
packages/client/ui-trajectory/src/client/locales.ts

@@ -180,7 +180,9 @@ export const zh = {
   'layout.compactionFailed': '上下文压缩失败',
   'layout.compacted': '上下文已压缩',
   'layout.toolCallOnly': '仅工具调用',
-  'layout.imageOnly': '图片 ×{count}',
+  'attachment.list': '附件',
+  'attachment.imageName': '图片 {index}',
+  'layout.imageCount': '图片 ×{count}',
   'layout.fileAttachments': '文件 ×{count}',
   'layout.initialSystemPrompt': '初始系统提示词',
   'layout.systemPromptUpdated': '系统提示词已更新',
@@ -380,7 +382,9 @@ export const en: Record<TrajectoryKey, string> = {
   'layout.compactionFailed': 'Compaction failed',
   'layout.compacted': 'Context compacted',
   'layout.toolCallOnly': 'Tool call only',
-  'layout.imageOnly': 'Images ×{count}',
+  'attachment.list': 'Attachments',
+  'attachment.imageName': 'Image {index}',
+  'layout.imageCount': 'Images ×{count}',
   'layout.fileAttachments': 'Files ×{count}',
   'layout.initialSystemPrompt': 'Initial System Prompt',
   'layout.systemPromptUpdated': 'System Prompt Updated',

+ 3 - 1
packages/client/ui-trajectory/src/client/trajectory-record.ts

@@ -1,7 +1,7 @@
 /** Shared trajectory record data and formatting contracts. */
 
 import type { HTMLAttributes } from 'react'
-import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { TrajectoryTranslate } from './locales.ts'
 
@@ -30,6 +30,8 @@ export interface TrajectorySourceBlock {
   type: string
   content: string
   attachment?: ImageAttachmentRef
+  /** Ordinary file metadata; never passed to the image loader. */
+  file?: FileAttachmentRef
   callId?: string
   toolName?: string
 }

+ 23 - 10
packages/client/ui-trajectory/tests/layout.client.spec.tsx

@@ -592,8 +592,8 @@ describe('durable image attachments', () => {
     expect(user?.text).toBe('Images ×2')
     expect(user?.previewMarkdown).toBeUndefined()
     expect(user?.sourceBlocks).toEqual([
-      { type: 'image', content: '', attachment },
-      { type: 'image', content: '', attachment },
+      { type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment },
+      { type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment },
     ])
   })
 
@@ -619,9 +619,9 @@ describe('durable image attachments', () => {
     ] as unknown as LegacyConversationSlice['nodes']
     const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
     const user = turns[0]?.groups[0]?.cells[0]
-    expect(user?.text).toBe('')
+    expect(user?.text).toBe('Images ×1')
     expect(user?.previewMarkdown).toBe('look at this')
-    expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: '', attachment })
+    expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment })
   })
 
   it('maps assistant image blocks to attachment source blocks and labels image-only output', () => {
@@ -634,7 +634,7 @@ describe('durable image attachments', () => {
     const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
     const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
     expect(message?.text).toBe('Images ×1')
-    expect(message?.sourceBlocks).toEqual([{ type: 'image', content: '', attachment }])
+    expect(message?.sourceBlocks).toEqual([{ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }])
   })
 
   it('carries tool-result image refs into outputBlocks and labels the result', () => {
@@ -653,7 +653,7 @@ describe('durable image attachments', () => {
     const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
     expect(tool?.result).toBe('Images ×1')
     expect(tool?.outputDetail).toBe('Images ×1')
-    expect(tool?.outputBlocks).toEqual([{ type: 'image', content: '', attachment }])
+    expect(tool?.outputBlocks).toEqual([{ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }])
   })
 
   it('shows wire-shaped blocks without an attachment as JSON, not as an image', () => {
@@ -677,7 +677,7 @@ describe('durable file attachments', () => {
     bytes: 2447 * 1024 * 1024,
   }
 
-  it('labels file-only and mixed-text user records without rendering file cards', () => {
+  it('labels file-only and mixed-text user records', () => {
     const nodes = [
       {
         kind: 'user', seq: 1, time: 1_000, source: null,
@@ -696,7 +696,7 @@ describe('durable file attachments', () => {
     expect(users[1]).toMatchObject({ text: 'Files ×1', previewMarkdown: 'review these' })
   })
 
-  it('keeps image and file counts in a file-only attachment summary', () => {
+  it.each([undefined, '', 'review these'])('keeps both counts and repeated attachments with text %j', (text) => {
     const image = {
       attachmentId: `sha256:${'e'.repeat(64)}`,
       mediaType: 'image/png',
@@ -706,9 +706,22 @@ describe('durable file attachments', () => {
     }
     const nodes = [{
       kind: 'user', seq: 1, time: 1_000, source: null,
-      content: [{ type: 'image', attachment: image }, { type: 'file', attachment }],
+      content: [
+        ...(text === undefined ? [] : [{ type: 'text', text }]),
+        { type: 'image', attachment: image },
+        { type: 'file', attachment },
+        { type: 'image', attachment: image },
+      ],
     }] as unknown as LegacyConversationSlice['nodes']
     const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
-    expect(turns[0]?.groups[0]?.cells[0]?.text).toBe('Images ×1 · Files ×1')
+    const cell = turns[0]?.groups[0]?.cells[0]
+    expect(cell?.text).toBe('Images ×2 · Files ×1')
+    const blocks = cell?.sourceBlocks?.filter(block => block.type !== 'text')
+    expect(blocks?.map(block => block.attachment ?? block.file)).toEqual([image, attachment, image])
+    expect(blocks?.map((block): unknown => JSON.parse(block.content))).toEqual([
+      { type: 'image', attachment: image },
+      { type: 'file', attachment },
+      { type: 'image', attachment: image },
+    ])
   })
 })

+ 56 - 42
packages/client/ui-trajectory/tests/table.client.spec.tsx

@@ -2,7 +2,8 @@
 /** Trajectory ledger selection, details, status, and fold behavior. */
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { AttachmentId } from '@deepseek-ai/dsh-attachment'
 import type { ComponentProps } from 'react'
 import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx'
@@ -1038,47 +1039,60 @@ describe('TrajectoryTable', () => {
     expect(screen.getByText('value:')).toBeTruthy()
   })
 
-  it('renders user image attachments through the shared gallery in the details panel', () => {
-    const attachment = {
-      attachmentId: `sha256:${'a'.repeat(64)}`,
-      mediaType: 'image/png',
-      bytes: 68,
-      width: 640,
-      height: 320,
-      name: 'screenshot.png',
-    } as unknown as NonNullable<
-      NonNullable<TrajectoryTurnModel['groups'][number]['cells'][number]['sourceBlocks']>[number]['attachment']
-    >
-    const turns: readonly TrajectoryTurnModel[] = [{
-      turn: 1,
-      groups: [{
-        title: 'Message',
-        cells: [{
-          index: 1,
-          kind: 'user',
-          text: 'Images ×2',
-          sourceBlocks: [
-            { type: 'image', content: '', attachment },
-            { type: 'image', content: '', attachment },
-          ],
-          timeSeconds: 0,
-        }],
-      }],
-    }]
-
-    render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
-    fireEvent.click(screen.getByRole('row', { name: /USER/ }))
-
-    const preview = screen.getAllByTestId('record-images')
-    expect(preview.length).toBeGreaterThan(0)
-    expect(preview[0]?.getAttribute('data-count')).toBe('2')
-
-    fireEvent.click(screen.getByRole('tab', { name: 'Raw' }))
-    const rawGalleries = screen.getAllByTestId('record-images')
-    expect(rawGalleries).toHaveLength(2)
-    expect(rawGalleries[0]?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id'))
-      .toBe(String(attachment.attachmentId))
-  })
+  it.each([['English', t, 'Attachments', 'Image 1', 'Summary', 'Preview', 'Raw'],
+    ['Chinese', tZh, '附件', '图片 1', '概述', '预览', '原始内容']] as const)(
+    'keeps mixed attachments ordered and raw fields complete in %s',
+    (_locale, translate, listLabel, imageLabel, summaryTab, previewTab, rawTab) => {
+      const attachment = {
+        attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
+        mediaType: 'image/png' as const,
+        bytes: 68,
+        width: 40,
+        height: 800,
+        originalDimensions: { width: 400, height: 8000 },
+      }
+      const file = {
+        attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`),
+        name: `${'long-filename-'.repeat(12)}.txt`,
+        bytes: 0,
+      }
+      const content = [
+        { type: 'text' as const, text: '**before**' },
+        { type: 'image' as const, attachment },
+        { type: 'text' as const, text: 'after' },
+        { type: 'file' as const, attachment: file },
+        { type: 'image' as const, attachment },
+      ]
+      const turns = deriveTrajectoryLayout({
+        nodes: [{ kind: 'user', seq: 1, time: 1000, source: { kind: 'user' }, content }],
+        partial: null,
+        runningCalls: [],
+      }, translate)
+      const renderImages = vi.fn<RenderMessageImages>(renderImagesStub)
+      const view = render(<LocalizedTrajectoryTable turns={turns} {...FOLD_PROPS} t={translate} renderImages={renderImages} />)
+      fireEvent.click(view.container.querySelector('[data-trajectory-row-key]')!)
+      for (const tab of [summaryTab, previewTab]) {
+        fireEvent.click(screen.getByRole('tab', { name: tab }))
+        const list = screen.getByRole('list', { name: listLabel })
+        const names = [...list.querySelectorAll('[title]')].map(el => el.getAttribute('title'))
+        expect(names).toEqual([imageLabel, file.name, imageLabel.replace('1', '2')])
+        expect(within(list).getByText('TXT · 0B')).toBeTruthy()
+        expect(within(list).getAllByText('image/png · 68B · 40 × 800')).toHaveLength(2)
+        expect(renderImages).toHaveBeenCalledWith({ images: [{ attachment, label: imageLabel }], align: 'start', thumbnail: true })
+      }
+      fireEvent.click(screen.getByRole('tab', { name: rawTab }))
+      expect(screen.queryByTestId('record-images')).toBeNull()
+      const disclosures = [...view.container.querySelectorAll('details')]
+      expect(disclosures).toHaveLength(3)
+      expect(disclosures.every(el => !el.open)).toBe(true)
+      expect(disclosures.map((el): unknown => JSON.parse(el.querySelector('pre')!.textContent)))
+        .toEqual([content[1], content[3], content[4]])
+      const blocks = [...disclosures[0]!.parentElement!.children]
+      expect(blocks.map(el => el.tagName)).toEqual(['SECTION', 'DETAILS', 'SECTION', 'DETAILS', 'DETAILS'])
+      expect(blocks[0]!.querySelector('pre')!.textContent).toBe('**before**')
+      expect(blocks[2]!.querySelector('pre')!.textContent).toBe('after')
+    },
+  )
 
   it('renders a tool-result image through the shared gallery in the Result tab', () => {
     const attachment = {

+ 25 - 25
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -406,7 +406,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n      { name: \'conversation.composer\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:170',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:176',
   },
   {
     key: 'conversation.composer.bar',
@@ -445,7 +445,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n      { name: \'conversation.composer.bar\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:188',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:194',
   },
   {
     key: 'conversation.composer.dock',
@@ -501,7 +501,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n      { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:182',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:188',
   },
   {
     key: 'conversation.hero.agentPreset',
@@ -538,7 +538,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n      { name: \'conversation.hero.agentPreset\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:176',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:182',
   },
   {
     key: 'conversation.hero.brand.mark',
@@ -567,7 +567,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n      { name: \'conversation.hero.brand.mark\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:174',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:180',
   },
   {
     key: 'conversation.hero.workspace',
@@ -600,7 +600,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n      { name: \'conversation.hero.workspace\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:172',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178',
   },
   {
     key: 'conversation.hero.workspace.directoryFlow',
@@ -673,7 +673,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n      { name: \'conversation.input.attachments\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:190',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:196',
   },
   {
     key: 'conversation.input.dock',
@@ -736,7 +736,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n      { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:184',
   },
   {
     key: 'conversation.input.left',
@@ -790,7 +790,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n      { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:184',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:190',
   },
   {
     key: 'conversation.input.model',
@@ -829,7 +829,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n      { name: \'conversation.input.model\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:200',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:206',
   },
   {
     key: 'conversation.input.overlay',
@@ -887,7 +887,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n      { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:180',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:186',
   },
   {
     key: 'conversation.input.permission',
@@ -926,7 +926,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n      { name: \'conversation.input.permission\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:198',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:204',
   },
   {
     key: 'conversation.input.plan',
@@ -965,7 +965,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n      { name: \'conversation.input.plan\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:196',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:202',
   },
   {
     key: 'conversation.input.right',
@@ -1019,7 +1019,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n      { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:186',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:192',
   },
   {
     key: 'conversation.message.images',
@@ -1029,7 +1029,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.',
     registerOptions: [],
     ownerProps: [
-      '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  /** Durable references or submission-echo previews in source order. */\n  images: readonly MessageImageSource[]\n  /** Session-authorized image URL loader for the durable arm. */\n  loadImage: MessageImageLoader\n  /** Horizontal placement inside the owning record. */\n  align: \'start\' | \'end\'\n  /** Force every image into the compact message-attachment tile size. */\n  compact?: boolean\n}',
+      '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  /** Durable references or submission-echo previews in source order. */\n  images: readonly MessageImageSource[]\n  /** Session-authorized image URL loader for the durable arm. */\n  loadImage: MessageImageLoader\n  /** Horizontal placement inside the owning record. */\n  align: \'start\' | \'end\'\n  /** Force every image into the compact message-attachment tile size. */\n  compact?: boolean\n  /** Fixed, uncropped thumbnail for an attachment list row. */\n  thumbnail?: boolean\n}',
     ],
     ownerPropsReferences: [
       'Message',
@@ -1157,7 +1157,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n      { name: \'conversation.session\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:129',
   },
   {
     key: 'conversation.session.header',
@@ -1194,7 +1194,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n      { name: \'conversation.session.header\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131',
   },
   {
     key: 'conversation.session.header.actions',
@@ -1256,7 +1256,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n      { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139',
   },
   {
     key: 'conversation.session.header.corner',
@@ -1295,7 +1295,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n      { name: \'conversation.session.header.corner\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:162',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:168',
   },
   {
     key: 'conversation.session.header.leading',
@@ -1334,7 +1334,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.leading\', () => ctx.slots.register(\n      { name: \'conversation.session.header.leading\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:151',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157',
   },
   {
     key: 'conversation.session.header.lineage',
@@ -1375,7 +1375,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n      { name: \'conversation.session.header.lineage\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133',
   },
   {
     key: 'conversation.session.header.utilities',
@@ -1434,7 +1434,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n      { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145',
   },
   {
     key: 'conversation.trajectory.images',
@@ -1444,7 +1444,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.',
     registerOptions: [],
     ownerProps: [
-      '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  /** Durable references or submission-echo previews in source order. */\n  images: readonly MessageImageSource[]\n  /** Session-authorized image URL loader for the durable arm. */\n  loadImage: MessageImageLoader\n  /** Horizontal placement inside the owning record. */\n  align: \'start\' | \'end\'\n  /** Force every image into the compact message-attachment tile size. */\n  compact?: boolean\n}',
+      '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  /** Durable references or submission-echo previews in source order. */\n  images: readonly MessageImageSource[]\n  /** Session-authorized image URL loader for the durable arm. */\n  loadImage: MessageImageLoader\n  /** Horizontal placement inside the owning record. */\n  align: \'start\' | \'end\'\n  /** Force every image into the compact message-attachment tile size. */\n  compact?: boolean\n  /** Fixed, uncropped thumbnail for an attachment list row. */\n  thumbnail?: boolean\n}',
     ],
     ownerPropsReferences: [
       'Message',
@@ -1538,7 +1538,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n      { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:168',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:174',
   },
   {
     key: 'main',
@@ -1610,7 +1610,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n      { name: \'main.conversation\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127',
   },
   {
     key: 'plugins.bundle.config',

+ 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

+ 38 - 2
snapshots/web/file-upload-round/trajectory.expected.md

@@ -1,3 +1,39 @@
-- row "USER, Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop.":
+# Ledger
+
+- row "USER, Images ×1 · Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop.":
   - cell "Turn 1 USER": USER
-  - cell "Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop."
+  - cell "Images ×1 · Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop."
+
+# Summary attachments
+
+- list "Attachments":
+  - listitem: poem.txt TXT · 16B
+  - listitem:
+    - button "reference-1.png, click to view original":
+      - img "reference-1.png"
+    - text: reference-1.png image/png · 69B · 1 × 1
+
+# Preview
+
+- tabpanel "Preview":
+  - paragraph: Read the attached file with the read tool, reply with exactly the single word it contains, and stop.
+  - list "Attachments":
+    - listitem: poem.txt TXT · 16B
+    - listitem:
+      - button "reference-1.png, click to view original":
+        - img "reference-1.png"
+      - text: reference-1.png image/png · 69B · 1 × 1
+
+# Raw (collapsed)
+
+- tabpanel "Raw":
+  - group: "Block #1 file poem.txt"
+  - group: "Block #2 image reference-1.png"
+  - text: "Block #3 text Read the attached file with the read tool, reply with exactly the single word it contains, and stop."
+
+# Raw (expanded)
+
+- tabpanel "Raw":
+  - group: "Block #1 file poem.txt { \"type\": \"file\", \"attachment\": { \"attachmentId\": \"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60\", \"name\": \"poem.txt\", \"bytes\": 16 } }"
+  - group: "Block #2 image reference-1.png { \"type\": \"image\", \"attachment\": { \"attachmentId\": \"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\", \"mediaType\": \"image/png\", \"width\": 1, \"height\": 1, \"bytes\": 69, \"name\": \"reference-1.png\" } }"
+  - text: "Block #3 text Read the attached file with the read tool, reply with exactly the single word it contains, and stop."