Răsfoiți Sursa

Merge pull request #3009 from deepseek-harness/worktree/2986-trajectory-image-attachments

feat(web): 在 Trajectory 中展示图片附件
CreatixChu 2 săptămâni în urmă
părinte
comite
09eda93884
57 a modificat fișierele cu 721 adăugiri și 250 ștergeri
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml
  5. 33 0
      .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md
  6. 33 0
      .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md
  7. 93 0
      apps/web/tests/trajectory-image-display.expected.e2e.ts
  8. 2 2
      docs/module-graph.i18n.yaml
  9. 5 4
      docs/module-graph.md
  10. 5 4
      docs/module-graph.zh.md
  11. 2 2
      docs/subsystems/slots.i18n.yaml
  12. 2 1
      docs/subsystems/slots.md
  13. 2 1
      docs/subsystems/slots.zh.md
  14. 2 2
      packages/client/ui-attachment/README.i18n.yaml
  15. 1 1
      packages/client/ui-attachment/README.md
  16. 1 1
      packages/client/ui-attachment/README.zh.md
  17. 5 2
      packages/client/ui-attachment/package.json
  18. 5 0
      packages/client/ui-attachment/src/client/index.ts
  19. 7 1
      packages/client/ui-attachment/tests/plugin.client.spec.ts
  20. 3 0
      packages/client/ui-attachment/tsconfig.json
  21. 2 2
      packages/client/ui-chat/README.i18n.yaml
  22. 1 1
      packages/client/ui-chat/README.md
  23. 1 1
      packages/client/ui-chat/README.zh.md
  24. 0 2
      packages/client/ui-chat/package.json
  25. 1 3
      packages/client/ui-chat/src/client/apply.ts
  26. 4 2
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  27. 1 12
      packages/client/ui-chat/src/client/contract/slots.ts
  28. 1 2
      packages/client/ui-chat/src/client/index.ts
  29. 1 1
      packages/client/ui-chat/tests/image-labels.client.spec.tsx
  30. 0 3
      packages/client/ui-chat/tsconfig.json
  31. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  32. 1 1
      packages/client/ui-conversation/README.md
  33. 1 1
      packages/client/ui-conversation/README.zh.md
  34. 14 0
      packages/client/ui-conversation/src/client/contract/slots.ts
  35. 15 0
      packages/client/ui-conversation/src/client/conversation/assembly.ts
  36. 11 12
      packages/client/ui-conversation/src/client/conversation/historical-images.ts
  37. 1 1
      packages/client/ui-conversation/src/client/index.ts
  38. 3 3
      packages/client/ui-conversation/tests/historical-images.client.spec.ts
  39. 2 2
      packages/client/ui-trajectory/README.i18n.yaml
  40. 0 0
      packages/client/ui-trajectory/README.md
  41. 0 0
      packages/client/ui-trajectory/README.zh.md
  42. 4 2
      packages/client/ui-trajectory/package.json
  43. 0 29
      packages/client/ui-trajectory/src/client/TrajectoryTable.module.css
  44. 59 46
      packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
  45. 15 5
      packages/client/ui-trajectory/src/client/TrajectoryView.tsx
  46. 4 0
      packages/client/ui-trajectory/src/client/index.ts
  47. 32 50
      packages/client/ui-trajectory/src/client/layout.ts
  48. 2 2
      packages/client/ui-trajectory/src/client/locales.ts
  49. 12 2
      packages/client/ui-trajectory/src/client/trajectory-contract.ts
  50. 2 2
      packages/client/ui-trajectory/src/client/trajectory-record.ts
  51. 1 1
      packages/client/ui-trajectory/src/client/trajectory-search-index.ts
  52. 100 0
      packages/client/ui-trajectory/tests/layout.client.spec.tsx
  53. 136 2
      packages/client/ui-trajectory/tests/table.client.spec.tsx
  54. 4 0
      packages/client/ui-trajectory/tests/views.client.spec.tsx
  55. 3 0
      packages/client/ui-trajectory/tsconfig.json
  56. 64 26
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  57. 6 3
      pnpm-lock.yaml

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.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/architecture/2026-08-20-client-session-conversation-ownership.md
-2026-08-20-client-session-conversation-ownership.md: 8e5521ff1981d83ab72db00dea556b4b2acc97fa
-2026-08-20-client-session-conversation-ownership.zh.md: a007a42b3d10ceeced8a2a64696521a96382f4e5
+2026-08-20-client-session-conversation-ownership.md: 12e137209bb43d21c3437d2ce5e9d2f9180bbc77
+2026-08-20-client-session-conversation-ownership.zh.md: f0d9861eeafc07481c536b85f5ba9667573a66ef

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md

@@ -82,7 +82,7 @@ Adding a target does not add a branch to the renderer or Session Controller. The
 | `client/ui-session` | Session scope, standard sources, `SessionProvider`, and pending-interaction aggregation | Session transport, Conversation assembly, Approval/Question results |
 | `client/ui-workspace` | Workspace hook, browser UI, and cross-Controller navigation policy | Workspace transport, copies of Session data |
 | `client/ui-conversation` | Conversation core, registries, bindings, shell, input, composer, queue, and View navigation | Session transport, Chat/Trajectory snapshots |
-| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, locale, and historical images | Session lifecycle, generic View navigation, Trajectory |
+| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, and locale | Session lifecycle, generic View navigation, Trajectory, historical-image cache |
 | `client/ui-trajectory` | Trajectory target, event-record projection, and inspection view | Session snapshots, Chat snapshots |
 | `client/ui-approval` | Pending Approval, Remote listener, composer, and approval UI | Session control, generic composer election |
 | `client/ui-user-questions` | Pending Question, Remote listener, composer, and question UI | Session control, generic composer election |
@@ -296,13 +296,13 @@ Draft and input state belong to Conversation UI and do not enter the Session sna
 
 ### Chat owner
 
-`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, Tool-inspection collaboration, and historical-image cache.
+`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, and Tool-inspection collaboration.
 
 It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatNodeSeat` and internal Chat consumers use `useChat` instead of passing `useConversation(snapshot => snapshot.views.get('chat'))`.
 
 Only visible non-command Chat Nodes activate Chat. Ordinary command-only history keeps the Hero visible; the `/goal` `command-input` Node activates a fresh Conversation.
 
-The historical-image cache's Session key, pending promise, generation guard, blob URL, and disposer all belong to `ui-chat`; draft images remain part of Conversation input.
+The historical-image cache moved to `ui-conversation` (`ctx.uiConversation.imageUrl`), so Chat and Trajectory share one authorized read and one browser URL per session attachment ([Trajectory durable image attachments](../feature/2026-08-24-trajectory-image-attachments.md)); draft images remain part of Conversation input.
 
 ### Trajectory owner
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md

@@ -82,7 +82,7 @@ UI 层可以同时读取多个 Controller 做一次导航决定,但不得把
 | `client/ui-session` | Session scope、标准 source、`SessionProvider`、pending interaction 聚合 | Session transport、Conversation 组装、Approval/Question 结果 |
 | `client/ui-workspace` | Workspace hook、浏览器 UI 和跨 Controller 导航策略 | Workspace transport、Session 数据副本 |
 | `client/ui-conversation` | Conversation core、registry、binding、shell、input、composer、queue 和 View 导航 | Session transport、Chat/Trajectory snapshot |
-| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details、locale 和历史图片 | Session 生命周期、通用 View 导航、Trajectory |
+| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details 和 locale | Session 生命周期、通用 View 导航、Trajectory、历史图片 cache |
 | `client/ui-trajectory` | Trajectory target、事件记录投影和检查视图 | Session snapshot、Chat snapshot |
 | `client/ui-approval` | Pending Approval、Remote listener、composer 和审批 UI | Session control、通用 composer election |
 | `client/ui-user-questions` | Pending Question、Remote listener、composer 和问题 UI | Session control、通用 composer election |
@@ -296,13 +296,13 @@ Draft 与输入状态属于 Conversation UI,不进入 Session snapshot。Queue
 
 ### Chat owner
 
-`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale、tool inspection 协作和历史图片 cache
+`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale 和 tool inspection 协作
 
 它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatNodeSeat` 和 Chat 内部消费者使用 `useChat`,不再传递 `useConversation(snapshot => snapshot.views.get('chat'))`。
 
 Chat activity 只由可见且非 command 的 Chat Node 激活。普通 command-only history 保持 Hero,`/goal` 的 `command-input` Node 激活 fresh Conversation。
 
-历史图片 cache 的 Session key、pending promise、generation guard、blob URL 和 disposer 同属 `ui-chat`;Draft 图片仍属于 Conversation input。
+历史图片 cache 已移入 `ui-conversation`(`ctx.uiConversation.imageUrl`),Chat 与 Trajectory 对同一会话附件共享一次授权读取和一个浏览器 URL([Trajectory 持久化图片附件](../feature/2026-08-24-trajectory-image-attachments.zh.md));Draft 图片仍属于 Conversation input。
 
 ### Trajectory owner
 

+ 6 - 0
.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.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/feature/2026-08-24-trajectory-image-attachments.md
+2026-08-24-trajectory-image-attachments.md: 6f89840a7d4686e45012ea2ae25f4cc939a5ca0c
+2026-08-24-trajectory-image-attachments.zh.md: f1e8634639e42adc094021c03a339b8f8223bfff

+ 33 - 0
.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md

