diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 3e3e892063..c24d181e58 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: ea234e557553dda03ef2b707b60d47d3f43eb8f8 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: be478ece73039201a820d804435610faea4cb3fe +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: f1fbcbd29b188505e647265c4c974886c066e936 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: a55467cacc412b6f313dd932a0e4868965c039ea diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index ea234e5575..f1fbcbd29b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -18,17 +18,17 @@ Peer products converge on an attachment rail above the editor, but their storage Pasted or dropped raster images are the Web composer's first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. The host validates and durably commits every accepted user image before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral `ImageBlock` references. -Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on double-click. File picking, generic files, PDF, audio, video, image copying, and a custom context menu remain separate follow-ups. +Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on a single click (display and interaction specifics superseded in part by the [attachment-display alignment note](2026-08-11-web-attachment-display-alignment.md)). File picking, generic files, PDF, audio, video, image copying, and a custom context menu remain separate follow-ups. ### Product behavior - Pasting or dropping one or more supported images adds ordered thumbnails above the textarea without inserting placeholder text. Dragging files over the composer highlights the drop target. - The same resident `InputBar` renders the rail in both blank-session Hero and active-session layouts. The rail is hidden when empty and scrolls horizontally instead of widening the composer. -- Each approximately 72-by-72-pixel thumbnail has a remove action and opens its original draft image on double-click. +- Each 64-by-64-pixel thumbnail carries a hover-revealed remove control inside the card and opens its original draft image on a single click; overflow pages with edge arrows instead of a visible scrollbar. - A prompt may contain text and images or images only. Pure text paste remains native browser behavior; mixed clipboard content inserts its text normally while adding its files to the rail, and file-only paste prevents default browser handling. File drops on the composer always prevent browser navigation and report unsupported files locally. - A failed send restores the complete text and image draft without clobbering text or images added while the request was in flight. Removal, successful send, session-scope disposal, rendered-history disposal, and application disposal revoke the object URLs they own. - Historical user and assistant images use one `MessageImage` control. Inline images preserve intrinsic aspect ratio, do not upscale, and stay within a 240-by-240-pixel box. -- Double-clicking a message image opens the stored original in a viewport-bounded modal. Escape, the close control, and backdrop activation close it and restore focus. +- Clicking a message image opens the stored original in a viewport-bounded modal. Escape, the close control, and backdrop activation close it and restore focus. - Version one does not override the browser context menu and provides no explicit image-copy action. ### Storage lifecycle and ownership @@ -122,7 +122,7 @@ Base64 crosses JSON-RPC once and is discarded after persistence. The host valida Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. +The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection announces through the composer's transient toast. The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. @@ -163,7 +163,7 @@ The attachment packages form the interface/implementation side of one capability ### Implementation -The implemented slice includes the attachment seam, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, compaction handling, and keyless assembled Web coverage. +The implemented slice includes the attachment seam, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index be478ece73..a55467cacc 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -18,17 +18,17 @@ Status: implemented 粘贴或拖放的光栅图片是 Web 输入区对持久附件能力的首个应用场景。未发送文件仍是由客户端持有的临时草稿状态。宿主在追加相应消息事件前,校验并持久提交每张已接受的用户图片。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。 -第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF,支持仅图片或混合提示词,支持渲染历史用户图片与助手图片,并支持双击预览原图。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单仍分别作为后续工作。 +第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF,支持仅图片或混合提示词,支持渲染历史用户图片与助手图片,并支持单击预览原图(展示与交互细节部分由[附件展示对齐 Note](2026-08-11-web-attachment-display-alignment.md)取代)。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单仍分别作为后续工作。 ### 产品行为 - 粘贴或拖放一张或多张受支持的图片后,文本框上方会按顺序显示缩略图,但不会插入占位文本。文件拖入输入区时会高亮放置目标。 - 同一个常驻 `InputBar` 会在空白会话 Hero 和活跃会话布局中渲染附件栏。附件栏为空时隐藏,通过横向滚动避免撑宽输入区。 -- 每个缩略图约为 72 × 72 像素,带有移除操作;双击时打开草稿原图。 +- 每个缩略图为 64 × 64 像素,移除按钮位于卡片内部、悬停时显示;单击打开草稿原图,溢出用两端箭头翻页而非可见滚动条。 - 提示词可同时包含文本与图片,也可仅包含图片。粘贴纯文本时保持浏览器原生行为;粘贴混合的剪贴板内容时,文本会正常插入,文件则同时添加到附件栏;仅粘贴文件时才阻止浏览器的默认处理。在输入区放置文件时总会阻止浏览器导航,并在本地报告不受支持的文件。 - 发送失败时恢复完整的文本与图片草稿,但不会覆盖请求飞行期间新增的文本或图片。移除、发送成功、会话 scope 释放、已渲染历史记录释放和应用释放都会撤销各自持有的对象 URL。 - 历史用户图片与助手图片共用一个 `MessageImage` 控件。行内图片保持固有宽高比、不放大,并限制在 240 × 240 像素的边界框内。 -- 双击消息图片会在不超出视口的模态框中打开存储的原图。按 Escape、激活关闭控件或激活背景区域都会关闭模态框并恢复焦点。 +- 单击消息图片会在不超出视口的模态框中打开存储的原图。按 Escape、激活关闭控件或激活背景区域都会关闭模态框并恢复焦点。 - 第一版不覆盖浏览器上下文菜单,也不提供明确的图片复制操作。 ### 存储生命周期与归属 @@ -122,7 +122,7 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规 模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 的短时 toast 播报。 Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 @@ -163,7 +163,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 实现 -已实现的范围包括附件服务边界、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、双击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。 +已实现的范围包括附件服务边界、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml new file mode 100644 index 0000000000..24bd9f6081 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md +2026-08-11-web-attachment-display-alignment.md: 18c732c078ef6efd17e5c83708ba6e68ac663ce6 +2026-08-11-web-attachment-display-alignment.zh.md: 8a4222c31cd1fa2ad75ca314513c057fdb780d07 diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md new file mode 100644 index 0000000000..18c732c078 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md @@ -0,0 +1,33 @@ +# Agent Note: Web attachment display aligns with DeepSeek Chat via attachment atoms + +Status: implemented + +English | [中文](2026-08-11-web-attachment-display-alignment.zh.md) + +## Problem + +The web composer's image surfaces missed basic usability (user feedback, issue #2248). The remove control hung outside each 72px thumbnail at `top/right: -6px`, so the rail's `overflow-x` box clipped it and clicks aimed at it often missed; previews opened only on double-click, an affordance nothing advertised except a tooltip; a rail wider than the composer produced a raw horizontal scrollbar inside the capsule; and image-intake rejections plus prompt failures (for example `attachment-error` when the selected model takes no image input) rendered as persistent inline red strips above the card. Every one of these surfaces already has a settled design in DeepSeek Chat that users know: single-click preview, an inside-the-card hover-revealed remove control, hidden-scrollbar arrow paging, and a transient top-center toast. + +The first multimodal ship recorded these surfaces in the [web multimodal note](2026-07-22-web-multimodal-image-input-and-durable-attachments.md); this note supersedes its display and interaction specifics (thumbnail geometry, click affordance, error presentation) while its attachment seam, admission, and durability decisions stand. + +All of this UI also lived inside `dsh-client-ui-conversation` — the rail inline in the 700-line `InputBar`, the history image and lightbox in `chat/` and `skeleton/` — with no seam that another surface could reuse and nothing enforcing the pure-props discipline the pieces already had. + +## Decision + +Attachment display lives in a new zero-cordis atoms package, `@deepseek-ai/dsh-client-ui-attachment` (`packages/client/ui-attachment`), patterned on `dsh-client-ui-primitives`: `AttachmentRail` (64px/16px-radius thumbnails, single-click `onOpen`, inside-the-card remove control revealed on hover or focus and permanent under `pointer: coarse`, hidden scrollbar with circular edge arrows recomputed from scroll geometry, vertical-wheel horizontal pan clamped to 60px/tick, end-reveal on growth), `MessageImage`/`ImageGallery` (single-click preview), and `ImageLightbox`. Strings arrive as label props; `ui-conversation` bridges its `conversation` dictionary through `src/client/image-labels.ts` and keeps the machine wiring (draft ids, preview state, intake callbacks). The cross-package import is sanctioned exactly because the package is an atoms library, not a client plugin: plugin-to-plugin component imports stay forbidden, and the composer's rail is composer-owned rendering, not a slot. + +Both overlays body-portal: the lightbox opened from a chat message sits under transformed ancestors that would trap `position: fixed` in their own box (the backdrop covered only the chat column), so `ImageLightbox` and `Toast` render through `createPortal(document.body)` and cover the viewport from every opener. The transient banner is a `ui-primitives` `Toast` atom (120px from the viewport top, horizontally centered over its optional anchor — the composer card, so it sits over the chat column — `role="alert"`, `pointer-events: none`, three-second hold then one-second fade, `onDone` unmount, keyed per show so identical repeated messages re-announce). `InputBar` routes both intake rejections (`addImages`'s returned reason) and `promptError` through it, replacing the inline strips, and `ModelSelect` routes rejected model selections through the same atom while its in-menu strip with Retry stays the catalog-load surface; the machine-notice strip is untouched. DeepSeek Chat's source (a local reference copy) provided the target behaviors: its `ImageThumbnailInInput` (64px cards, opacity-transition delete), `ScrollArrows` (sentinel-driven paging), and `useToast` usage. + +## Alternatives considered + +**Keep the components inside `ui-conversation` and only restyle.** Rejected by the user: the attachment surface is expected to grow (file cards, upload progress), and the repo's plugin discipline forbids other plugins importing `ui-conversation` internals, so growth inside the plugin builds an unreusable pile. The atoms package gives the same components a sanctioned import path. + +**A `ui-attachment` client plugin registering slots.** Rejected: the rail renders inside the composer the machine owns and the gallery inside chat nodes; neither is a composition hole another plugin should fill, and a plugin would force slot indirection for what are pure presentational components. + +**Toast inside `ui-conversation`.** Rejected: nothing about a transient banner is conversation-specific, and `ui-primitives` is the established home for zero-cordis atoms other surfaces may reuse. + +**Keep inline error strips and only add the toast for image intake.** Rejected: `promptError` (the `attachment-error` screenshot in the issue) is the surface users actually complained about, and two error presentations in one composer would leave the strip as the odd survivor. + +## Consequences + +The composer and history image surfaces now match DeepSeek Chat's interaction model, and the label-prop seam means the atoms render under any locale without reaching for one. The cost is a real package boundary: `ui-attachment` carries the standard scaffolding (invariant companion, bilingual README, tsconfig face, per-file 100% coverage) and item strings must be resolved by every future consumer rather than inherited. Error banners are now transient — a user who looks away for four seconds misses the message, the trade DeepSeek Chat itself makes. Non-image attachments remain unsupported; the rail's card model is ready for them but the composer's intake is image-only (tracked in the package README's limitations). diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md new file mode 100644 index 0000000000..8a4222c31c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web 附件展示经附件原子组件对齐 DeepSeek Chat + +Status: implemented + +[English](2026-08-11-web-attachment-display-alignment.md) | 中文 + +## 问题 + +Web 输入框的图片界面缺乏基本可用性(用户反馈,issue #2248)。删除按钮以 `top/right: -6px` 挂在 72px 缩略图外侧,被附件栏的 `overflow-x` 盒子裁切,点击经常落空;预览只能双击打开,除了 tooltip 没有任何提示这个操作;附件栏超出输入框宽度时在胶囊内部直接出现原生横向滚动条;图片接收被拒和发送失败(例如所选模型不支持图片输入时的 `attachment-error`)以常驻的内联红条显示在卡片上方。这些界面在 DeepSeek Chat 里都有用户熟悉的既定设计:单击预览、卡片内部悬停显示的删除按钮、隐藏滚动条的箭头翻页、顶部居中的短时 toast。 + +首个多模态版本把这些界面记录在[Web 多模态 Note](2026-07-22-web-multimodal-image-input-and-durable-attachments.md)中;本 Note 取代其中的展示与交互细节(缩略图几何、点击方式、错误呈现),其附件服务边界、准入与持久化决策继续有效。 + +这些 UI 还全部住在 `dsh-client-ui-conversation` 里——附件栏内联在 700 行的 `InputBar` 中,历史图片和灯箱分散在 `chat/` 与 `skeleton/`——没有其他界面可复用的接缝,纯 props 的纪律也无从约束。 + +## 决定 + +附件展示落位到新的零 cordis 原子组件包 `@deepseek-ai/dsh-client-ui-attachment`(`packages/client/ui-attachment`),模式照 `dsh-client-ui-primitives`:`AttachmentRail`(64px、16px 圆角缩略图,单击 `onOpen`,卡片内部的删除按钮悬停或聚焦显示、`pointer: coarse` 下常显,隐藏滚动条配两端圆形箭头并依滚动几何重算,纵向滚轮转横向平移且单次钳制 60px,新增条目滚到栏尾),`MessageImage`/`ImageGallery`(单击预览),以及 `ImageLightbox`。文案经 label props 传入;`ui-conversation` 通过 `src/client/image-labels.ts` 桥接 `conversation` 词典,并保留状态机接线(草稿 id、预览状态、接收回调)。跨包 import 之所以是被允许的路径,正因为它是原子组件库而非 client 插件:插件之间仍禁止互相 import 组件,且附件栏是输入框自有的渲染,不是插槽。 + +两个浮层都 portal 到 body:从聊天消息打开的灯箱位于带 transform 的祖先之下,`position: fixed` 会被困在祖先的盒子里(遮罩只盖住聊天列),因此 `ImageLightbox` 与 `Toast` 经 `createPortal(document.body)` 渲染,从任何打开位置都覆盖整个视口。短时横幅是 `ui-primitives` 的 `Toast` 原子(距视口顶部 120px,水平中心跟随可选锚点——composer 卡片,因此横幅在聊天列上居中——`role="alert"`、`pointer-events: none`,停留三秒再一秒淡出,`onDone` 卸载,按展示序号作 key 使相同文案重新播报)。`InputBar` 把接收拒绝(`addImages` 返回的原因)和 `promptError` 都改走 toast,替换内联红条,`ModelSelect` 的模型选择被拒也走同一原子,其菜单内带 Retry 的错误条仍是目录加载的呈现面;状态机 notice 条不受影响。DeepSeek Chat 源码(本地参考副本)提供了目标行为:其 `ImageThumbnailInInput`(64px 卡片、透明度过渡的删除钮)、`ScrollArrows`(哨兵驱动的翻页)与 `useToast` 用法。 + +## 备选方案 + +**组件留在 `ui-conversation` 里只改样式。** 被用户否决:附件面预期还会长(文件卡片、上传进度),而仓库的插件纪律禁止其他插件 import `ui-conversation` 内部实现,在插件里生长只会堆出无法复用的一坨。原子组件包给了同样的组件一条被允许的 import 路径。 + +**做成注册插槽的 `ui-attachment` client 插件。** 否决:附件栏渲染在状态机持有的输入框里,画廊渲染在聊天节点里,二者都不是该由其他插件填充的组合孔位,插件形态会为纯展示组件强加插槽间接层。 + +**Toast 放在 `ui-conversation`。** 否决:短时横幅没有任何会话特有的东西,`ui-primitives` 是零 cordis 原子组件的既定归属,其他界面也可能复用。 + +**保留内联红条,只给图片接收加 toast。** 否决:`promptError`(issue 截图里的 `attachment-error`)恰是用户实际抱怨的界面,一个输入框里存在两种错误呈现会让红条成为孤例。 + +## 结果 + +输入框与历史图片界面的交互模型现已与 DeepSeek Chat 一致,label props 接缝让原子组件在任何语言环境下渲染而无需触达 locale。代价是一个真实的包边界:`ui-attachment` 背上标准脚手架(invariant 伴生、双语 README、tsconfig face、逐文件 100% 覆盖率),且每个未来消费者都要自行解析条目文案而非继承。错误横幅变为短时——用户移开视线四秒就会错过消息,这正是 DeepSeek Chat 自己做的取舍。非图片附件仍不支持;附件栏的卡片模型已就绪,但输入框的接收仍只认图片(记录于包 README 的限制一节)。 diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index df286ec1c3..2d2dda42d6 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -4,7 +4,7 @@ // Opens the fixture history session whose turn 72 carries an image in BOTH a // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized -// sessions.attachment route, the double-click ImageLightbox, and the composer +// sessions.attachment route, the single-click ImageLightbox, and the composer // intake chain (paste → ordered thumbnail rail → image-only send enablement → remove). import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { expect, it } from 'vitest' @@ -66,10 +66,10 @@ it('renders the history image pair through the authorized attachment route and o `) const userImage = document.querySelector('[data-align="end"] img')! - // Double-click opens the original-size lightbox; Escape/close dismisses it. + // A single click opens the original-size lightbox; Escape/close dismisses it. const frame = userImage.closest('button') if (frame === null) throw new Error('image frame button missing') - fireEvent.doubleClick(frame) + fireEvent.click(frame) const lightbox = await screen.findByRole('dialog') expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob') fireEvent.click(within(lightbox).getByRole('button', { name: /Close/ })) @@ -133,4 +133,18 @@ it('accepts pasted images into the composer rail in order and removes them', asy await waitFor(() => { expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() }) + + // An unsupported file announces a transient toast (the inline strip is + // gone) and the banner dismisses itself after its hold-and-fade lifetime. + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'notes.txt', { type: 'text/plain' }) }], + getData: () => '', + }, + }) + const toast = await screen.findByRole('alert') + expect(toast.textContent).toContain('Unsupported image format: text/plain') + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeNull() + }, { timeout: 6_000 }) }) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 08e12b2e97..7b9ef7a15b 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -143,6 +143,7 @@ export default defineConfig({ { find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') }, + { find: /^@deepseek-ai\/dsh-client-ui-attachment$/, replacement: src('../../packages/client/ui-attachment/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-schema-form$/, replacement: src('../../packages/client/schema-form/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') }, ], diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 65155655e2..db8ddb38f4 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 5bebd0f2f9ed004afbf48e4d7b235cc89362aa67 -config-catalog.zh.md: a30074338f140b5807394bc21ea7a1e040730814 +config-catalog.md: 428a450f8f1686411afb9ffa22a0b6e4d552cd77 +config-catalog.zh.md: c634eba943498d0a5917f924fbd0afe68e6ada38 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5bebd0f2f9..428a450f8f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2834,6 +2834,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-client-schema-form` ([`packages/client/schema-form/src/index.ts`](../packages/client/schema-form/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a30074338f..c634eba943 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2834,6 +2834,7 @@ export interface Config { - `@deepseek-ai/dsh-brand`([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-client-schema-form`([`packages/client/schema-form/src/index.ts`](../packages/client/schema-form/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime`([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives`([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots`([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web`([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 290dd135bb..4a2a376551 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: dcba0a2401afa252fb6d3b81e4e1728784fd3cac -module-graph.zh.md: 862401c987e33cd6223804a240aa509373981c16 +module-graph.md: 9a52f23d1d3e9cc6b01e345535cd972affc439a1 +module-graph.zh.md: 5975a6cc98cb589e44c82741bce176bec9fa3f7c diff --git a/docs/module-graph.md b/docs/module-graph.md index dcba0a2401..9a52f23d1d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -149,6 +149,7 @@ flowchart TD pkg_client_schema_form["client-schema-form"] pkg_client_test_runtime["client-test-runtime"] pkg_client_ui_agent_preset["client-ui-agent-preset"] + pkg_client_ui_attachment["client-ui-attachment"] pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] @@ -318,6 +319,7 @@ flowchart TD pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_schema_form --> pkg_invariants + pkg_client_ui_attachment --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_slots --> pkg_invariants pkg_client_web --> pkg_invariants @@ -1143,6 +1145,7 @@ flowchart TD pkg_client_ui_conversation --> pkg_client_connection pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_attachment pkg_client_ui_conversation --> pkg_client_ui_primitives pkg_client_ui_conversation --> pkg_client_ui_settings pkg_client_ui_conversation --> pkg_client_ui_slash @@ -1311,6 +1314,7 @@ flowchart TD | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | @@ -1484,7 +1488,7 @@ flowchart TD | [`jsonrpc`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 862401c987..5975a6cc98 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -151,6 +151,7 @@ flowchart TD pkg_client_schema_form["client-schema-form"] pkg_client_test_runtime["client-test-runtime"] pkg_client_ui_agent_preset["client-ui-agent-preset"] + pkg_client_ui_attachment["client-ui-attachment"] pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] @@ -320,6 +321,7 @@ flowchart TD pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_schema_form --> pkg_invariants + pkg_client_ui_attachment --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_slots --> pkg_invariants pkg_client_web --> pkg_invariants @@ -1145,6 +1147,7 @@ flowchart TD pkg_client_ui_conversation --> pkg_client_connection pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_attachment pkg_client_ui_conversation --> pkg_client_ui_primitives pkg_client_ui_conversation --> pkg_client_ui_settings pkg_client_ui_conversation --> pkg_client_ui_slash @@ -1313,6 +1316,7 @@ flowchart TD | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | @@ -1486,7 +1490,7 @@ flowchart TD | [`jsonrpc`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 254fefeac9..842313125a 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: 42258a961b522271cf61367856c162a7312c79f2 -README.zh.md: 5ecebbfd1e3c8aaa294e3bfd1bf943600ab3366e +README.md: a349b79783fae8fcfc9b3af7d3e96de779514d01 +README.zh.md: 342442001bd268048de901233fc1913027b6d70f diff --git a/packages/client/README.md b/packages/client/README.md index 42258a961b..a349b79783 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -18,6 +18,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots. | | [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme. | | [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers. | +| [`ui-attachment/`](ui-attachment/README.md) | Provides attachment display atoms: draft-image rail, message gallery, and lightbox. | | [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions. | | [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. | | [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 5ecebbfd1e..342442001b 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -18,6 +18,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-slots/`](ui-slots/README.md) | 定义 UI 功能注册和组合扩展 slot 的方式。 | | [`ui-theme/`](ui-theme/README.md) | 应用所选颜色主题。 | | [`ui-primitives/`](ui-primitives/README.md) | 提供共享 React 控件、图标和内容渲染器。 | +| [`ui-attachment/`](ui-attachment/README.md) | 提供附件展示原子组件:草稿图片栏、消息画廊与灯箱。 | | [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 | | [`ui-sidebar/`](ui-sidebar/README.md) | 展示 Workspace 与会话导航。 | | [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 | diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml new file mode 100644 index 0000000000..faea84bfdb --- /dev/null +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md +README.md: 9fab9c23b958606030b1e87fcbfa45130c980947 +README.zh.md: 668dba11154538f52a9a87692020868c1b8a63d5 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md new file mode 100644 index 0000000000..9fab9c23b9 --- /dev/null +++ b/packages/client/ui-attachment/README.md @@ -0,0 +1,27 @@ +# @deepseek-ai/dsh-client-ui-attachment + +English | [中文](README.zh.md) + +Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), and the original-image lightbox (`ImageLightbox`). Every string arrives through label props resolved by the owning plugin's own locale namespace, and nothing here reads application state; `@deepseek-ai/dsh-client-ui-conversation` is the current consumer, bridging its `conversation` dictionary through its `image-labels` module. + +## Attachment rail + +`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling (instant under `prefers-reduced-motion: reduce`), and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and rail size changes (a ResizeObserver on the rail element, so sidebar and panel resizes count, not only window resizes). The rail scrolls horizontally only: a non-passive listener consumes every wheel tick with a vertical component — nothing scrolls the conversation behind the composer — converting a pure vertical wheel to a horizontal step (LINE/PAGE deltas normalized to pixels, per-tick travel clamped to 60px) and keeping a diagonal pan's horizontal intent, while purely horizontal pans stay native. A newly added item is revealed at the rail's end; removal keeps the scroll position, and a rail that mounts over an already-populated draft keeps its start position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist. + +## Message images and the lightbox + +`MessageImage` renders one durable history image bounded to 240px on its longer edge, loading a session-authorized URL through the owner's `ImageLoader`; a failed load renders an explicit retry control, and a settled load answers a single click by opening `ImageLightbox` (clicks during loading are ignored). `ImageGallery` wraps a message's images in one aligned flex group (`end` for user messages, `start` for assistant messages) and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview that closes on Escape, a backdrop press, or its close control, and restores focus to its opener on unmount. + +## Model Experience + +None, as the package renders pure React atoms in the browser; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Images only** — non-image files have no rail card or history renderer yet; DeepSeek Chat-style file cards and upload-progress states wait until the composer accepts non-image attachments. +- **No zoom or download in the lightbox** — the preview renders the original at fit-to-viewport size only. +- **The lightbox does not trap focus** — it sets `aria-modal` and restores focus on close, but Tab can reach the page behind it (behavior carried over from the pre-package component). diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md new file mode 100644 index 0000000000..668dba1115 --- /dev/null +++ b/packages/client/ui-attachment/README.zh.md @@ -0,0 +1,27 @@ +# @deepseek-ai/dsh-client-ui-attachment + +[English](README.md) | 中文 + +纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)与原图灯箱(`ImageLightbox`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。 + +## 附件栏 + +`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动(`prefers-reduced-motion: reduce` 下瞬时完成),箭头的显隐在滚动、条目数量变化和栏自身尺寸变化时依据滚动几何重算(rail 元素上的 ResizeObserver,因此侧栏、面板的宽度变化也计入,不只是窗口尺寸变化)。附件栏只允许横向滚动:非 passive 监听器消费所有带纵向分量的滚轮事件——不会滚动输入框背后的会话记录——纯纵向滚轮转为横向步进(LINE/PAGE 单位先归一化为像素,单次行程钳制在 60px 内),对角平移保留其横向分量,纯横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位,带着已有草稿重新挂载的栏保持起始位置。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。 + +## 消息图片与灯箱 + +`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。 + +## 模型体验 + +无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **仅支持图片** — 非图片文件尚无附件栏卡片与历史渲染;DeepSeek Chat 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。 +- **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。 +- **灯箱不锁定焦点** — 它设置 `aria-modal` 并在关闭时归还焦点,但 Tab 仍可移动到背后的页面(沿袭入包前组件的行为)。 diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json new file mode 100644 index 0000000000..e894a42196 --- /dev/null +++ b/packages/client/ui-attachment/package.json @@ -0,0 +1,51 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-attachment", + "description": "Pure React attachment atoms for the dsh web UI: draft-image rail, message image gallery, and original-image lightbox (zero cordis)", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-attachment" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "clsx": "^2.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" + } +} diff --git a/packages/client/ui-attachment/src/AttachmentRail.module.css b/packages/client/ui-attachment/src/AttachmentRail.module.css new file mode 100644 index 0000000000..ac8968f777 --- /dev/null +++ b/packages/client/ui-attachment/src/AttachmentRail.module.css @@ -0,0 +1,118 @@ +/* Thumbnail geometry mirrors DeepSeek Chat's composer rail: 64px cards with a + 16px radius, remove control fully inside the card, arrows overlaid at the + edges instead of a scrollbar. */ + +.root { + position: relative; + min-width: 0; +} + +.rail { + display: flex; + gap: 10px; + overflow-x: auto; + overflow-y: hidden; + /* Edge arrows page the overflow; the scrollbar stays hidden (both engines). */ + scrollbar-width: none; + /* The rail scrolls on the composer's elevated input surface: bind the l2 + pair (ui-theme styles/scrollbar.css rebinding contract) so anything that + does draw a thumb here matches the surface. */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.rail::-webkit-scrollbar { + display: none; +} + +.item { + position: relative; + flex: 0 0 64px; + width: 64px; + height: 64px; +} + +.thumbnail { + width: 64px; + height: 64px; + padding: 0; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 16px; + background: var(--dsw-alias-interactive-bg-hover); + cursor: zoom-in; +} + +.thumbnail img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.remove { + position: absolute; + top: 4px; + right: 4px; + z-index: 1; + display: grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--dsw-alias-button-contrast-fill); + color: var(--dsw-alias-label-primary-inverted); + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +.item:hover .remove, +.remove:focus-visible { + opacity: 1; +} + +/* Touch surfaces have no hover to reveal the control. */ +@media (pointer: coarse) { + .remove { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .remove { + transition: none; + } +} + +.arrow { + position: absolute; + top: 50%; + z-index: 2; + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 999px; + background: var(--dsw-specific-input-major); + color: var(--dsw-alias-label-secondary); + box-shadow: var(--dsw-shadow-lv2); + cursor: pointer; + transform: translateY(-50%); +} + +.arrow:hover { + background: var(--dsw-alias-interactive-bg-hover-solid); +} + +.arrowLeft { + left: 4px; +} + +.arrowRight { + right: 4px; +} diff --git a/packages/client/ui-attachment/src/AttachmentRail.tsx b/packages/client/ui-attachment/src/AttachmentRail.tsx new file mode 100644 index 0000000000..65e2df109b --- /dev/null +++ b/packages/client/ui-attachment/src/AttachmentRail.tsx @@ -0,0 +1,200 @@ +/** Draft-attachment thumbnail rail: scrollbar-less horizontal overflow paged + * by edge arrows, hover-revealed per-item remove, single-click open. */ + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import clsx from 'clsx' +import { + IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14, +} from '@deepseek-ai/dsh-client-ui-primitives' +import css from './AttachmentRail.module.css' + +/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */ +export interface AttachmentRailItem { + /** Stable identity for the React key. */ + id: string + /** Object or data URL rendered as the thumbnail. */ + previewUrl: string + /** Image alt text (display name with the owner's fallback applied). */ + alt: string + /** Accessible label of the item's remove control. */ + removeLabel: string +} + +/** Rail-level strings the owner resolves from its own locale namespace. */ +export interface AttachmentRailLabels { + /** Accessible name of the rail group. */ + group: string + /** Thumbnail tooltip inviting the original-image preview. */ + open: string + /** Accessible label of the left paging arrow. */ + scrollLeft: string + /** Accessible label of the right paging arrow. */ + scrollRight: string +} + +/** Approximate pixels per wheel step for `deltaMode` LINE deltas (Firefox + * notch wheels report lines, not pixels). */ +const WHEEL_LINE_PX = 16 + +/** Smooth paging unless the user asked for reduced motion. */ +function pageBehavior(): ScrollBehavior { + // jsdom (the unit lane) implements no matchMedia despite lib.dom's + // non-optional typing; the optional call keeps that lane on the default. + // oxlint-disable-next-line typescript/no-unnecessary-condition + return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' +} + +/** + * Horizontal thumbnail rail over the caller's draft attachments. + * + * The rail scrolls with its scrollbar hidden; overflow is announced by edge + * arrows recomputed from scroll geometry on scroll, item-count changes, and + * rail size changes (a ResizeObserver on the rail element, so sidebar or + * panel resizes count, not only window resizes). A vertical wheel pans the + * rail horizontally and is consumed exclusively (non-passive listener), a + * newly added item is revealed at the rail's end while a rail that mounts + * over an existing draft keeps its start position, and each thumbnail opens + * on a single click while its remove control sits inside the card and + * reveals on hover or focus. The owner decides mounting; it renders the rail + * only while items exist. + * + * @param props.items - resolved thumbnails in draft order. + * @param props.labels - rail-level strings (group name, open tooltip, arrows). + * @param props.onOpen - single-click open of one item's original image. + * @param props.onRemove - remove one item from the draft. + * @returns the rail group with its paging arrows. + */ +export function AttachmentRail({ items, labels, onOpen, onRemove }: { + items: readonly T[] + labels: AttachmentRailLabels + onOpen: (item: T) => void + onRemove: (item: T) => void +}) { + const railRef = useRef(null) + // null marks the first layout pass: a rail that MOUNTS over an existing + // draft (session switch back to held images) is initial display, not + // growth, and must not jump to the end. + const countRef = useRef(null) + const [edges, setEdges] = useState({ left: false, right: false }) + const updateEdges = useCallback(() => { + const el = railRef.current + /* v8 ignore next -- defensive: every caller runs while the rail element is mounted. */ + if (el === null) return + // 1px slack: engines report fractional scroll positions at the edges. + const left = el.scrollLeft > 1 + const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1 + setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right }) + }, []) + useLayoutEffect(() => { + const grew = countRef.current !== null && items.length > countRef.current + countRef.current = items.length + const el = railRef.current + /* v8 ignore next -- defensive: the rail div renders unconditionally, so the layout effect always finds it. */ + if (el === null) return + // A newly added attachment lands at the rail's end: reveal it. + if (grew) el.scrollLeft = el.scrollWidth - el.clientWidth + updateEdges() + }, [items.length, updateEdges]) + useEffect(() => { + const el = railRef.current + /* v8 ignore next -- defensive: the rail div renders unconditionally, so the mount effect always finds it. */ + if (el === null) return + // The rail's width follows the composer, which resizes with sidebars and + // panels, not only the window — observe the element itself. jsdom (the + // unit lane) implements no ResizeObserver; every browser gets the + // subscription. + let disconnect = (): void => {} + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver(updateEdges) + observer.observe(el) + disconnect = () => { observer.disconnect() } + } + // The rail scrolls horizontally ONLY: any wheel tick with a vertical + // component is consumed — without preventDefault it would also scroll the + // conversation behind the composer, and React's root wheel listener is + // passive, so the exclusion needs this manually attached non-passive + // listener. A diagonal trackpad pan keeps its horizontal intent; a pure + // vertical wheel converts to a horizontal step, with LINE and PAGE deltas + // (Firefox notch wheels) normalized to pixels before the per-tick clamp + // that keeps a fast wheel followable. A purely horizontal pan stays + // native. + const onWheel = (event: globalThis.WheelEvent): void => { + if (event.deltaY === 0) return + const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? WHEEL_LINE_PX + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1 + event.preventDefault() + el.scrollBy({ + left: event.deltaX !== 0 + ? event.deltaX * scale + : Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60), + behavior: 'auto', + }) + } + el.addEventListener('wheel', onWheel, { passive: false }) + return () => { + disconnect() + el.removeEventListener('wheel', onWheel) + } + }, [updateEdges]) + const page = (direction: -1 | 1): void => { + const el = railRef.current + /* v8 ignore next -- defensive: the arrows render only while the rail is mounted, so a click cannot find a null ref. */ + if (el === null) return + // One viewport minus a card keeps the last visible thumbnail as context; + // the floor keeps narrow rails paging a useful distance. + el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: pageBehavior() }) + } + return ( +
+ {edges.left && ( + + )} +
+ {items.map(item => ( +
+ + +
+ ))} +
+ {edges.right && ( + + )} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css b/packages/client/ui-attachment/src/ImageLightbox.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css rename to packages/client/ui-attachment/src/ImageLightbox.module.css diff --git a/packages/client/ui-attachment/src/ImageLightbox.tsx b/packages/client/ui-attachment/src/ImageLightbox.tsx new file mode 100644 index 0000000000..0207ee5a53 --- /dev/null +++ b/packages/client/ui-attachment/src/ImageLightbox.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import css from './ImageLightbox.module.css' + +/** Lightbox strings the owner resolves from its own locale namespace. */ +export interface ImageLightboxLabels { + /** Accessible name of the preview dialog. */ + dialog: string + /** Accessible label of the close control. */ + close: string +} + +/** + * Document-level original-image preview opened by clicking a thumbnail. + * Closes on Escape, backdrop press, or the close control, and restores focus + * to the opener on unmount. Rendered through a body portal: an opener inside + * a transformed or filtered ancestor would otherwise trap the fixed backdrop + * in that ancestor's box instead of covering the viewport. + * + * @param props.src - the original image URL. + * @param props.alt - the image's alt text. + * @param props.labels - dialog and close-control strings. + * @param props.onClose - dismiss callback owned by the opener. + * @returns the modal preview dialog. + */ +export function ImageLightbox({ src, alt, labels, onClose }: { + src: string + alt: string + labels: ImageLightboxLabels + onClose: () => void +}) { + const closeRef = useRef(null) + const restoreRef = useRef(null) + + useEffect(() => { + restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null + closeRef.current?.focus() + const onKeyDown = (event: globalThis.KeyboardEvent): void => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + restoreRef.current?.focus() + } + }, [onClose]) + + return createPortal( +
{ if (event.target === event.currentTarget) onClose() }} + > + {alt} + +
, + document.body, + ) +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css similarity index 97% rename from packages/client/ui-conversation/src/client/chat/MessageImage.module.css rename to packages/client/ui-attachment/src/MessageImage.module.css index e05a6fc625..17ac423640 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -24,7 +24,7 @@ padding: 0; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 12px; + border-radius: 16px; background: var(--dsw-alias-interactive-bg-hover); cursor: zoom-in; } diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx new file mode 100644 index 0000000000..15420c9569 --- /dev/null +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { ImageLightbox } from './ImageLightbox.tsx' +import type { ImageLightboxLabels } from './ImageLightbox.tsx' +import css from './MessageImage.module.css' + +/** Loads a session-authorized durable image URL. */ +export type ImageLoader = (attachment: ImageAttachmentRef) => Promise + +/** Message-image strings the owner resolves from its own locale namespace. */ +export interface MessageImageLabels { + /** Fallback display name for an unnamed image. */ + image: string + /** Thumbnail tooltip inviting the original-image preview. */ + open: string + /** Accessible thumbnail label; receives the image's display name. */ + openNamed: (label: string) => string + /** Loading placeholder shown until bytes resolve. */ + loading: string + /** Retry-control label shown when the load fails. */ + loadFailed: string + /** Lightbox strings forwarded to the opened preview. */ + lightbox: ImageLightboxLabels +} + +/** + * Compact history renderer with retryable loading and click-to-open original + * preview. + * + * @param props.attachment - the durable image reference to load and bound. + * @param props.load - session-authorized URL loader. + * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). + * @returns the bounded thumbnail button, or the retry control on failure. + */ +export function MessageImage({ attachment, load, labels }: { + attachment: ImageAttachmentRef + load: ImageLoader + labels: MessageImageLabels +}) { + const [src, setSrc] = useState(null) + const [error, setError] = useState(false) + const [open, setOpen] = useState(false) + // Retry re-arms the one load effect below, so every attempt — first load or + // retry — runs under the same liveness guard and the same reset. + const [attempt, setAttempt] = useState(0) + const request = useCallback(() => { setAttempt(a => a + 1) }, []) + const close = useCallback(() => { setOpen(false) }, []) + const size = useMemo(() => { + const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height) + return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) } + }, [attachment.height, attachment.width]) + + useEffect(() => { + let live = true + setError(false) + setSrc(null) + void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) }) + return () => { live = false } + }, [attachment, load, attempt]) + + const label = attachment.name ?? labels.image + if (error) return + return ( + <> + + {open && src !== null && } + + ) +} + +/** Wrapping image group shared by user and assistant history. */ +export function ImageGallery({ images, load, align, labels }: { + images: readonly { attachment: ImageAttachmentRef }[] + load: ImageLoader + align: 'start' | 'end' + labels: MessageImageLabels +}) { + if (images.length === 0) return null + return ( +
+ {images.map((image, index) => ( + + ))} +
+ ) +} diff --git a/packages/client/ui-attachment/src/css-modules.d.ts b/packages/client/ui-attachment/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-attachment/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-attachment/src/index.ts b/packages/client/ui-attachment/src/index.ts new file mode 100644 index 0000000000..8757915fee --- /dev/null +++ b/packages/client/ui-attachment/src/index.ts @@ -0,0 +1,14 @@ +/** + * Pure React attachment atoms (zero cordis): the composer draft-image rail, + * the chat-history image gallery, and the original-image lightbox. Owners + * resolve every string through their own locale namespace and pass it down; + * nothing here reads application state. + * @module @deepseek-ai/dsh-client-ui-attachment + */ + +export { AttachmentRail } from './AttachmentRail.tsx' +export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx' +export { ImageLightbox } from './ImageLightbox.tsx' +export type { ImageLightboxLabels } from './ImageLightbox.tsx' +export { ImageGallery, MessageImage } from './MessageImage.tsx' +export type { ImageLoader, MessageImageLabels } from './MessageImage.tsx' diff --git a/packages/client/ui-attachment/src/invariant.ts b/packages/client/ui-attachment/src/invariant.ts new file mode 100644 index 0000000000..47d18f97b8 --- /dev/null +++ b/packages/client/ui-attachment/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-attachment`. + * @module @deepseek-ai/dsh-client-ui-attachment/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-attachment' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-attachment-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: pure props-in React atoms with no Cordis API — + * no events, no services, no mutable cross-plugin state; rendering contracts + * are asserted directly by this package's component specs. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-attachment/tests/attachment-rail.spec.tsx b/packages/client/ui-attachment/tests/attachment-rail.spec.tsx new file mode 100644 index 0000000000..a373464b61 --- /dev/null +++ b/packages/client/ui-attachment/tests/attachment-rail.spec.tsx @@ -0,0 +1,173 @@ +// @vitest-environment jsdom +// AttachmentRail behavior in the jsdom lane: item rendering and callbacks, +// arrow paging over stubbed scroll geometry (jsdom lays nothing out), the +// exclusive vertical-wheel pan, and the new-item end reveal. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { AttachmentRail } from '../src/AttachmentRail.tsx' +import type { AttachmentRailItem, AttachmentRailLabels } from '../src/AttachmentRail.tsx' + +afterEach(cleanup) + +// jsdom implements no ResizeObserver; the stub records instances so a test +// can drive the size-change recompute path. +const observers: { callback: ResizeObserverCallback; observed: Element[] }[] = [] +beforeEach(() => { + observers.length = 0 + vi.stubGlobal('ResizeObserver', class { + observed: Element[] = [] + constructor(callback: ResizeObserverCallback) { + observers.push({ callback, observed: this.observed }) + } + + observe(el: Element) { this.observed.push(el) } + disconnect() { this.observed.length = 0 } + }) +}) +afterEach(() => { vi.unstubAllGlobals() }) + +const labels: AttachmentRailLabels = { + group: '待发送图片', + open: '查看原图', + scrollLeft: '向左滚动图片', + scrollRight: '向右滚动图片', +} + +function item(id: string): AttachmentRailItem { + return { id, previewUrl: `blob:${id}`, alt: `${id}.png`, removeLabel: `移除图片 ${id}.png` } +} + +/** Stub the rail's scroll geometry (jsdom reports 0 for every metric). */ +function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) { + Object.defineProperty(rail, 'scrollWidth', { value: scrollWidth, configurable: true }) + Object.defineProperty(rail, 'clientWidth', { value: clientWidth, configurable: true }) + let scrollLeft = 0 + Object.defineProperty(rail, 'scrollLeft', { + configurable: true, + get: () => scrollLeft, + set: (value: number) => { scrollLeft = value }, + }) + const scrollBy = vi.fn((options: { left: number }) => { + scrollLeft = Math.max(0, Math.min(scrollWidth - clientWidth, scrollLeft + options.left)) + }) + rail.scrollBy = scrollBy as unknown as typeof rail.scrollBy + return { scrollBy, setScrollLeft: (value: number) => { scrollLeft = value } } +} + +describe('AttachmentRail', () => { + it('renders thumbnails in order and routes open and remove clicks', () => { + const onOpen = vi.fn() + const onRemove = vi.fn() + const items = [item('a'), item('b')] + const view = render() + const rail = view.getByRole('group', { name: '待发送图片' }) + expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['a.png', 'b.png']) + fireEvent.click(view.getAllByTitle('查看原图')[0]!) + expect(onOpen).toHaveBeenCalledWith(items[0]) + fireEvent.click(view.getByRole('button', { name: '移除图片 b.png' })) + expect(onRemove).toHaveBeenCalledWith(items[1]) + }) + + it('shows edge arrows from scroll geometry and pages a viewport at a time', () => { + const view = render( + , + ) + const rail = view.getByRole('group', { name: '待发送图片' }) + const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) + // No arrows until geometry is observed (mount saw jsdom's zero metrics). + expect(view.queryByLabelText('向右滚动图片')).toBeNull() + fireEvent.scroll(rail) + // Same-edges scroll takes the memoized-state path. + fireEvent.scroll(rail) + expect(view.queryByLabelText('向左滚动图片')).toBeNull() + const right = view.getByLabelText('向右滚动图片') + // clientWidth 200 - 64 < the 200 floor: pages by the floor. + fireEvent.click(right) + expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' }) + fireEvent.scroll(rail) + // Scrolled to the far edge: only the left arrow remains. + expect(view.queryByLabelText('向右滚动图片')).toBeNull() + fireEvent.click(view.getByLabelText('向左滚动图片')) + expect(scrollBy).toHaveBeenCalledWith({ left: -200, behavior: 'smooth' }) + fireEvent.scroll(rail) + expect(view.queryByLabelText('向左滚动图片')).toBeNull() + expect(view.getByLabelText('向右滚动图片')).toBeTruthy() + }) + + it('shows both arrows mid-scroll and recomputes when the rail itself resizes', () => { + const view = render( + , + ) + const rail = view.getByRole('group', { name: '待发送图片' }) + const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) + setScrollLeft(100) + // The component observes the rail element, not the window: a sidebar or + // panel resize reaches it through the ResizeObserver callback. + expect(observers.at(-1)?.observed).toContain(rail) + act(() => { observers.at(-1)!.callback([], undefined as never) }) + expect(view.getByLabelText('向左滚动图片')).toBeTruthy() + expect(view.getByLabelText('向右滚动图片')).toBeTruthy() + }) + + it('pans horizontally on a vertical wheel, consuming the event, with clamped normalized travel', () => { + const view = render( + , + ) + const rail = view.getByRole('group', { name: '待发送图片' }) + const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) + // Converted ticks are consumed (preventDefault): fireEvent returns false. + expect(fireEvent.wheel(rail, { deltaY: 30 })).toBe(false) + expect(scrollBy).toHaveBeenCalledWith({ left: 30, behavior: 'auto' }) + fireEvent.wheel(rail, { deltaY: 500 }) + expect(scrollBy).toHaveBeenCalledWith({ left: 60, behavior: 'auto' }) + fireEvent.wheel(rail, { deltaY: -500 }) + expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' }) + // Firefox notch wheels report lines; a page-mode wheel reports viewports. + fireEvent.wheel(rail, { deltaY: 2, deltaMode: WheelEvent.DOM_DELTA_LINE }) + expect(scrollBy).toHaveBeenCalledWith({ left: 32, behavior: 'auto' }) + fireEvent.wheel(rail, { deltaY: -1, deltaMode: WheelEvent.DOM_DELTA_PAGE }) + expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' }) + // A diagonal pan is consumed too — nothing vertical may escape the rail — + // and keeps its horizontal intent. + expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(false) + expect(scrollBy).toHaveBeenCalledWith({ left: 12, behavior: 'auto' }) + // A purely horizontal pan and a zero-delta wheel keep native behavior. + expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 0 })).toBe(true) + fireEvent.wheel(rail, { deltaY: 0 }) + expect(scrollBy).toHaveBeenCalledTimes(6) + }) + + it('pages instantly under a reduced-motion preference, smoothly otherwise', () => { + for (const [matches, behavior] of [[true, 'auto'], [false, 'smooth']] as const) { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList)) + const view = render( + , + ) + const rail = view.getByRole('group', { name: '待发送图片' }) + const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) + fireEvent.scroll(rail) + fireEvent.click(view.getByLabelText('向右滚动图片')) + expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior }) + view.unmount() + } + }) + + it('reveals the rail end when an item is added, not when one is removed', () => { + const first = [item('a'), item('b')] + const view = render( + , + ) + const rail = view.getByRole('group', { name: '待发送图片' }) + stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) + view.rerender( + , + ) + expect(rail.scrollLeft).toBe(200) + view.rerender( + , + ) + // Removal keeps the position; only growth jumps to the end. + expect(rail.scrollLeft).toBe(200) + }) +}) diff --git a/packages/client/ui-attachment/tests/image-lightbox.spec.tsx b/packages/client/ui-attachment/tests/image-lightbox.spec.tsx new file mode 100644 index 0000000000..6152fc5ec2 --- /dev/null +++ b/packages/client/ui-attachment/tests/image-lightbox.spec.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { ImageLightbox } from '../src/ImageLightbox.tsx' + +afterEach(cleanup) + +const labels = { dialog: '原图预览', close: '关闭原图预览' } + +describe('ImageLightbox', () => { + it('focuses its close control, closes by button and Escape, and restores focus', () => { + const opener = document.createElement('button') + document.body.appendChild(opener) + opener.focus() + const onClose = vi.fn() + const view = render() + const close = view.getByRole('button', { name: '关闭原图预览' }) + expect(document.activeElement).toBe(close) + fireEvent.keyDown(window, { key: 'a' }) + expect(onClose).not.toHaveBeenCalled() + fireEvent.keyDown(window, { key: 'Escape' }) + fireEvent.click(close) + expect(onClose).toHaveBeenCalledTimes(2) + view.unmount() + expect(document.activeElement).toBe(opener) + opener.remove() + }) + + it('tolerates a focus owner it cannot restore (no active element at mount)', () => { + // jsdom always reports body as the fallback active element; stub the + // element-less state a detached focus can leave. + Object.defineProperty(document, 'activeElement', { configurable: true, get: () => null }) + try { + const view = render() + view.unmount() + } finally { + delete (document as { activeElement?: unknown }).activeElement + } + }) + + it('closes on a backdrop press but not on a press over the image', () => { + const onClose = vi.fn() + const view = render() + fireEvent.mouseDown(view.getByRole('img')) + expect(onClose).not.toHaveBeenCalled() + fireEvent.mouseDown(view.getByRole('dialog', { name: '原图预览' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/client/ui-attachment/tests/invariant.spec.ts b/packages/client/ui-attachment/tests/invariant.spec.ts new file mode 100644 index 0000000000..4e76b3776f --- /dev/null +++ b/packages/client/ui-attachment/tests/invariant.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import * as AttachmentInvariant from '@deepseek-ai/dsh-client-ui-attachment/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(AttachmentInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/client/ui-attachment/tests/message-image.spec.tsx b/packages/client/ui-attachment/tests/message-image.spec.tsx new file mode 100644 index 0000000000..6dbf4cd746 --- /dev/null +++ b/packages/client/ui-attachment/tests/message-image.spec.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { ImageGallery, MessageImage } from '../src/MessageImage.tsx' +import type { MessageImageLabels } from '../src/MessageImage.tsx' + +afterEach(cleanup) + +const labels: MessageImageLabels = { + image: '图片', + open: '查看原图', + openNamed: label => `${label},点击查看原图`, + loading: '图片加载中…', + loadFailed: '图片加载失败,点击重试', + lightbox: { dialog: '原图预览', close: '关闭原图预览' }, +} + +const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 640, + height: 320, + name: 'history.png', +} + +describe('MessageImage', () => { + it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { + const load = vi.fn().mockResolvedValue('blob:history') + const view = render() + const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) + expect(frame.getAttribute('style')).toContain('width: 240px') + expect(frame.getAttribute('style')).toContain('height: 120px') + expect(frame.getAttribute('title')).toBe('查看原图') + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + expect(load).toHaveBeenCalledWith(attachment) + fireEvent.click(frame) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) + expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + }) + + it('ignores a click while the thumbnail is still loading', () => { + const load = vi.fn(() => new Promise(() => {})) + const view = render() + const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) + expect(view.getByText('图片加载中…')).toBeTruthy() + fireEvent.click(frame) + expect(view.queryByRole('dialog')).toBeNull() + }) + + it('falls back to the image label for an unnamed attachment', async () => { + const { name: _named, ...unnamed } = attachment + const load = vi.fn().mockResolvedValue('blob:unnamed') + const view = render() + await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() }) + expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy() + }) + + it('surfaces a retry control when durable bytes cannot be read, including a failed retry', async () => { + const load = vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce(new Error('still offline')) + .mockResolvedValueOnce('blob:retry') + const view = render() + const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) + fireEvent.click(retry) + const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' }) + fireEvent.click(retryAgain) + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + expect(load).toHaveBeenCalledTimes(3) + }) + + it('ignores a load settling after unmount', async () => { + let resolve: ((url: string) => void) | undefined + const load = vi.fn(() => new Promise((r) => { resolve = r })) + const view = render() + view.unmount() + resolve?.('blob:late') + await Promise.resolve() + let reject: ((error: Error) => void) | undefined + const failing = vi.fn(() => new Promise((_r, rej) => { reject = rej })) + const second = render() + second.unmount() + reject?.(new Error('late failure')) + await Promise.resolve() + }) +}) + +describe('ImageGallery', () => { + it('renders nothing without images and an aligned wrapping group with them', async () => { + const load = vi.fn().mockResolvedValue('blob:gallery') + const empty = render() + expect(empty.container.firstChild).toBeNull() + const view = render( + , + ) + expect(view.container.querySelector('[data-align="end"]')).not.toBeNull() + await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) }) + }) +}) diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json new file mode 100644 index 0000000000..e034eef4e3 --- /dev/null +++ b/packages/client/ui-attachment/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../attachment/attachment" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../ui-primitives" + } + ] +} diff --git a/packages/client/ui-attachment/tsdown.config.ts b/packages/client/ui-attachment/tsdown.config.ts new file mode 100644 index 0000000000..d8c37d8a2c --- /dev/null +++ b/packages/client/ui-attachment/tsdown.config.ts @@ -0,0 +1,35 @@ +import { clientOnly } from '../tsdown.client.ts' + +// TODO(client-atoms): verbatim copy of ui-primitives/tsdown.config.ts (only +// the package differs). On a third atoms package, extract a shared css-stub +// client-library preset in packages/client/tsdown.client.ts instead of a +// fourth copy. +/** + * ui-attachment is browser-only, but its lib bundle IS imported under plain + * Node because the web shell is a lib (dsh-client-web's lib chain reaches + * this package). CSS imports are therefore stubbed to empty modules instead + * of externalized — the hashed class maps only matter in bundler contexts + * (loader module table / vite source paths), which compile src directly and + * never read lib. + */ +export default clientOnly([{ + entry: ['lib/types/index.js', 'lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'neutral', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + plugins: [{ + name: 'dsh-css-stub', + resolveId(source: string) { + if (!source.endsWith('.css')) return null + return `\0dsh-css-stub:${source}.mjs` + }, + load(id: string) { + if (!id.startsWith('\0dsh-css-stub:')) return null + return 'export default {};' + }, + }], +}]) diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 710cfa81bb..2f4258639b 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -61,6 +61,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", @@ -84,6 +85,7 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 7a8590c2b8..4365f4c453 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -13,8 +13,9 @@ import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { ImageGallery, type ImageLoader } from './MessageImage.tsx' +import { messageImageLabels } from '../image-labels.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' @@ -62,7 +63,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ /> ) case 'reasoning': return - case 'image': return + case 'image': return // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. case 'tool-call': return null default: return ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx b/packages/client/ui-conversation/src/client/chat/MessageImage.tsx deleted file mode 100644 index 3f22ff72b8..0000000000 --- a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { ChatViewSlotProps } from '../contract/slots.ts' -import { ImageLightbox } from '../skeleton/ImageLightbox.tsx' -import css from './MessageImage.module.css' - -/** Loads a session-authorized durable image URL. */ -export type ImageLoader = (attachment: ImageAttachmentRef) => Promise - -/** Compact history renderer with retryable loading and double-click original preview. */ -export function MessageImage({ attachment, load, t }: { - attachment: ImageAttachmentRef - load: ImageLoader - t: ChatViewSlotProps['t'] -}) { - const [src, setSrc] = useState(null) - const [error, setError] = useState(false) - const [open, setOpen] = useState(false) - const close = useCallback(() => { setOpen(false) }, []) - const size = useMemo(() => { - const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height) - return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) } - }, [attachment.height, attachment.width]) - - const request = useCallback(() => { - setError(false) - setSrc(null) - void load(attachment).then(setSrc).catch(() => { setError(true) }) - }, [attachment, load]) - - useEffect(() => { - let live = true - setError(false) - void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) }) - return () => { live = false } - }, [attachment, load]) - - const label = attachment.name ?? t('image.label') - if (error) return - return ( - <> - - {open && src !== null && } - - ) -} - -/** Wrapping image group shared by user and assistant history. */ -export function ImageGallery({ images, load, align, t }: { - images: readonly { attachment: ImageAttachmentRef }[] - load: ImageLoader - align: 'start' | 'end' - t: ChatViewSlotProps['t'] -}) { - if (images.length === 0) return null - return ( -
- {images.map((image, index) => ( - - ))} -
- ) -} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index f7c451ce8a..d2bfe8868f 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -10,10 +10,11 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' +import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment' +import { messageImageLabels } from '../image-labels.ts' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' -import { ImageGallery, type ImageLoader } from './MessageImage.tsx' import css from './MessageItem.module.css' type UserImage = Extract @@ -177,7 +178,7 @@ function UserStyleBubble({ return (
- + {showBubble &&
{projectUserText(text)} {rest.map((block, i) => )} diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts new file mode 100644 index 0000000000..493ddbbbbe --- /dev/null +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -0,0 +1,48 @@ +/** Bridges the `conversation` locale namespace to the zero-cordis attachment + * atoms' label props (`@deepseek-ai/dsh-client-ui-attachment` reads no + * application state; owners resolve every string). */ + +import type { + AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels, +} from '@deepseek-ai/dsh-client-ui-attachment' +import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' +import type { ConversationKey } from './locales.ts' + +/** + * Resolve the original-image lightbox strings. + * @param t - the conversation-namespace translate. + * @returns the lightbox dialog and close-control labels. + */ +export function lightboxLabels(t: Translate): ImageLightboxLabels { + return { dialog: t('image.preview'), close: t('image.closePreview') } +} + +/** + * Resolve the chat-history image strings. + * @param t - the conversation-namespace translate. + * @returns the message-image labels including the forwarded lightbox strings. + */ +export function messageImageLabels(t: Translate): MessageImageLabels { + return { + image: t('image.label'), + open: t('image.openOriginal'), + openNamed: label => t('image.openOriginalLabel', { label }), + loading: t('image.loading'), + loadFailed: t('image.loadFailed'), + lightbox: lightboxLabels(t), + } +} + +/** + * Resolve the composer draft-image rail strings. + * @param t - the conversation-namespace translate. + * @returns the rail group, open-tooltip, and paging-arrow labels. + */ +export function attachmentRailLabels(t: Translate): AttachmentRailLabels { + return { + group: t('image.pending'), + open: t('image.openOriginal'), + scrollLeft: t('image.scrollLeft'), + scrollRight: t('image.scrollRight'), + } +} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index dcf04264e8..080bb9b67b 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -27,9 +27,11 @@ export const zh = { 'input.accessMode': '访问模式,当前:{name}', 'image.dropHint': '松开以添加图片', 'image.pending': '待发送图片', - 'image.openOriginal': '双击查看原图', - 'image.openOriginalLabel': '{label},双击查看原图', + 'image.openOriginal': '查看原图', + 'image.openOriginalLabel': '{label},点击查看原图', 'image.remove': '移除图片 {name}', + 'image.scrollLeft': '向左滚动图片', + 'image.scrollRight': '向右滚动图片', 'image.original': '原图', 'image.label': '图片', 'image.loadFailed': '图片加载失败,点击重试', @@ -184,9 +186,11 @@ export const en = { 'input.accessMode': 'Access mode, current: {name}', 'image.dropHint': 'Drop to add images', 'image.pending': 'Pending images', - 'image.openOriginal': 'Double-click to view original', - 'image.openOriginalLabel': '{label}, double-click to view original', + 'image.openOriginal': 'View original', + 'image.openOriginalLabel': '{label}, click to view original', 'image.remove': 'Remove image {name}', + 'image.scrollLeft': 'Scroll images left', + 'image.scrollRight': 'Scroll images right', 'image.original': 'Original image', 'image.label': 'Image', 'image.loadFailed': 'Image failed to load; click to retry', diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx deleted file mode 100644 index 43cbc441a5..0000000000 --- a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useRef } from 'react' -import type { ChatViewSlotProps } from '../contract/slots.ts' -import css from './ImageLightbox.module.css' - -/** Document-level original-image preview opened by an explicit double-click. */ -export function ImageLightbox({ src, alt, onClose, t }: { - src: string - alt: string - onClose: () => void - t: ChatViewSlotProps['t'] -}) { - const closeRef = useRef(null) - const restoreRef = useRef(null) - - useEffect(() => { - restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null - closeRef.current?.focus() - const onKeyDown = (event: globalThis.KeyboardEvent): void => { - if (event.key === 'Escape') onClose() - } - window.addEventListener('keydown', onKeyDown) - return () => { - window.removeEventListener('keydown', onKeyDown) - restoreRef.current?.focus() - } - }, [onClose]) - - return ( -
{ if (event.target === event.currentTarget) onClose() }} - > - {alt} - -
- ) -} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index ad1a6ed275..751e253e22 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -35,22 +35,6 @@ padding: 0 var(--dsh-composer-side-clearance); } -.error, -.status { - width: 100%; - max-width: var(--dsh-composer-card-max-width); - margin-bottom: 6px; - padding: 4px 8px; - border-radius: 8px; - font-size: 12px; - line-height: 18px; -} - -.status { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-secondary); -} - .notice { width: 100%; max-width: var(--dsh-composer-card-max-width); @@ -68,11 +52,6 @@ color: var(--dsw-alias-state-error-primary); } -.error { - background: var(--dsw-alias-interactive-bg-hover-danger); - color: var(--dsw-alias-state-error-primary); -} - .card { box-sizing: border-box; position: relative; /* overlay anchor positioning context */ @@ -162,55 +141,13 @@ padding: 10px 12px 0; } +/* Rail seat: the card's top padding (10px) plus this 4px matches DeepSeek + Chat's spacing above the thumbnails; the card's 12px flex gap owns the space + below. The rail itself (arrows, hidden scrollbar, card geometry) is the + ui-attachment atom's. */ .attachments { - display: flex; - gap: 8px; min-width: 0; - padding: 12px 12px 0; - overflow-x: auto; - overflow-y: hidden; -} - -.attachment { - position: relative; - flex: 0 0 72px; - width: 72px; - height: 72px; -} - -.thumbnail { - width: 72px; - height: 72px; - padding: 0; - overflow: hidden; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 12px; - background: var(--dsw-alias-interactive-bg-hover); - cursor: zoom-in; -} - -.thumbnail img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.remove { - position: absolute; - top: -6px; - right: -6px; - display: grid; - place-items: center; - width: 22px; - height: 22px; - padding: 0; - border: 1px solid var(--dsw-specific-input-major); - border-radius: 999px; - background: var(--dsw-alias-label-primary); - color: var(--dsw-specific-input-major); - font-size: 16px; - line-height: 1; - cursor: pointer; + padding: 4px 12px 0; } /* Floating overlay anchor (menu / popupSelect shell): entries position diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 5423a2bac6..901bdc4682 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -9,7 +9,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' -import { IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment' +import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). import type {} from '@deepseek-ai/dsh-plan-mode/client' @@ -19,18 +23,17 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import type { DraftDecorations } from '../input/decorations.ts' +import { attachmentRailLabels, lightboxLabels } from '../image-labels.ts' import { ContextMeter } from './ContextMeter.tsx' -import { ImageLightbox } from './ImageLightbox.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import css from './InputBar.module.css' /** Decoration product of the no-session state (no machine, empty draft). */ const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null } -/** Prompt failure surface (derived from promptError). */ -export interface InputBarError { - op: 'send' | 'stop' - message: string +/** Rail thumbnail carrying its source attachment for the open/remove callbacks. */ +interface ComposerRailItem extends AttachmentRailItem { + attachment: ComposerAttachment } export type InputBarProps = ComposerBarProps @@ -56,12 +59,6 @@ export function InputBar({ const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active)) // Absent (undefined: no frame yet) and cleared (null) both mean no goal. const hasGoal = useProjection('goal', goal => goal != null) - // Prompt failures are ordinary failures (no create/attach transaction - // exists anymore): the strip renders promptError, the draft stays in the - // machine, and the user resubmits. - const error: InputBarError | null = promptError === null - ? null - : { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` } // Session-maybe: the machine faces are absent together while no session is // current; the bar renders the same DOM inert instead of a parallel tree. const live = input !== undefined && keyboard !== undefined && inputActions !== undefined @@ -73,8 +70,26 @@ export function InputBar({ const empty = draft.trim() === '' && attachments.length === 0 const [preview, setPreview] = useState(null) const [dragActive, setDragActive] = useState(false) - const [dropError, setDropError] = useState(null) + // Transient error banner (image-intake rejections and prompt failures): the + // seq keys the Toast so an identical repeated message restarts the + // hold-then-fade cycle instead of silently reusing the faded one. + const [toast, setToast] = useState<{ seq: number; text: string } | null>(null) + const toastSeq = useRef(0) + const showToast = useCallback((text: string) => { + toastSeq.current += 1 + setToast({ seq: toastSeq.current, text }) + }, []) + const dismissToast = useCallback(() => { setToast(null) }, []) + // Prompt failures are ordinary failures (no create/attach transaction exists + // anymore): the toast announces promptError, the draft stays in the machine, + // and the user resubmits. A remount over a session whose machine still holds + // an unresolved promptError deliberately re-announces it once — the failure + // is still pending, and a transient banner is its only surface. + useEffect(() => { + if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`) + }, [promptError, showToast]) const inputRef = useRef(null) + const cardRef = useRef(null) const dragDepthRef = useRef(0) const scrollRef = useRef(null) const mirrorRef = useRef(null) @@ -369,7 +384,10 @@ export function InputBar({ .filter(item => item.kind === 'file') .map(item => item.getAsFile()) .filter((file): file is File => file !== null) - if (files.length > 0 && addImages !== undefined) setDropError(addImages(files)) + if (files.length > 0 && addImages !== undefined) { + const rejected = addImages(files) + if (rejected !== null) showToast(rejected) + } const text = e.clipboardData.getData('text/plain') if (text === '') { if (files.length > 0) e.preventDefault() @@ -393,7 +411,6 @@ export function InputBar({ event.preventDefault() if (locked || machineBusy || addImages === undefined) return dragDepthRef.current += 1 - setDropError(null) setDragActive(true) } @@ -416,11 +433,24 @@ export function InputBar({ setDragActive(false) if (locked || machineBusy || addImages === undefined) return const dropped = [...event.dataTransfer.files] - if (dropped.length > 0) setDropError(addImages(dropped)) + if (dropped.length > 0) { + const rejected = addImages(dropped) + if (rejected !== null) showToast(rejected) + } } const closePreview = useCallback(() => { setPreview(null) }, []) + // Rail thumbnails with their strings resolved here: the attachment atoms are + // zero-cordis and read no locale. + const railItems = useMemo(() => attachments.map(attachment => ({ + id: attachment.id, + previewUrl: attachment.previewUrl, + alt: attachment.file.name || t('image.pending'), + removeLabel: t('image.remove', { name: attachment.file.name }), + attachment, + })), [attachments, t]) + const onSelect = (e: React.SyntheticEvent): void => { // Any caret/selection gesture ends a live paste attempt (the machine // cannot observe DOM selection). Cheap no-op when none is live. @@ -543,10 +573,14 @@ export function InputBar({ return (
- {error !== null && ( -
- {error.message} -
+ {toast !== null && ( + } + anchor={cardRef.current} + onDone={dismissToast} + /> )} {notice !== null && (
@@ -558,8 +592,8 @@ export function InputBar({ their pointer events), so the WHOLE capsule is the pick target. pointerdown stops here so the Menu's outside-close cannot race the click's reopen (close-then-open flickers the chip's open echo). */} - {dropError !== null &&
{dropError}
}
{t('image.dropHint')}
} {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} - {attachments.length > 0 && ( -
- {attachments.map(attachment => ( -
- - -
- ))} + {railItems.length > 0 && ( +
+ { setPreview(item.attachment) }} + onRemove={(item) => { removeImage?.(item.attachment.id) }} + />
)} {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the @@ -628,10 +647,7 @@ export function InputBar({ ? t('placeholder.steerQueue') : planActive ? t('placeholder.plan') : t('placeholder.default'))} rows={2} - onChange={(event) => { - setDropError(null) - onChange(event) - }} + onChange={onChange} onKeyDown={onKeyDown} onSelect={onSelect} onCopy={(e) => { onCopyOrCut(e, false) }} @@ -712,8 +728,8 @@ export function InputBar({ )} {footer} diff --git a/packages/client/ui-conversation/tests/image-labels.spec.tsx b/packages/client/ui-conversation/tests/image-labels.spec.tsx new file mode 100644 index 0000000000..6aa5810e38 --- /dev/null +++ b/packages/client/ui-conversation/tests/image-labels.spec.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +// The conversation-side bridge to the ui-attachment atoms: dictionary strings +// flow through image-labels into the gallery, and assistant images keep their +// block position between text blocks. + +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = makeTranslate(zh, commonZh) +const enT = makeTranslate(en, commonZh) + +const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 640, + height: 320, + name: 'history.png', +} + +describe('assistant images through the label bridge', () => { + it('resolves zh dictionary strings and opens the lightbox on a single click', async () => { + const view = render( + Promise.resolve('blob:history')} + />, + ) + const frame = await view.findByRole('button', { name: 'history.png,点击查看原图' }) + expect(frame.getAttribute('title')).toBe('查看原图') + await view.findByAltText('history.png') + fireEvent.click(frame) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) + expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + }) + + it('resolves the active English dictionary', async () => { + const view = render( + Promise.resolve('blob:history')} + />, + ) + const frame = await view.findByRole('button', { name: 'history.png, click to view original' }) + await view.findByAltText('history.png') + fireEvent.click(frame) + expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy() + expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy() + }) + + it('keeps assistant images at their original position between text blocks', async () => { + const view = render( + Promise.resolve('blob:middle')} + />, + ) + const image = await view.findByAltText('history.png') + const before = view.getByText('before') + const after = view.getByText('after') + expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + }) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index f7b0669e3e..a766781b5f 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -237,15 +237,49 @@ describe('image draft rail', () => { expect(removeImage).toHaveBeenCalledWith('draft-1') }) - it('opens the original image on double-click and closes it with Escape', () => { + it('opens the original image on a single click and closes it with Escape', () => { const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' } const { view } = bench({ attachments: [attachment] }) - fireEvent.doubleClick(view.getByTitle('双击查看原图')) + fireEvent.click(view.getByTitle('查看原图')) expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() fireEvent.keyDown(window, { key: 'Escape' }) expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() }) + + it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => { + vi.useFakeTimers() + try { + const addImages = vi.fn(() => '不支持的图片格式:text/plain') + const { view, textarea } = bench({ addImages }) + const paste = () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'note.txt', { type: 'text/plain' }) }], + getData: () => '', + }, + }) + } + paste() + expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain') + act(() => { vi.advanceTimersByTime(4000) }) + expect(view.queryByRole('alert')).toBeNull() + // The identical rejection re-announces: the toast is keyed per show. + paste() + expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain') + } finally { + vi.useRealTimers() + } + }) + + it('announces a rejected drop through the same toast', () => { + const addImages = vi.fn(() => '图片读取服务不可用') + const { view } = bench({ addImages }) + const card = view.container.querySelector('[class*="card"]')! + const dataTransfer = { types: ['Files'], files: [new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' })], dropEffect: 'none' } + fireEvent.drop(card, { dataTransfer }) + expect(view.getByRole('alert').textContent).toContain('图片读取服务不可用') + }) }) describe('Enter semantics', () => { @@ -941,10 +975,19 @@ describe('insertText (scoped event body)', () => { }) describe('strips and variants', () => { - it('derives the failure strip from promptError (ordinary failure — no transaction UI, no Retry)', () => { - const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } }) - expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom (agent-busy)') - expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull() + it('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => { + vi.useFakeTimers() + try { + const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } }) + // The toast body-portals (transformed ancestors must not trap it), so + // queries go through the view's document-bound helpers. + expect(send.view.getByRole('alert').textContent).toContain('boom (agent-busy)') + expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull() + act(() => { vi.advanceTimersByTime(4000) }) + expect(send.view.queryByRole('alert')).toBeNull() + } finally { + vi.useRealTimers() + } }) it('renders the notice strip from the machine notice store', () => { diff --git a/packages/client/ui-conversation/tests/message-image.spec.tsx b/packages/client/ui-conversation/tests/message-image.spec.tsx deleted file mode 100644 index 6da4d42f12..0000000000 --- a/packages/client/ui-conversation/tests/message-image.spec.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// @vitest-environment jsdom - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { MessageImage } from '../src/client/chat/MessageImage.tsx' -import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' -import { en, zh } from '../src/client/locales.ts' - -afterEach(cleanup) - -const t = makeTranslate(zh, commonZh) -const enT = makeTranslate(en, commonZh) - -const attachment = { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png' as const, - bytes: 68, - width: 640, - height: 320, - name: 'history.png', -} - -describe('MessageImage', () => { - it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => { - const load = vi.fn().mockResolvedValue('blob:history') - const view = render() - const frame = view.getByRole('button', { name: 'history.png,双击查看原图' }) - expect(frame.getAttribute('style')).toContain('width: 240px') - expect(frame.getAttribute('style')).toContain('height: 120px') - await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) - expect(load).toHaveBeenCalledWith(attachment) - fireEvent.doubleClick(frame) - expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() - fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) - expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() - }) - - it('surfaces a retry control when durable bytes cannot be read', async () => { - const load = vi.fn() - .mockRejectedValueOnce(new Error('offline')) - .mockResolvedValueOnce('blob:retry') - const view = render() - const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) - fireEvent.click(retry) - await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) - expect(load).toHaveBeenCalledTimes(2) - }) - - it('renders image controls from the active English dictionary', async () => { - const load = vi.fn().mockResolvedValue('blob:history') - const view = render() - const frame = view.getByRole('button', { name: 'history.png, double-click to view original' }) - await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) - fireEvent.doubleClick(frame) - expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy() - expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy() - }) - - it('keeps assistant images at their original position between text blocks', async () => { - const view = render( - Promise.resolve('blob:middle')} - />, - ) - const image = await view.findByAltText('history.png') - const before = view.getByText('before') - const after = view.getByText('after') - expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) - expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) - }) -}) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index b6190dc3ae..58af4f794f 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../ui-slots" }, + { + "path": "../ui-attachment" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-model/src/client/ModelSelect.tsx b/packages/client/ui-model/src/client/ModelSelect.tsx index 6b7bfd19b9..7be74a90d7 100644 --- a/packages/client/ui-model/src/client/ModelSelect.tsx +++ b/packages/client/ui-model/src/client/ModelSelect.tsx @@ -7,7 +7,9 @@ * ToggleButton) shows both: model name + effort in the caption tone. * Data and submission ride the SAME per-session ModelDirectory as the * /model popup; exact-model reasoning metadata and the selected effort come - * from the Host rather than a client-owned vocabulary. + * from the Host rather than a client-owned vocabulary. A rejected selection + * announces through the shared transient Toast anchored to the composer + * card; the in-menu strip with Retry remains the catalog-load surface. */ import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, @@ -17,6 +19,7 @@ import clsx from 'clsx' import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-client-connection/client' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14, + IconWarningOutline16, Toast, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelSelectInjected } from './slots.ts' @@ -49,6 +52,13 @@ export function ModelSelect( ) const [open, setOpen] = useState(false) const [pane, setPane] = useState('root') + // The in-menu error strip serves catalog loads (its Retry re-runs the + // load); a rejected SELECTION announces through the transient toast + // instead, so the strip renders only while the latest failure-capable + // action was a load. + const lastActionRef = useRef<'load' | 'select'>('load') + const [toast, setToast] = useState<{ seq: number; text: string } | null>(null) + const toastSeq = useRef(0) const rootRef = useRef(null) const triggerRef = useRef(null) const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) @@ -92,9 +102,17 @@ export function ModelSelect( ], [reasoning, t]) const busy = state.status === 'selecting' + const reload = (): void => { + lastActionRef.current = 'load' + load() + } + // Mount-time load resolves the trigger label; every open refreshes. useEffect(() => { - if (available) load() + if (available) { + lastActionRef.current = 'load' + load() + } }, [available, load]) useEffect(() => { @@ -111,7 +129,7 @@ export function ModelSelect( const show = (): void => { setPane('root') setOpen(true) - load() + reload() } const close = (restoreFocus = false): void => { @@ -148,14 +166,25 @@ export function ModelSelect( close() } + const settleSelection = (accepted: boolean): void => { + if (accepted) { + if (rootRef.current !== null) close(true) + return + } + const message = directory.getSnapshot().error + if (message !== null) { + toastSeq.current += 1 + setToast({ seq: toastSeq.current, text: t('error.action', { message }) }) + } + } + const choose = (selection: ModelSelection): void => { if (state.current?.provider === selection.provider && state.current.model === selection.model) { close(true) return } - void select(selection).then((accepted) => { - if (accepted && rootRef.current !== null) close(true) - }) + lastActionRef.current = 'select' + void select(selection).then(settleSelection) } const chooseEffort = (effort: string | undefined): void => { @@ -169,9 +198,8 @@ export function ModelSelect( model: state.current.model, ...effort === undefined ? {} : { reasoningEffort: effort }, } - void select(selection).then((accepted) => { - if (accepted && rootRef.current !== null) close(true) - }) + lastActionRef.current = 'select' + void select(selection).then(settleSelection) } const modelLabel = currentChoice?.model.name ?? t('trigger.fallback') @@ -243,16 +271,16 @@ export function ModelSelect( {state.status === 'loading' && (
{t('status.loading')}
)} - {state.error !== null && ( + {state.error !== null && lastActionRef.current === 'load' && (
{t('error.action', { message: state.error })} - +
)} {state.failures.map(failure => (
{t('warning.groupLoad', { name: failure.name, message: failure.message })} - +
))}
@@ -299,10 +327,10 @@ export function ModelSelect( {pane === 'effort' && ( <> - {state.error !== null && ( + {state.error !== null && lastActionRef.current === 'load' && (
{t('error.action', { message: state.error })} - +
)} {effortChoices.length === 0 @@ -333,6 +361,15 @@ export function ModelSelect( )}
)} + {toast !== null && ( + } + anchor={rootRef.current?.closest('[data-composer-card]') ?? null} + onDone={() => { setToast(null) }} + /> + )}
) } diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx index 1b1e1ef17c..4f6a37d151 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -135,6 +135,38 @@ describe('ModelSelect reasoning effort', () => { expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy() }) + it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => { + const groups = [{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [ + { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning }, + { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' }, + ], + }] + const directory = createSnapshotStore(state({ groups })) + const select = vi.fn(async () => { + directory.set(state({ groups, status: 'error', error: 'model-unavailable: session already contains images' })) + return false + }) + render() + + fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ })) + const toast = await screen.findByRole('alert') + expect(toast.textContent).toContain('模型操作失败:model-unavailable: session already contains images') + // The selection failure does not render the in-menu load strip (no Retry). + expect(screen.queryByRole('button', { name: '重试' })).toBeNull() + }) + it('renders no Agent-bound control for an addressed subagent session', () => { const load = vi.fn() render( void +}) { + useEffect(() => { + const timer = setTimeout(onDone, HOLD_MS + FADE_MS) + return () => { clearTimeout(timer) } + }, [onDone]) + // Anchor-centered placement re-measures on window resizes; the banner lives + // four seconds, so sub-window layout drift within that span stays out of + // scope. + const [left, setLeft] = useState(null) + useLayoutEffect(() => { + if (anchor == null) return + const measure = (): void => { + const rect = anchor.getBoundingClientRect() + setLeft(rect.left + rect.width / 2) + } + measure() + window.addEventListener('resize', measure) + return () => { window.removeEventListener('resize', measure) } + }, [anchor]) + return createPortal( +
+ {icon !== undefined && {icon}} + {text} +
, + document.body, + ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 18e5b2c7e9..05baf26f8d 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -23,6 +23,7 @@ export { FishLogo } from './FishLogo.tsx' export { BrandWordmark } from './BrandWordmark.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' +export { Toast } from './Toast.tsx' export { writeClipboard } from './clipboard.ts' export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' diff --git a/packages/client/ui-primitives/tests/toast.spec.tsx b/packages/client/ui-primitives/tests/toast.spec.tsx new file mode 100644 index 0000000000..1e2bb35d51 --- /dev/null +++ b/packages/client/ui-primitives/tests/toast.spec.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { Toast } from '../src/Toast.tsx' + +afterEach(cleanup) + +describe('Toast', () => { + it('announces its text and reports done after the hold-and-fade lifetime', () => { + vi.useFakeTimers() + try { + const onDone = vi.fn() + const view = render(} onDone={onDone} />) + const banner = view.getByRole('alert') + expect(banner.textContent).toContain('最多添加 50 张图片') + expect(view.getByTestId('icon')).toBeTruthy() + vi.advanceTimersByTime(3999) + expect(onDone).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(onDone).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('centers over its anchor and re-measures on window resize', () => { + vi.useFakeTimers() + try { + const anchor = document.createElement('div') + document.body.appendChild(anchor) + anchor.getBoundingClientRect = () => ({ left: 100, width: 400 }) as DOMRect + const view = render() + expect(view.getByRole('alert').style.left).toBe('300px') + anchor.getBoundingClientRect = () => ({ left: 200, width: 400 }) as DOMRect + fireEvent(window, new Event('resize')) + expect(view.getByRole('alert').style.left).toBe('400px') + anchor.remove() + } finally { + vi.useRealTimers() + } + }) + + it('renders without an icon and cancels its timer on unmount', () => { + vi.useFakeTimers() + try { + const onDone = vi.fn() + const view = render() + expect(view.getByRole('alert').querySelector('[aria-hidden]')).toBeNull() + view.unmount() + vi.advanceTimersByTime(10_000) + expect(onDone).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index a754570859..5255f9fb2f 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -117,4 +117,3 @@ export interface SettingsOnboardingOwnerProps { /** Open the settings panel directly on one registered section. */ openSection: (id: string) => void } - diff --git a/packages/client/ui-settings/tests/settings-scope.spec.ts b/packages/client/ui-settings/tests/settings-scope.spec.ts index e6b165e9f3..67de7bb2fd 100644 --- a/packages/client/ui-settings/tests/settings-scope.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.spec.ts @@ -354,4 +354,3 @@ describe('SettingsScopeService.bind', () => { expect(describeCall).not.toHaveBeenCalled() }) }) - diff --git a/packages/client/web/package.json b/packages/client/web/package.json index df5192e070..d23c343b3c 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -29,6 +29,7 @@ "dependencies": { "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-schema-form": "workspace:^", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", diff --git a/packages/client/web/src/platform.ts b/packages/client/web/src/platform.ts index e7997cf728..dd7248c117 100644 --- a/packages/client/web/src/platform.ts +++ b/packages/client/web/src/platform.ts @@ -10,6 +10,7 @@ export const PLATFORM_MODULES = [ '@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-web-react', '@deepseek-ai/dsh-client-ui-primitives', + '@deepseek-ai/dsh-client-ui-attachment', '@deepseek-ai/dsh-client-schema-form', ] as const diff --git a/packages/client/web/src/seed.ts b/packages/client/web/src/seed.ts index 78ed31099e..8299f225c7 100644 --- a/packages/client/web/src/seed.ts +++ b/packages/client/web/src/seed.ts @@ -14,6 +14,7 @@ import * as Cordis from '@deepseek-ai/cordis' import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots' import * as WebReact from '@deepseek-ai/dsh-client-web-react' import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' +import * as UiAttachment from '@deepseek-ai/dsh-client-ui-attachment' import * as SchemaForm from '@deepseek-ai/dsh-client-schema-form' import type { PlatformModule } from './platform.ts' @@ -34,6 +35,7 @@ export function getStaticModules(): Record { '@deepseek-ai/dsh-client-ui-slots': UiSlots, '@deepseek-ai/dsh-client-web-react': WebReact, '@deepseek-ai/dsh-client-ui-primitives': UiPrimitives, + '@deepseek-ai/dsh-client-ui-attachment': UiAttachment, '@deepseek-ai/dsh-client-schema-form': SchemaForm, } satisfies Record } diff --git a/packages/client/web/tsconfig.json b/packages/client/web/tsconfig.json index 9240c34891..db2e3b8ed8 100644 --- a/packages/client/web/tsconfig.json +++ b/packages/client/web/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../ui-slots" }, + { + "path": "../ui-attachment" + }, { "path": "../ui-primitives" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f77a37e5c4..125d0609d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1961,6 +1961,37 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-attachment: + dependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + packages/client/ui-command: dependencies: clsx: @@ -2046,6 +2077,9 @@ importers: '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-attachment': + specifier: workspace:^ + version: link:../ui-attachment '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../ui-layout @@ -3070,6 +3104,9 @@ importers: '@deepseek-ai/dsh-client-schema-form': specifier: workspace:^ version: link:../schema-form + '@deepseek-ai/dsh-client-ui-attachment': + specifier: workspace:^ + version: link:../ui-attachment '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index cef363c1df..969351e998 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -61,6 +61,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers nothing model-facing.' }, 'packages/client/test-runtime': { kind: 'none', reason: 'Browser-side test infrastructure (jsdom bench); registers nothing model-facing.' }, 'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-attachment': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers nothing model-facing.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 2984f6fafd..06fc4bbe93 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -161,6 +161,7 @@ "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"], + "@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"], "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"], "@deepseek-ai/dsh-client-schema-form": ["./packages/client/schema-form/src"], "@deepseek-ai/dsh-client-schema-form/invariant": ["./packages/client/schema-form/src/invariant.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 3ccdbc3f1d..ce6d160a2d 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -46,6 +46,7 @@ { "path": "./packages/compact/compact" }, { "path": "./packages/client/ui-slots" }, { "path": "./packages/client/schema-form" }, + { "path": "./packages/client/ui-attachment" }, { "path": "./packages/client/ui-primitives" }, { "path": "./packages/client/web-react" }, { "path": "./packages/client/modules" },