diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml index f616ad829c..a1245a8831 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md index 8e5521ff19..12e137209b 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md index a007a42b3d..f0d9861eea 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml index b82bec2f49..6b70d9c8f6 100644 --- a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md -2026-08-24-trajectory-image-attachments.md: 4b79ecbb113a0f36b4cf31f9b0c68cb1fc013d13 -2026-08-24-trajectory-image-attachments.zh.md: f7695ab322f80699f1f424d26d179415f0181efc +2026-08-24-trajectory-image-attachments.md: 6f89840a7d4686e45012ea2ae25f4cc939a5ca0c +2026-08-24-trajectory-image-attachments.zh.md: f1e8634639e42adc094021c03a339b8f8223bfff diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md index 4b79ecbb11..6f89840a7d 100644 --- a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md @@ -10,7 +10,7 @@ Trajectory did not display session images. A durable `{ type: 'image', attachmen ## 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. +- `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. diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md index f7695ab322..f1e8634639 100644 --- a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md @@ -10,7 +10,7 @@ Trajectory 不展示会话图片。持久化的 `{ type: 'image', attachment: Im ## 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 释放而撤销。 +- `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 转储。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index ad6907574f..cda920ea03 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/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 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index d201223c9e..6eb61780ca 100644 --- a/docs/subsystems/slots.md +++ b/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 diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 23277e94e2..e5a25763d3 100644 --- a/docs/subsystems/slots.zh.md +++ b/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 diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index 6f37d011f3..f819db101d 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -50,9 +50,6 @@ { "path": "../../runtime-diagnostics/invariants" }, - { - "path": "../../util/crypto" - }, { "path": "../../util/workspace-path" }, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 703675795e..81a428aa35 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1160,6 +1160,8 @@ function SourceBlocks({ )} + {/* 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' }) :
{block.content}
} @@ -1388,11 +1390,14 @@ function SystemPromptDiff({ function ToolOutputBlocks({ blocks, error, + errorDetail, preview, renderImages, }: { blocks: readonly TrajectorySourceBlock[] error: boolean + /** Failure name and code preserved beside image-only error content. */ + errorDetail?: string | undefined preview: boolean renderImages: RenderMessageImages }) { @@ -1403,6 +1408,8 @@ function ToolOutputBlocks({ error ? css.errorPayload : undefined, ].filter((value): value is string => value !== undefined).join(' ')} > + {error && errorDetail !== undefined && errorDetail !== '' + &&
{errorDetail}
} {blocks.map((block, index) => ( block.attachment !== undefined ? ( @@ -1630,6 +1637,7 @@ function RecordPayload({ diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 0283f4fde3..a811e915f2 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -120,7 +120,10 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): 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: previewMarkdown === undefined && images > 0 @@ -792,7 +795,7 @@ function summarizeAssistantActivity( if (tools.size > 0) { return t('layout.toolCallOnly') } - const images = imageBlockCount(blocks.map(block => ({ type: block.kind }))) + const images = blocks.filter(block => block.kind === 'image').length if (images > 0) return t('layout.imageOnly', { count: images }) return '' } @@ -828,9 +831,14 @@ function sourceBlock(value: unknown): TrajectorySourceBlock { if (typeof block.text === 'string') { return { type: type === 'reasoning' ? 'thinking' : type, content: block.text } } - if (type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { - // Typed content only reaches here as a core ImageBlock; wire-shaped - // 'other' blocks never define `attachment`. + if ( + type === 'image' + && typeof block.attachment === 'object' && block.attachment !== null + && typeof (block.attachment as Record).attachmentId === 'string' + ) { + // Session-log content is validated into core ContentBlocks by the + // Conversation node assembly; the `attachmentId` guard only keeps + // wire-shaped 'other' blocks with an unrelated `attachment` member out. return { type, content: '', attachment: block.attachment as ImageAttachmentRef } } return { type, content: stringifySourceValue(value) } diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index bd834cbdb0..fbb454decf 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -594,6 +594,19 @@ describe('durable image attachments', () => { ]) }) + 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 = [ { diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 3d3955dcb3..9bd8de74f0 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -1035,6 +1035,42 @@ describe('TrajectoryTable', () => { .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[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() + 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(