diff --git a/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.i18n.yaml index 7890db3713..ac38c709dc 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.i18n.yaml @@ -2,21 +2,21 @@ # section, a hash of its English and Chinese blocks outside code blocks and generated regions. # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.md -/agent-note-message-image-factories-with-explicit-loaders: - en: 2643c4807b39dfd5 - zh: a44a57b04b5835f6 -/agent-note-message-image-factories-with-explicit-loaders/problem: - en: 2d301b07133242c4 - zh: 695d3c0553e96268 -/agent-note-message-image-factories-with-explicit-loaders/decision: - en: 559cc1ca16188e22 - zh: 155da636b04fed38 -/agent-note-message-image-factories-with-explicit-loaders/alternatives-considered: - en: 401aadb0bebc31f6 - zh: 064f9612f3bcf87a -/agent-note-message-image-factories-with-explicit-loaders/verification: - en: 4efdf85afc83048a - zh: 9be23ccebfc14f13 -/agent-note-message-image-factories-with-explicit-loaders/consequences: - en: 59066f3fab26b634 - zh: d027037d84165128 +/agent-note-domain-image-slots-and-a-shared-gallery-factory: + en: bce6358fb4211261 + zh: bfebd3bbdb1affb1 +/agent-note-domain-image-slots-and-a-shared-gallery-factory/problem: + en: ec6ffd7f737a1f56 + zh: 5e368f87ce52e28c +/agent-note-domain-image-slots-and-a-shared-gallery-factory/decision: + en: b807777fd48560eb + zh: e7b1625bd4448ce1 +/agent-note-domain-image-slots-and-a-shared-gallery-factory/alternatives-considered: + en: db14fd6d0e2ce78b + zh: c29a19cde692a1dd +/agent-note-domain-image-slots-and-a-shared-gallery-factory/verification: + en: d82e036edb981e7c + zh: 5a05e9a2e6489d21 +/agent-note-domain-image-slots-and-a-shared-gallery-factory/consequences: + en: ac387ffb5a316920 + zh: ab584aaafe554278 diff --git a/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.md b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.md index e3c20bd1ab..054dbe24ac 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.md +++ b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.md @@ -1,4 +1,4 @@ -# Agent Note: Message image Factories with explicit loaders +# Agent Note: Domain image Slots and a shared gallery Factory Status: implemented @@ -6,48 +6,49 @@ English | [中文](2026-10-09-message-node-factories-and-delegates.zh.md) ## Problem -Native message images need reusable rendering entry points that remain separate from image-loading capability. Chat, Trajectory, and Tool retain their own image replacement positions while official default entries and third-party wrapper entries reuse the same gallery implementation. +User messages, assistant output, Trajectory records, and tool results need independently replaceable image presentation. Their official entries can share one gallery, but sharing that implementation must not merge their replacement positions or hide the caller's image-reading authority. ## Decision -Follow the [division between Body Slots and Content Factories](2026-10-09-reasoning-content-slot-factory.md), using only the existing `registerFactory`, `renderFactorySlot`, and ordinary image Slots. +Each domain declares its own image Slot and supplies the framework's `renderSlot` seat to the component that renders it. The official attachment entries use one reusable Content Factory, following the [Body Slot and Content Factory division](2026-10-09-reasoning-content-slot-factory.md). -| Entry point | Responsibility | +| Entry | Declaration owner and renderer | | --- | --- | -| `conversation.message.images.view` Factory | Reusable entry point for Chat images, scoped to the Session; owns and calls the existing `conversation.message.images` Slot. | -| `conversation.trajectory.images.view` Factory | Reusable entry point for Trajectory images, scoped to the Session; owns and calls the existing `conversation.trajectory.images` Slot. | -| `conversation.message.images.content` Factory | Official gallery implementation, scoped to root; receives images, an authorized loader, layout parameters, and optional complete labels. | +| `conversation.chat.user.images` Slot | The `conversation.chat.user.content` Factory declares it; `UserContent` renders it for user messages, pending steering, and local echoes. | +| `conversation.chat.message.images` Slot | The Assistant node registration declares it; `AssistantMarkdown` renders consecutive output-image groups. | +| `conversation.trajectory.images` Slot | The Trajectory view registration declares it; record attachments and tool-output blocks render it through the ledger's Slot seat. | +| `tool.call.images` Slot | The `read_image` tool-view registration declares it; `ToolRow` renders the result image. | +| `conversation.images.content` Factory | The attachment plugin registers this root-scoped gallery, implemented by `MessageImagesContent`. | -Chat and Trajectory each retain the owning package and Session scope of their existing image Slot. Each entry-point Factory exclusively owns its Slot declaration; ChatFlow and TrajectoryView do not repeat it. The attachment plugin continues to provide the default entries; both defaults and the Tool image entry call the same Content Factory. Disabling the official attachment plugin does not remove the Chat or Trajectory image positions, so third parties can still provide implementations. +The attachment plugin fills all four image Slots with the same `MessageImages` React adapter at priority 100. A smaller priority replaces only the selected domain's entry; disposing that replacement restores the official entry. Each adapter invokes `conversation.images.content` with explicit image references, `loadImage`, and layout parameters. The shared Factory neither declares the domain Slots nor selects a domain from a scene parameter. -Image-rendering components call the corresponding entry point through the framework's `renderFactorySlot`, explicitly passing image references, `loadImage`, and presentation parameters. Conversation continues to manage read authorization, per-Session URL caching, and cleanup; Factories do not look up a global current Session. The gallery, load retries, thumbnails, and lightbox continue to use the attachment package's existing components; they do not move to ui-primitives. +The image callers use their declared Slot directly; no image-entry Factory exists solely to forward one Slot call. User text remains a separate shared `conversation.user.text` Factory with its own keyed node Slot, as specified by the [user-text decision](2026-10-10-user-text-json-nodes.md). -`AssistantNodeView` passes `loadImage` to `AssistantMarkdown`; user, pending-steering, and local-echo wrappers pass it through `UserStyleBubble` to the `conversation.chat.user.content` Factory. `AssistantMarkdown` and `UserContent` call the Chat image entry-point Factory at their image positions, retaining image order and keys. These prop paths keep the authorized loader explicit. Cross-plugin `ChatNodeOwnerProps.loadImage` and the image Factories' loader parameters remain explicit. +`AssistantNodeView` passes its Slot seat and `loadImage` to `AssistantMarkdown`. User wrappers pass the loader through `UserStyleBubble` to `UserContent`. Trajectory passes its Slot seat and loader through the ledger. These paths preserve component responsibilities, image order, and keys without custom ReactNode-producing image callbacks. -`ChatView` directly supplies the existing `MarkdownDelegateProvider` with `requestOpenFile`, `openExternalLink`, and `fileImages`. The wrapped file opener retains Chat's failure feedback and retry handling; Markdown navigation and image handling remain unchanged. +Conversation owns read authorization, per-Session URL caching, and cleanup. The shared gallery never searches for a global current Session: persistent reads use the loader supplied by the caller, while submission-echo previews retain their existing browser URLs. Gallery layout, retries, thumbnails, and lightbox behavior remain in the attachment package. -`MessageImagesContentInput` adds an optional complete `MessageImageLabels` to the existing image parameters. Supplied labels are used unchanged; when omitted, the official Content Factory's locale seat provides the default copy. +`ChatView` keeps the existing `MarkdownDelegateProvider` with its wrapped file opener, external-link action, and file-image capabilities. Native-image composition does not replace Markdown image handling or its failure feedback. + +`MessageImagesContentInput` accepts optional complete `MessageImageLabels`. Supplied labels are used unchanged; when omitted, the Content Factory's locale seat supplies the default copy. ## Alternatives considered -**Expose only the gallery component and import it directly across feature packages.** This bypasses the standard image Slots and framework assembly and cannot preserve existing third-party replacement support. +**One shared image Slot for every caller.** A replacement intended for one domain would also affect the others; shared implementation does not require shared replacement identity. -**Continue passing custom image-rendering functions.** This mixes rendering entry points with authorized reads; the existing Factories already provide reusable entry points. +**Import the gallery component across feature packages.** This bypasses standard Slot replacement and the reusable Factory entry point. -**Supply the loader implicitly through ImageDelegateProvider and extend Markdown node bindings.** This exceeds what native message-image reuse requires; the short explicit-loader prop paths and existing Markdown implementation are sufficient. +**Pass custom image-rendering functions or supply the loader through a private Context.** Framework Slot seats already express rendering authority, while explicit loader props preserve the caller's Session authority. ## Verification -- Chat and Trajectory no longer define or pass custom rendering functions of type `RenderMessageImages`; image entry points use the framework's Factory renderer. -- Default image entries for Chat, Trajectory, and Tool reuse the official Content Factory; plugins replacing the original image Slots can still take over the corresponding positions. -- Image Factories and cross-plugin Slots receive the loader explicitly, preserving existing authorization, caching, preview URLs, image order, layout, retry, and lightbox behavior. -- Native Assistant, user-message, and pending-input images retain their component responsibilities, drawing positions, and keys, with `loadImage` passed through explicit props to each Factory call. -- Markdown still uses Chat's wrapped file-open action through the existing `MarkdownDelegateProvider`, with the same external-link and file-image capabilities. -- The Content Factory supports both default labels and caller-supplied complete custom labels. -- Image composition preserves Markdown code blocks, Markdown image processing, and generic Delegate Providers. [User-text transforms and node rendering](2026-10-10-user-text-json-nodes.md) have a separate owning decision. +- User and output images use separate Slots; replacing either leaves the other and its mounted gallery untouched. +- Each official image entry has priority 100; a priority-0 replacement wins and disposal restores the official gallery. +- All four defaults call the same Content Factory with their original image data, authorized loader, and layout choices. +- Source order, keys, cached previews, failed-load retries, and lightbox interactions retain their existing behavior. +- The Content Factory accepts complete custom labels and resolves default labels when the caller omits them. +- Unloading the attachment implementation removes its entries without removing Slots declared by the owning domains. ## Consequences -Each entry-point Factory must own the sole declaration of its original image Slot; downstream calls must not bypass existing replacement positions. The shared gallery remains root-scoped, but reads of persistent images must use the authorized loader explicitly provided by the caller. - -Explicit prop forwarding requires each native-image call site to supply its authorized loader. Required verification covers image Factory assembly, replacement and unloading, native Assistant/user/pending-input loader paths, preserved keys, default and custom labels, and Markdown file-open failure feedback. +Independent Slots allow domain-specific presentation without copying the gallery. Domain callers must forward the framework Slot seat to their plain React descendants and must supply an authorized loader at each image call. A Factory and an ordinary Slot have different lifetimes and ownership; a reusable implementation does not own every position where it appears. diff --git a/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.zh.md b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.zh.md index d968aed0ac..03869f2dbb 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-09-message-node-factories-and-delegates.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 消息图片 Factory 与显式 loader +# Agent Note: 领域图片 Slot 与共享画廊 Factory Status: implemented @@ -6,48 +6,49 @@ Status: implemented ## 问题 -原生消息图片需要可复用的渲染入口,并将渲染入口与图片读取能力分开。Chat、Trajectory 和 Tool 保留各自的图片替换位置,官方默认条目和第三方包装条目复用同一份画廊实现。 +用户消息、助手输出、Trajectory 记录和工具结果需要可独立替换的图片展示。官方条目可以共享同一画廊,但共享实现不能合并各自的替换位置,也不能隐藏调用方的图片读取授权。 ## 决策 -沿用 [Body Slot 与 Content Factory 的分工](2026-10-09-reasoning-content-slot-factory.zh.md),仅使用现有的 `registerFactory`、`renderFactorySlot` 和普通图片 Slot。 +各领域声明自己的图片 Slot,并把框架 `renderSlot` 席位交给实际渲染它的组件。官方附件条目复用一个 Content Factory,沿用 [Body Slot 与 Content Factory 的分工](2026-10-09-reasoning-content-slot-factory.zh.md)。 -| 入口 | 职责 | +| 入口 | 声明所有者与渲染组件 | | --- | --- | -| `conversation.message.images.view` Factory | Chat 图片的可复用入口,Session 作用域,拥有并调用原有 `conversation.message.images` Slot。 | -| `conversation.trajectory.images.view` Factory | Trajectory 图片的可复用入口,Session 作用域,拥有并调用原有 `conversation.trajectory.images` Slot。 | -| `conversation.message.images.content` Factory | 官方画廊实现,root 作用域,接收图片、授权 loader、布局参数和可选完整 labels。 | +| `conversation.chat.user.images` Slot | `conversation.chat.user.content` Factory 声明,由 `UserContent` 为用户消息、待处理 steering 和本地回显渲染。 | +| `conversation.chat.message.images` Slot | Assistant 节点注册声明,由 `AssistantMarkdown` 渲染连续的输出图片组。 | +| `conversation.trajectory.images` Slot | Trajectory 视图注册声明,记录附件及工具输出块通过账本的 Slot 席位渲染。 | +| `tool.call.images` Slot | `read_image` 工具视图注册声明,由 `ToolRow` 渲染结果图片。 | +| `conversation.images.content` Factory | 附件插件注册此 root 作用域画廊,由 `MessageImagesContent` 实现。 | -Chat 和 Trajectory 分别保留原有图片 Slot 的所属包与 Session 作用域,声明由入口 Factory 独立拥有,不由 ChatFlow 或 TrajectoryView 重复声明。附件插件继续提供默认条目;两个默认条目与工具图片条目都调用同一个 Content Factory。停用官方附件插件不会撤销 Chat/Trajectory 的图片位置,第三方仍能提供实现。 +附件插件以优先级 100 为四个图片 Slot 填入同一个 `MessageImages` React 适配组件。较小的优先级只替换选定领域的条目;替换条目释放后恢复官方条目。适配组件显式向 `conversation.images.content` 传入图片引用、`loadImage` 和布局参数。共享 Factory 不声明各领域的 Slot,也不通过场景参数选择领域。 -图片渲染组件通过框架提供的 `renderFactorySlot` 调用对应入口,显式传入图片引用、`loadImage` 和展示参数。读取授权、逐会话 URL 缓存与回收继续由 Conversation 管理;Factory 不查找全局当前 Session。画廊、加载重试、缩略图及灯箱继续使用附件包现有组件,不迁到 ui-primitives。 +图片调用方直接使用其声明的 Slot,不设置仅转发一次 Slot 调用的图片入口 Factory。用户文本仍使用独立共享的 `conversation.user.text` Factory 及其 keyed 节点 Slot,详见[用户文本决策](2026-10-10-user-text-json-nodes.zh.md)。 -`AssistantNodeView` 将 `loadImage` 传给 `AssistantMarkdown`;用户消息、待处理 steering 和本地回显的包装层通过 `UserStyleBubble` 将它传给 `conversation.chat.user.content` Factory。`AssistantMarkdown` 和 `UserContent` 在各自图片绘制位置调用 Chat 图片入口 Factory,保持图片顺序和 key。这些 props 路径使授权 loader 保持显式。跨插件的 `ChatNodeOwnerProps.loadImage` 和图片 Factory 的 loader 参数保持显式。 +`AssistantNodeView` 将 Slot 席位与 `loadImage` 传给 `AssistantMarkdown`。用户包装层通过 `UserStyleBubble` 将 loader 传给 `UserContent`。Trajectory 通过账本传递自己的 Slot 席位与 loader。这些路径保持组件职责、图片顺序和 key,不使用自制的 ReactNode 图片渲染回调。 -`ChatView` 直接向既有的 `MarkdownDelegateProvider` 提供 `requestOpenFile`、`openExternalLink` 和 `fileImages`。包装后的文件打开动作保留 Chat 的失败反馈与重试处理;Markdown 导航与图片处理保持不变。 +Conversation 拥有读取授权、逐 Session URL 缓存与回收。共享画廊不查找全局当前 Session:持久图片使用调用方提供的 loader 读取,本地提交回显预览保留已有浏览器 URL。画廊布局、重试、缩略图和灯箱行为仍由附件包负责。 -`MessageImagesContentInput` 在原有图片参数上增加可选的完整 `MessageImageLabels`。提供 labels 时原样使用,省略时由官方 Content Factory 的 locale 席位提供默认文案。 +`ChatView` 保留既有 `MarkdownDelegateProvider` 及其包装后的文件打开动作、外链动作和文件图片能力。原生图片组合不替代 Markdown 图片处理及失败反馈。 + +`MessageImagesContentInput` 接受可选的完整 `MessageImageLabels`。提供 labels 时原样使用,省略时由 Content Factory 的 locale 席位提供默认文案。 ## 考虑过的替代方案 -**只公开画廊组件,跨功能包直接导入。** 绕过标准图片 Slot 和框架装配,不能保留现有的第三方替换能力。 +**所有调用方共用一个图片 Slot。** 面向某个领域的替换也会影响其他领域;共享实现不要求共享替换身份。 -**继续传递自制图片渲染函数。** 将渲染入口与授权读取混在一起;现有 Factory 已能提供可复用入口。 +**跨功能包导入画廊组件。** 绕过标准 Slot 替换机制和可复用的 Factory 入口。 -**通过 ImageDelegateProvider 隐式供应 loader,并扩展 Markdown 节点绑定。** 超出原生消息图片复用所需范围,短路径显式透传 loader 和现有 Markdown 实现已足够。 +**传递自制图片渲染函数,或通过私有 Context 供应 loader。** 框架 Slot 席位已经表达渲染权限,显式 loader props 则保留调用方的 Session 授权。 ## 验证 -- Chat/Trajectory 不再定义或传递 `RenderMessageImages` 型自制渲染函数;图片入口使用框架的 Factory renderer。 -- Chat、Trajectory、Tool 的默认图片条目复用官方 Content Factory;替换原图片 Slot 的插件仍可接管对应位置。 -- 图片 Factory 与跨插件 Slot 显式接收 loader,保留现有授权、缓存、预览 URL、图片顺序、布局、重试与灯箱行为。 -- 原生 Assistant、用户消息与待处理输入图片保留组件分工、绘制位置和 key,通过显式 props 向每个 Factory 调用传入 `loadImage`。 -- Markdown 仍通过既有的 `MarkdownDelegateProvider` 使用 Chat 包装过的文件打开动作,外链及文件图片能力保持不变。 -- Content Factory 支持默认及调用者自定义的完整 labels。 -- 图片组合保留 Markdown 代码块、Markdown 图片处理和通用 Delegate Provider 的既有行为。[用户文本变换与节点渲染](2026-10-10-user-text-json-nodes.zh.md)由独立的决策记录说明。 +- 用户与输出图片使用独立 Slot;替换任意一方时,另一方及其已挂载画廊不受影响。 +- 各官方图片条目优先级为 100;优先级 0 的替换条目胜出,释放后恢复官方画廊。 +- 四个默认条目携带原有图片数据、授权 loader 和布局选项调用同一个 Content Factory。 +- 来源顺序、key、缓存预览、读取失败重试及灯箱交互保持既有行为。 +- Content Factory 接受完整自定义 labels,调用方省略时解析默认 labels。 +- 卸载附件实现会移除其条目,但不移除各领域声明的 Slot。 ## 影响 -入口 Factory 必须拥有原图片 Slot 的唯一声明;下游调用不能绕过既有替换位置。共享画廊保持 root 作用域,但持久图片读取必须使用调用者显式提供的授权 loader。 - -显式 props 透传要求每个原生图片调用点提供自身的授权 loader。必要验证涵盖图片 Factory 的装配、替换和卸载,原生 Assistant/用户/待处理输入的 loader 路径,key 保持,默认及自定义 labels,以及 Markdown 文件打开失败反馈。 +独立 Slot 支持各领域定制展示而无需复制画廊。领域调用方需要将框架 Slot 席位传给普通 React 后代组件,并在每次图片调用中提供授权 loader。Factory 与普通 Slot 的生命周期和所有权不同;可复用实现不拥有它出现的所有位置。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 9aa254ff44..ca0ba5d9a3 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -21,8 +21,8 @@ en: 4fb5222bb2b064e7 zh: b961f792052b4ce5 /web-client-slots/current-hierarchy: - en: 8a4cc4b8608ce0f6 - zh: 7dcc51b213782a40 + en: 1161f1b9d3f6914a + zh: 5fa1c689cca7a972 /web-client-slots/extension-rules: en: b2cce357141883ee zh: 278080a5fb941189 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 052b79daa2..9b13b64442 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -110,90 +110,75 @@ Use owner props for values already known at one render occurrence, registration ## Current hierarchy -The hierarchy below is the shipped declaration tree. A child exists only while the named parent entry is mounted; optional feature entries can therefore make a subtree appear or disappear as one lifecycle unit. +The following Conversation paths distinguish declaration owners from reusable Factory roots; unrelated shell positions are omitted. An entry owns its declared children, while a Factory owns its child declarations for the lifetime of its registered definition. Bracketed names identify the default React components; a plain React descendant can render its owner's Slot. Factory calls are references to independent reusable definitions, not new Slot declarations. ```text -root -├─ sidebar -│ ├─ sidebar.brand.mark -│ ├─ sidebar.brand.name -│ ├─ sidebar.panellist -│ ├─ sidebar.footer.action -│ ├─ sidebar.workspaces -│ │ ├─ sidebar.workspaces.directoryFlow -│ │ ├─ sidebar.workspaces.session.menu.item -│ │ └─ sidebar.workspaces.session.row.action -│ └─ sidebar.settings -│ ├─ settings.trigger -│ ├─ settings.header -│ ├─ settings.action -│ ├─ settings.close -│ ├─ settings.onboarding -│ └─ settings.section -│ ├─ settings.general.item -│ ├─ settings.models.provider-card -│ ├─ settings.models.footer -│ └─ settings.plugins.tab -├─ main -│ ├─ plugins.add.actions -│ ├─ plugins.item -│ ├─ plugins.bundle.config -│ ├─ plugins.row.config -│ ├─ plugins.detail.actions -│ ├─ plugins.detail.badge -│ ├─ plugins.detail.section -│ └─ main.conversation -│ ├─ conversation.session -│ │ └─ conversation.view -│ │ ├─ conversation.chat.flow -│ │ │ ├─ conversation.chat.node -│ │ │ │ ├─ conversation.chat.assistant-actions -│ │ │ │ ├─ conversation.chat.reasoning.body -│ │ │ │ ├─ conversation.chat.commandview -│ │ │ │ ├─ conversation.chat.turnTail -│ │ │ │ └─ tool.call.toolview -│ │ │ │ ├─ tool.call.images -│ │ │ │ └─ tool.view.cordis -│ │ │ └─ conversation.message.images -│ │ └─ conversation.trajectory.images -│ ├─ conversation.header -│ │ ├─ conversation.header.leading -│ │ └─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner -│ ├─ conversation.composer -│ │ ├─ conversation.approval.detail -│ │ └─ conversation.plan-review.actions -│ ├─ conversation.composer.bar -│ │ ├─ conversation.input.attachments -│ │ ├─ conversation.input.permission -│ │ ├─ conversation.input.plan -│ │ └─ conversation.input.model -│ ├─ conversation.input.overlay -│ ├─ conversation.input.dock -│ ├─ conversation.composer.dock -│ ├─ conversation.input.left -│ ├─ conversation.input.right -│ ├─ conversation.hero.brand.mark -│ ├─ conversation.hero.workspace -│ │ └─ conversation.hero.workspace.directoryFlow -│ └─ conversation.hero.agentPreset -├─ rightbar -│ └─ rightbar.session -│ ├─ sidebar.right.pane.tab -│ │ ├─ sidebar.right.tab.guide -│ │ └─ sidebar.right.tab.guide.entry -│ ├─ sidebar.right.pane.tab.title -│ └─ sidebar.right.tab.menu.item -├─ shell.bottom -├─ shell.leading -└─ shell.overlay - ├─ shell.quota-notice - └─ workspace.toast +root [AppFrame] +└─ main[key=conversation] [ConversationPanel] + └─ main.conversation [ConversationRoot / ConversationMainPanel] + ├─ conversation.header [ConversationHeader] + │ ├─ conversation.header.leading + │ └─ conversation.session.header [ConversationSessionHeader] + │ ├─ conversation.session.header.lineage + │ ├─ conversation.session.header.actions + │ ├─ conversation.session.header.utilities + │ └─ conversation.session.header.corner + └─ calls Factory: conversation.content + +Factory: conversation.content [ConversationContent] +├─ conversation.session [ConversationSession / DefaultConversationViews] +│ └─ conversation.view +│ ├─ id=chat [ChatView] +│ │ └─ conversation.chat.flow [ChatFlow] +│ │ └─ conversation.chat.node [ChatNodeSeat dispatch] +│ │ ├─ key=user,steering [UserMessageNodeView] +│ │ │ └─ calls Factory: conversation.chat.user.content +│ │ ├─ key=assistant-step [AssistantNodeView / AssistantMarkdown] +│ │ │ ├─ conversation.chat.message.images [MessageImages] +│ │ │ └─ conversation.chat.reasoning.body [DefaultReasoningBody] +│ │ │ └─ calls Factory: conversation.chat.reasoning.content +│ │ ├─ key=tool-call [ToolCallTree] +│ │ │ └─ tool.call.toolview +│ │ │ └─ key=read_image [ReadImageRow / ToolRow] +│ │ │ └─ tool.call.images [MessageImages] +│ │ ├─ key=command [CommandNodeView] +│ │ │ └─ conversation.chat.commandview +│ │ └─ key=turn-tail [TurnTailNodeView] +│ │ ├─ conversation.chat.turnTail +│ │ └─ conversation.chat.assistant-actions +│ └─ id=trajectory [TrajectoryView / TrajectoryTable] +│ └─ conversation.trajectory.images [MessageImages] +├─ conversation.composer +│ ├─ conversation.approval.detail +│ └─ conversation.plan-review.actions +├─ conversation.composer.bar [InputBar] +│ ├─ conversation.input.attachments +│ ├─ conversation.input.permission +│ ├─ conversation.input.plan +│ ├─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ └─ conversation.input.right +├─ conversation.input.dock +├─ conversation.hero.brand.mark +├─ conversation.hero.workspace +│ └─ conversation.hero.workspace.directoryFlow +└─ conversation.hero.agentPreset + +Factory: conversation.chat.user.content [UserContent] +├─ conversation.chat.user.images [MessageImages] +└─ calls Factory: conversation.user.text + +Factory: conversation.user.text [UserText] +└─ conversation.user.text.node [keyed, inline] + +Factory: conversation.images.content [MessageImagesContent / ImageGallery] +Factory: conversation.chat.reasoning.content [ReasoningContent / MarkdownText] ``` +All four image Slots default to `MessageImages`, which calls `conversation.images.content` with the caller's image references and authorized loader. User and output image replacements are independent. The `conversation.user.text` Factory is shared by user content, Queue, and Goal; its `inline: true` keyed Slot renders span anchors and inline error placeholders rather than block wrappers. + The generated Client inspect catalog is the exhaustive contract for each key: cardinality, scope, owner props, standard props, current occupants, declaration owner, and replacement risk. A running dynamic package can query the live tree and an exact key with `cordis_inspect what:"client"`; the source catalog is generated from `SlotMap` declarations and `slots.register()` call sites by `pnpm run gen-client-catalog`. ## Extension rules diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index f234cf97eb..362d806512 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -112,90 +112,75 @@ Renderer 还会根据声明的 store 创建 `useStore`,并根据声明的 loca ## 当前层级 -下图是当前发布组合的声明树。只有具名 parent entry 已挂载时,其 child 才存在;因此可选功能 entry 可以作为一个生命周期单元让整棵子树出现或消失。 +下面的 Conversation 路径区分声明所有者与可复用 Factory 根,省略无关的外壳位置。条目拥有其声明的子 Slot;Factory 在其定义注册期间拥有子 Slot 声明。方括号标出默认 React 组件,普通 React 后代组件可以渲染其所有者的 Slot。Factory 调用指向独立的可复用定义,不是新的 Slot 声明。 ```text -root -├─ sidebar -│ ├─ sidebar.brand.mark -│ ├─ sidebar.brand.name -│ ├─ sidebar.panellist -│ ├─ sidebar.footer.action -│ ├─ sidebar.workspaces -│ │ ├─ sidebar.workspaces.directoryFlow -│ │ ├─ sidebar.workspaces.session.menu.item -│ │ └─ sidebar.workspaces.session.row.action -│ └─ sidebar.settings -│ ├─ settings.trigger -│ ├─ settings.header -│ ├─ settings.action -│ ├─ settings.close -│ ├─ settings.onboarding -│ └─ settings.section -│ ├─ settings.general.item -│ ├─ settings.models.provider-card -│ ├─ settings.models.footer -│ └─ settings.plugins.tab -├─ main -│ ├─ plugins.add.actions -│ ├─ plugins.item -│ ├─ plugins.bundle.config -│ ├─ plugins.row.config -│ ├─ plugins.detail.actions -│ ├─ plugins.detail.badge -│ ├─ plugins.detail.section -│ └─ main.conversation -│ ├─ conversation.session -│ │ └─ conversation.view -│ │ ├─ conversation.chat.flow -│ │ │ ├─ conversation.chat.node -│ │ │ │ ├─ conversation.chat.assistant-actions -│ │ │ │ ├─ conversation.chat.reasoning.body -│ │ │ │ ├─ conversation.chat.commandview -│ │ │ │ ├─ conversation.chat.turnTail -│ │ │ │ └─ tool.call.toolview -│ │ │ │ ├─ tool.call.images -│ │ │ │ └─ tool.view.cordis -│ │ │ └─ conversation.message.images -│ │ └─ conversation.trajectory.images -│ ├─ conversation.header -│ │ ├─ conversation.header.leading -│ │ └─ conversation.session.header -│ │ ├─ conversation.session.header.lineage -│ │ ├─ conversation.session.header.actions -│ │ ├─ conversation.session.header.utilities -│ │ └─ conversation.session.header.corner -│ ├─ conversation.composer -│ │ ├─ conversation.approval.detail -│ │ └─ conversation.plan-review.actions -│ ├─ conversation.composer.bar -│ │ ├─ conversation.input.attachments -│ │ ├─ conversation.input.permission -│ │ ├─ conversation.input.plan -│ │ └─ conversation.input.model -│ ├─ conversation.input.overlay -│ ├─ conversation.input.dock -│ ├─ conversation.composer.dock -│ ├─ conversation.input.left -│ ├─ conversation.input.right -│ ├─ conversation.hero.brand.mark -│ ├─ conversation.hero.workspace -│ │ └─ conversation.hero.workspace.directoryFlow -│ └─ conversation.hero.agentPreset -├─ rightbar -│ └─ rightbar.session -│ ├─ sidebar.right.pane.tab -│ │ ├─ sidebar.right.tab.guide -│ │ └─ sidebar.right.tab.guide.entry -│ ├─ sidebar.right.pane.tab.title -│ └─ sidebar.right.tab.menu.item -├─ shell.bottom -├─ shell.leading -└─ shell.overlay - ├─ shell.quota-notice - └─ workspace.toast +root [AppFrame] +└─ main[key=conversation] [ConversationPanel] + └─ main.conversation [ConversationRoot / ConversationMainPanel] + ├─ conversation.header [ConversationHeader] + │ ├─ conversation.header.leading + │ └─ conversation.session.header [ConversationSessionHeader] + │ ├─ conversation.session.header.lineage + │ ├─ conversation.session.header.actions + │ ├─ conversation.session.header.utilities + │ └─ conversation.session.header.corner + └─ calls Factory: conversation.content + +Factory: conversation.content [ConversationContent] +├─ conversation.session [ConversationSession / DefaultConversationViews] +│ └─ conversation.view +│ ├─ id=chat [ChatView] +│ │ └─ conversation.chat.flow [ChatFlow] +│ │ └─ conversation.chat.node [ChatNodeSeat dispatch] +│ │ ├─ key=user,steering [UserMessageNodeView] +│ │ │ └─ calls Factory: conversation.chat.user.content +│ │ ├─ key=assistant-step [AssistantNodeView / AssistantMarkdown] +│ │ │ ├─ conversation.chat.message.images [MessageImages] +│ │ │ └─ conversation.chat.reasoning.body [DefaultReasoningBody] +│ │ │ └─ calls Factory: conversation.chat.reasoning.content +│ │ ├─ key=tool-call [ToolCallTree] +│ │ │ └─ tool.call.toolview +│ │ │ └─ key=read_image [ReadImageRow / ToolRow] +│ │ │ └─ tool.call.images [MessageImages] +│ │ ├─ key=command [CommandNodeView] +│ │ │ └─ conversation.chat.commandview +│ │ └─ key=turn-tail [TurnTailNodeView] +│ │ ├─ conversation.chat.turnTail +│ │ └─ conversation.chat.assistant-actions +│ └─ id=trajectory [TrajectoryView / TrajectoryTable] +│ └─ conversation.trajectory.images [MessageImages] +├─ conversation.composer +│ ├─ conversation.approval.detail +│ └─ conversation.plan-review.actions +├─ conversation.composer.bar [InputBar] +│ ├─ conversation.input.attachments +│ ├─ conversation.input.permission +│ ├─ conversation.input.plan +│ ├─ conversation.input.model +│ ├─ conversation.input.overlay +│ ├─ conversation.composer.dock +│ ├─ conversation.input.left +│ └─ conversation.input.right +├─ conversation.input.dock +├─ conversation.hero.brand.mark +├─ conversation.hero.workspace +│ └─ conversation.hero.workspace.directoryFlow +└─ conversation.hero.agentPreset + +Factory: conversation.chat.user.content [UserContent] +├─ conversation.chat.user.images [MessageImages] +└─ calls Factory: conversation.user.text + +Factory: conversation.user.text [UserText] +└─ conversation.user.text.node [keyed, inline] + +Factory: conversation.images.content [MessageImagesContent / ImageGallery] +Factory: conversation.chat.reasoning.content [ReasoningContent / MarkdownText] ``` +四个图片 Slot 默认均填入 `MessageImages`,由它携带调用者的图片引用和授权 loader 调用 `conversation.images.content`。用户与输出图片的替换互不影响。`conversation.user.text` Factory 由用户内容、Queue 和 Goal 共用;其 `inline: true` keyed Slot 使用 span 锚点及行内错误占位,而不是块级包装。 + 生成的 Client inspect catalog 是每个 key 的完整参考,包含 cardinality、scope、owner props、标准 props、当前 occupant、声明 owner 与替换风险。运行中的动态包可以用 `cordis_inspect what:"client"` 查询实时树与某个精确 key;源码 catalog 由 `pnpm run gen-client-catalog` 根据 `SlotMap` 声明和 `slots.register()` 调用点生成。 diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 7d591e9515..123b686b4e 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -27,8 +27,8 @@ en: 836a62c8f73366ac zh: 35da7836eaf68a67 /deepseek-ai-dsh-client-ui-attachment/understand-the-implementation: - en: a7c4bfd8343426e7 - zh: bdc29c7bb70627d4 + en: 07204b7d538b7434 + zh: 705cc6b0c9387cce /deepseek-ai-dsh-client-ui-attachment/further-exploration: en: 733ac3e4df52d6e9 zh: 98a6a8cac7d66645 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index fe32fe9c48..4fee29e123 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -49,7 +49,7 @@ While a file drag is over the page, the full-viewport overlay announces the drop
Implementation internals — click to expand -The plugin provides the root-scoped `conversation.message.images.content` Factory and waits for `conversation.input.attachments`, `conversation.message.images`, `conversation.trajectory.images`, and `tool.call.images` through `ctx.slots.inject`. Each official image entry registers at priority 100 and calls the same Content Factory, which owns gallery sizing, preview state, and default labels. Callers supply references, an authorized loader, layout hints, and optional complete labels. The Factory reuses this package's gallery without reading Session state; loading and URL caching remain with the resource owner. Chat and Trajectory retain ownership of their replaceable image positions. The composer rail and drop target remain ordinary entries, and the package entry exports no components. +The plugin provides the root-scoped `conversation.images.content` Factory. Its official `MessageImages` entries fill `conversation.chat.user.images`, `conversation.chat.message.images`, `conversation.trajectory.images`, and `tool.call.images` at priority 100 through `ctx.slots.inject`. A lower-priority entry replaces only its own position; removing it restores the official entry. All defaults reuse one gallery with explicit image references, authorized loader, layout hints, and optional complete labels. The Factory owns gallery presentation and default copy, not Session state or read authorization. Each domain owns its Slot declaration. The composer attachment rail remains a separate entry, and 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 d2dbd52712..706a6d97c7 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -49,7 +49,7 @@ Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪
实现细节——点击展开 -插件提供 root 作用域的 `conversation.message.images.content` Factory,并通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images`、`conversation.trajectory.images` 与 `tool.call.images`。各官方图片条目以优先级 100 注册,调用同一 Content Factory;Factory 负责画廊尺寸、预览状态和默认文案。调用者提供引用、授权 loader、布局提示及可选的完整 labels。Factory 复用本包画廊,不读取 Session 状态;读取和 URL 缓存仍由资源所有者负责。Chat 和 Trajectory 仍拥有各自可替换的图片位置。Composer rail 与拖放目标仍为普通条目,包入口不导出任何组件。 +插件提供 root 作用域的 `conversation.images.content` Factory。官方 `MessageImages` 条目通过 `ctx.slots.inject`,以优先级 100 填入 `conversation.chat.user.images`、`conversation.chat.message.images`、`conversation.trajectory.images` 和 `tool.call.images`。较低优先级的条目只替换自身所在位置,移除后恢复官方条目。各默认条目复用同一画廊,显式传入图片引用、授权 loader、布局参数和可选的完整 labels。Factory 负责画廊展示与默认文案,不拥有 Session 状态或读取授权。各领域拥有自己的 Slot 声明。Composer 附件栏仍为独立条目,包入口不导出任何组件。 | 文件 | 职责 | |---|---| diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index ffd4668dea..ff3fee2756 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -11,7 +11,7 @@ import { messageImageLabels } from './labels.ts' * @returns the official gallery; no Session binding is needed by this component. */ export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t }: -FactoryComponentPropsOf<'conversation.message.images.content'>) { +FactoryComponentPropsOf<'conversation.images.content'>) { const resolved = useMemo(() => labels ?? messageImageLabels(t), [labels, t]) return @@ -23,7 +23,7 @@ FactoryComponentPropsOf<'conversation.message.images.content'>) { * @returns the official gallery occurrence. */ export function MessageImages({ images, loadImage, align, compact, thumbnail, renderFactorySlot }: MessageImagesProps) { - return renderFactorySlot('conversation.message.images.content', { + return renderFactorySlot('conversation.images.content', { images, loadImage, align, ...compact === undefined ? {} : { compact }, ...thumbnail === undefined ? {} : { thumbnail }, diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index 8fe0f56ede..9d3845bed3 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -34,14 +34,18 @@ export function apply(ctx: ClientContext): void { }), }, AttachmentReference)) ctx.slots.registerFactory({ - name: 'conversation.message.images.content', scope: 'root', locale: 'conversation', + name: 'conversation.images.content', scope: 'root', locale: 'conversation', }, MessageImagesContent) ctx.slots.inject('conversation.input.attachments', () => ctx.slots.register({ name: 'conversation.input.attachments', locale: 'conversation', }, ComposerAttachments)) - ctx.slots.inject('conversation.message.images', () => ctx.slots.register({ - name: 'conversation.message.images', + ctx.slots.inject('conversation.chat.message.images', () => ctx.slots.register({ + name: 'conversation.chat.message.images', + priority: 100, + }, MessageImages)) + ctx.slots.inject('conversation.chat.user.images', () => ctx.slots.register({ + name: 'conversation.chat.user.images', priority: 100, }, MessageImages)) ctx.slots.inject('conversation.trajectory.images', () => ctx.slots.register({ diff --git a/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx new file mode 100644 index 0000000000..04f83f3ba8 --- /dev/null +++ b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +/** Independent domain image positions with one shared official gallery Factory. */ +import { act, cleanup, within } from '@testing-library/react' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/assembly.ts' +import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import type { MessageImagesOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import * as attachmentPlugin from '../src/client/index.ts' + +afterEach(cleanup) + +const SLOTS = [ + 'conversation.chat.user.images', 'conversation.chat.message.images', + 'conversation.trajectory.images', 'tool.call.images', +] as const + +async function bench() { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const locale = new LocaleRuntime(runtime.ctx) + locale.register('conversation', { en, zh }) + locale.setLocale('en') + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + new UiConversation(runtime.ctx, runtime.sessions) + await runtime.mount(attachmentPlugin) + await runtime.sessions.add({ id: 'image-domains' }) + const session = runtime.sessions.retainFor(runtime.ctx, SessionId('image-domains')) + const input: MessageImagesOwnerProps = { + images: [{ attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), mediaType: 'image/png', + name: 'shared.png', width: 1, height: 1, bytes: 68, + } }], + loadImage: vi.fn(async () => 'blob:shared'), align: 'start', + } + await runtime.root.declare({ + 'conversation.chat.user.images': { kind: 'single', scope: 'session' }, + 'conversation.chat.message.images': { kind: 'single', scope: 'session' }, + 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, + 'tool.call.images': { kind: 'single', scope: 'session' }, + }, ({ renderSlot, SessionProvider }) => + {SLOTS.map(name =>
{renderSlot(name, input)}
)} +
) + return { runtime, view: runtime.renderRoot() } +} + +describe('domain image Slots', () => { + it.each(SLOTS)('replaces only %s and restores the shared gallery on disposal', async (name) => { + const { runtime, view } = await bench() + expect(runtime.factoryOf('conversation.images.content').scope).toBe('root') + const images = new Map() + for (const slot of SLOTS) { + expect(runtime.slots.entries(slot)[0]?.options.priority).toBe(100) + images.set(slot, await within(view.getByTestId(slot)).findByAltText('shared.png')) + } + const override = await runtime.mount({ inject: ['slots'], apply(ctx) { + ctx.slots.register({ name, priority: 0 }, () => Domain override) + } }) + const target = within(view.getByTestId(name)) + expect(target.getByText('Domain override')).toBeTruthy() + expect(target.queryByAltText('shared.png')).toBeNull() + for (const other of SLOTS.filter(slot => slot !== name)) { + expect(within(view.getByTestId(other)).getByAltText('shared.png')).toBe(images.get(other)) + } + await act(async () => { await override.dispose() }) + expect(target.queryByText('Domain override')).toBeNull() + expect(await target.findByAltText('shared.png')).toBeTruthy() + for (const other of SLOTS.filter(slot => slot !== name)) { + expect(within(view.getByTestId(other)).getByAltText('shared.png')).toBe(images.get(other)) + } + }) +}) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index c6e472ae2b..16c2fec1cb 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -275,12 +275,12 @@ describe('ImageGallery', () => { runtime.ctx.provide('locale', locale) new UiConversation(runtime.ctx, runtime.sessions) runtime.slots.installLocale(locale) - await runtime.declare({ 'conversation.message.images': { kind: 'single', scope: 'session' } }) + await runtime.declare({ 'conversation.chat.message.images': { kind: 'single', scope: 'session' } }) await runtime.mount(attachmentPlugin) await runtime.sessions.add({ id: 'message-images-test' }) const session = runtime.sessions.retainFor(runtime.ctx, SessionId('message-images-test')) const loadImage = vi.fn().mockResolvedValue('blob:slot-image') - const slot = runtime.renderSlot('conversation.message.images', { + const slot = runtime.renderSlot('conversation.chat.message.images', { images: [{ attachment }], loadImage, align: 'end', }, { session }) const { view, container } = slot diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index c926848138..f29872ffd2 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -28,7 +28,8 @@ async function bench() { children: { 'conversation.user.text.node': { kind: 'keyed', scope: 'session' }, 'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' }, - 'conversation.message.images': { kind: 'single', scope: 'session' }, + 'conversation.chat.message.images': { kind: 'single', scope: 'session' }, + 'conversation.chat.user.images': { kind: 'single', scope: 'session' }, 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, 'tool.call.images': { kind: 'single', scope: 'session' }, }, @@ -69,16 +70,17 @@ describe('attachment plugin', () => { expect(ctx.slots.entries('conversation.user.text.node')[0]?.inject).toBeTypeOf('function') expect(ctx.uiConversation.userText.entries().map(rule => rule.id)).toEqual(['attachment']) expect(registerFactory).toHaveBeenCalledWith({ - name: 'conversation.message.images.content', scope: 'root', locale: 'conversation', + name: 'conversation.images.content', scope: 'root', locale: 'conversation', }, MessageImagesContent) - expect(ctx.slots.snapshot('factory:conversation.message.images.content')).toHaveLength(1) + expect(ctx.slots.snapshot('factory:conversation.images.content')).toHaveLength(1) expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{ locale: 'conversation', component: ComposerAttachments, }]) - expect(ctx.slots.entries('conversation.message.images')).toMatchObject([{ + expect(ctx.slots.entries('conversation.chat.message.images')).toMatchObject([{ component: MessageImages, }]) + expect(ctx.slots.entries('conversation.chat.user.images')).toMatchObject([{ component: MessageImages, options: { priority: 100 } }]) expect(ctx.slots.entries('conversation.trajectory.images')).toMatchObject([{ component: MessageImages, }]) @@ -91,9 +93,10 @@ describe('attachment plugin', () => { expect(ctx.slots.entries('conversation.user.text.node')).toHaveLength(0) expect(ctx.uiConversation.userText.entries()).toHaveLength(0) expect(ctx.slots.entries('conversation.input.attachments')).toHaveLength(0) - expect(ctx.slots.entries('conversation.message.images')).toHaveLength(0) + expect(ctx.slots.entries('conversation.chat.message.images')).toHaveLength(0) + expect(ctx.slots.entries('conversation.chat.user.images')).toHaveLength(0) expect(ctx.slots.entries('conversation.trajectory.images')).toHaveLength(0) expect(ctx.slots.entries('tool.call.images')).toHaveLength(0) - expect(ctx.slots.snapshot('factory:conversation.message.images.content')).toHaveLength(0) + expect(ctx.slots.snapshot('factory:conversation.images.content')).toHaveLength(0) }) }) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index d6eabaa213..3077ce4752 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -15,8 +15,8 @@ en: 41cd21266011d571 zh: 3172d1a2acbb27b7 /deepseek-ai-dsh-client-ui-chat/reference-previews: - en: 0e7998510f443720 - zh: dc1277ee5efa695b + en: aa6b1abec265bf1b + zh: 7260ce78afba2584 /deepseek-ai-dsh-client-ui-chat/hidden-chat-rows: en: cbf1f0a2205046a5 zh: 2e59d0815351349f diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 5c382c375f..b80ec590d6 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -37,9 +37,9 @@ Sent generic-file attachments retain the draft's [file-card radius](../../../doc `ChatView` supplies file and HTTP(S) navigation through the existing `MarkdownDelegateProvider` around its node list, passing `requestOpenFile`, `openExternalLink`, and `fileImages` directly. Its file opener retains Chat's failure feedback and retry handling. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. -Native message-image rendering receives `loadImage` through explicit props: `AssistantNodeView` passes it to `AssistantMarkdown`, while user, pending-steering, and local-echo wrappers pass it through `UserStyleBubble` to `conversation.chat.user.content`. `AssistantMarkdown` and `UserContent` call `conversation.message.images.view` through the framework's `renderFactorySlot` at their image positions, supplying image sources, the loader, and layout options. The view Factory owns the replaceable `conversation.message.images` Slot; its official entry reuses `conversation.message.images.content`. Cross-plugin Chat node props and image Factories retain explicit loaders. +Native message images use separate user and output positions. The `conversation.chat.user.content` Factory declares `conversation.chat.user.images`, which `UserContent` renders; the Assistant node registration declares `conversation.chat.message.images`, which `AssistantMarkdown` renders. Both receive image sources, the authorized `loadImage`, and layout options through explicit props. Their official `MessageImages` entries call the shared root-scoped `conversation.images.content` Factory. Each position can be replaced independently without duplicating the gallery implementation. -The session-scoped `conversation.chat.user.content` Factory renders only the user content stack: text, extra blocks, image/file attachments in input order (including image previews), and reference summaries. Its [`UserContentInput`]() carries prepared `content`, explicit `loadImage`, and optional paired `openFile`/`openSkill` actions in `references`; omitting those actions leaves references non-interactive, as in pending bubbles. The framework supplies the renderer and `chat` locale. Direct callers need an actual Session binding and the image Factory/Slot registrations, and supply their own row wrapper. +The session-scoped `conversation.chat.user.content` Factory renders only the user content stack: text, extra blocks, image/file attachments in input order, and reference summaries. Its [`UserContentInput`](src/client/contract/slots.ts) carries prepared `content`, transform `facts`, display props, and explicit `loadImage`; node-owned injections provide reference actions. It calls the shared `conversation.user.text` Factory for text and its own image Slot for images. Direct callers need a Session binding and supply their own row wrapper. `UserStyleBubble` and the [source-specific wrappers]() retain the user row, keys, pending/echo markers, timestamps, copy controls, and other message actions. The content Factory reads neither source-specific models nor private Context, and is not a global user-content override entry point. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 00c3b9c60e..c07cd9c21c 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -37,9 +37,9 @@ kind: "package-reference" `ChatView` 通过节点列表外围既有的 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航,直接传入 `requestOpenFile`、`openExternalLink` 和 `fileImages`。文件打开动作保留 Chat 的失败反馈与重试处理。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 -原生消息图片渲染通过显式 props 接收 `loadImage`:`AssistantNodeView` 将它传给 `AssistantMarkdown`,用户消息、待处理 steering 和本地回显的包装层则通过 `UserStyleBubble` 将它传给 `conversation.chat.user.content`。`AssistantMarkdown` 和 `UserContent` 在各自的图片绘制位置,通过框架 `renderFactorySlot` 调用 `conversation.message.images.view`,提供图片来源、loader 和布局参数。入口 Factory 拥有可替换的 `conversation.message.images` Slot,官方条目复用 `conversation.message.images.content`。跨插件的 Chat 节点 props 和图片 Factory 保留显式 loader。 +原生消息图片使用独立的用户与输出位置。`conversation.chat.user.content` Factory 声明 `conversation.chat.user.images`,由 `UserContent` 渲染;Assistant 节点注册声明 `conversation.chat.message.images`,由 `AssistantMarkdown` 渲染。两者通过显式 props 接收图片来源、授权 `loadImage` 和布局参数。官方 `MessageImages` 条目调用共享的 root 作用域 `conversation.images.content` Factory。每个位置均可独立替换,无需复制画廊实现。 -Session 作用域的 `conversation.chat.user.content` Factory 只渲染用户内容区域:文本、额外内容块、按输入顺序排列的图片/文件附件(含图片预览)及引用摘要。[`UserContentInput`]() 携带预处理的 `content`、显式 `loadImage`,以及 `references` 中可选的 `openFile`/`openSkill` 配对动作;省略这些动作时,引用与待处理气泡一样不可点击。框架提供 renderer 和 `chat` locale。直接调用需要实际的 Session 绑定及相应图片 Factory/Slot 注册,并由调用方自带行包装层。 +Session 作用域的 `conversation.chat.user.content` Factory 只渲染用户内容区域:文本、额外内容块、按输入顺序排列的图片/文件附件及引用摘要。[`UserContentInput`](src/client/contract/slots.ts) 携带预处理的 `content`、变换 `facts`、显示 props 和显式 `loadImage`;引用动作由节点自己的 Injection 提供。正文调用共享 `conversation.user.text` Factory,图片调用自身拥有的图片 Slot。直接调用需要 Session 绑定,行包装层由调用方提供。 `UserStyleBubble` 和[各来源包装层]() 保留用户行、key、pending/echo 标记、时间、复制控件及其他消息操作。内容 Factory 不读取来源专属模型或私有 Context,也不是全局用户内容替换入口。 diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index a26a2bfdc6..8aa63aa1f7 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -31,7 +31,6 @@ import { ApprovalCommand } from './chat/ApprovalCommand.tsx' import { ChatView } from './chat/ChatView.tsx' import { ChatFlow } from './chat/ChatFlow.tsx' import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' -import { MessageImagesView } from './chat/MessageImagesView.tsx' import { UserContent } from './chat/UserContent.tsx' import { ActivityPill, UsagePill } from './chat/StatsPills.tsx' import { registerConversationNodes } from './conversation-nodes/register.ts' @@ -171,13 +170,9 @@ export function apply(ctx: Context): void { ctx.effect(() => () => { transcriptView.dispose(); performancePolicy.dispose() }) const performanceUsage = performancePolicy.mode registerChatNodeRenderers(ctx, performanceUsage, presentation) - ctx.slots.registerFactory({ - name: 'conversation.message.images.view', - scope: 'session', - children: { 'conversation.message.images': { kind: 'single', scope: 'session' } }, - }, MessageImagesView) ctx.slots.registerFactory({ name: 'conversation.chat.user.content', scope: 'session', locale: NS, + children: { 'conversation.chat.user.images': { kind: 'single', scope: 'session' } }, }, UserContent) ctx.slots.inject('settings.general.item', () => ctx.slots.register({ diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 1b3962070e..22ae5bac70 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -1,6 +1,6 @@ import { Fragment, memo, useMemo } from 'react' import { fileMediaUrl } from '@deepseek-ai/dsh-util-workspace-path' -import type { PropsRenderFactories, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { ReactNode } from 'react' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' @@ -27,8 +27,9 @@ export function localPathMediaUrl(base: string, value: string): string | undefin return fileMediaUrl(base, path) } -export interface AssistantMarkdownProps extends Pick, 'renderSlot'>, - Pick, Pick { +export interface AssistantMarkdownProps extends + Pick, 'renderSlot'>, + Pick { /** Render only the requested business portion, preserving original block indexes. */ groupPart?: string | undefined /** Stable Hook forwarded to each independently expandable reasoning block. */ @@ -53,7 +54,7 @@ export interface AssistantMarkdownProps extends Pick - {renderFactorySlot('conversation.message.images.view', { + {renderSlot('conversation.chat.message.images', { images: group.map(({ attachment }) => ({ attachment })), loadImage, align: 'start', })} , diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index c241a5b440..5f61fe7007 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -4,11 +4,11 @@ import type { ChatNodeViewProps, PresentationInjected, TurnTailOwnerProps } from import { AssistantMarkdown } from './AssistantMarkdown.tsx' type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace - & PropsRenderSlots<'conversation.chat.reasoning.body'> + & PropsRenderSlots<'conversation.chat.reasoning.body' | 'conversation.chat.message.images'> /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ export const AssistantNodeView = memo(function AssistantNodeView({ - node, groupPart, useDisclosure, useGroupAction, useTurnData, turnProcess, openFile, loadImage, renderFactorySlot, fileMentions, + node, groupPart, useDisclosure, useGroupAction, useTurnData, turnProcess, openFile, loadImage, fileMentions, usePresentation, renderSlot, t, }: AssistantNodeViewProps) { const data = node.data @@ -38,7 +38,6 @@ export const AssistantNodeView = memo(function AssistantNodeView({ useDisclosure={useDisclosure} streaming={data.status === 'running'} interrupted={data.status === 'interrupted'} - renderFactorySlot={renderFactorySlot} loadImage={loadImage} reasoningHidden={reasoningHidden} useGroupAction={useGroupAction} diff --git a/packages/client/ui-chat/src/client/chat/MessageImagesView.tsx b/packages/client/ui-chat/src/client/chat/MessageImagesView.tsx deleted file mode 100644 index 8b6154e477..0000000000 --- a/packages/client/ui-chat/src/client/chat/MessageImagesView.tsx +++ /dev/null @@ -1,17 +0,0 @@ -/** Portable owner of the Chat message-image position. */ -import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' -import type {} from '../contract/slots.ts' - -/** - * Render Chat images through the same replaceable position from any Chat child. - * @param props - image references, authorized loader, presentation hints, and Slot renderer. - * @returns the registered image presentation. - */ -export function MessageImagesView({ images, loadImage, align, compact, thumbnail, renderSlot }: -FactoryComponentPropsOf<'conversation.message.images.view'>) { - return renderSlot('conversation.message.images', { - images, loadImage, align, - ...compact === undefined ? {} : { compact }, - ...thumbnail === undefined ? {} : { thumbnail }, - }) -} diff --git a/packages/client/ui-chat/src/client/chat/UserContent.tsx b/packages/client/ui-chat/src/client/chat/UserContent.tsx index 92a0139713..64024a096c 100644 --- a/packages/client/ui-chat/src/client/chat/UserContent.tsx +++ b/packages/client/ui-chat/src/client/chat/UserContent.tsx @@ -12,7 +12,7 @@ const EMPTY_LABELS: readonly string[] = [] * @param props - prepared content, facts, authorized image loader, and framework Factory/locale seats. * @returns the content stack using the existing user-message layout. */ -export function UserContent({ content, loadImage, facts, interactive = false, ellipsis = false, renderFactorySlot, t }: +export function UserContent({ content, loadImage, facts, interactive = false, ellipsis = false, renderSlot, renderFactorySlot, t }: FactoryComponentPropsOf<'conversation.chat.user.content'>) { const { text, attachments, extraBlocks, referenceLabels = EMPTY_LABELS } = content const compactImages = attachments.length > 1 @@ -25,7 +25,7 @@ FactoryComponentPropsOf<'conversation.chat.user.content'>) { {attachments.map((attachment, index) => attachment.type === 'image' ? ( - {renderFactorySlot('conversation.message.images.view', { + {renderSlot('conversation.chat.user.images', { images: [attachment.image], loadImage, align: 'end', compact: compactImages, })} diff --git a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts index a5d4f9bc7a..7e58223287 100644 --- a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts @@ -51,7 +51,10 @@ export function registerChatNodeRenderers( name: 'conversation.chat.node', key: 'assistant-step', locale: NS, - children: { 'conversation.chat.reasoning.body': { kind: 'single', scope: 'session' } }, + children: { + 'conversation.chat.reasoning.body': { kind: 'single', scope: 'session' }, + 'conversation.chat.message.images': { kind: 'single', scope: 'session' }, + }, inject: () => ({ hooks: { presentation } }), }, AssistantNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 82d262a502..9bce65619b 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -352,8 +352,9 @@ export type ChatViewSlotProps = & InjectFace & PropsLocale<'chat'> -/** Full props of the durable-message image renderer. */ -export type MessageImagesProps = PropsRuntime<'conversation.message.images'> & PropsRenderFactories +/** Full props shared by the user and output image entries. */ +export type MessageImagesProps = + PropsRuntime<'conversation.chat.user.images' | 'conversation.chat.message.images'> & PropsRenderFactories declare module '@deepseek-ai/dsh-client-ui-slots' { interface SessionStandardProps { @@ -372,12 +373,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { scope: 'session' props: UserContentInput locale: 'chat' - } - /** Portable Chat image position, retaining the existing image Slot. */ - 'conversation.message.images.view': { - scope: 'session' - props: MessageImagesOwnerProps - children: { 'conversation.message.images': { kind: 'single'; scope: 'session' } } + children: { 'conversation.chat.user.images': { kind: 'single'; scope: 'session' } } } /** Session-independent reasoning Markdown with Chat labels and compact typography. */ 'conversation.chat.reasoning.content': { @@ -414,7 +410,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * supplies image references, an authorized loader, and alignment. A * registration replaces the shipped gallery; without one, images are omitted. */ - 'conversation.message.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } + 'conversation.chat.message.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } + /** User-message images, including pending and local-echo previews, independently replaceable from output images. */ + 'conversation.chat.user.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } /** * Command row keyed by the command name. The component receives the folded * command lifecycle and linked compaction when present. Reusing a key diff --git a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx index 6acf5f43f4..c366be70e6 100644 --- a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx +++ b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx @@ -6,14 +6,12 @@ import { useDetailedPresentation } from './presentation-fixture.client.ts' import { unusedImageLoader } from './reasoning-component-fixture.tsx' import { useDisclosure } from '../src/client/chat/use-disclosure.ts' import { useSearchableHidden } from '../src/client/chat/searchable-hidden.ts' -import type { PropsRenderFactories } from '@deepseek-ai/dsh-client-ui-slots' import type { ChatViewSlotProps } from '../src/client/contract/slots.ts' import type { AssistantBlock } from '../src/client/contract/snapshot.ts' afterEach(() => { cleanup(); vi.restoreAllMocks() }) const t = ((_key: string) => 'label') as ChatViewSlotProps['t'] -const renderFactorySlot: PropsRenderFactories['renderFactorySlot'] = () => null function textBlock(text: string): AssistantBlock { return { kind: 'text', text } @@ -65,7 +63,7 @@ describe('AssistantMarkdown local-path images', () => { usePresentation={useDetailedPresentation} blocks={[textBlock('See ![diagram](/tmp/graph.png) for the layout.')]} streaming={false} - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} t={t} />, ) @@ -83,7 +81,7 @@ describe('AssistantMarkdown local-path images', () => { usePresentation={useDetailedPresentation} blocks={[textBlock('See ![diagram](relative.png).')]} streaming={false} - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} t={t} />, ) diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index 05bd0a04cb..2371a63043 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -142,32 +142,38 @@ describe('Chat apply wiring', () => { await b.runtime.dispose() }) - it('owns node dispatch in the shared-store flow and image rendering in its Factory', async () => { + it('owns node dispatch in the shared flow and image Slots in their domain renderers', async () => { const b = await bench() onTestFinished(() => b.runtime.dispose()) b.runtime.renderRoot() expect(b.runtime.slots.spec('conversation.chat.flow')).toMatchObject({ kind: 'single', scope: 'session' }) const view = b.runtime.slots.entries('conversation.view')[0] const flow = b.runtime.slots.entries('conversation.chat.flow')[0] - const images = b.runtime.factoryOf('conversation.message.images.view') + const assistant = b.runtime.slots.entries('conversation.chat.node').find(entry => entry.options.key === 'assistant-step') const userContent = b.runtime.factoryOf('conversation.chat.user.content') expect(userContent).toMatchObject({ scope: 'session', locale: 'chat', component: UserContent }) - expect(userContent.children).toBeUndefined() + expect(userContent.children?.['conversation.chat.user.images']).toMatchObject({ kind: 'single', scope: 'session' }) + expect(assistant?.children?.['conversation.chat.message.images']).toMatchObject({ kind: 'single', scope: 'session' }) expect(userContent.store).toBeUndefined() expect(userContent.inject).toBeUndefined() expect(view?.children?.['conversation.chat.flow']).toBeDefined() expect(view?.children?.['conversation.chat.node']).toBeUndefined() - expect(view?.children?.['conversation.message.images']).toBeUndefined() + expect(view?.children?.['conversation.chat.message.images']).toBeUndefined() + expect(view?.children?.['conversation.chat.user.images']).toBeUndefined() expect(flow?.children?.['conversation.chat.node']).toBeDefined() - expect(flow?.children?.['conversation.message.images']).toBeUndefined() - expect(images.children?.['conversation.message.images']).toMatchObject({ kind: 'single', scope: 'session' }) + expect(flow?.children?.['conversation.chat.message.images']).toBeUndefined() + expect(flow?.children?.['conversation.chat.user.images']).toBeUndefined() + expect(userContent.children?.['conversation.chat.message.images']).toBeUndefined() + expect(assistant?.children?.['conversation.chat.user.images']).toBeUndefined() + expect(b.runtime.slots.snapshot('factory:conversation.message.images.view')).toHaveLength(0) expect(flow?.store).toBeDefined() expect(flow?.store).toBe(view?.store) await b.chat.dispose() expect(b.runtime.slots.spec('conversation.chat.flow')).toBeUndefined() expect(b.runtime.slots.spec('conversation.chat.node')).toBeUndefined() - expect(b.runtime.slots.spec('conversation.message.images')).toBeUndefined() - expect(() => b.runtime.factoryOf('conversation.message.images.view')).toThrow('no definition') + expect(b.runtime.slots.spec('conversation.chat.message.images')).toBeUndefined() + expect(b.runtime.slots.spec('conversation.chat.user.images')).toBeUndefined() + expect(b.runtime.slots.snapshot('factory:conversation.message.images.view')).toHaveLength(0) expect(() => b.runtime.factoryOf('conversation.chat.user.content')).toThrow('no definition') }) diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 5da9fc3475..fa922ee1e1 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -15,7 +15,7 @@ import { CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView, UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' -import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' +import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' import { unusedImageLoader } from './reasoning-component-fixture.tsx' import { createUserContentFixture } from './user-content-component-fixture.tsx' @@ -32,7 +32,7 @@ afterEach(() => { }) const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh) -const renderFactorySlot: AssistantMarkdownProps['renderFactorySlot'] = () => null +const renderUserImages: Parameters>[1] = () => null let renderUserContent: ReturnType beforeEach(() => { renderUserContent = createUserContentFixture() }) const RETRY_ID = 'retry-fixture' as Extract['retryId'] @@ -73,7 +73,7 @@ function MessageItem({ node, t: translate, referenceLabels, skillNames }: Messag } const renderUserFactory: ChatNodeViewProps['renderFactorySlot'] = (name, input) => { if (name !== 'conversation.chat.user.content') throw new Error(`Unexpected user Factory: ${name}`) - return renderUserContent(input as UserContentInput, renderFactorySlot, translate) + return renderUserContent(input as UserContentInput, renderUserImages, translate) } const props: Partial> & { node: ChatConversationViewNode } = { node: viewNode, t: translate, loadImage: unusedImageLoader, renderFactorySlot: renderUserFactory, @@ -1065,7 +1065,7 @@ describe('small branch tails', () => { t={t} blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) expect(view.getByText('one-liner')).toBeTruthy() diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 30546c1031..d248901ed5 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -279,9 +279,6 @@ type ToolOwner = Pick & { const renderCommandSlot: React.ComponentProps['renderSlot'] = (_key, _owner, opts) => opts?.fallback ?? null const renderTurnTailSlot: React.ComponentProps['renderSlot'] = () => null -const assistantSlots: Pick, 'renderSlot'> = { - renderSlot: renderReasoningSlot, -} function HarnessChatFlow({ standard, owner, hookContext, renderSlot }: { readonly standard: HarnessStandardProps @@ -297,13 +294,14 @@ function HarnessChatFlow({ standard, owner, hookContext, renderSlot }: { return } -function HarnessChatNode({ standard, owner, hookContext, usePerformanceUsage, toolOwners, fallback }: { +function HarnessChatNode({ standard, owner, hookContext, usePerformanceUsage, toolOwners, fallback, renderAssistantSlot }: { readonly standard: HarnessStandardProps readonly owner: RoutedChatNodeOwner readonly hookContext: ChatNodeHookContext readonly usePerformanceUsage: React.ComponentProps['usePerformanceUsage'] readonly toolOwners: ToolOwner[] readonly fallback: React.ReactNode + readonly renderAssistantSlot: React.ComponentProps['renderSlot'] }) { const hooks = useMemo(() => ({ useTurnData: CHAT_NODE_INJECT.hooks.turnData(standard, hookContext), @@ -320,7 +318,7 @@ function HarnessChatNode({ standard, owner, hookContext, usePerformanceUsage, to case 'context': return case 'assistant-step': - return + return case 'command': return case 'manual-compaction': @@ -425,11 +423,19 @@ function makeHarness( const usePerformanceUsage = bindSnapshotSelector(performanceUsage) let nodeSlotOverride: NodeRenderer | undefined let imageSlotOverride: ImageRenderer | undefined + const renderImages = (name: string, owner: object, options?: { fallback?: React.ReactNode }) => { + if ((name !== 'conversation.chat.user.images' && name !== 'conversation.chat.message.images') || !isImageOwner(owner)) { + throw new Error(`Unexpected Chat image Slot: ${name}`) + } + return imageSlotOverride?.(owner) ?? options?.fallback ?? null + } + const renderAssistantSlot: React.ComponentProps['renderSlot'] = (name, owner, options) => + name === 'conversation.chat.message.images' ? renderImages(name, owner, options) : renderReasoningSlot(name, owner, options) const renderNodeSlot: NodeRenderer = (key, owner, opts) => { if (nodeSlotOverride !== undefined) return nodeSlotOverride(key, owner, opts) return + toolOwners={toolOwners} fallback={opts.fallback} renderAssistantSlot={renderAssistantSlot} /> } const renderFlowSlot: ChatFlowSlotProps['renderSlot'] = (key: string, owner: object, opts?: { fallback?: React.ReactNode @@ -440,14 +446,9 @@ function makeHarness( } throw new Error(`Undeclared flow child: ${key}`) } - const renderFactorySlot: PropsRenderFactories['renderFactorySlot'] = (name, owner, opts) => { - if (name === 'conversation.chat.user.content') { - return renderUserContent(owner as UserContentInput, renderFactorySlot, props.t) - } - if (name !== 'conversation.message.images.view' || !isImageOwner(owner)) { - throw new Error(`Unexpected Chat image Factory call: ${name}`) - } - return imageSlotOverride?.(owner) ?? opts?.fallback ?? null + const renderFactorySlot: PropsRenderFactories['renderFactorySlot'] = (name, owner) => { + if (name !== 'conversation.chat.user.content') throw new Error(`Unexpected Chat Factory: ${name}`) + return renderUserContent(owner as UserContentInput, renderImages, props.t) } const renderSlot: FlowRenderer = (key, owner, opts) => { if (key !== 'conversation.chat.flow') throw new Error(`Undeclared ChatView child: ${key}`) diff --git a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx index 128aa01a10..241a0faded 100644 --- a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx @@ -12,7 +12,6 @@ import { useSearchableHidden } from '../src/client/chat/searchable-hidden.ts' import { zh } from '../src/client/locale.ts' const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) -const renderFactorySlot: AssistantMarkdownProps['renderFactorySlot'] = () => null afterEach(cleanup) @@ -28,7 +27,7 @@ describe('tails', () => { { kind: 'other', block: { type: 'mystery' } }, ]} streaming - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) expect(view.getByText('思考')).toBeTruthy() @@ -41,7 +40,7 @@ describe('tails', () => { blocks={[{ kind: 'text', text: 'partial words' }]} streaming={false} interrupted - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) expect(stopped.getByText('已停止')).toBeTruthy() @@ -56,14 +55,14 @@ describe('tails', () => { t={t} blocks={[{ kind: 'tool-call', callId: 'c', name: 'todo_write', argsRaw: '{}' }]} streaming={false} - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) expect(empty.container.firstChild).toBeNull() const blank = render( null} useDisclosure={useDisclosure} useGroupAction={useSearchableHidden} usePresentation={useDetailedPresentation} - t={t} blocks={[]} streaming={false} loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} />, + t={t} blocks={[]} streaming={false} loadImage={unusedImageLoader} />, ) expect(blank.container.firstChild).toBeNull() }) diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index cc49c22716..a15de80592 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -14,7 +14,6 @@ import { zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) -const renderFactorySlot: AssistantMarkdownProps['renderFactorySlot'] = () => null afterEach(() => { cleanup() @@ -30,7 +29,7 @@ describe('render branch tails', () => { t={t} blocks={[{ kind: 'reasoning', text: 'done thinking' }, { kind: 'text', text: 'answer' }]} streaming - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) // reasoning at index 0 with a later block: running is false → ok state. @@ -73,7 +72,7 @@ describe('render branch tails', () => { t={t} blocks={[{ kind: 'reasoning', text: 'still thinking' }]} streaming - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() diff --git a/packages/client/ui-chat/tests/image-labels.client.spec.tsx b/packages/client/ui-chat/tests/image-labels.client.spec.tsx index d2a22ca65a..e37b5ec2c6 100644 --- a/packages/client/ui-chat/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-chat/tests/image-labels.client.spec.tsx @@ -8,7 +8,6 @@ import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-run import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { MessageImagesOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' -import { MessageImagesView } from '../src/client/chat/MessageImagesView.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' import { useDisclosure } from '../src/client/chat/use-disclosure.ts' import { useSearchableHidden } from '../src/client/chat/searchable-hidden.ts' @@ -31,12 +30,7 @@ async function renderImages(blocks: AssistantMarkdownProps['blocks'], calls: Mes const runtime = await SlotTestRuntime.create() onTestFinished(() => runtime.dispose()) const loadImage = vi.fn(async () => 'blob:history') - runtime.slots.registerFactory({ - name: 'conversation.message.images.view', - scope: 'session', - children: { 'conversation.message.images': { kind: 'single', scope: 'session' } }, - }, MessageImagesView) - runtime.slots.register({ name: 'conversation.message.images' }, (owner: MessageImagesOwnerProps) => { + runtime.slots.inject('conversation.chat.message.images', () => runtime.slots.register({ name: 'conversation.chat.message.images' }, (owner: MessageImagesOwnerProps) => { calls.push(owner) return (
@@ -47,14 +41,15 @@ async function renderImages(blocks: AssistantMarkdownProps['blocks'], calls: Mes })}
) - }) + })) await runtime.sessions.add({ id: 'image-labels' }) const session = runtime.sessions.retainFor(runtime.ctx, SessionId('image-labels')) await runtime.root.declare({ 'conversation.chat.reasoning.body': { kind: 'single', scope: 'session' }, - }, ({ renderFactorySlot, renderSlot, SessionProvider }) => ( + 'conversation.chat.message.images': { kind: 'single', scope: 'session' }, + }, ({ renderSlot, SessionProvider }) => ( - @@ -77,36 +72,36 @@ describe('assistant image slot handoff', () => { const firstLoader = vi.fn(async () => 'blob:first') const nextLoader = vi.fn(async () => 'blob:next') const siblingLoader = vi.fn(async () => 'blob:sibling') - const firstRenderer = vi.fn(() => first gallery) - const siblingRenderer = vi.fn(() => sibling gallery) + const firstRenderer = vi.fn((_key: string, _owner: object) => first gallery) + const siblingRenderer = vi.fn((_key: string, _owner: object) => sibling gallery) const props = { blocks: [{ kind: 'image', attachment }], streaming: false, t, - renderSlot: () => null, useDisclosure, useGroupAction: useSearchableHidden, + useDisclosure, useGroupAction: useSearchableHidden, usePresentation: useDetailedPresentation, - } satisfies Omit + } satisfies Omit const content = (loadImage: AssistantMarkdownProps['loadImage']) => ( <> - - + + ) const view = render(content(firstLoader)) const firstGallery = view.getByText('first gallery') const siblingGallery = view.getByText('sibling gallery') const owner = { images: [{ attachment }], align: 'start' } - expect(firstRenderer).toHaveBeenLastCalledWith('conversation.message.images.view', { + expect(firstRenderer).toHaveBeenLastCalledWith('conversation.chat.message.images', { ...owner, loadImage: firstLoader, }) - expect(siblingRenderer).toHaveBeenLastCalledWith('conversation.message.images.view', { + expect(siblingRenderer).toHaveBeenLastCalledWith('conversation.chat.message.images', { ...owner, loadImage: siblingLoader, }) view.rerender(content(nextLoader)) - expect(firstRenderer).toHaveBeenLastCalledWith('conversation.message.images.view', { + expect(firstRenderer).toHaveBeenLastCalledWith('conversation.chat.message.images', { ...owner, loadImage: nextLoader, }) - expect(siblingRenderer).toHaveBeenLastCalledWith('conversation.message.images.view', { + expect(siblingRenderer).toHaveBeenLastCalledWith('conversation.chat.message.images', { ...owner, loadImage: siblingLoader, }) expect(view.getByText('first gallery')).toBe(firstGallery) diff --git a/packages/client/ui-chat/tests/reasoning-component-fixture.tsx b/packages/client/ui-chat/tests/reasoning-component-fixture.tsx index cffd5f5b2c..c2e0543f3c 100644 --- a/packages/client/ui-chat/tests/reasoning-component-fixture.tsx +++ b/packages/client/ui-chat/tests/reasoning-component-fixture.tsx @@ -21,9 +21,10 @@ function content(input: ReasoningContentInput) { } /** Render the production child component without emulating a registry or its dispatch. */ -export const renderReasoningSlot = ( - (_key: 'conversation.chat.reasoning.body', input: ReasoningContentInput) => content(input) -) as AssistantMarkdownProps['renderSlot'] +export const renderReasoningSlot: AssistantMarkdownProps['renderSlot'] = (key: string, input: object) => { + if (key !== 'conversation.chat.reasoning.body') throw new Error(`Unexpected reasoning Slot: ${key}`) + return content(input as ReasoningContentInput) +} /** Supply the official component at the Factory call seam of a standalone component unit. */ export const renderReasoningFactory: PropsRenderFactories['renderFactorySlot'] = (name, input) => { diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index 2fee7ab0d3..29ad9a4e4d 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -19,12 +19,11 @@ afterEach(() => { }) const t = makeTranslate(zh, commonZh) -const renderFactorySlot: AssistantMarkdownProps['renderFactorySlot'] = () => null describe('AssistantMarkdown reasoning blocks', () => { const props = { renderSlot, useDisclosure, useGroupAction: useSearchableHidden, - usePresentation: useDetailedPresentation, renderMessageImages, t, + usePresentation: useDetailedPresentation, loadImage: unusedImageLoader, t, } it.each([false, true])('omits blank blocks without trimming body text when streaming=%s', (streaming) => { @@ -224,20 +223,20 @@ describe('ReasoningRow', () => { const view = render( , + t={t} blocks={[reasoning]} streaming loadImage={unusedImageLoader} />, ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') fireEvent.click(view.getByText('思考')) view.rerender( , + t={t} blocks={[reasoning, nextBlock]} streaming loadImage={unusedImageLoader} />, ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') view.rerender( , + t={t} blocks={[reasoning, nextBlock]} streaming={false} loadImage={unusedImageLoader} />, ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') expect(view.getByText(/Check persistence/)).toBeTruthy() @@ -252,7 +251,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nDetails\n\nNewest reasoning tokens\n' }]} streaming - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) expect(view.getByText('运行中')).toBeTruthy() @@ -266,7 +265,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nDetails\n\nNewest reasoning tokens\nMore details\n\nChecking boundaries' }]} streaming - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) expect(view.getByText('Newest reasoning tokens').closest('[data-streaming]') @@ -280,7 +279,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text }]} streaming - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) expect(view.getByText('Checking boundaries')).toBeTruthy() @@ -292,7 +291,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text }]} streaming={false} - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) const settledSummary = view.getByText('Inspect the session') @@ -308,7 +307,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]} streaming={false} - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) const row = view.getByRole('button') @@ -339,7 +338,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text }]} streaming={streaming} - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) @@ -360,7 +359,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text }]} streaming={false} - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) const summary = view.getByText('# Section 1') @@ -386,7 +385,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: first }]} streaming - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) fireEvent.click(view.getByText('思考')) @@ -399,7 +398,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text }]} streaming - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) expect(view.getByRole('heading', { name: 'Investigation' })).toBe(heading) @@ -414,7 +413,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]} streaming={false} - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) fireEvent.click(view.getByText('思考')) @@ -434,7 +433,7 @@ describe('ReasoningRow', () => { { kind: 'text', text: 'Answer' }, ]} streaming={false} - loadImage={unusedImageLoader} renderFactorySlot={renderFactorySlot} + loadImage={unusedImageLoader} />, ) // Collapsed: no `data-open`, so the sticky rule's gate never matches. diff --git a/packages/client/ui-chat/tests/user-content-component-fixture.tsx b/packages/client/ui-chat/tests/user-content-component-fixture.tsx index 24c4a642e5..eb6670fb33 100644 --- a/packages/client/ui-chat/tests/user-content-component-fixture.tsx +++ b/packages/client/ui-chat/tests/user-content-component-fixture.tsx @@ -1,6 +1,6 @@ /** Production user content for component units; registry integration uses SlotTestRuntime. */ import { createElement, type ComponentProps } from 'react' -import type { PropsLocale, PropsRenderFactories } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRenderFactories, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { UserTextInput } from '@deepseek-ai/dsh-client-ui-conversation/client' import { createUserTextFixture } from '../../ui-conversation/tests/user-text-component-fixture.client.tsx' import type { UserContentInput } from '../src/client/contract/slots.ts' @@ -9,19 +9,19 @@ import { UserContent } from '../src/client/chat/UserContent.tsx' /** * Bind real text parsing and business renderers for one component-test lifetime. * @param actions - reference operations supplied by the test's node-injection fixture. - * @returns renderer accepting prepared content, image Factory seats, and Chat locale. + * @returns renderer accepting prepared content, the user-image Slot seat, and Chat locale. */ export function createUserContentFixture(actions?: Parameters[0]) { const renderUserText = createUserTextFixture(actions) return ( input: UserContentInput, - renderFactorySlot: PropsRenderFactories['renderFactorySlot'], + renderSlot: PropsRenderSlots<'conversation.chat.user.images'>['renderSlot'], t: PropsLocale<'chat'>['t'], ) => { - const renderFactory: PropsRenderFactories['renderFactorySlot'] = (name, owner, options) => - name === 'conversation.user.text' - ? renderUserText(owner as UserTextInput) - : renderFactorySlot(name, owner, options) - return createElement(UserContent, { ...input, renderFactorySlot: renderFactory, t } as ComponentProps) + const renderFactory: PropsRenderFactories['renderFactorySlot'] = (name, owner) => { + if (name !== 'conversation.user.text') throw new Error(`Unexpected user content Factory: ${name}`) + return renderUserText(owner as UserTextInput) + } + return createElement(UserContent, { ...input, renderSlot, renderFactorySlot: renderFactory, t } as ComponentProps) } } diff --git a/packages/client/ui-chat/tests/user-content.client.spec.tsx b/packages/client/ui-chat/tests/user-content.client.spec.tsx index 7ce588e18b..201fa05101 100644 --- a/packages/client/ui-chat/tests/user-content.client.spec.tsx +++ b/packages/client/ui-chat/tests/user-content.client.spec.tsx @@ -11,7 +11,6 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import type { PropsRenderFactories, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { UserContentAttachment, UserContentData, UserContentInput } from '../src/client/contract/slots.ts' import { UserContent } from '../src/client/chat/UserContent.tsx' -import { MessageImagesView } from '../src/client/chat/MessageImagesView.tsx' import { en, NS, zh } from '../src/client/locale.ts' import type { UserTextInjected } from '@deepseek-ai/dsh-client-ui-conversation/client' import { UserTextRegistry } from '../../ui-conversation/src/client/conversation/user-text-registry.ts' @@ -51,7 +50,7 @@ async function bench() { locale.register('reference', { en: referenceEn, zh: referenceZh }) locale.setLocale('en') runtime.slots.installLocale(locale) - const imageCalls: PropsRuntime<'conversation.message.images'>[] = [] + const imageCalls: PropsRuntime<'conversation.chat.user.images'>[] = [] const openFile = vi.fn<(path: string) => void>() const openSkill = vi.fn<(name: string) => void>() await runtime.mount({ inject: ['slots'], apply: (ctx) => { @@ -79,21 +78,20 @@ async function bench() { inject: () => ({ openFile }) }, FileReference) ctx.slots.register({ name: 'conversation.user.text.node', key: 'folder' }, FolderReference) ctx.slots.register({ name: 'conversation.user.text.node', key: 'skill', inject: () => ({ openSkill }) }, SkillReference) - ctx.slots.registerFactory({ - name: 'conversation.message.images.view', scope: 'session', - children: { 'conversation.message.images': { kind: 'single', scope: 'session' } }, - }, MessageImagesView) - ctx.slots.register({ name: 'conversation.message.images' }, function ImageProbe(props) { + ctx.slots.inject('conversation.chat.user.images', () => ctx.slots.register({ name: 'conversation.chat.user.images' }, function ImageProbe(props) { imageCalls.push(props) const source = props.images[0] if (source === undefined) throw new Error('User content must pass one image to the image Slot') const name = 'attachment' in source ? source.attachment.name : source.preview.name return snapshot.sessionId)}>{name} - }) + })) } }) const mountContent = () => runtime.mount({ inject: ['slots'], apply: (ctx) => { - ctx.slots.registerFactory({ name: 'conversation.chat.user.content', scope: 'session', locale: NS }, UserContent) + ctx.slots.registerFactory({ + name: 'conversation.chat.user.content', scope: 'session', locale: NS, + children: { 'conversation.chat.user.images': { kind: 'single', scope: 'session' } }, + }, UserContent) } }) const owner = await mountContent() await runtime.sessions.add({ id: 'user-content' }) @@ -141,7 +139,7 @@ describe('user Content Factory', () => { }) }) - it('keeps mixed files, durable images, and local previews in their prepared order through the image Factory', async () => { + it('keeps mixed files, durable images, and local previews in their prepared order through its user-image Slot', async () => { const b = await bench() const content: UserContentData = { ...empty, text: 'Prepared text', attachments: [filePart, durable, { type: 'file', file: { ...file, name: 'last.md' } }, preview], diff --git a/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx b/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx index 187c3d963e..44ac8f2362 100644 --- a/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx +++ b/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx @@ -17,8 +17,8 @@ describe('Chat View type chain', () => { void chat.openFile({ turnSeq: 1, callId: 'c' }) // @ts-expect-error ChatView renders only its flow child. chat.renderSlot('conversation.chat.node', {}, { hookContext: nodeContext }) - // @ts-expect-error Image rendering belongs to its Factory. - chat.renderSlot('conversation.message.images', {}) + // @ts-expect-error Output images belong to the Assistant node entry. + chat.renderSlot('conversation.chat.message.images', {}) // @ts-expect-error Flow owner data cannot override the flow's render authority. void flowOwner.renderSlot // @ts-expect-error Node renderers receive the bound hook, not the viewport controller. diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 3203858369..d16610aac1 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -18,8 +18,8 @@ en: 1d2a3a7f98c2cc9b zh: f895dd5e9f91e0a9 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 1e773680b3d7ef37 - zh: f49d855c18494b57 + en: 0ff4669e7d9e9235 + zh: ec69fae087f04660 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 901cce39a8..35cb4692e5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -52,7 +52,7 @@ Target packages declaration-merge their snapshot and Location data maps, then re ## Shell and standard props -The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. `MessageImagesContentInput` adds optional complete `MessageImageLabels` for the root-scoped `conversation.message.images.content` Factory. Message, tool, and Factory inputs carry the view's authorized `loadImage` callback explicitly; loading and URL caching remain with the resource owner. [ui-attachment](../ui-attachment/README.md) provides this Factory and owns rendering and the lightbox. +The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. `MessageImagesContentInput` adds optional complete `MessageImageLabels` for the root-scoped `conversation.images.content` Factory. Message, tool, and Factory inputs carry the view's authorized `loadImage` callback explicitly; loading and URL caching remain with the resource owner. [ui-attachment](../ui-attachment/README.md) provides this Factory and owns rendering and the lightbox. The composer measures its expanded control groups after size, content, visibility, or font-loading changes. If they cannot share a line, the row sets `--dsh-composer-model-text-display: none` and `--dsh-composer-model-icon-display: block` for the model seat; their defaults are `block` and `none`. Wrapping remains available if even the icon cannot fit. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d176e5163d..0ce7647b08 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -52,7 +52,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, ## Shell 与标准 props -共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。`MessageImagesContentInput` 为 root 作用域的 `conversation.message.images.content` Factory 增加可选的完整 `MessageImageLabels`。消息、工具及 Factory 输入显式携带视图提供的授权 `loadImage` 回调;读取和 URL 缓存仍由资源所有者负责。[ui-attachment](../ui-attachment/README.zh.md) 提供该 Factory,负责渲染与灯箱。 +共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。`MessageImagesContentInput` 为 root 作用域的 `conversation.images.content` Factory 增加可选的完整 `MessageImageLabels`。消息、工具及 Factory 输入显式携带视图提供的授权 `loadImage` 回调;读取和 URL 缓存仍由资源所有者负责。[ui-attachment](../ui-attachment/README.zh.md) 提供该 Factory,负责渲染与灯箱。 控件组的尺寸、内容、可见性或字体加载状态变化后,composer 测量展开状态下的控件组。若无法排在同一行,控制栏为模型位设置 `--dsh-composer-model-text-display: none` 和 `--dsh-composer-model-icon-display: block`;两者默认值分别为 `block` 和 `none`。若连图标也放不下,仍允许换行。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 9f6410a0c5..9abf5afb1c 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -184,7 +184,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } /** Official reusable gallery, with explicit authorized loading and optional caller copy. */ - 'conversation.message.images.content': { + 'conversation.images.content': { scope: 'root' props: MessageImagesContentInput locale: 'conversation' diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 64cf3b21d1..6db4c334a3 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -18,8 +18,8 @@ en: 4bd17d05b8385675 zh: 5174e27f754f2a03 /deepseek-ai-dsh-client-ui-trajectory/use-this-package/inspecting-records: - en: aa11877b41449b02 - zh: e0740308272a9a2a + en: 130d568589ae108e + zh: 2a52a19499e9c0a7 /deepseek-ai-dsh-client-ui-trajectory/use-this-package/the-timing-overview: en: 0f4ee2189324ac06 zh: 12e76fed22c09bc1 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 5720bd9723..6a9fa9698d 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -31,7 +31,7 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit Calls are identified by their recorded tool name. For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Copying the original arguments retains their recorded JSON whitespace. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. -Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. Ledger children call `conversation.trajectory.images.view` through the framework Factory renderer and pass image references, display options, and the authorized loader explicitly. The view Factory owns the existing `conversation.trajectory.images` Slot; its official entry reuses `conversation.message.images.content` without a custom `renderImages` closure. A user record shows both nonzero image and ordinary-file counts beside its text, including attachment-only records. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. +Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. The Trajectory view declares `conversation.trajectory.images`; ledger children call this Slot through the framework `renderSlot` seat, passing image references, display options, and the authorized loader explicitly. Its official `MessageImages` entry calls the shared `conversation.images.content` Factory. A user record shows both nonzero image and ordinary-file counts beside its text, including attachment-only records. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Summary and Preview share one ordered attachment list after the message text, preserving repeated references. Each row shows a contained image thumbnail or file-type icon, the recorded filename (a localized numbered label for unnamed images), and recorded size, type, and image dimensions where available. Zero-byte files retain their size, and truncated filenames expose the full name in a tooltip. Images open the existing lightbox. Raw keeps content-block order and unrendered text, with images and files in initially collapsed disclosures containing their complete recorded fields. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index d2fbbf820f..c37632a926 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -31,7 +31,7 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 调用按已记录的工具名识别。对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。复制原始参数时保留记录中 JSON 的空白。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。 -选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。账本子组件通过框架 Factory renderer 调用 `conversation.trajectory.images.view`,显式传入图片引用、显示参数及授权 loader。入口 Factory 拥有现有 `conversation.trajectory.images` Slot,官方条目复用 `conversation.message.images.content`,不再使用自制的 `renderImages` 闭包。用户记录会在文本旁同时显示非零的图片与普通文件数量,仅含附件的记录也显示这些数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 +选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。Trajectory 视图声明 `conversation.trajectory.images`;账本子组件通过框架 `renderSlot` 席位调用该 Slot,显式传入图片引用、显示参数及授权 loader。官方 `MessageImages` 条目调用共享的 `conversation.images.content` Factory。用户记录会在文本旁同时显示非零的图片与普通文件数量,仅含附件的记录也显示这些数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 概述与预览共用正文之后的有序附件列表,保留重复引用。每行展示完整缩放的图片缩略图或文件类型图标、已记录的文件名(未命名图片使用本地化的编号名称),以及可用的已记录大小、类型和图片尺寸。零字节文件保留大小显示,截断的文件名通过提示显示完整名称。图片打开现有灯箱。原始内容保留内容块顺序和未渲染文本,图片与文件使用默认折叠的展开项,包含完整的已记录字段。 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryImagesView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryImagesView.tsx deleted file mode 100644 index bab3efba5f..0000000000 --- a/packages/client/ui-trajectory/src/client/TrajectoryImagesView.tsx +++ /dev/null @@ -1,17 +0,0 @@ -/** Portable owner of the Trajectory image presentation Slot. */ -import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' -import type {} from './trajectory-contract.ts' - -/** - * Render record images without passing a custom ReactNode-producing closure through the ledger. - * @param props - image references, authorized loader, presentation hints, and Slot renderer. - * @returns the registered Trajectory image presentation. - */ -export function TrajectoryImagesView({ images, loadImage, align, compact, thumbnail, renderSlot }: -FactoryComponentPropsOf<'conversation.trajectory.images.view'>) { - return renderSlot('conversation.trajectory.images', { - images, loadImage, align, - ...compact === undefined ? {} : { compact }, - ...thumbnail === undefined ? {} : { thumbnail }, - }) -} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index caebfd38be..4cf15a0847 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -23,7 +23,7 @@ import { writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { JsonTreeLabels, JsonTreeProps, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsRenderFactories } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { structuredPatch } from 'diff' import type { AssistantRequestConfig, ConversationPromptSnapshot, MessageImageLoader, @@ -393,7 +393,7 @@ export interface TrajectoryTableProps { /** Trajectory locale seat. */ t: TrajectoryTranslate /** Slot-backed durable image renderer shared with the Chat gallery. */ - renderFactorySlot: PropsRenderFactories['renderFactorySlot'] + renderSlot: PropsRenderSlots<'conversation.trajectory.images'>['renderSlot'] /** Session-bound loader for durable message image attachments. */ loadImage: MessageImageLoader /** Session-global request numbers for the request groups visible in this context. */ @@ -1241,13 +1241,13 @@ function recordAttachments( function RecordAttachments({ blocks, preview, - renderFactorySlot, + renderSlot, loadImage, t, }: { blocks: readonly TrajectorySourceBlock[] | undefined preview: boolean - renderFactorySlot: PropsRenderFactories['renderFactorySlot'] + renderSlot: PropsRenderSlots<'conversation.trajectory.images'>['renderSlot'] loadImage: MessageImageLoader t: TrajectoryTranslate }) { @@ -1261,7 +1261,7 @@ function RecordAttachments({ {attachments.map(({ block, index, name, metadata }) => (
  • {block.attachment !== undefined - ? renderFactorySlot('conversation.trajectory.images.view', { + ? renderSlot('conversation.trajectory.images', { images: [{ attachment: block.attachment, label: name }], align: 'start', thumbnail: true, @@ -1480,7 +1480,7 @@ function ToolOutputBlocks({ error, errorDetail, preview, - renderFactorySlot, + renderSlot, loadImage, }: { blocks: readonly TrajectorySourceBlock[] @@ -1488,7 +1488,7 @@ function ToolOutputBlocks({ /** Failure name and code preserved beside image-only error content. */ errorDetail?: string | undefined preview: boolean - renderFactorySlot: PropsRenderFactories['renderFactorySlot'] + renderSlot: PropsRenderSlots<'conversation.trajectory.images'>['renderSlot'] loadImage: MessageImageLoader }) { return ( @@ -1504,7 +1504,7 @@ function ToolOutputBlocks({ block.attachment !== undefined ? (
    - {renderFactorySlot('conversation.trajectory.images.view', { images: [{ attachment: block.attachment }], align: 'start', loadImage })} + {renderSlot('conversation.trajectory.images', { images: [{ attachment: block.attachment }], align: 'start', loadImage })}
    ) : block.content !== '' @@ -1522,7 +1522,7 @@ function MarkdownRecordContent({ thinkingExpanded, onThinkingExpandedChange, onOpenCall, - renderFactorySlot, + renderSlot, loadImage, t, }: { @@ -1532,7 +1532,7 @@ function MarkdownRecordContent({ thinkingExpanded: boolean onThinkingExpandedChange: (expanded: boolean) => void onOpenCall: (callId: string) => void - renderFactorySlot: PropsRenderFactories['renderFactorySlot'] + renderSlot: PropsRenderSlots<'conversation.trajectory.images'>['renderSlot'] loadImage: MessageImageLoader t: TrajectoryTranslate }) { @@ -1603,7 +1603,7 @@ function MarkdownRecordContent({ @@ -1635,7 +1635,7 @@ function MarkdownRecordContent({ /> )} + renderSlot={renderSlot} loadImage={loadImage} t={t} /> ) } @@ -1704,7 +1704,7 @@ function RecordPayload({ record, direction, preview = false, - renderFactorySlot, + renderSlot, loadImage, stringWrapping, t, @@ -1712,7 +1712,7 @@ function RecordPayload({ record: TableRecord direction: 'input' | 'output' preview?: boolean - renderFactorySlot: PropsRenderFactories['renderFactorySlot'] + renderSlot: PropsRenderSlots<'conversation.trajectory.images'>['renderSlot'] loadImage: MessageImageLoader stringWrapping: JsonTreeProps['stringWrapping'] t: TrajectoryTranslate @@ -1754,7 +1754,7 @@ function RecordPayload({ error={error} errorDetail={error ? value : undefined} preview={preview} - renderFactorySlot={renderFactorySlot} + renderSlot={renderSlot} loadImage={loadImage} /> ) @@ -2085,7 +2085,7 @@ function OverviewSection({ export function TrajectoryTable({ t, stringWrapping, - renderFactorySlot, + renderSlot, loadImage, requestNumbers: sessionRequestNumbers, turns, @@ -3310,7 +3310,7 @@ export function TrajectoryTable({ > { activateTab('rendered') }}> {selected.cell.inputDetail && ( { activateTab('input') }}> - + )} {selected.cell.outputDetail && ( { activateTab('output') }}> - + )} { activateTab('schema') }}> @@ -3488,7 +3488,7 @@ export function TrajectoryTable({ {!promptSelected && selected !== undefined && activeTab === 'rendered' && ( + ? : )} {!promptSelected && selected !== undefined && activeTab === 'output' && ( selectedProgram === undefined - ? + ? : )} {!promptSelected && selected !== undefined && activeTab === 'schema' && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 1fd6a571bf..e36386e819 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -5,7 +5,7 @@ import type { AssistantBlock, AssistantMessageNode, ConvViewProps, MessageImageLoader, ToolCallBlock, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { InjectFace, PropsLocale, PropsRenderFactories } 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 type { JsonTreeProps } from '@deepseek-ai/dsh-client-ui-primitives' import { @@ -131,9 +131,9 @@ function addUsage( export function TrajectoryView({ useSession, useTrajectory, useDuration, loadOlder, loadImage, setActualDuration, - viewRequest, completeViewRequest, renderFactorySlot, t, jsonStringWrapping, + viewRequest, completeViewRequest, renderSlot, t, jsonStringWrapping, }: ConvViewProps - & PropsRenderFactories + & Pick, 'renderSlot'> & InjectFace & PropsLocale<'trajectory'>) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) @@ -550,7 +550,7 @@ export function TrajectoryView({ ...jsonStringWrapping, label: t('record.wrapLines'), }} - renderFactorySlot={renderFactorySlot} + renderSlot={renderSlot} loadImage={loadImage} requestNumbers={requestNumbers} turns={timelineTurns} diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 57327710bf..0b438d1aae 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -27,7 +27,6 @@ import { import type { TrajectorySnapshot } from './trajectory-contract.ts' import { registerTrajectoryToolDefinition } from './trajectory-tool-definition.ts' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' -import { TrajectoryImagesView } from './TrajectoryImagesView.tsx' export type { TrajectoryKey } from './locales.ts' export type { @@ -77,17 +76,13 @@ export function apply(ctx: Context): void { hooks: ['trajectory'], resolve: binding => ({ hooks: { trajectory: trajectorySource(binding) } }), }) - ctx.slots.registerFactory({ - name: 'conversation.trajectory.images.view', - scope: 'session', - children: { 'conversation.trajectory.images': { kind: 'single', scope: 'session' } }, - }, TrajectoryImagesView) ctx.slots.inject('conversation.view', () => ctx.slots.register({ name: 'conversation.view', id: 'trajectory', 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) { diff --git a/packages/client/ui-trajectory/src/client/trajectory-contract.ts b/packages/client/ui-trajectory/src/client/trajectory-contract.ts index cd3c3b23bf..c75c32d346 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-contract.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-contract.ts @@ -93,15 +93,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { useTrajectory: UseTrajectory } - interface SlotFactoryMap { - /** Portable Trajectory image position retaining its independent Slot. */ - 'conversation.trajectory.images.view': { - scope: 'session' - props: MessageImagesOwnerProps - children: { 'conversation.trajectory.images': { kind: 'single'; scope: 'session' } } - } - } - interface SlotMap { /** * Renderer for one group of durable record images in the Trajectory diff --git a/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx index 59ff0a2146..ab9bd12d41 100644 --- a/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx @@ -26,7 +26,7 @@ function Harness({ cell = CELL, getDefault = () => false, setDefault = () => {} }) { return null} loadImage={async () => 'blob:unused'} t={t} + renderSlot={() => null} loadImage={async () => 'blob:unused'} t={t} stringWrapping={{ label: 'Wrap lines', getDefault, setDefault }} collapsedTurns={new Set()} onToggleTurn={() => {}} collapsedAssistants={new Set()} onToggleAssistant={() => {}} diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index b354525665..188b96bb5f 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -8,15 +8,14 @@ import { useState, type ComponentProps, type ReactNode } from 'react' import { SessionId } from '@deepseek-ai/dsh-session/types' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import type { MessageImageLoader, MessageImagesOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { PropsRenderFactories, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { TrajectoryImagesView } from '../src/client/TrajectoryImagesView.tsx' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx' import { deriveTrajectoryLayout, type TrajectoryTurnModel } from '../src/client/layout.ts' import { trajectoryRecordId } from '../src/client/trajectory-record.ts' import { t, tZh } from './locale.client.ts' const loadImage: MessageImageLoader = async () => 'blob:trajectory' -const renderNoFactories: PropsRenderFactories['renderFactorySlot'] = () => null +const renderNoImages: PropsRenderSlots<'conversation.trajectory.images'>['renderSlot'] = () => null const RecordImages = ({ images }: MessageImagesOwnerProps) => (
    @@ -29,30 +28,26 @@ const RecordImages = ({ images }: MessageImagesOwnerProps) => (
    ) -async function imageFactory() { +async function imageSlotFixture() { const runtime = await SlotTestRuntime.create() onTestFinished(() => runtime.dispose()) - runtime.slots.registerFactory({ - name: 'conversation.trajectory.images.view', scope: 'session', - children: { 'conversation.trajectory.images': { kind: 'single', scope: 'session' } }, - }, TrajectoryImagesView) - runtime.slots.register({ name: 'conversation.trajectory.images' }, RecordImages) await runtime.sessions.add({ id: 'trajectory-images' }) const session = runtime.sessions.retainFor(runtime.ctx, SessionId('trajectory-images')) - type Seats = PropsRenderFactories & Pick, 'SessionProvider'> + type Seats = PropsRenderSlots<'conversation.trajectory.images'> const captured: { seats?: Seats; setContent?: (content: ReactNode) => void } = {} - await runtime.root.declare({ 'conversation.view': { kind: 'list', scope: 'session' } }, function TestRoot(props) { + await runtime.root.declare({ 'conversation.trajectory.images': { kind: 'single', scope: 'session' } }, function TestRoot(props) { const [content, setContent] = useState(null) captured.seats = props captured.setContent = setContent const { SessionProvider } = props return {content} }) + runtime.slots.register({ name: 'conversation.trajectory.images' }, RecordImages) const view = runtime.renderRoot() const { seats, setContent } = captured if (seats === undefined || setContent === undefined) throw new Error('trajectory image fixture root did not render') return { - renderFactorySlot: seats.renderFactorySlot, + renderSlot: seats.renderSlot, render: (content: ReactNode) => { act(() => { setContent(content) }) return view @@ -63,8 +58,8 @@ async function imageFactory() { type TableProps = ComponentProps function TrajectoryTable( - props: Omit - & Partial>, + props: Omit + & Partial>, ) { const inferred: Array[number] & { firstIndex: number }> = [] for (const turn of props.turns) { @@ -98,7 +93,7 @@ function TrajectoryTable( .map(({ firstIndex: _firstIndex, ...request }, index) => ({ ...request, number: index + 1 })) return ( { render( ()} onToggleTurn={() => {}} @@ -1003,7 +998,7 @@ describe('TrajectoryTable', () => { }] const table = (status: 'running' | 'complete' | 'error') => ( { partial: null, runningCalls: [], }, translate) - const { render, renderFactorySlot: factorySlot } = await imageFactory() - const renderFactorySlot = vi.fn(factorySlot) + const { render, renderSlot: slotRenderer } = await imageSlotFixture() + const renderSlot = vi.fn((name: 'conversation.trajectory.images', owner: object) => slotRenderer(name, owner as MessageImagesOwnerProps)) const view = render() + renderSlot={renderSlot} loadImage={loadImage} />) fireEvent.click(view.container.querySelector('[data-trajectory-row-key]')!) for (const tab of [summaryTab, previewTab]) { fireEvent.click(screen.getByRole('tab', { name: tab })) @@ -1184,7 +1179,7 @@ describe('TrajectoryTable', () => { expect(names).toEqual([imageLabel, file.name, imageLabel.replace('1', '2')]) expect(within(list).getByText('TXT · 0B')).toBeTruthy() expect(within(list).getAllByText('image/png · 68B · 40 × 800')).toHaveLength(2) - expect(renderFactorySlot).toHaveBeenCalledWith('conversation.trajectory.images.view', { + expect(renderSlot).toHaveBeenCalledWith('conversation.trajectory.images', { images: [{ attachment, label: imageLabel }], align: 'start', thumbnail: true, loadImage, }) } @@ -1203,7 +1198,7 @@ describe('TrajectoryTable', () => { ) it('renders a tool-result image through the shared gallery in the Result tab', async () => { - const { render, renderFactorySlot } = await imageFactory() + const { render, renderSlot } = await imageSlotFixture() const attachment = { attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), mediaType: 'image/png', @@ -1229,7 +1224,7 @@ describe('TrajectoryTable', () => { }], }] - render() + render() fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) fireEvent.click(screen.getByRole('tab', { name: 'Result' })) @@ -1240,7 +1235,7 @@ describe('TrajectoryTable', () => { }) it('keeps the failure name beside an image-only error result', async () => { - const { render, renderFactorySlot } = await imageFactory() + const { render, renderSlot } = await imageSlotFixture() const attachment = { attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), mediaType: 'image/png', @@ -1267,7 +1262,7 @@ describe('TrajectoryTable', () => { }], }] - render() + render() fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) fireEvent.click(screen.getByRole('tab', { name: 'Result' })) diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 0428ddeab3..2f5dd4a45e 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -7,7 +7,7 @@ * event ledger with its timing overview, and fiber disposal removes the tab. * Timeline projection and inclusive focus edge cases ride along. */ -import type { GlobalStandardProps, PropsRenderFactories } from '@deepseek-ai/dsh-client-ui-slots' +import type { GlobalStandardProps, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' @@ -244,7 +244,7 @@ function standaloneProps( openView: () => {}, completeViewRequest: () => {}, // Image seats the outlet would bake: standalone renders omit the gallery. - renderFactorySlot: () => null, + renderSlot: () => null, loadImage: () => Promise.reject(new Error('standalone views load no images')), // The locale seat the outlet would inject for the declared namespace. t: tZh, @@ -393,8 +393,8 @@ function mount(fixture: Awaited>) { } })() : injected - const viewProps: ConvViewProps & PropsRenderFactories = { - ...owner, ...standardProps, renderFactorySlot: () => null, + const viewProps: ConvViewProps & Pick, 'renderSlot'> = { + ...owner, ...standardProps, renderSlot: () => null, } return ( 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:441', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:439', }, { key: 'conversation.chat.commandview', @@ -224,7 +224,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: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:423', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:421', }, { key: 'conversation.chat.flow', @@ -267,7 +267,50 @@ 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.flow\', () => ctx.slots.register(\n { name: \'conversation.chat.flow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:392', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:388', + }, + { + key: 'conversation.chat.message.images', + kind: 'single', + scope: 'session', + summary: 'Renderer for one consecutive group of durable message images.', + doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', + registerOptions: [], + ownerProps: [ + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n /** Fixed, uncropped thumbnail for an attachment list row. */\n thumbnail?: boolean\n}', + ], + ownerPropsReferences: [ + 'Message', + 'MessageImageLoader', + 'MessageImageSource', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-chat), 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.chat.message.images\', () => ctx.slots.register(\n { name: \'conversation.chat.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:413', }, { key: 'conversation.chat.node', @@ -337,7 +380,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: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:404', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:400', }, { key: 'conversation.chat.reasoning.body', @@ -377,7 +420,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.reasoning.body\', () => ctx.slots.register(\n { name: \'conversation.chat.reasoning.body\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:429', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:427', }, { key: 'conversation.chat.turnTail', @@ -439,7 +482,50 @@ 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\', 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:435', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:433', + }, + { + key: 'conversation.chat.user.images', + kind: 'single', + scope: 'session', + summary: 'User-message images, including pending and local-echo previews, independently replaceable from output images.', + doc: 'User-message images, including pending and local-echo previews, independently replaceable from output images.', + registerOptions: [], + ownerProps: [ + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n /** Fixed, uncropped thumbnail for an attachment list row. */\n thumbnail?: boolean\n}', + ], + ownerPropsReferences: [ + 'Message', + 'MessageImageLoader', + 'MessageImageSource', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'factory \'conversation.chat.user.content\' (client-ui-chat), so it exists while that definition is registered', + occupants: [ + 'client-ui-attachment MessageImages', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.user.images\', () => ctx.slots.register(\n { name: \'conversation.chat.user.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:415', }, { key: 'conversation.composer', @@ -1213,49 +1299,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 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:276', }, - { - key: 'conversation.message.images', - kind: 'single', - scope: 'session', - summary: 'Renderer for one consecutive group of durable message images.', - doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', - registerOptions: [], - ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n /** Fixed, uncropped thumbnail for an attachment list row. */\n thumbnail?: boolean\n}', - ], - ownerPropsReferences: [ - 'Message', - 'MessageImageLoader', - 'MessageImageSource', - ], - standardProps: [ - 'useResource: UseResource', - 'useWorkspaces: SnapshotSelectorHook', - 'usePanelInfo: UsePanelInfo', - 'useSessions: UseSessions', - 'useSessionStatus: UseSessionStatus', - 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', - 'useChat: UseChat', - 'useConversation: UseConversation', - 'useInput: SnapshotSelectorHook', - 'inputActions: InputActions', - 'useSession: SessionSnapshotSelector', - 'sessionId: SessionId', - 'useProjection: UseProjection', - 'useTrajectory: UseTrajectory', - ], - keyDomain: '', - hookContext: '', - slotInject: '', - declaredBy: 'factory \'conversation.message.images.view\' (client-ui-chat), so it exists while that definition is registered', - occupants: [ - 'client-ui-attachment MessageImages', - ], - 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:417', - }, { key: 'conversation.plan-review.actions', kind: 'list', @@ -1624,13 +1667,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'factory \'conversation.trajectory.images.view\' (client-ui-trajectory), so it exists while that definition is registered', + 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:112', + source: 'packages/client/ui-trajectory/src/client/trajectory-contract.ts:103', }, { key: 'conversation.user.text.node', @@ -3071,7 +3114,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.quota-notice\', () => ctx.slots.register(\n { name: \'shell.quota-notice\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:449', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:447', }, { key: 'sidebar',