Web attachment display aligns with DeepSeek Chat via attachment atoms
Web 附件展示经附件原子组件对齐 DeepSeek Chat
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
English
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; 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 @deepseek-ai/dsh-client-ui-attachment (packages/client/ui-attachment): 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. These remain internal pure-props components. ui-conversation declares the composer-attachment and message-image slots and supplies draft ids, image loading, intake callbacks, and its locale seat; the dynamic ui-attachment client entry waits on those declarations and registers the presentation. The dynamic render and attachment ownership note owns this package integration; the visual behavior recorded here is unchanged.
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.
Export attachment atoms and import them directly from ui-conversation. Rejected by the package integration decision: direct component imports bypass dynamic plugin lifecycle and cross-plugin slot composition. The conversation package still owns the data and render sites, while ui-attachment owns their optional presentation entries.
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 match DeepSeek Chat's interaction model, and the pure-props components render under the conversation slot's locale seat without reaching into application state. The cost is a real dynamic package boundary: ui-attachment carries the standard plugin scaffolding (client bundle, invariant companion, bilingual README, tsconfig face, per-file 100% coverage), and omitting it leaves the two optional attachment slots empty. Error banners are 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).
中文
问题
Web 输入框的图片界面缺乏基本可用性(用户反馈,issue #2248)。删除按钮以 top/right: -6px 挂在 72px 缩略图外侧,被附件栏的 overflow-x 盒子裁切,点击经常落空;预览只能双击打开,除了 tooltip 没有任何提示这个操作;附件栏超出输入框宽度时在胶囊内部直接出现原生横向滚动条;图片接收被拒和发送失败(例如所选模型不支持图片输入时的 attachment-error)以常驻的内联红条显示在卡片上方。这些界面在 DeepSeek Chat 里都有用户熟悉的既定设计:单击预览、卡片内部悬停显示的删除按钮、隐藏滚动条的箭头翻页、顶部居中的短时 toast。
首个多模态版本把这些界面记录在Web 多模态 Note中;本 Note 取代其中的展示与交互细节(缩略图几何、点击方式、错误呈现),其附件服务边界、准入与持久化决策继续有效。
这些 UI 还全部住在 dsh-client-ui-conversation 里——附件栏内联在 700 行的 InputBar 中,历史图片和灯箱分散在 chat/ 与 skeleton/——没有其他界面可复用的接缝,纯 props 的纪律也无从约束。
决定
附件展示位于 @deepseek-ai/dsh-client-ui-attachment(packages/client/ui-attachment):AttachmentRail(64px、16px 圆角缩略图,单击 onOpen,卡片内部的删除按钮悬停或聚焦显示、pointer: coarse 下常显,隐藏滚动条配两端圆形箭头并依滚动几何重算,纵向滚轮转横向平移且单次钳制 60px,新增条目滚到栏尾),MessageImage/ImageGallery(单击预览),以及 ImageLightbox。这些组件仍是包内的纯 props 组件。ui-conversation 声明输入框附件与消息图片 slot,并提供草稿 id、图片加载、接收回调及其 locale seat;动态 ui-attachment 客户端 entry 等待这些声明并注册呈现。动态渲染与附件归属 Note负责这项包集成决策;本 Note 记录的视觉行为保持不变。
两个浮层都 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-conversation 直接导入。 被包集成决策否决:直接导入组件会绕过动态插件生命周期与跨插件 slot 组合。conversation 包仍持有数据与渲染位置,ui-attachment 则持有其中可选的呈现 entry。
Toast 放在 ui-conversation。 否决:短时横幅没有任何会话特有的东西,ui-primitives 是零 cordis 原子组件的既定归属,其他界面也可能复用。
保留内联红条,只给图片接收加 toast。 否决:promptError(issue 截图里的 attachment-error)恰是用户实际抱怨的界面,一个输入框里存在两种错误呈现会让红条成为孤例。
结果
输入框与历史图片界面的交互模型与 DeepSeek Chat 一致,纯 props 组件通过 conversation slot 的 locale seat 渲染,无需触达应用状态。代价是一个真实的动态包边界:ui-attachment 带有标准插件脚手架(客户端 bundle、invariant 伴生、双语 README、tsconfig face、逐文件 100% 覆盖率),省略该插件会让两个可选附件 slot 保持为空。错误横幅是短时的——用户移开视线四秒就会错过消息,这正是 DeepSeek Chat 自己做的取舍。非图片附件仍不支持;附件栏的卡片模型已就绪,但输入框的接收仍只认图片(记录于包 README 的限制一节)。