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 index e967409825..3386b9fec0 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md -2026-08-11-web-attachment-display-alignment.md: 84d51aada0a463145115f0cebfaf73e9b2bd9e9b -2026-08-11-web-attachment-display-alignment.zh.md: 2676fcea333b54017c37cb4ee85f59ad7c76cfac +2026-08-11-web-attachment-display-alignment.md: 0c41b337d90293525c31afd60aa16bbc3f7cf16c +2026-08-11-web-attachment-display-alignment.zh.md: c2ca9cf546cdb62f3674867ae420e8a4f071f98f 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 index 84d51aada0..0c41b337d9 100644 --- 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 @@ -8,13 +8,15 @@ English | [中文](2026-08-11-web-attachment-display-alignment.zh.md) 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. -The transient banner is a `ui-primitives` `Toast` atom (top-center, `role="alert"`, 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; 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. +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 (top-center, `role="alert"`, 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; 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 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 index 2676fcea33..c2ca9cf546 100644 --- 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 @@ -8,13 +8,15 @@ Status: implemented 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 组件,且附件栏是输入框自有的渲染,不是插槽。 -短时横幅是 `ui-primitives` 的 `Toast` 原子(顶部居中,`role="alert"`,停留三秒再一秒淡出,`onDone` 卸载,按展示序号作 key 使相同文案重新播报)。`InputBar` 把接收拒绝(`addImages` 返回的原因)和 `promptError` 都改走 toast,替换内联红条;状态机 notice 条不受影响。DeepSeek Chat 源码(本地参考副本)提供了目标行为:其 `ImageThumbnailInInput`(64px 卡片、透明度过渡的删除钮)、`ScrollArrows`(哨兵驱动的翻页)与 `useToast` 用法。 +两个浮层都 portal 到 body:从聊天消息打开的灯箱位于带 transform 的祖先之下,`position: fixed` 会被困在祖先的盒子里(遮罩只盖住聊天列),因此 `ImageLightbox` 与 `Toast` 经 `createPortal(document.body)` 渲染,从任何打开位置都覆盖整个视口。短时横幅是 `ui-primitives` 的 `Toast` 原子(顶部居中,`role="alert"`,停留三秒再一秒淡出,`onDone` 卸载,按展示序号作 key 使相同文案重新播报)。`InputBar` 把接收拒绝(`addImages` 返回的原因)和 `promptError` 都改走 toast,替换内联红条;状态机 notice 条不受影响。DeepSeek Chat 源码(本地参考副本)提供了目标行为:其 `ImageThumbnailInInput`(64px 卡片、透明度过渡的删除钮)、`ScrollArrows`(哨兵驱动的翻页)与 `useToast` 用法。 ## 备选方案 diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 9f22cb55f7..2d2dda42d6 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -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/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 4a66c5ebe6..03417393c9 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md -README.md: a0a410532a631f243c3dfef7debfc6ce0bb0699e -README.zh.md: 4d8ae638a30dc37d89531f15cf3c541d8e09f8f6 +README.md: 7e67a0064f5611ac814a9d489db0d4cc471309e1 +README.zh.md: bf44f6a42db0e1a2c06e1f42133918150b123c2e diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index a0a410532a..7e67a0064f 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -6,7 +6,7 @@ Pure React attachment atoms (zero cordis): the composer draft-image rail (`Attac ## 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, and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and window resizes. A vertical wheel pans the rail horizontally with per-tick travel clamped to 60px, while trackpad horizontal pans keep native scrolling. A newly added item is revealed at the rail's end; removal keeps the scroll 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. +`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). A vertical wheel pans the rail horizontally through a non-passive listener that consumes the event — the same tick never also scrolls the conversation — with LINE/PAGE deltas normalized to pixels and per-tick travel clamped to 60px, while trackpad horizontal pans keep native scrolling. 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 @@ -24,3 +24,4 @@ None; this package neither assembles nor sends a provider request. - **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 index 4d8ae638a3..bf44f6a42d 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -6,21 +6,22 @@ ## 附件栏 -`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动,箭头的显隐在滚动、条目数量变化和窗口尺寸变化时依据滚动几何重算。纵向滚轮转为横向平移,单次行程钳制在 60px 内,触控板的横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。 +`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、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。 -## Model Experience +## 模型体验 -None, as the package renders pure React atoms in the browser; nothing here reaches a model request. +无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。 -#### KV Cache effect +#### KV Cache 影响 -None; this package neither assembles nor sends a provider request. +无;该包既不组装也不发送提供方请求。 -## Known Limitations and Deferred Work +## 已知限制与暂缓事项 - **仅支持图片** — 非图片文件尚无附件栏卡片与历史渲染;DeepSeek Chat 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。 - **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。 +- **灯箱不锁定焦点** — 它设置 `aria-modal` 并在关闭时归还焦点,但 Tab 仍可移动到背后的页面(沿袭入包前组件的行为)。 diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 3174f4c8b9..e894a42196 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -30,12 +30,14 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "clsx": "^2.0.0", - "react": "^18.2.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": "~18.3.1", + "@types/react-dom": "~18.3.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-attachment/src/AttachmentRail.module.css b/packages/client/ui-attachment/src/AttachmentRail.module.css index 5e53374af2..ac8968f777 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.module.css +++ b/packages/client/ui-attachment/src/AttachmentRail.module.css @@ -81,6 +81,12 @@ } } +@media (prefers-reduced-motion: reduce) { + .remove { + transition: none; + } +} + .arrow { position: absolute; top: 50%; diff --git a/packages/client/ui-attachment/src/AttachmentRail.tsx b/packages/client/ui-attachment/src/AttachmentRail.tsx index db83184efc..22ecf39147 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.tsx +++ b/packages/client/ui-attachment/src/AttachmentRail.tsx @@ -2,7 +2,6 @@ * by edge arrows, hover-revealed per-item remove, single-click open. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { WheelEvent } from 'react' import clsx from 'clsx' import { IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14, @@ -33,15 +32,31 @@ export interface AttachmentRailLabels { 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 - * window resizes. A vertical wheel pans horizontally, a newly added item is - * revealed at the rail's end, 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. + * 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). @@ -56,7 +71,10 @@ export function AttachmentRail({ items, labels, on onRemove: (item: T) => void }) { const railRef = useRef(null) - const countRef = useRef(0) + // 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 @@ -68,16 +86,51 @@ export function AttachmentRail({ items, labels, on setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right }) }, []) useLayoutEffect(() => { - const grew = items.length > countRef.current + 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 !== null) el.scrollLeft = el.scrollWidth - el.clientWidth + if (grew) el.scrollLeft = el.scrollWidth - el.clientWidth updateEdges() }, [items.length, updateEdges]) useEffect(() => { - window.addEventListener('resize', updateEdges) - return () => { window.removeEventListener('resize', updateEdges) } + 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() } + } + // A vertical wheel pans the rail horizontally and is consumed: without + // preventDefault the same tick would also scroll the conversation behind + // the composer. React's root wheel listener is passive, so the exclusive + // conversion needs this manually attached non-passive listener. LINE and + // PAGE deltas (Firefox notch wheels) are normalized to pixels before the + // per-tick clamp that keeps a fast wheel followable. + const onWheel = (event: globalThis.WheelEvent): void => { + if (event.deltaX !== 0 || 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: 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 @@ -85,16 +138,7 @@ export function AttachmentRail({ items, labels, on 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: 'smooth' }) - } - // A vertical wheel pans the rail horizontally (trackpads pan natively via - // deltaX); per-tick travel is clamped so a fast notch wheel stays followable. - const onWheel = (event: WheelEvent): void => { - if (event.deltaX !== 0 || event.deltaY === 0) return - event.currentTarget.scrollBy({ - left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY), 60), - behavior: 'auto', - }) + el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: pageBehavior() }) } return (
@@ -114,7 +158,6 @@ export function AttachmentRail({ items, labels, on role="group" aria-label={labels.group} onScroll={updateEdges} - onWheel={onWheel} > {items.map(item => (
diff --git a/packages/client/ui-attachment/src/ImageLightbox.tsx b/packages/client/ui-attachment/src/ImageLightbox.tsx index dcf01bbc41..0207ee5a53 100644 --- a/packages/client/ui-attachment/src/ImageLightbox.tsx +++ b/packages/client/ui-attachment/src/ImageLightbox.tsx @@ -1,4 +1,5 @@ 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. */ @@ -12,7 +13,9 @@ export interface ImageLightboxLabels { /** * 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. + * 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. @@ -42,7 +45,7 @@ export function ImageLightbox({ src, alt, labels, onClose }: { } }, [onClose]) - return ( + return createPortal(
{alt} -
+
, + document.body, ) } diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 943d1fd158..15420c9569 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -40,24 +40,23 @@ export function MessageImage({ attachment, load, labels }: { 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]) - const request = useCallback(() => { - setError(false) - setSrc(null) - void load(attachment).then(setSrc).catch(() => { setError(true) }) - }, [attachment, load]) - 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]) + }, [attachment, load, attempt]) const label = attachment.name ?? labels.image if (error) return diff --git a/packages/client/ui-attachment/tests/attachment-rail.spec.tsx b/packages/client/ui-attachment/tests/attachment-rail.spec.tsx index de468bd6ce..210b8829c9 100644 --- a/packages/client/ui-attachment/tests/attachment-rail.spec.tsx +++ b/packages/client/ui-attachment/tests/attachment-rail.spec.tsx @@ -1,15 +1,32 @@ // @vitest-environment jsdom // AttachmentRail behavior in the jsdom lane: item rendering and callbacks, // arrow paging over stubbed scroll geometry (jsdom lays nothing out), the -// vertical-wheel pan, and the new-item end reveal. +// exclusive vertical-wheel pan, and the new-item end reveal. -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render } from '@testing-library/react' +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: '查看原图', @@ -78,34 +95,58 @@ describe('AttachmentRail', () => { expect(view.getByLabelText('向右滚动图片')).toBeTruthy() }) - it('shows both arrows mid-scroll and recomputes on window resize', () => { + 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) - fireEvent(window, new Event('resize')) + // 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 with clamped travel', () => { + 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 }) - fireEvent.wheel(rail, { deltaY: 30 }) + // 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 trackpad pan (deltaX) and a zero-delta wheel keep native behavior. - fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 }) + expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(true) fireEvent.wheel(rail, { deltaY: 0 }) - expect(scrollBy).toHaveBeenCalledTimes(3) + expect(scrollBy).toHaveBeenCalledTimes(5) + }) + + 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', () => { diff --git a/packages/client/ui-attachment/tsdown.config.ts b/packages/client/ui-attachment/tsdown.config.ts index 2ffa80a8d1..d8c37d8a2c 100644 --- a/packages/client/ui-attachment/tsdown.config.ts +++ b/packages/client/ui-attachment/tsdown.config.ts @@ -1,5 +1,9 @@ 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 diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 555177e6f6..b6b60b9053 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -82,7 +82,9 @@ export function InputBar({ 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. + // 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]) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 084a3a48e5..a766781b5f 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -979,10 +979,12 @@ describe('strips and variants', () => { vi.useFakeTimers() try { const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } }) - expect(send.view.container.querySelector('[role="alert"]')?.textContent).toContain('boom (agent-busy)') + // 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.container.querySelector('[role="alert"]')).toBeNull() + expect(send.view.queryByRole('alert')).toBeNull() } finally { vi.useRealTimers() } diff --git a/packages/client/ui-primitives/src/Toast.module.css b/packages/client/ui-primitives/src/Toast.module.css index e4dbdf6bea..6c5fecb1d1 100644 --- a/packages/client/ui-primitives/src/Toast.module.css +++ b/packages/client/ui-primitives/src/Toast.module.css @@ -11,6 +11,10 @@ /* Above the 1000 the image lightbox backdrop uses: a failure reported while a preview is open must stay readable. */ z-index: 1100; + /* Purely an announcement: it must never intercept clicks — in particular + after the CSS fade finished while a throttled background-tab timer has + not yet unmounted the still-hit-testable fixed element. */ + pointer-events: none; display: flex; align-items: center; gap: 10px; @@ -56,3 +60,11 @@ opacity: 0; } } + +/* Reduced motion drops the slide-in; the delayed fade (an opacity change, + not movement) still ends the banner before the timed unmount. */ +@media (prefers-reduced-motion: reduce) { + .toast { + animation: dsh-toast-fade 1000ms ease 3000ms forwards; + } +} diff --git a/packages/client/ui-primitives/src/Toast.tsx b/packages/client/ui-primitives/src/Toast.tsx index 37352cb460..1d9a3b24ce 100644 --- a/packages/client/ui-primitives/src/Toast.tsx +++ b/packages/client/ui-primitives/src/Toast.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' +import { createPortal } from 'react-dom' import css from './Toast.module.css' /** Full-opacity hold before the fade starts. Must agree with the stylesheet's @@ -12,7 +13,9 @@ const FADE_MS = 1000 * Transient top-center banner: slides in, holds at full opacity, fades out, * then reports done so the owner can unmount it. Re-showing the same text * restarts the cycle when the owner remounts the component (key it by a - * per-show sequence). + * per-show sequence). Rendered through a body portal so an owner inside a + * transformed or filtered ancestor cannot trap the fixed banner in that + * ancestor's box. * * @param props.text - resolved banner copy; the owner passes localized text. * @param props.icon - optional leading glyph (e.g. a warning icon). @@ -28,10 +31,11 @@ export function Toast({ text, icon, onDone }: { const timer = setTimeout(onDone, HOLD_MS + FADE_MS) return () => { clearTimeout(timer) } }, [onDone]) - return ( + return createPortal(
{icon !== undefined && {icon}} {text} -
+
, + document.body, ) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb80f707d2..c390556f80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1951,6 +1951,9 @@ importers: 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:^ @@ -1961,6 +1964,9 @@ importers: '@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: