From a4d44047083d4e30d894b6e4a871d45483cc1da4 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 29 Aug 2026 15:35:15 +0800 Subject: [PATCH 01/47] feat(ui-tool): render read_image results as the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A settled top-level `read_image` call printed its raw attachment object as literal text in the tool card — `{"type":"image","attachment":{…}}` — instead of the image, because no presentation metadata told a client card how to present the reference and the tool-card layer had no image concept. Host: `read_image` declares an `output.presentationMeta` persisting `{ path }` only. The attachment reference deliberately lives in the settled result content — the single record a `tools/post-execute` replacement rewrites — not in `meta`; the id is opaque and provider-owned, checked for existence only. Client: `imageCardModel` derives the card from the call head, the meta path, the result's own image block, and a shape-matched envelope. ToolRow gains an `image` card slot; the `read_image` toolview declares the Tool-owned `tool.call.images` slot as its child and dispatches the gallery through it. ui-chat down-threads the session-authorized loader (`ChatNodeOwnerProps.loadImage`), so the tool layer supplies only derived references plus the loader and never imports an attachment implementation; ui-attachment fills the slot with its message gallery renderer. The card keeps the envelope text below the gallery for the no-attachment-plugin deployment. An image-bearing tool registers a keyed toolview; the generic fallback keeps its flattened text. `read_image` joins the read variant with its own locale title key; both rows share `read-family-row.tsx`. Verification: `read-image.spec.ts` (metadata projection, envelope by shape, reference narrowing, real-execution round trip, rejection branches incl. non-digest ids), `image-card.client.spec.tsx` (derivation, row render site dispatching the slot, keyed registration with the child-slot declaration, empty-slot fallbacks, media-type enum), keyless snapshots (`read-image-gif` added; read-image/-dimension/-reencode updated to the `{path}` meta), five injected-defect negative controls, and a demo GIF recorded from this PR's head through the official image-capable model. --- ...26-08-10-minimal-read-image-tool.i18n.yaml | 4 +- .../2026-08-10-minimal-read-image-tool.md | 2 +- .../2026-08-10-minimal-read-image-tool.zh.md | 2 +- ...26-08-20-tool-card-image-results.i18n.yaml | 6 + .../2026-08-20-tool-card-image-results.md | 57 +++ .../2026-08-20-tool-card-image-results.zh.md | 57 +++ ...026-08-27-port-tool-owned-render.i18n.yaml | 6 + .../2026-08-27-port-tool-owned-render.md | 35 ++ .../2026-08-27-port-tool-owned-render.zh.md | 35 ++ docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 1 + docs/subsystems/slots.zh.md | 1 + .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 6 +- packages/client/ui-attachment/README.zh.md | 6 +- packages/client/ui-attachment/package.json | 6 +- .../client/ui-attachment/src/client/index.ts | 9 +- .../ui-attachment/tests/plugin.client.spec.ts | 6 + packages/client/ui-attachment/tsconfig.json | 3 + .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 5 +- .../ui-chat/src/client/chat/ChatView.tsx | 1 + .../ui-chat/src/client/contract/slots.ts | 7 + .../ui-conversation/src/client/locales.ts | 2 + packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 6 +- packages/client/ui-tool/README.zh.md | 6 +- packages/client/ui-tool/package.json | 3 +- packages/client/ui-tool/src/client/apply.ts | 2 + .../ui-tool/src/client/contract/slots.ts | 31 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 16 +- .../client/tool/components/ToolRow.module.css | 23 ++ .../src/client/tool/components/ToolRow.tsx | 133 ++++--- .../client/tool/models/image-card-model.ts | 232 +++++++++++ .../src/client/tool/models/tool-call-model.ts | 6 + .../client/tool/toolviews/read-family-row.tsx | 59 +++ .../client/tool/toolviews/read-image-row.tsx | 61 +++ .../src/client/tool/toolviews/read-row.tsx | 28 +- .../tests/coverage-tails.client.spec.tsx | 1 + .../ui-tool/tests/diff-card.client.spec.tsx | 4 +- .../ui-tool/tests/image-card.client.spec.tsx | 371 ++++++++++++++++++ .../ui-tool/tests/read-card.client.spec.tsx | 3 +- .../ui-tool/tests/search-card.client.spec.tsx | 1 + .../tests/terminal-card.client.spec.tsx | 1 + .../tests/tool-call-tree.client.spec.tsx | 1 + .../ui-tool/tests/tool-row.client.spec.tsx | 1 + .../ui-tool/tests/web-card.client.spec.tsx | 2 +- packages/client/ui-tool/tsconfig.json | 3 + .../tests/workflow-run.client.spec.tsx | 1 + .../tests/image-loadable.spec.ts | 18 +- .../src/client/slot-catalog.ts | 88 +++-- packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/README.zh.md | 2 +- packages/fs/tool-fs/src/read-image.ts | 9 + packages/fs/tool-fs/tests/read-image.spec.ts | 68 ++++ pnpm-lock.yaml | 6 + .../read-image-dimension/session.jsonl | 2 +- .../session/read-image-gif/session.jsonl | 29 ++ snapshots/session/read-image-gif/snapshot.yml | 7 + .../session/read-image-gif/workspace/red.gif | Bin 0 -> 42 bytes .../session/read-image-reencode/session.jsonl | 2 +- snapshots/session/read-image/session.jsonl | 2 +- 62 files changed, 1366 insertions(+), 137 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md create mode 100644 .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md create mode 100644 .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.i18n.yaml create mode 100644 .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md create mode 100644 .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.zh.md create mode 100644 packages/client/ui-tool/src/client/tool/models/image-card-model.ts create mode 100644 packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx create mode 100644 packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx create mode 100644 packages/client/ui-tool/tests/image-card.client.spec.tsx create mode 100644 snapshots/session/read-image-gif/session.jsonl create mode 100644 snapshots/session/read-image-gif/snapshot.yml create mode 100644 snapshots/session/read-image-gif/workspace/red.gif diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml index 4c276c6b0d..0b46846ce1 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.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-10-minimal-read-image-tool.md -2026-08-10-minimal-read-image-tool.md: 8880032b2648846df679ea8fa3301d182a95c06b -2026-08-10-minimal-read-image-tool.zh.md: aec34e19fc58037b031f7d4116d2fa664b2b45b5 +2026-08-10-minimal-read-image-tool.md: 67a738e71901fb251b8e9e167d52bac4f2f3720a +2026-08-10-minimal-read-image-tool.zh.md: 7a468372542a45088946bdaac7f1869c7a1909ee diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md index 8880032b26..67a738e719 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md @@ -29,4 +29,4 @@ Both image-reading operations live in `dsh-tool-fs` and publish ordinary logged - The tools refuse execution on a text-only route, while existing images in session history are represented by request-local placeholders. - Repeated image results accumulate request cost until request projection or compaction removes them; content addressing deduplicates durable bytes. -- The tool-result card renders the durable reference, not pixels; inline preview is deferred to the UI packages. +- The tool-result card now renders the image itself through the browser's `tool.call.images` slot (see [the tool-card image results note](2026-08-20-tool-card-image-results.md)); a UI without the attachment presentation plugin shows the result's envelope text. diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md index aec34e19fc..7a46837254 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md @@ -29,4 +29,4 @@ Status: implemented - 工具在纯文本路由上拒绝执行,而会话历史中已经存在的图片会由请求期占位符表示。 - 重复的图片结果会累积请求成本,直到请求投影或压缩将其移除;内容寻址只去重持久字节。 -- 工具结果卡片渲染持久引用而非像素;内嵌预览延后到 UI 包处理。 +- 工具结果卡片现在经由浏览器的 `tool.call.images` 槽位渲染图像本身(见 [tool-card image results 笔记](2026-08-20-tool-card-image-results.zh.md));未组合附件呈现插件的 UI 显示结果的信封文本。 diff --git a/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.i18n.yaml new file mode 100644 index 0000000000..303334fb75 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.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-20-tool-card-image-results.md +2026-08-20-tool-card-image-results.md: c2f069a6c8eb6d7be48c80fca846bcb2c2be1dd3 +2026-08-20-tool-card-image-results.zh.md: 018a4f0e20f254285189965102fbb4390c160e1c diff --git a/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md b/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md new file mode 100644 index 0000000000..c2f069a6c8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md @@ -0,0 +1,57 @@ +# Agent Note: Tool-card image results + +Status: implemented + +English | [中文](2026-08-20-tool-card-image-results.zh.md) + +## Problem + +A settled `read_image` call rendered its raw attachment object as literal text in the tool card — `{"type":"image","attachment":{"attachmentId":"sha256:…","mediaType":"image/png","bytes":24588,"width":1496,…}}` — instead of showing the image. + +Two independent gaps produced that. `read_image` declared no `output.presentationMeta`, so no presentation metadata told a client card how to present the reference — the tool card printed the raw result content as text. Separately, the tool-card layer had no image concept: `packages/client/ui-tool/src` contained no occurrence of `image` or `attachment`, and `ToolRow`'s card slots were terminal, diff, read, search, and web. + +The rendering capability already existed, but only on the message path. `MessageImages` draws durable image groups for user and assistant history through the `conversation.message.images` slot. That asymmetry explains a confusing observation: a **nested** `read_image` displayed correctly, because `execute` defers a real user message for a nested call, while a top-level call — which returns the image only as tool-result content — did not. + +## Decision + +**Host.** `read_image` gains an `output.presentationMeta` that persists `{ path }` — the path only. + +The attachment reference is deliberately not persisted there. The settled `content` already carries the image block with the complete reference, and that block is what a `tools/post-execute` hook replaces when it legitimately rewrites a result. A second copy in `meta` would therefore be a duplicate record of one fact, and a stale one exactly when the content changed — the card would keep showing an image the result no longer returns. The path is the one fact the content does not carry as a structured field: the model-facing envelope embeds the backend-resolved path as text, and the client never parses that text. + +No `presentResult`, and no new member of the closed `ToolResultView` union. Client cards derive from raw event fields, and host `presentCall`/`presentResult` values never enter the client ([ui-tool README](../../../../packages/client/ui-tool/README.md)), so a result-view arm would have been a public type extension with no consumer. + +**Client.** `imageCardModel` derives the card the way every other first-party card does: `parsedToolCall` validates the call head and its `file_path`, `block.meta` supplies the path, the attachment reference is narrowed out of the result's own image block, and the envelope is located in the same content. It matches its own envelope by shape rather than using `singleResultText`, because that helper accepts only a lone text block while an image read returns `[text envelope, image block]` — matching by shape also means content another layer prepended is never mistaken for the envelope. + +The narrowing checks the attachment id for existence only. The id is opaque and provider-owned: the local store mints content addresses, but consumers must neither parse that representation nor assume its shape, and a provider may change it without notice. Pattern-matching the local form would reject a legitimate id from an alternative store and silently degrade every image card in that deployment. + +`ToolRow` gains an `image` card slot, and `read_image` gets a keyed toolview that declares the Tool-owned `tool.call.images` slot as its child and renders the gallery through it. The tool layer never loads or authorizes anything: the row supplies only the references it derived from the result plus the `loadImage` loader the chat node now passes down (`ChatNodeOwnerProps.loadImage`), and the attachment presentation plugin fills the slot with the same gallery it uses for message images. An image-bearing tool therefore registers a keyed toolview (`read_image` is the template for the row assembly and card model; the `tool.call.images` child declaration is not reusable verbatim, because a slot is declared by exactly one entry); the generic fallback keeps its flattened text. + +The card keeps the derived envelope text below the gallery. That is not redundancy: `tool.call.images` renders nothing in a deployment without the attachment presentation plugin, and that empty gallery must not leave a blank card — measured, not assumed: a slot returning `null` rendered an empty container with no visible text. + +`read_image` joins the `read` variant and gets its own locale title key. Left unclassified it fell to `others`, which titles the row generically and derives no `filePath` (only read/write/edit variants do), so the openable path the row advertises would never have been openable. + +`read` and `read_image` are the same single-file card row with different card material, so their shared assembly lives in `read-family-row.tsx` rather than being copied. + +## Alternatives considered + +- **Add `card: 'image'` arm to `ToolResultView` and a `presentResult`.** This is what the first version did. Client cards derive from raw events and host presentation values never reach the client, so the arm had no consumer — an extension of a closed public union that nothing read. Dropped in favour of `presentationMeta` alone. +- **Pass a rendering closure down (the `renderMessageImages` pattern).** The next version reused `ChatNodeOwnerProps.renderMessageImages` as a `renderImages` owner prop, mirroring what `AssistantMarkdown` and the message rows do. Review rejected it: the client rule forbids new ReactNode-valued owner props, and the compliant shape is a slot the tool layer itself declares. With `loadImage` down-threaded from the chat node, the row renders `tool.call.images` directly and no rendering capability crosses the owner boundary. +- **Render the image on the generic fallback too.** The slot design cannot: a slot is declared by exactly one entry, and the fallback component is not a registered entry, so it has no dispatch seat for a child it did not declare. The keyed row is the only image render site; future image tools register their own. +- **Use `singleResultText` like the read card.** It accepts only a lone text block by design, and an image read returns two, so the card matches its own envelope shape instead. +- **Persist the reference in `meta` as well.** The first version did, and it read the card from there. Review pointed out the duplication, and the recorded log confirmed it: `meta.image` and the content block's `attachment` were byte-identical. Reading from the content instead leaves one record and follows a post-execute replacement. +- **Validate the attachment id against `sha256:`.** Tried, then reverted: it contradicts the documented opacity of `AttachmentId` and would break any deployment whose store mints another shape. +- **Give the image card its own primitive in `ui-primitives`.** Rejected as duplication — the message gallery's fit rules, crop anchors, and lightbox are the behavior a card needs. + +## Verification + +`read-image.spec.ts` covers the metadata projection, the omitted display name, and a real execution whose persisted reference matches what the attachment store committed. `image-card.client.spec.tsx` covers the derivation from metadata and envelope, path relativization, opaque ids from alternative stores, every rejection branch of the defensive narrowing, the running/error/nested declines, the keyed row render site dispatching `tool.call.images` with the loader, keyed registration with the child-slot declaration, and the empty-slot fallback. + +Negative controls were run against each assertion group before it was kept: removing the variant classification, disabling the image render branch, mistyping the registrant key, restoring the `sha256:` id pattern, and pointing the card's text back at the row's flattened output each turned the intended assertion red. + +## Consequences + +A top-level `read_image` now renders as the image, matching what a nested call already did, and the tool card gains an image kind. The image kind is not automatic from the metadata alone: the card also requires the `tool.call.images` slot to be filled (the attachment presentation plugin) and a keyed toolview for the tool, because the model narrows the call head to `read_image` and the slot is rendered from a declared child entry. + +The persisted presentation metadata adds one small `{ path }` record per image read to the session log. The attachment reference is not in the log as metadata at all — it lives in the settled result content's image block — and the image bytes themselves are never logged, because the store is content-addressed and the block carries only the attachment id. + +Because the card derives from `block.meta` plus the settled content, a session logged before this change carries no image metadata and replays as the generic text card. That is the documented fallback for every raw-event-derived card, not a special case here. diff --git a/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md b/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md new file mode 100644 index 0000000000..018a4f0e20 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md @@ -0,0 +1,57 @@ +# Agent Note:工具卡片的图像结果 + +Status: implemented + +[English](2026-08-20-tool-card-image-results.md) | 中文 + +## 问题 + +已结算的 `read_image` 调用在工具卡片里把原始附件对象当字面文本渲染出来——`{"type":"image","attachment":{"attachmentId":"sha256:…","mediaType":"image/png","bytes":24588,"width":1496,…}}`——而不是显示图像本身。 + +这由两个彼此独立的缺口造成。`read_image` 没有声明 `output.presentationMeta`,因此没有任何呈现元数据告诉客户端卡片如何展示引用——工具卡片把原始结果内容当作文本打了出来。另一方面,工具卡片层完全没有图像概念:`packages/client/ui-tool/src` 中 `image` 和 `attachment` 一次都没出现,而 `ToolRow` 的卡片槽只有 terminal、diff、read、search、web。 + +渲染能力其实已经存在,但只接在消息路径上。`MessageImages` 通过 `conversation.message.images` 槽位为用户与助手历史绘制持久图像组。这个不对称解释了一个容易困惑的现象:**嵌套的** `read_image` 能正确显示,因为嵌套调用时 `execute` 会 defer 一条真正的用户消息;而顶层调用只把图像作为工具结果内容返回,就不显示。 + +## 决定 + +**宿主侧。** `read_image` 获得只持久化 `{ path }` 的 `output.presentationMeta`——仅路径一项。 + +附件引用有意不写在那里。已结算的 `content` 本身就带着含完整引用的 image 块,而当 `tools/post-execute` 钩子合法重写结果时,被替换的正是那个块。因此在 `meta` 里再存一份就是同一事实的重复记录,且恰恰在内容变化时变成过期副本——卡片会继续显示结果已不再返回的图像。路径是 content 唯一不作为结构化字段携带的事实:面向模型的信封把后端解析出的路径写成文本,而客户端从不解析那段文本。 + +不加 `presentResult`,也不给封闭的 `ToolResultView` 联合新增成员。客户端卡片从原始 event 字段派生,宿主的 `presentCall`/`presentResult` 值永不进入客户端(见 [ui-tool README](../../../../packages/client/ui-tool/README.zh.md)),因此新增一个 result-view 分支等于扩展一个无人读取的封闭公共联合。 + +**客户端侧。** `imageCardModel` 按其他所有第一方卡片的方式派生:`parsedToolCall` 校验调用头与其 `file_path`,`block.meta` 提供路径,附件引用从结果自己的 image 块中防御式 narrow 出来,信封在同一内容中定位。它按形状匹配自己的信封而不用 `singleResultText`,因为那个 helper 只接受单个文本块,而图像读取返回 `[text envelope, image block]`——按形状匹配同时意味着其他层前置的内容永远不会被误认为信封。 + +该 narrowing 只检查附件 id 是否存在。id 是不透明且由提供方拥有的:本地存储铸造内容地址,但消费者既不得解析该表示、也不得假定其形状,且提供方可以不经通知改变它。按本地形式做模式匹配会拒绝替代存储铸造的合法 id,并让该部署中每个图像卡片静默降级。 + +`ToolRow` 获得 `image` 卡片槽,`read_image` 获得按 key 注册的 toolview,并在其 registration 上把 Tool 自有的 `tool.call.images` 槽位声明为子槽,通过它渲染图库。工具层自己既不加载也不授权:这一行只提供从结果派生出的引用,以及聊天节点新下传的 `loadImage` loader(`ChatNodeOwnerProps.loadImage`),附件呈现插件用与消息图像相同的图库填充该槽位。因此携带图像的工具需要注册按 key 的 toolview(`read_image` 是行装配与 card model 的模板;`tool.call.images` 子槽声明不能逐字复用,因为一个槽位只能由一个 entry 声明);generic fallback 保留压平文本。 + +卡片在图库下方保留派生出的信封文本。这不是冗余:在未组合附件呈现插件的部署里 `tool.call.images` 什么都不渲染,而空图库不能留下空白卡片——这是实测而非假设:用一个返回 `null` 的槽位探测,渲染出的是空容器,没有任何可见文本。 + +`read_image` 归入 `read` variant 并获得自己的 locale 标题 key。不分类时它落到 `others`,标题变成通用文案且不派生 `filePath`(只有 read/write/edit variant 会派生),于是该行声称可点击打开的路径永远不可点击。 + +`read` 与 `read_image` 是同一种单文件卡片行、只是卡片材料不同,因此它们共享的装配放在 `read-family-row.tsx` 而不是复制一份。 + +## 考虑过的备选方案 + +- **给 `ToolResultView` 加 `card: 'image'` 分支并实现 `presentResult`。** 第一版就是这么做的。客户端卡片从原始 event 派生、宿主呈现值不进入客户端,因此该分支没有消费方——等于扩展一个无人读取的封闭公共联合。改为只用 `presentationMeta`。 +- **向下传递渲染闭包(`renderMessageImages` 模式)。** 下一版复用了 `ChatNodeOwnerProps.renderMessageImages` 作为 `renderImages` owner prop,与 `AssistantMarkdown` 和消息行的做法一致。review 拒绝了它:客户端规则禁止新增 ReactNode-valued owner props,合规形态是工具层自己声明的槽位。把 `loadImage` 从聊天节点下传后,这一行直接渲染 `tool.call.images`,不再有任何渲染能力穿过 owner 边界。 +- **在 generic fallback 上也渲染图像。** 槽位设计做不到:一个槽位只能由一个 entry 声明,而 fallback 组件不是已注册 entry,没有为未声明的子槽提供 dispatch 席位。按 key 的行是唯一图像渲染点;将来的图像工具注册自己的行。 +- **像 read 卡片那样用 `singleResultText`。** 它按设计只接受单个文本块,而图像读取返回两块,因此卡片改为按自己的信封形状匹配。 +- **在 `meta` 里也持久化引用。** 第一版就是这么做的,卡片也从那里读取。review 指出了这处重复,实录日志也证实了:`meta.image` 与 content 中 image 块的 `attachment` 逐字节相同。改从 content 读取后只剩一份记录,并且会跟随 post-execute 的替换。 +- **按 `sha256:` 校验附件 id。** 试过后撤回:它与 `AttachmentId` 文档化的不透明性相矛盾,并会让任何采用其他 id 形状的部署失效。 +- **在 `ui-primitives` 里给图像卡片做专属 primitive。** 作为重复实现否决——消息图库的适配规则、裁剪锚点和灯箱正是卡片需要的行为。 + +## 验证 + +`read-image.spec.ts` 覆盖元数据投影、省略显示名,以及一次真实执行——其持久化的引用与附件存储实际提交的一致。`image-card.client.spec.tsx` 覆盖从元数据与信封的派生、路径相对化、来自替代存储的不透明 id、防御式 narrowing 的每个拒绝分支、running/error/嵌套三种拒绝、按 key 的行渲染点(携带 loader 分发 `tool.call.images`)、带子槽声明的按 key 注册,以及空槽位降级。 + +每组断言在保留之前都跑过负例:移除 variant 分类、让图像渲染分支失效、把 registrant 的 key 打错、恢复 `sha256:` id 模式、把卡片文本指回行的压平输出——每一项都让目标断言变红。 + +## 后果 + +顶层 `read_image` 现在渲染为图像,与嵌套调用早已具备的行为一致;工具卡片获得一种图像种类。图像种类并不由元数据单独自动产生:卡片还要求 `tool.call.images` 槽位被填充(附件呈现插件),并且工具注册按 key 的 toolview——因为模型把调用头收窄到 `read_image`,槽位只能从声明的子 entry 渲染。 + +持久化的呈现元数据为每次图像读取在会话日志中增加一条很小的 `{ path }` 记录。附件引用完全不在元数据里——它位于已结算结果 content 的 image 块中;图像字节本身从不进入日志,因为存储是内容寻址的,块里只携带附件 id。 + +由于卡片从 `block.meta` 派生,本次改动之前记录的会话没有图像元数据,会以通用文本卡片重放。这是每个从原始 event 派生的卡片都遵循的既有降级路径,不是这里的特例。 diff --git a/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.i18n.yaml b/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.i18n.yaml new file mode 100644 index 0000000000..0c57f66e04 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.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/proposed/process/2026-08-27-port-tool-owned-render.md +2026-08-27-port-tool-owned-render.md: 4ab27142fdc2d13d7d152c21c2f246e340fb4711 +2026-08-27-port-tool-owned-render.zh.md: 9b5ece1a222551a308f6008e316fb4c251c36d88 diff --git a/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md b/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md new file mode 100644 index 0000000000..4ab27142fd --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md @@ -0,0 +1,35 @@ +# Agent Note: Port tool-owned render into current DSH APIs + +Status: proposed + +English | [中文](2026-08-27-port-tool-owned-render.zh.md) + +## Problem + +The `dsh-tool-owned-render` prototype (`Chinesezjc/dsh-tool-owned-render`) ships tool-owned render registrants for `read`, `bash`, `write`/`edit`, `grep`/`glob`, and `web_search`/`web_fetch`, written against an older API where `ToolCallBlock` exposed `callView` / `resultView` and the client received host `presentResult` output. Current master derives client cards from raw `block.call` / `block.content` / `block.meta`, and `ctx.slots` requires the `@deepseek-ai/dsh-client-ui-renderer/client` module augmentation. A direct merge of the prototype does not typecheck, so its registrants cannot ship without a port. + +## Proposal + +- Add `packages/client/tool-owned-render` as a workspace package. +- Port the `read`, `bash`, `write`/`edit`, `grep`/`glob`, and `web_search`/`web_fetch` registrants to derive from current `ToolCallBlock` fields. +- Add a `read_image` registrant using the same ToolCard/Segment primitives. +- Wire `ctx.slots` type augmentation through `dsh-client-ui-renderer`. +- Keep PR #2828 mergeable while this port proceeds separately. + +## Alternatives considered + +- **Merge the prototype and fix its type errors in place** — rejected: every registrant would have to be re-derived from the current `ToolCallBlock` fields anyway, so the port is the same work with the obsolete `callView` / `resultView` contract already gone. +- **Fold the port into PR #2828** — rejected: the image card is one feature with a defined scope, and a second package plus five more registrants would enlarge the review surface of an already large PR. + +## Acceptance criteria + +- `packages/client/tool-owned-render` exists as a workspace package. +- The ported registrants derive card state from current `ToolCallBlock` fields and typecheck on master. +- A `read_image` registrant renders through the same primitives as `read`. +- The `ctx.slots` type augmentation resolves through `dsh-client-ui-renderer`. +- PR #2828 merges independently of this port. + +## Risks + +- The port may not reproduce the prototype's exact visual output, because the current card primitives differ from the old `callView` / `resultView` contract. +- API drift while the port proceeds can stale this proposal; the acceptance criteria are re-checked against master at port time. diff --git a/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.zh.md b/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.zh.md new file mode 100644 index 0000000000..9b5ece1a22 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 把 tool-owned render 移植到当前 DSH API + +状态:proposed + +[English](2026-08-27-port-tool-owned-render.md) | 中文 + +## 问题 + +`dsh-tool-owned-render` 原型(`Chinesezjc/dsh-tool-owned-render`)带有 `read`、`bash`、`write`/`edit`、`grep`/`glob`、`web_search`/`web_fetch` 的 tool-owned render 注册项,基于旧 API 编写:`ToolCallBlock` 暴露 `callView` / `resultView`,客户端能拿到 host `presentResult` 输出。当前 master 从原始 `block.call` / `block.content` / `block.meta` 推导客户端卡片,`ctx.slots` 也需要 `@deepseek-ai/dsh-client-ui-renderer/client` 模块增强。直接合并原型不能通过类型检查,因此这些注册项不经移植无法发布。 + +## 提案 + +- 新增 `packages/client/tool-owned-render` workspace 包。 +- 把 `read`、`bash`、`write`/`edit`、`grep`/`glob`、`web_search`/`web_fetch` 注册项移植到从当前 `ToolCallBlock` 字段推导。 +- 增加 `read_image` 注册项,使用同一套 ToolCard/Segment 原语。 +- 通过 `dsh-client-ui-renderer` 接通 `ctx.slots` 类型增强。 +- 移植单独推进,保持 PR #2828 可合并。 + +## 已考虑的替代方案 + +- **直接合并原型并就地修复类型错误** — 否决:每个注册项反正都要按当前 `ToolCallBlock` 字段重新推导,移植就是同一份工作,只是旧的 `callView` / `resultView` 契约已不存在。 +- **把移植并入 PR #2828** — 否决:image 卡片是一个范围明确的单一功能,再加一个新包和五个注册项会扩大本已很大的 PR 的审查面。 + +## 验收标准 + +- `packages/client/tool-owned-render` 作为 workspace 包存在。 +- 移植后的注册项从当前 `ToolCallBlock` 字段推导卡片状态,并在 master 上通过类型检查。 +- `read_image` 注册项与 `read` 使用同一套原语渲染。 +- `ctx.slots` 类型增强通过 `dsh-client-ui-renderer` 解析。 +- PR #2828 独立于本移植合并。 + +## 风险 + +- 移植可能无法复现原型的精确视觉输出,因为当前卡片原语与旧的 `callView` / `resultView` 契约不同。 +- 移植推进期间 API 继续漂移会使本提案过时;验收标准在移植时按当时的 master 重新核对。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index d0c94878b2..1c96cdce9a 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: 8e115e30aed68e543eca2f1aac6e28ad9f57cf73 -slots.zh.md: 3894b69d8d020b4bb67ce325d389ab3f20cfcc9a +slots.md: a37374ff11f9d460a2566a7a30f8318e69cbfa0b +slots.zh.md: 09329b1f299f4912490f888efa3146ec0ce11e28 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 8e115e30ae..a37374ff11 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -135,6 +135,7 @@ root │ │ │ ├─ conversation.chat.commandview │ │ │ ├─ conversation.chat.turnTail │ │ │ └─ tool.call.toolview +│ │ │ ├─ tool.call.images │ │ │ └─ tool.view.cordis │ │ ├─ conversation.message.images │ │ └─ conversation.trajectory.images diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 3894b69d8d..09329b1f29 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -135,6 +135,7 @@ root │ │ │ ├─ conversation.chat.commandview │ │ │ ├─ conversation.chat.turnTail │ │ │ └─ tool.call.toolview +│ │ │ ├─ tool.call.images │ │ │ └─ tool.view.cordis │ │ ├─ conversation.message.images │ │ └─ conversation.trajectory.images diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 5e0e50e06a..8fa8015caf 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/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: 9fa03432b43686dd55af494640708a8bf0983f9a -README.zh.md: 48e467280bfb83b2f4341f0e4c833b0b44cdce18 +README.md: 47faee79e1e638927dfbff117e7ab8ea18360363 +README.zh.md: 483484e2bec057d422b03440a1386682d6bebb9f diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 9fa03432b4..47faee79e1 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package renders everything the conversation UI shows about attachments: pending draft images under the composer, a full-viewport drop invitation, durable images in Chat and Trajectory, and a lightbox for the original image. It is a pure presentation layer — attachment data, image loading, and callbacks come from the conversation package through declared slots. Choose it for the DeepSeek Chat-style image experience; non-image files have no surface here. +This package renders everything the conversation UI shows about attachments: pending draft images under the composer, a full-viewport drop invitation, durable images in Chat, Trajectory, and Tool results, and a lightbox for the original image. It is a pure presentation layer — attachment data, image loading, and callbacks come from the conversation package through declared slots. Choose it for the DeepSeek Chat-style image experience; non-image files have no surface here. ## Table of Contents @@ -25,7 +25,7 @@ This package renders everything the conversation UI shows about attachments: pen ## Use this package -Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md); it waits for the conversation package's slot declarations and registers its surfaces into them. Users then see the draft-image rail with per-image remove and click-to-open, the drop overlay with its limits line, message images sized by count, and the Escape/mask/close lightbox. +Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) (and [`ui-tool`](../ui-tool/README.md) for the tool-result gallery); it waits for the conversation package's slot declarations and registers its surfaces into them. Users then see the draft-image rail with per-image remove and click-to-open, the drop overlay with its limits line, message images sized by count, the tool card's gallery, and the Escape/mask/close lightbox. ### Draft images @@ -47,7 +47,7 @@ While a file drag is over the page, the full-viewport overlay announces the drop
Implementation internals — click to expand -The plugin waits for `conversation.input.attachments`, `conversation.message.images`, and `conversation.trajectory.images` through `ctx.slots.inject`. It then registers the composer rail, document drop target, shared history gallery for Chat and Trajectory, and original-image lightbox. The presentation components are pure props: the conversation slot owner supplies attachment data, image loading, callbacks, and the locale translator; the package entry exports no components. +The plugin waits for `conversation.input.attachments`, `conversation.message.images`, `conversation.trajectory.images`, and `tool.call.images` through `ctx.slots.inject`. It then registers the composer rail, document drop target, shared history gallery for Chat, Trajectory, and Tool results, and original-image lightbox. The presentation components are pure props: the slot owner supplies attachment data, image loading, callbacks, and the locale translator; the package entry exports no components. | File | Role | |---|---| diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 48e467280b..483484e2be 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包渲染对话 UI 中与附件相关的一切:composer 下的待发送草稿图片、全视口拖放邀请层、Chat 与 Trajectory 中的持久图片,以及查看原图的灯箱。它是纯呈现层——附件数据、图片加载与回调都经声明槽位来自 conversation 包。需要 DeepSeek Chat 风格的图片体验时选择它;非图片文件在此没有任何表面。 +本包渲染对话 UI 中与附件相关的一切:composer 下的待发送草稿图片、全视口拖放邀请层、Chat、Trajectory 与工具结果中的持久图片,以及查看原图的灯箱。它是纯呈现层——附件数据、图片加载与回调都经声明槽位来自 conversation 包。需要 DeepSeek Chat 风格的图片体验时选择它;非图片文件在此没有任何表面。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 [`ui-conversation`](../ui-conversation/README.zh.md) 一起挂载本插件;它等待 conversation 包的槽位声明,并把自身表面注册进这些槽位。用户随即看到:带逐图删除与点击打开的草稿图片栏、带上限说明的拖放遮罩、按数量定尺寸的消息图片,以及支持 Escape/遮罩/关闭按钮的灯箱。 +与 [`ui-conversation`](../ui-conversation/README.zh.md)(以及工具结果图库所需的 [`ui-tool`](../ui-tool/README.zh.md))一起挂载本插件;它等待 conversation 包的槽位声明,并把自身表面注册进这些槽位。用户随即看到:带逐图删除与点击打开的草稿图片栏、带上限说明的拖放遮罩、按数量定尺寸的消息图片、工具卡片的图库,以及支持 Escape/遮罩/关闭按钮的灯箱。 ### 草稿图片 @@ -47,7 +47,7 @@ kind: "package-reference"
实现细节——点击展开 -插件通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images` 与 `conversation.trajectory.images`。随后它注册 composer rail、文档拖放目标、供 Chat 和 Trajectory 共用的历史图片 gallery,以及原图灯箱。呈现组件保持纯 props:conversation 槽位持有方提供附件数据、图片加载、回调与语言包翻译器;包入口不导出任何组件。 +插件通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images`、`conversation.trajectory.images` 与 `tool.call.images`。随后它注册 composer rail、文档拖放目标、供 Chat、Trajectory 与工具结果共用的历史图片 gallery,以及原图灯箱。呈现组件保持纯 props:槽位持有方提供附件数据、图片加载、回调与语言包翻译器;包入口不导出任何组件。 | 文件 | 职责 | |---|---| diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 42d140be37..2273acf709 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -35,7 +35,8 @@ "@deepseek-ai/dsh-client-ui-chat", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-renderer", - "@deepseek-ai/dsh-client-ui-trajectory" + "@deepseek-ai/dsh-client-ui-trajectory", + "@deepseek-ai/dsh-client-ui-tool" ], "platform": "web" } @@ -61,7 +62,8 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0", - "@deepseek-ai/dsh-attachment": "workspace:^" + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-ui-tool": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index 8fe94f64ee..62deeebfc3 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -1,8 +1,9 @@ -/** Browser attachment plugin: fills conversation's composer and message-image slots. */ +/** Browser attachment plugin: fills conversation's composer and image slots. */ 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-tool/client' import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client' import { ComposerAttachments } from './ComposerAttachments.tsx' import { MessageImages } from './MessageImages.tsx' @@ -24,4 +25,10 @@ export function apply(ctx: ClientContext): void { name: 'conversation.trajectory.images', locale: 'conversation', }, MessageImages)) + // The tool image gallery reuses the message gallery renderer: its owner + // carries the same images/loadImage/align share the message arm does. + ctx.slots.inject('tool.call.images', () => ctx.slots.register({ + name: 'tool.call.images', + locale: 'conversation', + }, MessageImages)) } diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index 21d84953de..a83b33e8e0 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -15,6 +15,7 @@ async function bench() { 'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' }, 'conversation.message.images': { kind: 'single', scope: 'session' }, 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, + 'tool.call.images': { kind: 'single', scope: 'session' }, }, } as never, () => null) const fiber = ctx.plugin({ inject: [...inject], apply }) @@ -42,11 +43,16 @@ describe('attachment plugin', () => { locale: 'conversation', component: MessageImages, }]) + expect(ctx.slots.entries('tool.call.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) + expect(ctx.slots.entries('tool.call.images')).toHaveLength(0) }) }) diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index b2783b20e9..dd526af933 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-tool" + }, { "path": "../ui-trajectory" }, diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 00eb9fd70d..5e74943b8a 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -80,7 +80,7 @@ function turnProcessLayout( export const ChatNodeSeat = memo(function ChatNodeSeat({ nodeKey, historyIncomplete, compactTranscript, selectedCallId, cwd, openFile, inspectCall, forkAt, - renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t, + loadImage, renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t, }: ChatNodeSeatProps) { const node = useChat(snapshot => snapshot.nodes.get(nodeKey)) const processSignature = useChat((snapshot) => { @@ -181,12 +181,13 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ openFile, inspectCall, forkAt, + loadImage, renderMessageImages, fileMentions, turnProcess, }, [ node, selectedCallId, cwd, openFile, inspectCall, forkAt, - renderMessageImages, fileMentions, turnProcess, + loadImage, renderMessageImages, fileMentions, turnProcess, ]) if (routedNode === undefined || owner === null) return null const location = routedNode.location diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index b5f0313f15..a5dd412ea8 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -606,6 +606,7 @@ export function ChatView({ openFile={requestOpenFile} inspectCall={inspectCall} forkAt={forkAt} + loadImage={loadImage} renderMessageImages={renderMessageImages} fileMentions={fileMentions} renderSlot={renderSlot} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 307d8f703b..0cd01495b4 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -66,6 +66,13 @@ export interface ChatNodeOwnerProps { openFile: (path: string) => void inspectCall: (callId: ToolCallId) => void forkAt: (seq: number) => void + /** + * Session-authorized image loader, down-threaded from the Chat view so a + * chat-node renderer can render the attachment presentation slot directly + * with only the durable references plus this loader, instead of receiving a + * rendering closure. + */ + loadImage: MessageImageLoader renderMessageImages: RenderMessageImages fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined /** Turn-process state when this Node belongs to a projected Turn. */ diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9eaf2a82a0..9d9b26253a 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -102,6 +102,7 @@ export const zh = { 'tool.title.stopCordis': '停止 Cordis 插件', 'tool.title.removeCordis': '移除 Cordis 插件', 'tool.title.pwsh': 'Pwsh', + 'tool.title.readImage': '读取图片', 'tool.title.grep': 'Grep', 'tool.title.glob': 'Glob', 'tool.title.webSearch': '网页搜索', @@ -250,6 +251,7 @@ export const en = { 'tool.title.stopCordis': 'Stop Cordis Plugin', 'tool.title.removeCordis': 'Remove Cordis Plugin', 'tool.title.pwsh': 'Pwsh', + 'tool.title.readImage': 'Read image', 'tool.title.grep': 'Grep', 'tool.title.glob': 'Glob', 'tool.title.webSearch': 'Search', diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 5cfd56493f..cbcf777c22 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: 773a93801ebc214e2d5c94d52864f5c5dd887100 -README.zh.md: 88df08d7b5b7d5d3978d90fd4df4cbbb2efeb1fa +README.md: 3461f6a90f791cccad968604b6cdd83535d15647 +README.zh.md: d2eb38b8382f56c77827540309237e876623df33 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 773a93801e..3461f6a90f 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -39,11 +39,11 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. A Code Dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants keep the generic flattened form without another placement flag. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service. +The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, the session-authorized `loadImage` loader (for a view whose result carries durable images), and plain `openFile`/`inspect` callbacks. A Code Dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants keep the generic flattened form without another placement flag. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service. ### Built-in views -This package owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. +This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. ----- @@ -61,7 +61,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool ### Details and cards -The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes. +The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card; the image card is row-only because its gallery renders through the tool-owned `tool.call.images` slot the details panel does not declare. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [image](../../../.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes.
diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 88df08d7b5..d2eb38b838 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -39,11 +39,11 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`/`inspect` 回调。Code Dispatch block 保留事件的 `parentCallId`;root Session call 没有该字段,因此 descendant 无需另一项 placement 标志即可保持 generic 压平形态。路径摘要先相对 Session cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规 Session slot runtime share,但不会收到 React node 或 runtime service。 +owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`、会话授权的 `loadImage` loader(供结果携带持久图像的视图使用),以及普通的 `openFile`/`inspect` 回调。Code Dispatch block 保留事件的 `parentCallId`;root Session call 没有该字段,因此 descendant 无需另一项 placement 标志即可保持 generic 压平形态。路径摘要先相对 Session cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规 Session slot runtime share,但不会收到 React node 或 runtime service。 ### 内置视图 -本包拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 +本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 ----- @@ -61,7 +61,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 详情与卡片 -本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。 +本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model;image 卡片仅属于行,因为其图库经由工具自有 `tool.call.images` 槽位渲染,而 details 面板不声明该槽位。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[image](../../../.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。
diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 3a26243df1..129ecd9cd3 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -69,7 +69,8 @@ "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^" + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index 1324e8cbed..7c153dc515 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -13,6 +13,7 @@ import { askQuestionToolview } from './tool/toolviews/ask-question-row.tsx' import { bashToolviewSample } from './tool/toolviews/bash-sample.tsx' import { fileMutationToolview } from './tool/toolviews/file-mutation-row.tsx' import { readToolview } from './tool/toolviews/read-row.tsx' +import { readImageToolview } from './tool/toolviews/read-image-row.tsx' import { searchToolview } from './tool/toolviews/search-row.tsx' import { todoToolview } from './tool/toolviews/todo-row.tsx' import { webToolview } from './tool/toolviews/web-row.tsx' @@ -48,6 +49,7 @@ export function apply(ctx: ClientContext): void { ctx.plugin(bashToolviewSample) ctx.plugin(readToolview) + ctx.plugin(readImageToolview) ctx.plugin(fileMutationToolview) ctx.plugin(searchToolview) ctx.plugin(webToolview) diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 703e102ac4..d99a64a626 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -4,7 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { MessageImageLoader, MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -24,9 +24,30 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * function of what the turn already knows. */ 'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps } + /** + * Durable images of a settled image-bearing Tool call, rendered through + * the attachment presentation plugin. The Tool layer never imports an + * attachment implementation: a toolview declares this slot as a child and + * renders it with the image card's references plus the session-authorized + * loader it received in its owner, and the attachment plugin fills the + * gallery. Composing no attachment presentation plugin renders nothing, + * which is why the image card keeps its own envelope text beside the + * gallery. + */ + 'tool.call.images': { kind: 'single'; scope: 'session'; owner: ToolImagesOwnerProps } } } +/** Owner currency of the Tool image gallery slot: references plus the loader. */ +export interface ToolImagesOwnerProps { + /** Durable references or submission-echo previews in result order. */ + images: readonly MessageImageSource[] + /** Session-authorized image URL loader for the durable arm. */ + loadImage: MessageImageLoader + /** Horizontal placement inside the owning record. */ + align: 'start' | 'end' +} + /** Standard owner currency supplied to every atomic Tool view. */ export interface ToolCallOwnerProps { /** Tool call identity, stable across running and settled forms. */ @@ -41,6 +62,14 @@ export interface ToolCallOwnerProps { home?: string | undefined /** Open a Tool argument path through the Host. */ openFile: (path: string) => void + /** + * Session-authorized image loader for the `tool.call.images` slot, supplied + * by the chat node that owns this call. A composed chat node always + * supplies it (`ChatNodeOwnerProps.loadImage` is required), so the tool + * layer never imports an attachment implementation nor handles URL + * authorization. + */ + loadImage: MessageImageLoader /** Inspect this call in the trajectory view when available. */ inspect?: (() => void) | undefined } diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 2a11948077..d8288fa71a 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -12,8 +12,8 @@ function callName(node: ToolCallBlock): string { /** One atomic call dispatched through the Tool-owned keyed slot. */ const ToolCall = memo(function ToolCall({ - renderSlot, callId, toolName, block, openFile, selected, cwd, home, inspectCall, t, children, -}: Pick & { + renderSlot, callId, toolName, block, openFile, selected, cwd, home, inspectCall, loadImage, t, children, +}: Pick & { callId: string toolName: string block: ToolCallBlock @@ -28,8 +28,9 @@ const ToolCall = memo(function ToolCall({ openFile, cwd, home, + loadImage, inspect: () => { inspectCall(callId) }, - }), [callId, toolName, block, openFile, cwd, home, inspectCall]) + }), [callId, toolName, block, openFile, cwd, home, loadImage, inspectCall]) return (
& { + renderSlot, block, selectedCallId, cwd, home, openFile, inspectCall, loadImage, t, +}: Pick & { block: ToolCallBlock home?: string | undefined }) { @@ -63,6 +64,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ cwd={cwd} home={home} inspectCall={inspectCall} + loadImage={loadImage} t={t} > {block.subCalls.length > 0 ? ( @@ -77,6 +79,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ home={home} openFile={openFile} inspectCall={inspectCall} + loadImage={loadImage} t={t} /> ))} @@ -93,7 +96,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostInfo, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, loadImage, useHostInfo, t, }: ToolTreeProps) { const home = useHostInfo(info => info.home) const block = node.data.root @@ -106,6 +109,7 @@ export function ToolCallTree({ home={home} openFile={openFile} inspectCall={inspectCall} + loadImage={loadImage} t={t} /> ) diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 2f344ac0dd..da78d23872 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -288,6 +288,7 @@ .terminalBody, .diffBody, .readBody, +.imageBody, .searchBody, .webBody { margin: 4px 0 4px 4px; @@ -304,6 +305,28 @@ color: var(--dsw-alias-label-tertiary); } +/* The image card's in-card label: the view's replacement title, else the + shortened path — the same role ReadBlock's own label plays, so the presentation + contract's replacement-title rule is honoured here too. */ +.imageLabel { + margin-bottom: 4px; + overflow-wrap: anywhere; + font: var(--dsw-font-sm-13); + color: var(--dsw-alias-label-secondary); +} + +/* The image card's own result text (media type, dimensions, byte size) under the + gallery, in the same muted tone the search recovery footer uses. Always + rendered: the attachment presentation slot is optional, so when nothing + occupies it the gallery is empty and this line is the only evidence an image + was returned. The enclosing .imageBody already carries the row indent. */ +.imageMeta { + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} + /* In-row code renders at the smaller code size (12/18) via each primitive's rebindable content-font seam; standalone markdown code blocks keep 13/22. */ .codeBody { diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index ab38bc9dbc..f3517130cb 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -4,9 +4,11 @@ import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, diffTotals, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsRenderSlots, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { MessageImageLoader } from '@deepseek-ai/dsh-client-ui-conversation/client' import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts' import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-model.ts' +import type { ImageCardModel } from '../models/image-card-model.ts' import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../models/search-card-model.ts' import { localizeTerminalCardModel, terminalBlockLabels, type TerminalCardModel, @@ -48,6 +50,21 @@ export interface ToolRowProps { terminal?: TerminalCardModel | null | undefined diff?: DiffCardModel | null | undefined read?: ReadCardModel | null | undefined + /** + * Image-card material for a call whose result is an image (derived by + * `imageCardModel`). Rendered through the `tool.call.images` slot, so the + * tool layer never imports an attachment implementation nor handles URL + * authorization. + */ + image?: ImageCardModel | null | undefined + /** + * Dispatch the image gallery through the tool-owned `tool.call.images` + * slot, supplied by the toolview that owns this row together with the + * session-authorized loader. + */ + renderSlot?: PropsRenderSlots<'tool.call.images'>['renderSlot'] | undefined + /** Session-authorized image URL loader for the gallery slot. */ + loadImage?: MessageImageLoader | undefined search?: SearchCardModel | null | undefined web?: WebCardModelProps | null | undefined state: ToolRowState @@ -101,6 +118,9 @@ export function ToolRow({ terminal, diff, read, + image, + renderSlot, + loadImage, search, web, state, @@ -119,11 +139,14 @@ export function ToolRow({ : localizeTerminalCardModel(terminal, t) const diffBody = diff ?? null const readBody = read ?? null + const imageBody = image !== undefined && image !== null && renderSlot !== undefined && loadImage !== undefined + ? image + : null const searchBody = search ?? null const webBody = web ?? null const askQuestionBody = askQuestion ?? null const outputText = output ?? null - const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody + const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? imageBody ?? searchBody ?? webBody const expandable = body !== null || outputText !== null || card !== null const open = expanded && expandable const status = stateStatus(state, t) @@ -215,54 +238,74 @@ export function ToolRow({ ? : readBody !== null ? - : searchBody !== null + : imageBody !== null ? ( - <> - - {/* A capped search's recovery locator lives only in the result - text; show it below the card so the dropped rows survive. */} - {searchBody.recovery !== undefined && ( -
{searchBody.recovery}
- )} - + /* Label, gallery, then the result's OWN envelope text. The text + comes from the image card model (which reads the result's text + block), never from the row's flattened output: an image read's + content is [text envelope, image block] and flattening + JSON.stringifies the image block, printing the raw attachment + object under the picture. It is not redundant either — the + attachment slot can render nothing, and then this line is the + only evidence an image was returned. */ +
+
{imageBody.label}
+ {renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', { + images: imageBody.images, + loadImage, + align: 'start', + })} +
{imageBody.text}
+
) - : webBody !== null - ? - : ( + : searchBody !== null + ? ( <> - {variant === 'code' && body !== null && ( -
- -
- )} - {(cardBody !== null || outputText !== null) && ( -
- {cardBody !== null && ( -
- {t('row.input')} - {cardBody} -
- )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
- {t('row.output')} - - {outputText} - -
- )} -
+ + {/* A capped search's recovery locator lives only in the result + text; show it below the card so the dropped rows survive. */} + {searchBody.recovery !== undefined && ( +
{searchBody.recovery}
)} - )} + ) + : webBody !== null + ? + : ( + <> + {variant === 'code' && body !== null && ( +
+ +
+ )} + {(cardBody !== null || outputText !== null) && ( +
+ {cardBody !== null && ( +
+ {t('row.input')} + {cardBody} +
+ )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
+ {t('row.output')} + + {outputText} + +
+ )} +
+ )} + + )} {inspect !== undefined && (