@@ -0,0 +1,33 @@
+# Agent Note: Trajectory durable image attachments
+
+Status: implemented
+
+English | [中文](2026-08-24-trajectory-image-attachments.zh.md)
+
+## Problem
+
+Trajectory did not display session images. A durable `{ type: 'image', attachment: ImageAttachmentRef }` block rendered as pretty-printed JSON in the details panel, and an image-only user message produced an empty ledger row. The only image path Trajectory knew was `imageSrc` sniffing over inline wire fields (`url`, `image_url`, base64 `data`), which no production event carries: every producer commits a durable `ImageAttachmentRef` before its event is appended. Users could not confirm from the execution ledger which image the model saw ([issue #2986](https://github.com/deepseek-harness/deepseek-harness/issues/2986)), while Chat already displayed the same attachments.
+
+## Decision
+
+- `ui-conversation` owns the per-session durable image URL cache. `HistoricalImageCache` moved from `ui-chat` into `packages/client/ui-conversation/src/client/conversation/historical-images.ts` and is served as `ctx.uiConversation.imageUrl(sessionId, attachment)`. Chat and Trajectory resolve through the same instance, so one session attachment costs one `session.attachment` read and one browser URL, revoked when the Session binding is released. This partially supersedes the `ui-chat` cache ownership recorded in [client Session/Conversation ownership](../architecture/2026-08-20-client-session-conversation-ownership.md).
+- The gallery owner contract (`MessageImagesOwnerProps`, `RenderMessageImages`) moved to the `ui-conversation` client contract. `ui-chat` keeps its `conversation.message.images` SlotMap row over the shared owner type; `ui-trajectory` declares its own child slot `conversation.trajectory.images` with the same owner type; `ui-attachment` registers the one `MessageImages` gallery component into both keys, so loading, retry, and lightbox behavior is identical in both views.
+- `TrajectorySourceBlock` carries `attachment?: ImageAttachmentRef` instead of `imageSrc`/`imageAlt`. The inline-source sniffing (`sourceImage`, `safeImageSource`) and the Trajectory-local `PanelImage` renderer are removed: no producer writes inline image bytes or URLs into the session log, so those paths were dead code, and the issue explicitly excludes upload-time transient paths.
+- A record whose content has images but no text labels its ledger row with the locale-owned `layout.imageOnly` count; tool results with only images use the same label for their result summary instead of a JSON dump.
+- Neither the storage nor the BFF changes: `session.attachment` already authorizes by session-log reference (missing, corrupt, and unreferenced attachments fail loud into the gallery's retry state), and sha256 content addressing already stores each image once.
+
+## Alternatives considered
+
+**Keep Trajectory's own `<img>` rendering and feed it resolved URLs.** This duplicates the loading placeholder, retry control, and lightbox that `ui-attachment` already owns, and contradicts [slot-based attachment ownership](../architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md), which rejected cross-plugin component imports.
+
+**Lift the `conversation.message.images` declaration to a shared parent so both views render one key.** `renderSlot` is typed to the declaring entry's own children table, so a sibling `conversation.view` entry cannot render another entry's child key; the slot registry also rejects a second declaration of the same key. A second key sharing the owner type is the supported composition and lets a theme replace either gallery independently.
+
+**Keep the inline `imageSrc` sniffing beside the durable path.** All producers (host prompt admission, `read_image`, MCP projection, ACP ingress) commit durable refs before their events append, so the sniffing matched nothing; keeping it would preserve a non-durable rendering path the acceptance criteria exclude.
+
+**A Trajectory-owned image cache.** A second cache per view issues duplicate `session.attachment` RPCs and duplicate blob URLs for the same session attachment, violating the "Chat and Trajectory reference the same session attachment" requirement for no benefit.
+
+## Consequences
+
+- Both views present one gallery implementation, so image behavior (sizing, retry, lightbox, labels) cannot drift between Chat and Trajectory, and a session attachment is read once regardless of how many views show it.
+- `TrajectoryTable` threads a required `renderImages` prop through its detail components; `ui-trajectory` gains a type-only dependency on `dsh-attachment`, and `ui-attachment` gains a type-only dependency on `ui-trajectory` for the new SlotMap row.
+- The keyless assembled snapshot `apps/web/tests/trajectory-image-display.snapshot.ts` pins the shared-cache fact directly: the details-panel image URL is string-identical to the Chat gallery's URL for the same fixture attachment.

+ 33 - 0
.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: Trajectory 持久化图片附件
+
+Status: implemented
+
+[English](2026-08-24-trajectory-image-attachments.md) | 中文
+
+## Problem
+
+Trajectory 不展示会话图片。持久化的 `{ type: 'image', attachment: ImageAttachmentRef }` 块在详情面板里渲染成格式化 JSON,纯图片的用户消息在记录表中是一个空行。Trajectory 唯一认识的图片路径是对内联 wire 字段(`url`、`image_url`、base64 `data`)的 `imageSrc` 嗅探,而生产事件从不携带这些字段:每个生产方都在事件追加前提交持久化的 `ImageAttachmentRef`。用户无法从执行记录确认模型看到了哪张图([issue #2986](https://github.com/deepseek-harness/deepseek-harness/issues/2986)),而 Chat 已经能展示同样的附件。
+
+## Decision
+
+- `ui-conversation` 拥有按会话的持久化图片 URL 缓存。`HistoricalImageCache` 从 `ui-chat` 移入 `packages/client/ui-conversation/src/client/conversation/historical-images.ts`,以 `ctx.uiConversation.imageUrl(sessionId, attachment)` 提供。Chat 与 Trajectory 通过同一实例解析,因此一个会话附件只产生一次 `session.attachment` 读取和一个浏览器 URL,并随 Session binding 释放而撤销。这部分取代了 [client Session/Conversation 所有权](../architecture/2026-08-20-client-session-conversation-ownership.zh.md)中记录的 `ui-chat` 缓存归属。
+- 画廊 owner 契约(`MessageImagesOwnerProps`、`RenderMessageImages`)移入 `ui-conversation` 客户端契约。`ui-chat` 的 `conversation.message.images` SlotMap 行沿用共享 owner 类型;`ui-trajectory` 以同一 owner 类型声明自己的子槽位 `conversation.trajectory.images`;`ui-attachment` 把同一个 `MessageImages` 画廊组件注册进两个键,因此加载、重试与灯箱行为在两个视图中完全一致。
+- `TrajectorySourceBlock` 以 `attachment?: ImageAttachmentRef` 取代 `imageSrc`/`imageAlt`。内联来源嗅探(`sourceImage`、`safeImageSource`)与 Trajectory 本地的 `PanelImage` 渲染器一并删除:没有生产方向会话日志写入内联图片字节或 URL,这些路径是死代码,且 issue 明确排除上传来源的临时路径。
+- 内容含图片但没有文本的记录,其记录表行以 locale 持有的 `layout.imageOnly` 计数标注;只含图片的工具结果的摘要也使用同一标签,而不是 JSON 转储。
+- 存储与 BFF 均不改动:`session.attachment` 已按会话日志引用授权(缺失、损坏与未被引用的附件显式失败并进入画廊的重试态),sha256 内容寻址已保证每张图片只存一份。
+
+## Alternatives considered
+
+**保留 Trajectory 自己的 `<img>` 渲染并喂给它解析好的 URL。** 这会重复 `ui-attachment` 已拥有的加载占位、重试控件和灯箱,并与[基于 slot 的附件所有权](../architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md)相抵触,该决定已拒绝跨插件直接 import 组件。
+
+**把 `conversation.message.images` 的声明上提到共享父级,让两个视图渲染同一个键。** `renderSlot` 的类型限定在声明入口自己的 children 表内,同级的 `conversation.view` 入口无法渲染另一个入口的子键;slot registry 也拒绝对同一键的第二次声明。共享 owner 类型的第二个键是受支持的组合方式,且允许主题独立替换任一画廊。
+
+**在持久化路径之外保留内联 `imageSrc` 嗅探。** 所有生产方(宿主 prompt admission、`read_image`、MCP 投影、ACP 入口)都在事件追加前提交持久化引用,嗅探不会命中任何东西;保留它等于保留验收标准明确排除的非持久化渲染路径。
+
+**Trajectory 自有的图片缓存。** 每个视图一份缓存会对同一会话附件发出重复的 `session.attachment` RPC 和重复的 blob URL,违背"Chat 与 Trajectory 引用同一会话附件"的要求,且没有任何收益。
+
+## Consequences
+
+- 两个视图共用一个画廊实现,图片行为(尺寸、重试、灯箱、文案)不会在 Chat 与 Trajectory 之间漂移,且无论多少个视图展示,一个会话附件只读取一次。
+- `TrajectoryTable` 需要把必填的 `renderImages` prop 逐层传入详情组件;`ui-trajectory` 新增对 `dsh-attachment` 的仅类型依赖,`ui-attachment` 为新的 SlotMap 行新增对 `ui-trajectory` 的仅类型依赖。
+- keyless 组装快照 `apps/web/tests/trajectory-image-display.snapshot.ts` 直接钉住共享缓存这一事实:详情面板中的图片 URL 与 Chat 画廊对同一 fixture 附件的 URL 字符串相同。

+ 93 - 0
apps/web/tests/trajectory-image-display.expected.e2e.ts

@@ -0,0 +1,93 @@
+// @vitest-environment jsdom
+// Trajectory image surfaces over the BUILT client graph (the code-mode-fixture
+// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
+// Opens the fixture history session whose turn 73 carries an image in BOTH a
+// user message and an assistant message, and pins the Trajectory surfaces:
+// selecting the ledger record renders the shared ui-attachment gallery from
+// the durable session-log reference, and the browser URL is the SAME object
+// URL Chat resolved — one sessions.attachment read per session attachment.
+import { fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { expect, it, vi } from 'vitest'
+import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
+
+installAssembledBootEnv()
+
+/** Open the fixture history session and wait for the Chat gallery to load. */
+async function openFixtureSession(): Promise<void> {
+  const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+  const group = (await within(tree).findAllByText('fixture'))
+    .map(el => el.closest<HTMLElement>('[role="treeitem"]'))
+    .find(el => el?.getAttribute('aria-expanded') !== null)
+  if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
+  if (group.getAttribute('aria-expanded') === 'false') {
+    fireEvent.click(within(group).getByText('fixture'))
+    await waitFor(() => {
+      expect(group.getAttribute('aria-expanded')).toBe('true')
+    })
+  }
+  const session = await within(tree).findByText('Fixture 历史会话')
+  fireEvent.click(session)
+  await waitFor(() => {
+    expect(document.querySelectorAll('[data-align] img').length).toBeGreaterThan(0)
+  }, { timeout: 10_000 })
+}
+
+/** Scroll the virtual ledger until the row whose text contains `needle` mounts. */
+async function scrollRowIntoWindow(needle: string): Promise<HTMLElement> {
+  await waitFor(() => {
+    if (document.querySelectorAll('tr[data-trajectory-row-key]').length === 0) {
+      throw new Error('trajectory rows not mounted')
+    }
+  }, { timeout: 10_000 })
+  const pane = document.querySelector('[data-trajectory-scroll] table')?.parentElement
+  if (!(pane instanceof HTMLElement)) throw new Error('trajectory scroll pane missing')
+  for (let top = 0; top <= 40_000; top += 1_000) {
+    pane.scrollTop = top
+    fireEvent.scroll(pane)
+    // Let the virtualizer publish the new window before probing.
+    await new Promise(resolve => setTimeout(resolve, 25))
+    const hit = [...document.querySelectorAll<HTMLElement>('tr[data-trajectory-row-key]')]
+      .find(row => row.textContent?.includes(needle))
+    if (hit !== undefined) return hit
+  }
+  throw new Error(`trajectory row containing ${JSON.stringify(needle)} never mounted`)
+}
+
+it('renders durable record images in the Trajectory details panel from the shared cache', async () => {
+  // The virtual ledger needs a measurable viewport; jsdom reports zero
+  // heights, so pin one and neutralize the imperative tail scroll.
+  vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
+  Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
+    configurable: true,
+    value: () => {},
+  })
+  mountAssembledApp()
+  await openFixtureSession()
+  const chatSrc = document.querySelector('[data-align="end"] img')?.getAttribute('src')
+  if (chatSrc === null || chatSrc === undefined) throw new Error('chat gallery image missing')
+
+  fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
+  const userRow = await scrollRowIntoWindow('历史用户图片')
+  fireEvent.click(userRow)
+
+  // Selecting the record opens the details panel; the ui-attachment gallery
+  // resolves the durable reference through the SAME per-session cache Chat
+  // used, so the object URL is identical — no second attachment read.
+  const panel = await screen.findByRole('tabpanel')
+  await waitFor(() => {
+    expect(within(panel).getAllByRole('img').length).toBeGreaterThan(0)
+  }, { timeout: 10_000 })
+  expect(within(panel).getAllByRole('img').map(img => ({
+    alt: img.getAttribute('alt'),
+    scheme: img.getAttribute('src')?.split(':')[0],
+    sharedWithChat: img.getAttribute('src') === chatSrc,
+  }))).toMatchInlineSnapshot(`
+    [
+      {
+        "alt": "fixture-image.png",
+        "scheme": "blob",
+        "sharedWithChat": true,
+      },
+    ]
+  `)
+})

+ 2 - 2
docs/module-graph.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 docs/module-graph.md
-module-graph.md: 985636d3889394b912dcc1d68cb9e0a4fc1cb11b
-module-graph.zh.md: d352643e21e8d54e523885ea06320610e6951dfe
+module-graph.md: 1cca77ee09ce2a47e732893d814531daac3c07ad
+module-graph.zh.md: 46390d0c897f55c79bb183ea56deb2843fd80b94

+ 5 - 4
docs/module-graph.md

@@ -1454,6 +1454,7 @@ flowchart TD
   pkg_client_ui_settings_general --> pkg_settings
   pkg_client_ui_trajectory --> pkg_agent
   pkg_client_ui_trajectory --> pkg_api_session_controller
+  pkg_client_ui_trajectory --> pkg_attachment
   pkg_client_ui_trajectory --> pkg_client_locale
   pkg_client_ui_trajectory --> pkg_client_ui_conversation
   pkg_client_ui_trajectory --> pkg_client_ui_renderer
@@ -1494,7 +1495,6 @@ flowchart TD
   pkg_client_ui_chat --> pkg_session_stats
   pkg_client_ui_chat --> pkg_token_meter
   pkg_client_ui_chat --> pkg_tools
-  pkg_client_ui_chat --> pkg_util_crypto
   pkg_client_ui_chat --> pkg_util_workspace_path
   pkg_client_ui_commands --> pkg_api_remotes
   pkg_client_ui_commands --> pkg_api_session_controller
@@ -1541,6 +1541,7 @@ flowchart TD
   pkg_client_ui_attachment --> pkg_client_ui_chat
   pkg_client_ui_attachment --> pkg_client_ui_conversation
   pkg_client_ui_attachment --> pkg_client_ui_renderer
+  pkg_client_ui_attachment --> pkg_client_ui_trajectory
   pkg_client_ui_attachment --> pkg_invariants
   pkg_client_ui_deliverables --> pkg_client_connection
   pkg_client_ui_deliverables --> pkg_client_locale
@@ -1876,15 +1877,15 @@ flowchart TD
 | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) |
 | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
-| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
+| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
-| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path) |
+| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
 | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) |
-| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
 | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
 | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |

+ 5 - 4
docs/module-graph.zh.md

@@ -1456,6 +1456,7 @@ flowchart TD
   pkg_client_ui_settings_general --> pkg_settings
   pkg_client_ui_trajectory --> pkg_agent
   pkg_client_ui_trajectory --> pkg_api_session_controller
+  pkg_client_ui_trajectory --> pkg_attachment
   pkg_client_ui_trajectory --> pkg_client_locale
   pkg_client_ui_trajectory --> pkg_client_ui_conversation
   pkg_client_ui_trajectory --> pkg_client_ui_renderer
@@ -1496,7 +1497,6 @@ flowchart TD
   pkg_client_ui_chat --> pkg_session_stats
   pkg_client_ui_chat --> pkg_token_meter
   pkg_client_ui_chat --> pkg_tools
-  pkg_client_ui_chat --> pkg_util_crypto
   pkg_client_ui_chat --> pkg_util_workspace_path
   pkg_client_ui_commands --> pkg_api_remotes
   pkg_client_ui_commands --> pkg_api_session_controller
@@ -1543,6 +1543,7 @@ flowchart TD
   pkg_client_ui_attachment --> pkg_client_ui_chat
   pkg_client_ui_attachment --> pkg_client_ui_conversation
   pkg_client_ui_attachment --> pkg_client_ui_renderer
+  pkg_client_ui_attachment --> pkg_client_ui_trajectory
   pkg_client_ui_attachment --> pkg_invariants
   pkg_client_ui_deliverables --> pkg_client_connection
   pkg_client_ui_deliverables --> pkg_client_locale
@@ -1878,15 +1879,15 @@ flowchart TD
 | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) |
 | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
-| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
+| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
-| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path) |
+| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
 | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) |
-| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
 | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
 | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |

+ 2 - 2
docs/subsystems/slots.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 docs/subsystems/slots.md
-slots.md: d201223c9e630f16f310d8ff90318ab8c43211e5
-slots.zh.md: 23277e94e2a9e85be7f1745a172dfd9cb8a5be37
+slots.md: 6eb61780ca2f06ebc38a0fcf2638a7fafcd5ceee
+slots.zh.md: e5a25763d382c228525f3da24694e41dd09737e0

+ 2 - 1
docs/subsystems/slots.md

@@ -134,7 +134,8 @@ root
 │  │     │  ├─ conversation.chat.turnTail
 │  │     │  └─ tool.call.toolview
 │  │     │     └─ tool.view.cordis
-│  │     └─ conversation.message.images
+│  │     ├─ conversation.message.images
+│  │     └─ conversation.trajectory.images
 │  ├─ conversation.session.header
 │  │  ├─ conversation.session.header.lineage
 │  │  ├─ conversation.session.header.actions

+ 2 - 1
docs/subsystems/slots.zh.md

@@ -134,7 +134,8 @@ root
 │  │     │  ├─ conversation.chat.turnTail
 │  │     │  └─ tool.call.toolview
 │  │     │     └─ tool.view.cordis
-│  │     └─ conversation.message.images
+│  │     ├─ conversation.message.images
+│  │     └─ conversation.trajectory.images
 │  ├─ conversation.session.header
 │  │  ├─ conversation.session.header.lineage
 │  │  ├─ conversation.session.header.actions

+ 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: 925187da23e9acec9a69959bfc29ce7ec62f0096
-README.zh.md: 2872dac19e36143923ba39e4b3efbd6e8bb27cac
+README.md: ee9b604d0227b5ae52f833f97f80429e4ab93bea
+README.zh.md: 8d8cd43c32587f258de3e6a26fa7e0d0127dbf57

+ 1 - 1
packages/client/ui-attachment/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Dynamic attachment presentation plugin for the conversation UI. It waits for the conversation package's `conversation.input.attachments` and `conversation.message.images` declarations through `ctx.slots.inject`, then registers the composer draft-image rail, document drop target, chat-history image gallery, and original-image lightbox. The conversation slot owner supplies attachment data, image loading, callbacks, and its namespace translator; presentation components remain pure props and are not exported from the package entry.
+Dynamic attachment presentation plugin for the conversation UI. It waits for the `conversation.input.attachments`, `conversation.message.images`, and `conversation.trajectory.images` declarations through `ctx.slots.inject`, then registers the composer draft-image rail, document drop target, the history image gallery serving both the Chat transcript and the Trajectory inspector, and the original-image lightbox. The conversation slot owner supplies attachment data, image loading, callbacks, and its namespace translator; presentation components remain pure props and are not exported from the package entry.
 
 ## Attachment rail
 

+ 1 - 1
packages/client/ui-attachment/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-对话 UI 的动态附件呈现插件。它通过 `ctx.slots.inject` 等待 conversation 包声明 `conversation.input.attachments` 与 `conversation.message.images`,随后注册输入框草稿图片栏、文档拖放目标、聊天历史图片画廊和原图灯箱。conversation slot 持有方提供附件数据、图片加载、回调及其命名空间翻译器;呈现组件保持纯 props,且不从包入口导出。
+对话 UI 的动态附件呈现插件。它通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images` 与 `conversation.trajectory.images` 声明,随后注册输入框草稿图片栏、文档拖放目标、同时服务 Chat 会话记录与 Trajectory 检查器的历史图片画廊,以及原图灯箱。conversation slot 持有方提供附件数据、图片加载、回调及其命名空间翻译器;呈现组件保持纯 props,且不从包入口导出。
 
 ## 附件栏
 

+ 5 - 2
packages/client/ui-attachment/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-attachment",
-  "description": "Dynamic attachment presentation plugin for conversation input and message-image slots",
+  "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots",
   "version": "0.1.1-rc.2",
   "publishConfig": {
     "access": "public"
@@ -34,7 +34,8 @@
       "inject": [
         "@deepseek-ai/dsh-client-ui-chat",
         "@deepseek-ai/dsh-client-ui-conversation",
-        "@deepseek-ai/dsh-client-ui-renderer"
+        "@deepseek-ai/dsh-client-ui-renderer",
+        "@deepseek-ai/dsh-client-ui-trajectory"
       ],
       "platform": "web"
     }
@@ -54,6 +55,7 @@
     "@types/react-dom": "~18.3.0",
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
@@ -72,6 +74,7 @@
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^"
   }

+ 5 - 0
packages/client/ui-attachment/src/client/index.ts

@@ -3,6 +3,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client'
 import { ComposerAttachments } from './ComposerAttachments.tsx'
 import { MessageImages } from './MessageImages.tsx'
 
@@ -19,4 +20,8 @@ export function apply(ctx: ClientContext): void {
     name: 'conversation.message.images',
     locale: 'conversation',
   }, MessageImages))
+  ctx.slots.inject('conversation.trajectory.images', () => ctx.slots.register({
+    name: 'conversation.trajectory.images',
+    locale: 'conversation',
+  }, MessageImages))
 }

+ 7 - 1
packages/client/ui-attachment/tests/plugin.client.spec.ts

@@ -14,6 +14,7 @@ async function bench() {
     children: {
       'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' },
       'conversation.message.images': { kind: 'single', scope: 'session' },
+      'conversation.trajectory.images': { kind: 'single', scope: 'session' },
     },
   } as never, () => null)
   const fiber = ctx.plugin({ inject: [...inject], apply })
@@ -26,7 +27,7 @@ describe('attachment plugin', () => {
     expect(() => { applyHost() }).not.toThrow()
   })
 
-  it('registers both entries and removes them with the plugin fiber', async () => {
+  it('registers all entries and removes them with the plugin fiber', async () => {
     const { ctx, fiber } = await bench()
     expect(inject).toEqual(['slots'])
     expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{
@@ -37,10 +38,15 @@ describe('attachment plugin', () => {
       locale: 'conversation',
       component: MessageImages,
     }])
+    expect(ctx.slots.entries('conversation.trajectory.images')).toMatchObject([{
+      locale: 'conversation',
+      component: MessageImages,
+    }])
 
     await fiber.dispose()
 
     expect(ctx.slots.entries('conversation.input.attachments')).toHaveLength(0)
     expect(ctx.slots.entries('conversation.message.images')).toHaveLength(0)
+    expect(ctx.slots.entries('conversation.trajectory.images')).toHaveLength(0)
   })
 })

+ 3 - 0
packages/client/ui-attachment/tsconfig.json

@@ -23,6 +23,9 @@
     {
       "path": "../ui-conversation"
     },
+    {
+      "path": "../ui-trajectory"
+    },
     {
       "path": "../ui-slots"
     },

+ 2 - 2
packages/client/ui-chat/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-chat/README.md
-README.md: 56bb20ab7b4d9b5c0c95142b311a07ad9b8a1fd3
-README.zh.md: 40ee1ee710e2f86e802ffaac4dc0bb10852f128f
+README.md: 5253cb95b0e5c0b89c32646e2ae2915936d35288
+README.zh.md: 8cd2d0d581a0493892aed23f42ebc0c229a0bc17

+ 1 - 1
packages/client/ui-chat/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, historical images, and scroll restoration.
+The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`).
 
 ## Model Experience
 

+ 1 - 1
packages/client/ui-chat/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化、历史图片与滚动位置恢复。
+Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析
 
 ## 模型体验
 

+ 0 - 2
packages/client/ui-chat/package.json

@@ -72,7 +72,6 @@
     "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
-    "@deepseek-ai/dsh-util-crypto": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^"
   },
   "devDependencies": {
@@ -101,7 +100,6 @@
     "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
-    "@deepseek-ai/dsh-util-crypto": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@types/react": "~18.3.1",
     "react": "^18.2.0"

+ 1 - 3
packages/client/ui-chat/src/client/apply.ts

@@ -23,7 +23,6 @@ import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
 import { StatsLine } from './chat/StatsLine.tsx'
 import { registerConversationNodes } from './conversation-nodes/register.ts'
 import { DetailsPanel } from './details/DetailsPanel.tsx'
-import { HistoricalImageCache } from './historical-images.ts'
 import { en, NS, zh } from './locale.ts'
 import { createChatStore } from './stores.ts'
 
@@ -74,7 +73,6 @@ export function apply(ctx: Context): void {
   const t = ctx.locale.bind(NS)
   const chatStore = createChatStore()
   const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
-  const images = new HistoricalImageCache(ctx)
 
   ctx.slots.inject('conversation.view', () => {
     const disposeView = ctx.slots.register({
@@ -102,7 +100,7 @@ export function apply(ctx: Context): void {
             return ctx.uiWorkspace.openPath(resolveWorkspacePath(cwd, path))
           },
           loadOlder: () => { void session.loadOlder() },
-          loadImage: attachment => images.resolve(sessionId, attachment),
+          loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment),
           chatScroll: {
             save: (position) => {
               if (position === null) chatScrollPositions.delete(sessionId)

+ 4 - 2
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -2,9 +2,11 @@
 // otherwise this view owns it. Each row subscribes to one stable node key.
 
 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
-import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type {
+  ConversationTimelineSnapshot, RenderMessageImages,
+} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts'
+import type { ChatViewSlotProps } from '../contract/slots.ts'
 import { PendingSteeringBubble } from './MessageItem.tsx'
 import { ChatNodeSeat } from './ChatNodeSeat.tsx'
 import { formatRunDuration } from './message-chrome.ts'

+ 1 - 12
packages/client/ui-chat/src/client/contract/slots.ts

@@ -1,9 +1,8 @@
 /** Chat-owned Slot declarations and composed component props. */
-import type { ReactNode } from 'react'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type {
-  ConversationTurnDataMap, TurnLocation,
+  ConversationTurnDataMap, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {
   InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
@@ -19,16 +18,6 @@ import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock }
 /** Selector hook over the current Conversation binding's Chat target. */
 export type UseChat = SnapshotSelectorHook<ChatSnapshot>
 
-/** Historical image group handed to the optional attachment presentation plugin. */
-export interface MessageImagesOwnerProps {
-  images: readonly { readonly attachment: ImageAttachmentRef }[]
-  loadImage: (attachment: ImageAttachmentRef) => Promise<string>
-  align: 'start' | 'end'
-}
-
-/** Slot-backed renderer used by Chat nodes without importing an attachment implementation. */
-export type RenderMessageImages = (owner: Omit<MessageImagesOwnerProps, 'loadImage'>) => ReactNode
-
 /** Owner currency of the completed-Turn extension chain. */
 export interface TurnTailOwnerProps {
   turn: TurnLocation

+ 1 - 2
packages/client/ui-chat/src/client/index.ts

@@ -28,8 +28,7 @@ export type {
   AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected,
   ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps,
   CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps,
-  DetailsToolOwnerProps, MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages,
-  TurnTailOwnerProps, UseChat, UseChatNodeTurnData,
+  DetailsToolOwnerProps, MessageImagesProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData,
 } from './contract/slots.ts'
 export type { ChatKey } from './locale.ts'
 export type { ConversationContext, ConversationContextOriginKind } from './model/conversation-context.ts'

+ 1 - 1
packages/client/ui-chat/tests/image-labels.client.spec.tsx

@@ -5,8 +5,8 @@ import { cleanup, render } from '@testing-library/react'
 import { AttachmentId } from '@deepseek-ai/dsh-attachment'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
-import type { RenderMessageImages } from '../src/client/contract/slots.ts'
 import { zh } from '../src/client/locale.ts'
 
 afterEach(cleanup)

+ 0 - 3
packages/client/ui-chat/tsconfig.json

@@ -50,9 +50,6 @@
     {
       "path": "../../runtime-diagnostics/invariants"
     },
-    {
-      "path": "../../util/crypto"
-    },
     {
       "path": "../../util/workspace-path"
     },

+ 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: 08d1408bea404a80da491078aa013989835fccaf
-README.zh.md: 874aea8865e6f74a64da2c249087bdad032d98d5
+README.md: 8506cc9e2d2935151003ad11e34d056dda70e0ff
+README.zh.md: bf6643112878f6537d3db1d1686db046202fada5

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller event feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers.
+`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller event feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one session-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers.
 
 ## Conversation assembly
 

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller event feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。
+`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller event feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。
 
 ## Conversation 组装
 

+ 14 - 0
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -1,5 +1,6 @@
 /** Target-neutral Conversation slot declarations and composed component props. */
 import type { ReactNode, RefObject } from 'react'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type {
@@ -43,6 +44,19 @@ export interface ComposerAttachmentsOwnerProps {
   dropLimits?: { readonly count: number; readonly size: string } | undefined
 }
 
+/** Durable image group handed to the optional attachment presentation plugin. */
+export interface MessageImagesOwnerProps {
+  /** Durable image references in source order. */
+  images: readonly { readonly attachment: ImageAttachmentRef }[]
+  /** Session-authorized image URL loader. */
+  loadImage: (attachment: ImageAttachmentRef) => Promise<string>
+  /** Horizontal placement inside the owning record. */
+  align: 'start' | 'end'
+}
+
+/** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */
+export type RenderMessageImages = (owner: Omit<MessageImagesOwnerProps, 'loadImage'>) => ReactNode
+
 /** Selector hook over the current Session's assembled Conversation. */
 export type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
 /** Selector hook over the registered Conversation View roster. */

+ 15 - 0
packages/client/ui-conversation/src/client/conversation/assembly.ts

@@ -1,5 +1,6 @@
 /** Per-Session target-neutral Conversation assembly. */
 import { Service, type Context } from '@deepseek-ai/cordis'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {
   ISessions, SessionBinding, SessionEventSource, SessionEventWindow,
 } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -15,6 +16,7 @@ import type {
 import type { ConversationSnapshot } from '../contract/snapshot.ts'
 import { ConversationNodeAssembler } from './assembler.ts'
 import { ConversationEventRegistry } from './event-registry.ts'
+import { HistoricalImageCache } from './historical-images.ts'
 import { ConversationViewRegistry } from './view-registry.ts'
 
 /** Observable faces published for one Session's Conversation assembly. */
@@ -144,6 +146,7 @@ export class UiConversation extends Service {
   /** Registry of target View definitions. */
   readonly views: ConversationViewRegistry
   private readonly bindings = new Map<SessionId, BindingRecord>()
+  private readonly images: HistoricalImageCache
 
   /**
    * @param ctx - owning Client context.
@@ -153,6 +156,7 @@ export class UiConversation extends Service {
     super(ctx, 'uiConversation')
     this.events = new ConversationEventRegistry(ctx)
     this.views = new ConversationViewRegistry(ctx)
+    this.images = new HistoricalImageCache(ctx, sessions)
     const rebuild = (): void => {
       for (const record of this.bindings.values()) record.binding.rebuild()
     }
@@ -202,6 +206,17 @@ export class UiConversation extends Service {
     return binding
   }
 
+  /**
+   * Resolve one session-authorized durable image URL, cached per Session so
+   * every Conversation target shares one read and one browser URL.
+   * @param sessionId - Session authorization and lifetime scope.
+   * @param attachment - Durable image reference from a session event.
+   * @returns browser URL valid until the Session binding is released.
+   */
+  imageUrl(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
+    return this.images.resolve(sessionId, attachment)
+  }
+
   private drop(record: BindingRecord, releaseScope: boolean): void {
     if (this.bindings.get(record.source.sessionId) !== record) return
     this.bindings.delete(record.source.sessionId)

+ 11 - 12
packages/client/ui-chat/src/client/historical-images.ts → packages/client/ui-conversation/src/client/conversation/historical-images.ts

@@ -1,4 +1,4 @@
-/** Session-scoped historical image URL cache owned by the Chat plugin. */
+/** Session-scoped durable image URL cache shared by Conversation targets. */
 import type { Context } from '@deepseek-ai/cordis'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -11,9 +11,8 @@ interface ImageUrlEntry {
   readonly pending: Promise<string>
 }
 
-/** Resolve durable Chat images and release their browser URLs with Session scope. */
+/** Resolve durable Conversation images and release their browser URLs with Session scope. */
 export class HistoricalImageCache {
-  private readonly sessions: ISessions
   private readonly entries = new Map<string, ImageUrlEntry>()
   private readonly generations = new Map<SessionId, number>()
   private readonly scopeDisposers = new Map<SessionId, () => void>()
@@ -21,11 +20,11 @@ export class HistoricalImageCache {
   private disposed = false
 
   /**
-   * @param ctx - Owning ui-chat fiber.
+   * @param ctx - Owning ui-conversation fiber.
+   * @param sessions - Session Controller object layer.
    */
-  constructor(ctx: Context) {
-    this.sessions = ctx.sessions
-    ctx.effect(() => () => { this.dispose() }, 'ui-chat historical image cache')
+  constructor(ctx: Context, private readonly sessions: ISessions) {
+    ctx.effect(() => () => { this.dispose() }, 'ui-conversation historical image cache')
   }
 
   /**
@@ -35,22 +34,22 @@ export class HistoricalImageCache {
    * @returns browser URL valid until the Session binding is released.
    */
   resolve(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
-    if (this.disposed) return Promise.reject(new Error('ui-chat image cache is disposed'))
+    if (this.disposed) return Promise.reject(new Error('ui-conversation image cache is disposed'))
     const key = `${sessionId}:${attachment.attachmentId}`
     const cached = this.entries.get(key)
     if (cached !== undefined) return cached.pending
     const binding = this.sessions.binding(sessionId)
     if (binding === undefined) {
-      return Promise.reject(new Error(`ui-chat: unknown session "${sessionId}"`))
+      return Promise.reject(new Error(`ui-conversation: unknown session "${sessionId}"`))
     }
     this.bindScope(sessionId, binding.ctx)
     const generation = this.generations.get(sessionId) ?? 0
     const pending = binding.session.readAttachment(attachment.attachmentId)
       .then((result) => {
         if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
-        if (this.disposed) throw new Error('ui-chat image cache was disposed before loading completed')
+        if (this.disposed) throw new Error('ui-conversation image cache was disposed before loading completed')
         if ((this.generations.get(sessionId) ?? 0) !== generation) {
-          throw new Error('ui-chat image scope was released before loading completed')
+          throw new Error('ui-conversation image scope was released before loading completed')
         }
         if (typeof URL.createObjectURL !== 'function') {
           return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}`
@@ -73,7 +72,7 @@ export class HistoricalImageCache {
     const dispose = scope.effect(() => () => {
       this.scopeDisposers.delete(sessionId)
       this.release(sessionId)
-    }, 'ui-chat historical image scope')
+    }, 'ui-conversation historical image scope')
     this.scopeDisposers.set(sessionId, () => { void dispose() })
   }
 

+ 1 - 1
packages/client/ui-conversation/src/client/index.ts

@@ -51,7 +51,7 @@ export type {
   ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps,
   ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps,
   HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone,
-  UseConversation, UseConversationViews,
+  MessageImagesOwnerProps, RenderMessageImages, UseConversation, UseConversationViews,
 } from './contract/slots.ts'
 export type {
   ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest,

+ 3 - 3
packages/client/ui-chat/tests/historical-images.client.spec.ts → packages/client/ui-conversation/tests/historical-images.client.spec.ts

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
 import { AttachmentId } from '@deepseek-ai/dsh-attachment'
 import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client'
 import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
-import { HistoricalImageCache } from '../src/client/historical-images.ts'
+import { HistoricalImageCache } from '../src/client/conversation/historical-images.ts'
 
 describe('HistoricalImageCache', () => {
   it('invalidates a pending image load when its Session binding is released', async () => {
@@ -13,7 +13,7 @@ describe('HistoricalImageCache', () => {
       id: 's1',
       session: { readAttachment: () => read.promise },
     })
-    const cache = new HistoricalImageCache(runtime.ctx)
+    const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions)
     const attachment = {
       attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
     } as const
@@ -22,7 +22,7 @@ describe('HistoricalImageCache', () => {
     await runtime.sessions.remove(sessionId)
     read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } })
 
-    await expect(pending).rejects.toThrow('ui-chat image scope was released before loading completed')
+    await expect(pending).rejects.toThrow('ui-conversation image scope was released before loading completed')
     await runtime.dispose()
   })
 })

+ 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: 97badd562bbf132c9766c763ff604306d1a6c08d
-README.zh.md: 5a17ee811047e8ffd15be849595d87adfe4ddf00
+README.md: c623105b9bb84edbd8ff6a91244271f6fc92d943
+README.zh.md: 007f4451be9ac2f19c7fdd8e6eefe410813c7eb7

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
packages/client/ui-trajectory/README.md


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
packages/client/ui-trajectory/README.zh.md


+ 4 - 2
packages/client/ui-trajectory/package.json

@@ -62,7 +62,8 @@
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
-    "@deepseek-ai/dsh-llm": "workspace:^"
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-attachment": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
@@ -85,7 +86,8 @@
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
-    "@deepseek-ai/dsh-llm": "workspace:^"
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-attachment": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 0 - 29
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css

@@ -1545,35 +1545,6 @@
   white-space: pre-wrap;
 }
 
-.panelImageLink {
-  display: block;
-  width: auto;
-  max-width: 100%;
-  overflow: hidden;
-  border-radius: 4px;
-  cursor: zoom-in;
-}
-
-.panelImageLinkPreview {
-  max-height: 140px;
-}
-
-.panelImage {
-  display: block;
-  width: auto;
-  max-width: 100%;
-  height: auto;
-  max-height: 320px;
-  margin: 0;
-  border-radius: inherit;
-  background: var(--dsw-alias-bg-base);
-  object-fit: contain;
-}
-
-.panelImageLinkPreview .panelImage {
-  max-height: 140px;
-}
-
 .messageImages {
   display: flex;
   flex-direction: column;

+ 59 - 46
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -14,8 +14,9 @@ import {
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { JsonTreeLabels, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
 import { structuredPatch } from 'diff'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {
-  AssistantRequestConfig, ConversationPromptSnapshot,
+  AssistantRequestConfig, ConversationPromptSnapshot, RenderMessageImages,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {
   AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
@@ -377,6 +378,8 @@ function AssistantTimingPanel({
 export interface TrajectoryTableProps {
   /** Trajectory locale seat. */
   t: TrajectoryTranslate
+  /** Slot-backed durable image renderer shared with the Chat gallery. */
+  renderImages: RenderMessageImages
   /** Session-global request numbers for the request groups visible in this context. */
   requestNumbers?: readonly TrajectoryRequestNumber[]
   /** Grouped records in display order. */
@@ -1121,10 +1124,12 @@ function MarkdownFragment({
 function SourceBlocks({
   blocks,
   onOpenCall,
+  renderImages,
   t,
 }: {
   blocks: readonly TrajectorySourceBlock[]
   onOpenCall: (callId: string) => void
+  renderImages: RenderMessageImages
   t: TrajectoryTranslate
 }) {
   return (
@@ -1155,8 +1160,10 @@ function SourceBlocks({
                 </span>
               </div>
             )}
-          {block.imageSrc !== undefined
-            ? <PanelImage block={block} t={t} />
+          {/* 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>
       ))}
@@ -1164,47 +1171,27 @@ function SourceBlocks({
   )
 }
 
-function PanelImage({
-  block,
-  preview = false,
-  t,
-}: {
-  block: TrajectorySourceBlock
-  preview?: boolean
-  t: TrajectoryTranslate
-}) {
-  if (block.imageSrc === undefined) return null
-  return (
-    <a
-      className={preview ? `${css.panelImageLink} ${css.panelImageLinkPreview}` : css.panelImageLink}
-      href={block.imageSrc}
-      target="_blank"
-      rel="noopener noreferrer"
-      title={t('block.openImage')}
-    >
-      <img
-        className={css.panelImage}
-        src={block.imageSrc}
-        alt={block.imageAlt ?? ''}
-      />
-    </a>
-  )
+function recordImages(
+  blocks: readonly TrajectorySourceBlock[] | undefined,
+): { readonly attachment: ImageAttachmentRef }[] {
+  return (blocks ?? []).flatMap(block =>
+    block.attachment !== undefined ? [{ attachment: block.attachment }] : [])
 }
 
 function MessageImages({
   blocks,
   preview,
-  t,
+  renderImages,
 }: {
   blocks: readonly TrajectorySourceBlock[] | undefined
   preview: boolean
-  t: TrajectoryTranslate
+  renderImages: RenderMessageImages
 }) {
-  const images = blocks?.filter(block => block.imageSrc !== undefined) ?? []
+  const images = recordImages(blocks)
   if (images.length === 0) return null
   return (
     <div className={preview ? `${css.messageImages} ${css.messageImagesPreview}` : css.messageImages}>
-      {images.map((block, index) => <PanelImage block={block} preview={preview} t={t} key={index} />)}
+      {renderImages({ images, align: 'start' })}
     </div>
   )
 }
@@ -1403,13 +1390,16 @@ function SystemPromptDiff({
 function ToolOutputBlocks({
   blocks,
   error,
+  errorDetail,
   preview,
-  t,
+  renderImages,
 }: {
   blocks: readonly TrajectorySourceBlock[]
   error: boolean
+  /** Failure name and code preserved beside image-only error content. */
+  errorDetail?: string | undefined
   preview: boolean
-  t: TrajectoryTranslate
+  renderImages: RenderMessageImages
 }) {
   return (
     <div className={[
@@ -1418,9 +1408,15 @@ function ToolOutputBlocks({
       error ? css.errorPayload : undefined,
     ].filter((value): value is string => value !== undefined).join(' ')}
     >
+      {error && errorDetail !== undefined && errorDetail !== ''
+        && <pre className={css.resultBlockText}>{errorDetail}</pre>}
       {blocks.map((block, index) => (
-        block.imageSrc !== undefined
-          ? <PanelImage block={block} preview={preview} t={t} key={index} />
+        block.attachment !== undefined
+          ? (
+            <div className={css.messageImages} key={index}>
+              {renderImages({ images: [{ attachment: block.attachment }], align: 'start' })}
+            </div>
+          )
           : block.content !== ''
             ? <pre className={css.resultBlockText} key={index}>{block.content}</pre>
             : null
@@ -1436,6 +1432,7 @@ function MarkdownRecordContent({
   thinkingExpanded,
   onThinkingExpandedChange,
   onOpenCall,
+  renderImages,
   t,
 }: {
   record: TableRecord
@@ -1444,10 +1441,18 @@ function MarkdownRecordContent({
   thinkingExpanded: boolean
   onThinkingExpandedChange: (expanded: boolean) => void
   onOpenCall: (callId: string) => void
+  renderImages: RenderMessageImages
   t: TrajectoryTranslate
 }) {
   if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) {
-    return <SourceBlocks blocks={record.cell.sourceBlocks} onOpenCall={onOpenCall} t={t} />
+    return (
+      <SourceBlocks
+        blocks={record.cell.sourceBlocks}
+        onOpenCall={onOpenCall}
+        renderImages={renderImages}
+        t={t}
+      />
+    )
   }
   if (record.cell.thinkingDetail) {
     if (!rendered) {
@@ -1502,13 +1507,13 @@ function MarkdownRecordContent({
         <MessageImages
           blocks={record.cell.sourceBlocks}
           preview={preview}
-          t={t}
+          renderImages={renderImages}
         />
       </div>
     )
   }
   const source = markdownSource(record)
-  const hasImages = record.cell.sourceBlocks?.some(block => block.imageSrc !== undefined) === true
+  const hasImages = record.cell.sourceBlocks?.some(block => block.attachment !== undefined) === true
   const hasToolCalls = record.cell.kind === 'message'
     && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true
   if (!source && !hasImages && !hasToolCalls) {
@@ -1531,7 +1536,7 @@ function MarkdownRecordContent({
           t={t}
         />
       )}
-      <MessageImages blocks={record.cell.sourceBlocks} preview={preview} t={t} />
+      <MessageImages blocks={record.cell.sourceBlocks} preview={preview} renderImages={renderImages} />
     </div>
   )
 }
@@ -1590,11 +1595,13 @@ function RecordPayload({
   record,
   direction,
   preview = false,
+  renderImages,
   t,
 }: {
   record: TableRecord
   direction: 'input' | 'output'
   preview?: boolean
+  renderImages: RenderMessageImages
   t: TrajectoryTranslate
 }) {
   const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail
@@ -1624,14 +1631,15 @@ function RecordPayload({
   if (
     direction === 'output'
     && record.cell.outputBlocks?.some(block =>
-      block.imageSrc !== undefined || block.content !== '') === true
+      block.attachment !== undefined || block.content !== '') === true
   ) {
     return (
       <ToolOutputBlocks
         blocks={record.cell.outputBlocks}
         error={error}
+        errorDetail={error ? value : undefined}
         preview={preview}
-        t={t}
+        renderImages={renderImages}
       />
     )
   }
@@ -1791,6 +1799,7 @@ function OverviewSection({
  */
 export function TrajectoryTable({
   t,
+  renderImages,
   requestNumbers: sessionRequestNumbers,
   turns,
   streamingCells = [],
@@ -2990,6 +2999,7 @@ export function TrajectoryTable({
                   >
                     <MarkdownRecordContent
                       record={selected}
+                      renderImages={renderImages}
                       rendered
                       thinkingExpanded={thinkingExpanded}
                       onThinkingExpandedChange={setThinkingExpanded}
@@ -3103,6 +3113,7 @@ export function TrajectoryTable({
                         <OverviewSection label={t('tab.preview')} onOpen={() => { activateTab('rendered') }}>
                           <MarkdownRecordContent
                             record={selected}
+                            renderImages={renderImages}
                             rendered
                             preview
                             thinkingExpanded={thinkingExpanded}
@@ -3117,12 +3128,12 @@ export function TrajectoryTable({
                       <>
                         {selected.cell.inputDetail && (
                           <OverviewSection label={t('tab.payload')} onOpen={() => { activateTab('input') }}>
-                            <RecordPayload record={selected} direction="input" preview t={t} />
+                            <RecordPayload record={selected} direction="input" preview renderImages={renderImages} t={t} />
                           </OverviewSection>
                         )}
                         {selected.cell.outputDetail && (
                           <OverviewSection label={t('tab.result')} onOpen={() => { activateTab('output') }}>
-                            <RecordPayload record={selected} direction="output" preview t={t} />
+                            <RecordPayload record={selected} direction="output" preview renderImages={renderImages} t={t} />
                           </OverviewSection>
                         )}
                         <OverviewSection label={t('tab.schema')} onOpen={() => { activateTab('schema') }}>
@@ -3151,6 +3162,7 @@ export function TrajectoryTable({
             {!promptSelected && selected !== undefined && activeTab === 'rendered' && (
               <MarkdownRecordContent
                 record={selected}
+                renderImages={renderImages}
                 rendered
                 thinkingExpanded={thinkingExpanded}
                 onThinkingExpandedChange={setThinkingExpanded}
@@ -3161,6 +3173,7 @@ export function TrajectoryTable({
             {!promptSelected && selected !== undefined && activeTab === 'raw' && (
               <MarkdownRecordContent
                 record={selected}
+                renderImages={renderImages}
                 rendered={false}
                 thinkingExpanded={thinkingExpanded}
                 onThinkingExpandedChange={setThinkingExpanded}
@@ -3172,10 +3185,10 @@ export function TrajectoryTable({
               <MessageSource record={selected} t={t} />
             )}
             {!promptSelected && selected !== undefined && activeTab === 'input' && (
-              <RecordPayload record={selected} direction="input" t={t} />
+              <RecordPayload record={selected} direction="input" renderImages={renderImages} t={t} />
             )}
             {!promptSelected && selected !== undefined && activeTab === 'output' && (
-              <RecordPayload record={selected} direction="output" t={t} />
+              <RecordPayload record={selected} direction="output" renderImages={renderImages} t={t} />
             )}
             {!promptSelected && selected !== undefined && activeTab === 'schema' && (
               <RecordSchema record={selected} t={t} />

+ 15 - 5
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -1,10 +1,11 @@
 /** Trajectory view: compact summary over a turn-aware event ledger. */
 
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {
-  AssistantBlock, AssistantMessageNode, ConvViewProps,
+  AssistantBlock, AssistantMessageNode, ConvViewProps, RenderMessageImages,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import {
   TrajectoryTable,
@@ -69,6 +70,7 @@ export interface TrajectoryViewInjected {
     duration: SnapshotStore<boolean>
   }
   loadOlder: () => Promise<boolean>
+  loadImage: (attachment: ImageAttachmentRef) => Promise<string>
   setActualDuration: (actualDuration: boolean) => void
 }
 
@@ -117,10 +119,17 @@ function addUsage(
 }
 
 export function TrajectoryView({
-  useSession, useTrajectory, useDuration, loadOlder, setActualDuration,
-  viewRequest, completeViewRequest, t,
-}: ConvViewProps & InjectFace<TrajectoryViewInjected> & PropsLocale<'trajectory'>) {
+  useSession, useTrajectory, useDuration, loadOlder, loadImage, setActualDuration,
+  viewRequest, completeViewRequest, renderSlot, t,
+}: ConvViewProps
+  & PropsRenderSlots<'conversation.trajectory.images'>
+  & InjectFace<TrajectoryViewInjected>
+  & PropsLocale<'trajectory'>) {
   const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
+  const renderImages = useCallback<RenderMessageImages>(
+    owner => renderSlot('conversation.trajectory.images', { ...owner, loadImage }),
+    [loadImage, renderSlot],
+  )
   const [collapsedAssistants, setCollapsedAssistants] =
     useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
   const [timelineSelection, setTimelineSelection] = useState<TrajectoryTimeRange | null>(null)
@@ -481,6 +490,7 @@ export function TrajectoryView({
       <div className={css.ledger}>
         <TrajectoryTable
           t={t}
+          renderImages={renderImages}
           requestNumbers={requestNumbers}
           turns={timelineTurns}
           streamingCells={streamingCells}

+ 4 - 0
packages/client/ui-trajectory/src/client/index.ts

@@ -79,6 +79,9 @@ export function apply(ctx: Context): void {
     order: 10,
     locale: NS,
     label: () => t('view.trajectory'),
+    children: {
+      'conversation.trajectory.images': { kind: 'single', scope: 'session' },
+    },
     inject: (sessionId: SessionId): TrajectoryViewInjected => {
       const session = ctx.sessions.binding(sessionId)?.session
       if (session === undefined) {
@@ -92,6 +95,7 @@ export function apply(ctx: Context): void {
           await session.loadOlder()
           return trajectory.getSnapshot() !== before
         },
+        loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment),
         setActualDuration: (value) => { duration.set(value) },
       }
     },

+ 32 - 50
packages/client/ui-trajectory/src/client/layout.ts

@@ -12,6 +12,7 @@ import type {
   ToolCallBlock,
   ToolResultNode,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {
   TrajectoryCellProps,
   TrajectorySourceBlock,
@@ -108,7 +109,7 @@ function layoutEntryOrder(entry: OrderedLayoutEntry): number {
     : entry.seq
 }
 
-function inputCellDetail(node: InputNode): Pick<
+function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick<
   TrajectoryCellProps,
   | 'text'
   | 'previewMarkdown'
@@ -119,9 +120,15 @@ function inputCellDetail(node: InputNode): Pick<
   | 'timeSeconds'
   | 'startedAt'
 > {
-  const previewMarkdown = previewContent(node.content)
+  // 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)
   return {
-    text: '',
+    text: previewMarkdown === undefined && images > 0
+      ? t('layout.imageOnly', { count: images })
+      : '',
     ...(previewMarkdown === undefined ? {} : { previewMarkdown }),
     sourceSeq: node.seq,
     messageSource: node.source,
@@ -368,7 +375,7 @@ export function deriveTrajectoryLayout(
         cell: {
           index: ++index,
           kind: 'user',
-          ...inputCellDetail(node),
+          ...inputCellDetail(node, t),
           opensTurn: true,
         },
       })
@@ -387,7 +394,7 @@ export function deriveTrajectoryLayout(
         cell: {
           index: ++index,
           kind: 'user' as const,
-          ...inputCellDetail(node),
+          ...inputCellDetail(node, t),
         },
       }
       if (placement.step === undefined) pushMessage(placement.turn, laid)
@@ -415,7 +422,7 @@ export function deriveTrajectoryLayout(
         cell: {
           index: ++index,
           kind: 'context',
-          ...inputCellDetail(node),
+          ...inputCellDetail(node, t),
         },
       })
       prevAbsTime = finiteTime(node.time) ?? prevAbsTime
@@ -788,6 +795,8 @@ function summarizeAssistantActivity(
   if (tools.size > 0) {
     return t('layout.toolCallOnly')
   }
+  const images = blocks.filter(block => block.kind === 'image').length
+  if (images > 0) return t('layout.imageOnly', { count: images })
   return ''
 }
 
@@ -808,12 +817,7 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
       callId: block.callId,
       toolName: block.name,
     }
-    // Attachment refs carry no fetchable bytes, so the record shows the
-    // durable metadata instead of an inline preview.
-    case 'image': return {
-      type: 'image',
-      content: stringifySourceValue(block.attachment),
-    }
+    case 'image': return { type: 'image', content: '', attachment: block.attachment }
     case 'other': return sourceBlock(block.block)
   }
 }
@@ -827,47 +831,21 @@ function sourceBlock(value: unknown): TrajectorySourceBlock {
   if (typeof block.text === 'string') {
     return { type: type === 'reasoning' ? 'thinking' : type, content: block.text }
   }
-  const imageSrc = sourceImage(block)
-  const imageAlt = typeof block.alt === 'string' ? block.alt : undefined
-  return {
-    type,
-    content: imageSrc === undefined ? stringifySourceValue(value) : '',
-    ...(imageSrc !== undefined ? { imageSrc } : {}),
-    ...(imageAlt !== undefined ? { imageAlt } : {}),
-  }
-}
-
-function sourceImage(block: Record<string, unknown>): string | undefined {
-  if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined
-  for (const candidate of [block.url, block.image_url]) {
-    if (typeof candidate === 'string') return safeImageSource(candidate)
-  }
-  if (typeof block.data === 'string') {
-    const mediaType = [block.mimeType, block.mediaType, block.media_type]
-      .find((candidate): candidate is string => typeof candidate === 'string')
-      ?? 'image/png'
-    return safeImageSource(
-      block.data.startsWith('data:')
-        ? block.data
-        : `data:${mediaType};base64,${block.data}`,
-    )
+  if (
+    type === 'image'
+    && 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 }
   }
-  if (typeof block.source !== 'object' || block.source === null) return undefined
-  const source = block.source as Record<string, unknown>
-  if (typeof source.url === 'string') return safeImageSource(source.url)
-  if (typeof source.data !== 'string') return undefined
-  const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png'
-  return safeImageSource(`data:${mediaType};base64,${source.data}`)
+  return { type, content: stringifySourceValue(value) }
 }
 
-function safeImageSource(value: string): string | undefined {
-  if (value.startsWith('data:image/') || value.startsWith('blob:')) return value
-  try {
-    const protocol = new URL(value).protocol
-    return protocol === 'http:' || protocol === 'https:' ? value : undefined
-  } catch {
-    return undefined
-  }
+function imageBlockCount(content: readonly { type: string }[]): number {
+  return content.filter(block => block.type === 'image').length
 }
 
 function stringifySourceValue(value: unknown): string {
@@ -1087,6 +1065,8 @@ function summarizeResult(
       return { result: '', resultPreviewMarkdown: block.text }
     }
   }
+  const images = imageBlockCount(node.content)
+  if (images > 0) return { result: t('layout.imageOnly', { count: images }) }
   return { result: t('record.noOutput') }
 }
 
@@ -1112,6 +1092,8 @@ function detailResult(node: ToolResultNode, t: TrajectoryTranslate): string {
     .map(block => block.type === 'text' ? block.text : '')
     .join('\n')
   if (text !== '') return text
+  const images = imageBlockCount(node.content)
+  if (images > 0) return t('layout.imageOnly', { count: images })
   if (
     node.content.length === 0
     || node.content.every(block =>

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

@@ -121,7 +121,6 @@ export const zh = {
   'block.openSummary': '打开第 {index} 个块的工具调用概述',
   'block.openSummaryTitle': '打开工具调用概述',
   'block.label': '块 #{index} {type}',
-  'block.openImage': '打开图片',
   'history.loadingTrajectory': '正在加载轨迹…',
   'history.loadingEarlier': '正在加载更早的历史…',
   'history.loadingEarlierAria': '正在加载更早的历史…',
@@ -174,6 +173,7 @@ export const zh = {
   'layout.compactionFailed': '上下文压缩失败',
   'layout.compacted': '上下文已压缩',
   'layout.toolCallOnly': '仅工具调用',
+  'layout.imageOnly': '图片 ×{count}',
   'layout.initialSystemPrompt': '初始系统提示词',
   'layout.systemPromptUpdated': '系统提示词已更新',
   'layout.toolsUpdated': '工具已更新',
@@ -313,7 +313,6 @@ export const en: Record<TrajectoryKey, string> = {
   'block.openSummary': 'Open Block #{index} tool call summary',
   'block.openSummaryTitle': 'Open tool call summary',
   'block.label': 'Block #{index} {type}',
-  'block.openImage': 'Open image',
   'history.loadingTrajectory': 'Loading trajectory…',
   'history.loadingEarlier': 'Loading earlier history…',
   'history.loadingEarlierAria': 'Loading earlier history…',
@@ -366,6 +365,7 @@ export const en: Record<TrajectoryKey, string> = {
   'layout.compactionFailed': 'Compaction failed',
   'layout.compacted': 'Context compacted',
   'layout.toolCallOnly': 'Tool call only',
+  'layout.imageOnly': 'Images ×{count}',
   'layout.initialSystemPrompt': 'Initial System Prompt',
   'layout.systemPromptUpdated': 'System Prompt Updated',
   'layout.toolsUpdated': 'Tools Updated',

+ 12 - 2
packages/client/ui-trajectory/src/client/trajectory-contract.ts

@@ -1,7 +1,7 @@
 import type {
   AssistantMessageNode, ConversationLocation, ConversationNode, ConversationPromptSnapshot,
-  ConversationViewNode, PartialAssistant, RequestPromptChange, RequestView, RunningToolCall,
-  ToolCallBlock,
+  ConversationViewNode, MessageImagesOwnerProps, PartialAssistant, RequestPromptChange,
+  RequestView, RunningToolCall, ToolCallBlock,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 
@@ -84,4 +84,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     /** Selector hook over the current Conversation binding's Trajectory target. */
     useTrajectory: UseTrajectory
   }
+
+  interface SlotMap {
+    /**
+     * Renderer for one group of durable record images in the Trajectory
+     * ledger. The owner supplies image references, an authorized loader, and
+     * alignment. A registration replaces the shipped gallery; without one,
+     * images are omitted.
+     */
+    'conversation.trajectory.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps }
+  }
 }

+ 2 - 2
packages/client/ui-trajectory/src/client/trajectory-record.ts

@@ -1,6 +1,7 @@
 /** Shared trajectory record data and formatting contracts. */
 
 import type { HTMLAttributes } from 'react'
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { TrajectoryTranslate } from './locales.ts'
 
@@ -28,8 +29,7 @@ export interface AssistantMetricDetail {
 export interface TrajectorySourceBlock {
   type: string
   content: string
-  imageSrc?: string
-  imageAlt?: string
+  attachment?: ImageAttachmentRef
   callId?: string
   toolName?: string
 }

+ 1 - 1
packages/client/ui-trajectory/src/client/trajectory-search-index.ts

@@ -64,7 +64,7 @@ function recordSources(
       block.content,
       block.callId ?? '',
       block.toolName ?? '',
-      block.imageAlt ?? '',
+      block.attachment?.name ?? '',
     ]),
     searchableJson(cell.messageSource),
     searchableJson(cell.promptDetail),

+ 100 - 0
packages/client/ui-trajectory/tests/layout.client.spec.tsx

@@ -566,3 +566,103 @@ describe('run_code sub-dispatch cells', () => {
     expect(cells.map(cell => cell.index)).toEqual([1, 2, 3, 4])
   })
 })
+
+describe('durable image attachments', () => {
+  const attachment = {
+    attachmentId: `sha256:${'a'.repeat(64)}`,
+    mediaType: 'image/png',
+    bytes: 68,
+    width: 640,
+    height: 320,
+    name: 'screenshot.png',
+  }
+
+  it('carries user image refs into sourceBlocks and labels an image-only record', () => {
+    const nodes = [
+      {
+        kind: 'user', seq: 1, time: 1_000, source: null,
+        content: [{ type: 'image', attachment }, { type: 'image', attachment }],
+      },
+    ] as unknown as LegacyConversationSlice['nodes']
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+    const user = turns[0]?.groups[0]?.cells[0]
+    expect(user?.text).toBe('Images ×2')
+    expect(user?.previewMarkdown).toBeUndefined()
+    expect(user?.sourceBlocks).toEqual([
+      { type: 'image', content: '', attachment },
+      { type: 'image', content: '', attachment },
+    ])
+  })
+
+  it('labels a record whose only text block is empty as image-only', () => {
+    const nodes = [
+      {
+        kind: 'user', seq: 1, time: 1_000, source: null,
+        content: [{ type: 'text', text: '' }, { type: 'image', attachment }],
+      },
+    ] as unknown as LegacyConversationSlice['nodes']
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+    const user = turns[0]?.groups[0]?.cells[0]
+    expect(user?.text).toBe('Images ×1')
+    expect(user?.previewMarkdown).toBeUndefined()
+  })
+
+  it('keeps the text preview when a user message mixes text and images', () => {
+    const nodes = [
+      {
+        kind: 'user', seq: 1, time: 1_000, source: null,
+        content: [{ type: 'text', text: 'look at this' }, { type: 'image', attachment }],
+      },
+    ] 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?.previewMarkdown).toBe('look at this')
+    expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: '', attachment })
+  })
+
+  it('maps assistant image blocks to attachment source blocks and labels image-only output', () => {
+    const nodes = [
+      {
+        kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 0,
+        blocks: [{ kind: 'image', attachment }],
+      },
+    ] as unknown as LegacyConversationSlice['nodes']
+    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 }])
+  })
+
+  it('carries tool-result image refs into outputBlocks and labels the result', () => {
+    const nodes = [
+      {
+        kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1,
+        blocks: [{ kind: 'tool-call', callId: 'c1', name: 'read_image', argsRaw: '{}' }],
+      },
+      {
+        kind: 'tool-result', seq: 2, time: 2_000, callId: 'c1',
+        call: { name: 'read_image', argsRaw: '{}' }, callTime: 1_200,
+        content: [{ type: 'image', attachment }], isError: false,
+      },
+    ] as unknown as LegacyConversationSlice['nodes']
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+    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 }])
+  })
+
+  it('shows wire-shaped blocks without an attachment as JSON, not as an image', () => {
+    const nodes = [
+      {
+        kind: 'user', seq: 1, time: 1_000, source: null,
+        content: [{ type: 'image', url: 'https://example.com/a.png' }],
+      },
+    ] as unknown as LegacyConversationSlice['nodes']
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+    const block = turns[0]?.groups[0]?.cells[0]?.sourceBlocks?.[0]
+    expect(block?.attachment).toBeUndefined()
+    expect(block?.content).toContain('https://example.com/a.png')
+  })
+})

+ 136 - 2
packages/client/ui-trajectory/tests/table.client.spec.tsx

@@ -4,12 +4,24 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 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'
 import type { TrajectoryTurnModel } from '../src/client/layout.ts'
 import { trajectoryRecordId } from '../src/client/trajectory-record.ts'
 import { t, tZh } from './locale.client.ts'
 
-function TrajectoryTable(props: Omit<ComponentProps<typeof LocalizedTrajectoryTable>, 't'>) {
+const renderImagesStub: RenderMessageImages = ({ images }) => (
+  <div data-testid="record-images" data-count={images.length}>
+    {images.map((image, index) => (
+      <span key={index} data-attachment-id={image.attachment.attachmentId} />
+    ))}
+  </div>
+)
+
+function TrajectoryTable(
+  props: Omit<ComponentProps<typeof LocalizedTrajectoryTable>, 't' | 'renderImages'>
+    & { renderImages?: RenderMessageImages },
+) {
   const inferred: Array<NonNullable<typeof props.requestNumbers>[number] & { firstIndex: number }> = []
   for (const turn of props.turns) {
     for (const group of turn.groups) {
@@ -40,7 +52,14 @@ function TrajectoryTable(props: Omit<ComponentProps<typeof LocalizedTrajectoryTa
   const requestNumbers = props.requestNumbers ?? inferred
     .sort((left, right) => left.firstIndex - right.firstIndex)
     .map(({ firstIndex: _firstIndex, ...request }, index) => ({ ...request, number: index + 1 }))
-  return <LocalizedTrajectoryTable {...props} requestNumbers={requestNumbers} t={t} />
+  return (
+    <LocalizedTrajectoryTable
+      renderImages={renderImagesStub}
+      {...props}
+      requestNumbers={requestNumbers}
+      t={t}
+    />
+  )
 }
 
 afterEach(() => {
@@ -129,6 +148,7 @@ describe('TrajectoryTable', () => {
     render(
       <LocalizedTrajectoryTable
         t={tZh}
+        renderImages={renderImagesStub}
         turns={TURNS}
         collapsedTurns={new Set<number>()}
         onToggleTurn={() => {}}
@@ -937,6 +957,120 @@ 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('renders a tool-result image through the shared gallery in the Result tab', () => {
+    const attachment = {
+      attachmentId: `sha256:${'b'.repeat(64)}`,
+      mediaType: 'image/png',
+      bytes: 68,
+      width: 320,
+      height: 640,
+      name: 'capture.png',
+    } as unknown as NonNullable<
+      NonNullable<TrajectoryTurnModel['groups'][number]['cells'][number]['outputBlocks']>[number]['attachment']
+    >
+    const turns: readonly TrajectoryTurnModel[] = [{
+      turn: 1,
+      groups: [{
+        title: 'Step 1',
+        cells: [{
+          index: 1,
+          kind: 'tool',
+          text: 'read_image {"path":"a.png"}',
+          outputDetail: 'Images ×1',
+          outputBlocks: [{ type: 'image', content: '', attachment }],
+          timeSeconds: 0.1,
+        }],
+      }],
+    }]
+
+    render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
+    fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
+    fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
+
+    const gallery = screen.getAllByTestId('record-images').at(-1)
+    expect(gallery?.getAttribute('data-count')).toBe('1')
+    expect(gallery?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id'))
+      .toBe(String(attachment.attachmentId))
+  })
+
+  it('keeps the failure name beside an image-only error result', () => {
+    const attachment = {
+      attachmentId: `sha256:${'c'.repeat(64)}`,
+      mediaType: 'image/png',
+      bytes: 68,
+      width: 320,
+      height: 320,
+      name: 'failed.png',
+    } as unknown as NonNullable<
+      NonNullable<TrajectoryTurnModel['groups'][number]['cells'][number]['outputBlocks']>[number]['attachment']
+    >
+    const turns: readonly TrajectoryTurnModel[] = [{
+      turn: 1,
+      groups: [{
+        title: 'Step 1',
+        cells: [{
+          index: 1,
+          kind: 'tool',
+          text: 'render {"target":"chart"}',
+          outputDetail: 'ToolError: RENDER_TRUNCATED',
+          outputBlocks: [{ type: 'image', content: '', attachment }],
+          isError: true,
+          timeSeconds: 0.1,
+        }],
+      }],
+    }]
+
+    render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
+    fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
+    fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
+
+    expect(screen.getByText('ToolError: RENDER_TRUNCATED')).toBeTruthy()
+    const gallery = screen.getAllByTestId('record-images').at(-1)
+    expect(gallery?.getAttribute('data-count')).toBe('1')
+  })
+
   it('keeps the first row and a compact summary when a turn is collapsed', () => {
     render(
       <TrajectoryTable

+ 4 - 0
packages/client/ui-trajectory/tests/views.client.spec.tsx

@@ -228,6 +228,10 @@ function standaloneProps(
     viewRequest: null,
     openView: () => {},
     completeViewRequest: () => {},
+    // Image seats the outlet would bake: standalone renders omit the gallery.
+    renderSlot: () => null,
+    SessionProvider: ({ children }) => <>{children}</>,
+    loadImage: () => Promise.reject(new Error('standalone views load no images')),
     // The locale seat the outlet would inject for the declared namespace.
     t: tZh,
   }

+ 3 - 0
packages/client/ui-trajectory/tsconfig.json

@@ -49,6 +49,9 @@
     },
     {
       "path": "../../llm/llm"
+    },
+    {
+      "path": "../../attachment/attachment"
     }
   ]
 }

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

@@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n      { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:197',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:186',
   },
   {
     key: 'conversation.chat.commandview',
@@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n      { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:185',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:174',
   },
   {
     key: 'conversation.chat.node',
@@ -310,7 +310,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.chat.node\', () => ctx.slots.register(\n      { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:166',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:155',
   },
   {
     key: 'conversation.chat.turnTail',
@@ -355,7 +355,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n      { name: \'conversation.chat.turnTail\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:191',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:180',
   },
   {
     key: 'conversation.composer',
@@ -404,7 +404,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:78',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:92',
   },
   {
     key: 'conversation.composer.bar',
@@ -440,7 +440,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:96',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:110',
   },
   {
     key: 'conversation.composer.dock',
@@ -498,7 +498,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:90',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:104',
   },
   {
     key: 'conversation.details.tool',
@@ -534,7 +534,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.details.tool\', () => ctx.slots.register(\n      { name: \'conversation.details.tool\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:203',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:192',
   },
   {
     key: 'conversation.hero.agentPreset',
@@ -562,7 +562,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:84',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:98',
   },
   {
     key: 'conversation.hero.brand.mark',
@@ -590,7 +590,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.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:82',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:96',
   },
   {
     key: 'conversation.hero.workspace',
@@ -620,7 +620,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:80',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:94',
   },
   {
     key: 'conversation.hero.workspace.directoryFlow',
@@ -686,7 +686,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:98',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:112',
   },
   {
     key: 'conversation.input.dock',
@@ -746,7 +746,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:86',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:100',
   },
   {
     key: 'conversation.input.left',
@@ -802,7 +802,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:92',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:106',
   },
   {
     key: 'conversation.input.model',
@@ -838,7 +838,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:106',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:120',
   },
   {
     key: 'conversation.input.overlay',
@@ -892,7 +892,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:88',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:102',
   },
   {
     key: 'conversation.input.plan',
@@ -928,7 +928,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:104',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:118',
   },
   {
     key: 'conversation.input.right',
@@ -984,7 +984,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:94',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:108',
   },
   {
     key: 'conversation.message.images',
@@ -994,7 +994,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: [
-      '/** Historical image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  images: readonly { readonly attachment: ImageAttachmentRef }[]\n  loadImage: (attachment: ImageAttachmentRef) => Promise<string>\n  align: \'start\' | \'end\'\n}',
+      '/** Durable image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  /** Durable image references in source order. */\n  images: readonly { readonly attachment: ImageAttachmentRef }[]\n  /** Session-authorized image URL loader. */\n  loadImage: (attachment: ImageAttachmentRef) => Promise<string>\n  /** Horizontal placement inside the owning record. */\n  align: \'start\' | \'end\'\n}',
     ],
     ownerPropsReferences: [
       'ImageAttachmentRef',
@@ -1022,7 +1022,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.message.images\', () => ctx.slots.register(\n      { name: \'conversation.message.images\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:179',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:168',
   },
   {
     key: 'conversation.session',
@@ -1056,7 +1056,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:54',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:68',
   },
   {
     key: 'conversation.session.header',
@@ -1090,7 +1090,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:56',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:70',
   },
   {
     key: 'conversation.session.header.actions',
@@ -1146,7 +1146,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:64',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:78',
   },
   {
     key: 'conversation.session.header.lineage',
@@ -1184,7 +1184,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:58',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:72',
   },
   {
     key: 'conversation.session.header.utilities',
@@ -1239,7 +1239,45 @@ 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:70',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:84',
+  },
+  {
+    key: 'conversation.trajectory.images',
+    kind: 'single',
+    scope: 'session',
+    summary: 'Renderer for one group of durable record images in the Trajectory ledger.',
+    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: [
+      '/** Durable image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  /** Durable image references in source order. */\n  images: readonly { readonly attachment: ImageAttachmentRef }[]\n  /** Session-authorized image URL loader. */\n  loadImage: (attachment: ImageAttachmentRef) => Promise<string>\n  /** Horizontal placement inside the owning record. */\n  align: \'start\' | \'end\'\n}',
+    ],
+    ownerPropsReferences: [
+      'ImageAttachmentRef',
+    ],
+    standardProps: [
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'useSessions: UseSessions',
+      'useSessionPendingInteraction: UseSessionPendingInteraction',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'useChat: UseChat',
+      'useConversation: UseConversation',
+      'useInput: SnapshotSelectorHook<InputState>',
+      'inputActions: InputActions',
+      'useSession: SessionSnapshotSelector',
+      'sessionId: SessionId',
+      'useProjection: UseProjection',
+      'useTrajectory: UseTrajectory',
+    ],
+    keyDomain: '',
+    hookContext: '',
+    slotInject: '',
+    declaredBy: 'an entry in \'conversation.view\' (client-ui-trajectory), so it exists while that entry is mounted',
+    occupants: [
+      'client-ui-attachment MessageImages',
+    ],
+    replaceRisk: 'shadows-shipped-ui',
+    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.trajectory.images\', () => ctx.slots.register(\n      { name: \'conversation.trajectory.images\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
+    source: 'packages/client/ui-trajectory/src/client/trajectory-contract.ts:95',
   },
   {
     key: 'conversation.view',
@@ -1297,7 +1335,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:76',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:90',
   },
   {
     key: 'details',

+ 6 - 3
pnpm-lock.yaml

@@ -1835,6 +1835,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-client-ui-trajectory':
+        specifier: workspace:^
+        version: link:../ui-trajectory
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
@@ -1961,9 +1964,6 @@ importers:
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
-      '@deepseek-ai/dsh-util-crypto':
-        specifier: workspace:^
-        version: link:../../util/crypto
       '@deepseek-ai/dsh-util-workspace-path':
         specifier: workspace:^
         version: link:../../util/workspace-path
@@ -3379,6 +3379,9 @@ importers:
       '@deepseek-ai/dsh-api-session-controller':
         specifier: workspace:^
         version: link:../../api/session-controller
+      '@deepseek-ai/dsh-attachment':
+        specifier: workspace:^
+        version: link:../../attachment/attachment
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff