Browse Source

feat(web): align attachment display with DeepSeek Chat via ui-attachment atoms

Single-click original preview in the composer rail and chat history; remove
control inside the thumbnail, revealed on hover/focus (always on touch);
hidden-scrollbar rail overflow paged by edge arrows with wheel panning and
end-reveal on add; image-intake rejections and prompt failures announce as a
transient top-center toast instead of inline strips.

The attachment atoms move to a new zero-cordis package
@deepseek-ai/dsh-client-ui-attachment (rail, message gallery, lightbox),
seeded as a platform module; the toast is a ui-primitives atom. Strings
arrive as label props bridged from the conversation dictionary.
creatixchu 2 tháng trước cách đây
mục cha
commit
e611e825b1
56 tập tin đã thay đổi với 1366 bổ sung và 251 xóa
  1. 6 0
      .agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml
  2. 31 0
      .agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md
  3. 31 0
      .agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md
  4. 3 3
      apps/web/tests/image-display.snapshot.ts
  5. 1 0
      apps/web/vite.config.ts
  6. 2 2
      docs/config-catalog.i18n.yaml
  7. 1 0
      docs/config-catalog.md
  8. 1 0
      docs/config-catalog.zh.md
  9. 2 2
      packages/client/README.i18n.yaml
  10. 1 0
      packages/client/README.md
  11. 1 0
      packages/client/README.zh.md
  12. 6 0
      packages/client/ui-attachment/README.i18n.yaml
  13. 26 0
      packages/client/ui-attachment/README.md
  14. 26 0
      packages/client/ui-attachment/README.zh.md
  15. 49 0
      packages/client/ui-attachment/package.json
  16. 112 0
      packages/client/ui-attachment/src/AttachmentRail.module.css
  17. 152 0
      packages/client/ui-attachment/src/AttachmentRail.tsx
  18. 0 0
      packages/client/ui-attachment/src/ImageLightbox.module.css
  19. 23 6
      packages/client/ui-attachment/src/ImageLightbox.tsx
  20. 1 1
      packages/client/ui-attachment/src/MessageImage.module.css
  21. 39 15
      packages/client/ui-attachment/src/MessageImage.tsx
  22. 6 0
      packages/client/ui-attachment/src/css-modules.d.ts
  23. 14 0
      packages/client/ui-attachment/src/index.ts
  24. 31 0
      packages/client/ui-attachment/src/invariant.ts
  25. 128 0
      packages/client/ui-attachment/tests/attachment-rail.spec.tsx
  26. 50 0
      packages/client/ui-attachment/tests/image-lightbox.spec.tsx
  27. 12 0
      packages/client/ui-attachment/tests/invariant.spec.ts
  28. 103 0
      packages/client/ui-attachment/tests/message-image.spec.tsx
  29. 21 0
      packages/client/ui-attachment/tsconfig.json
  30. 31 0
      packages/client/ui-attachment/tsdown.config.ts
  31. 2 0
      packages/client/ui-conversation/package.json
  32. 3 2
      packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
  33. 3 2
      packages/client/ui-conversation/src/client/chat/MessageItem.tsx
  34. 48 0
      packages/client/ui-conversation/src/client/image-labels.ts
  35. 8 4
      packages/client/ui-conversation/src/client/locales.ts
  36. 5 68
      packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
  37. 60 49
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  38. 82 0
      packages/client/ui-conversation/tests/image-labels.spec.tsx
  39. 47 6
      packages/client/ui-conversation/tests/input-bar.spec.tsx
  40. 0 81
      packages/client/ui-conversation/tests/message-image.spec.tsx
  41. 3 0
      packages/client/ui-conversation/tsconfig.json
  42. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  43. 5 1
      packages/client/ui-primitives/README.md
  44. 5 1
      packages/client/ui-primitives/README.zh.md
  45. 58 0
      packages/client/ui-primitives/src/Toast.module.css
  46. 37 0
      packages/client/ui-primitives/src/Toast.tsx
  47. 1 0
      packages/client/ui-primitives/src/index.ts
  48. 40 0
      packages/client/ui-primitives/tests/toast.spec.tsx
  49. 1 0
      packages/client/web/package.json
  50. 1 0
      packages/client/web/src/platform.ts
  51. 2 0
      packages/client/web/src/seed.ts
  52. 3 0
      packages/client/web/tsconfig.json
  53. 37 6
      pnpm-lock.yaml
  54. 1 0
      scripts/verify-package-readme-model-experience.ts
  55. 1 0
      tsconfig.base.json
  56. 1 0
      tsconfig.client.json

+ 6 - 0
.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: 84d51aada0a463145115f0cebfaf73e9b2bd9e9b
+2026-08-11-web-attachment-display-alignment.zh.md: 2676fcea333b54017c37cb4ee85f59ad7c76cfac

+ 31 - 0
.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md

@@ -0,0 +1,31 @@
+# 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.
+
+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.
+
+## 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).

+ 31 - 0
.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md

@@ -0,0 +1,31 @@
+# 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。
+
+这些 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` 用法。
+
+## 备选方案
+
+**组件留在 `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 的限制一节)。

+ 3 - 3
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<HTMLElement>('[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/ }))

+ 1 - 0
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') },
     ],

+ 2 - 2
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: 911255077833354351b08bd2800f2116510ca3c0
-config-catalog.zh.md: d3141ab389cb1b8f60b88d504e2598ab1938decc
+config-catalog.md: 59e426a484d7ed1db67bc3d48490b069e04bc1e3
+config-catalog.zh.md: e64d9f0bdb9bcedc2084c3552c6b5e71c561c75c

+ 1 - 0
docs/config-catalog.md

@@ -2817,6 +2817,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))

+ 1 - 0
docs/config-catalog.zh.md

@@ -2817,6 +2817,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))

+ 2 - 2
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: bbc32fb3944dcb3b7aa48ef1f8e24e5c93ff7a67
-README.zh.md: 5bfbd1ce6b41a44d3ef421ea59ecc29e1c329b3c
+README.md: 40005f982e4003a6ea211b958002940ef73d77ef
+README.zh.md: 4d51d7a425fe531a558d8f61f048412572ed153d

+ 1 - 0
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. |

+ 1 - 0
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 选择与创建界面。 |

+ 6 - 0
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: a0a410532a631f243c3dfef7debfc6ce0bb0699e
+README.zh.md: 4d8ae638a30dc37d89531f15cf3c541d8e09f8f6

+ 26 - 0
packages/client/ui-attachment/README.md

@@ -0,0 +1,26 @@
+# @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, 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.
+
+## 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.

+ 26 - 0
packages/client/ui-attachment/README.zh.md

@@ -0,0 +1,26 @@
+# @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)并平滑滚动,箭头的显隐在滚动、条目数量变化和窗口尺寸变化时依据滚动几何重算。纵向滚轮转为横向平移,单次行程钳制在 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.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **仅支持图片** — 非图片文件尚无附件栏卡片与历史渲染;DeepSeek Chat 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。
+- **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。

+ 49 - 0
packages/client/ui-attachment/package.json

@@ -0,0 +1,49 @@
+{
+  "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"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@types/react": "~18.3.1"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^"
+  }
+}

+ 112 - 0
packages/client/ui-attachment/src/AttachmentRail.module.css

@@ -0,0 +1,112 @@
+/* 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;
+  }
+}
+
+.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;
+}

+ 152 - 0
packages/client/ui-attachment/src/AttachmentRail.tsx

@@ -0,0 +1,152 @@
+/** 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 type { WheelEvent } 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
+}
+
+/**
+ * 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.
+ *
+ * @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<T extends AttachmentRailItem>({ items, labels, onOpen, onRemove }: {
+  items: readonly T[]
+  labels: AttachmentRailLabels
+  onOpen: (item: T) => void
+  onRemove: (item: T) => void
+}) {
+  const railRef = useRef<HTMLDivElement | null>(null)
+  const countRef = useRef(0)
+  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 = items.length > countRef.current
+    countRef.current = items.length
+    const el = railRef.current
+    // A newly added attachment lands at the rail's end: reveal it.
+    if (grew && el !== null) el.scrollLeft = el.scrollWidth - el.clientWidth
+    updateEdges()
+  }, [items.length, updateEdges])
+  useEffect(() => {
+    window.addEventListener('resize', updateEdges)
+    return () => { window.removeEventListener('resize', updateEdges) }
+  }, [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: '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<HTMLDivElement>): 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',
+    })
+  }
+  return (
+    <div className={css.root}>
+      {edges.left && (
+        <button
+          type="button"
+          className={clsx(css.arrow, css.arrowLeft)}
+          aria-label={labels.scrollLeft}
+          onClick={() => { page(-1) }}
+        >
+          <IconChevronLeftOutline14 />
+        </button>
+      )}
+      <div
+        ref={railRef}
+        className={css.rail}
+        role="group"
+        aria-label={labels.group}
+        onScroll={updateEdges}
+        onWheel={onWheel}
+      >
+        {items.map(item => (
+          <div key={item.id} className={css.item}>
+            <button
+              type="button"
+              className={css.thumbnail}
+              title={labels.open}
+              onClick={() => { onOpen(item) }}
+            >
+              <img src={item.previewUrl} alt={item.alt} />
+            </button>
+            <button
+              type="button"
+              className={css.remove}
+              aria-label={item.removeLabel}
+              onClick={() => { onRemove(item) }}
+            >
+              <IconCloseFill14 size={12} />
+            </button>
+          </div>
+        ))}
+      </div>
+      {edges.right && (
+        <button
+          type="button"
+          className={clsx(css.arrow, css.arrowRight)}
+          aria-label={labels.scrollRight}
+          onClick={() => { page(1) }}
+        >
+          <IconChevronRightOutline14 />
+        </button>
+      )}
+    </div>
+  )
+}

+ 0 - 0
packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css → packages/client/ui-attachment/src/ImageLightbox.module.css


+ 23 - 6
packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx → packages/client/ui-attachment/src/ImageLightbox.tsx

@@ -1,13 +1,30 @@
 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 }: {
+/** 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.
+ *
+ * @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
-  t: ChatViewSlotProps['t']
 }) {
   const closeRef = useRef<HTMLButtonElement | null>(null)
   const restoreRef = useRef<HTMLElement | null>(null)
@@ -30,11 +47,11 @@ export function ImageLightbox({ src, alt, onClose, t }: {
       className={css.backdrop}
       role="dialog"
       aria-modal="true"
-      aria-label={t('image.preview')}
+      aria-label={labels.dialog}
       onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
     >
       <img className={css.image} src={src} alt={alt} />
-      <button ref={closeRef} type="button" className={css.close} aria-label={t('image.closePreview')} onClick={onClose}>×</button>
+      <button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
     </div>
   )
 }

+ 1 - 1
packages/client/ui-conversation/src/client/chat/MessageImage.module.css → 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;
 }

+ 39 - 15
packages/client/ui-conversation/src/client/chat/MessageImage.tsx → packages/client/ui-attachment/src/MessageImage.tsx

@@ -1,17 +1,41 @@
 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 { 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<string>
 
-/** Compact history renderer with retryable loading and double-click original preview. */
-export function MessageImage({ attachment, load, t }: {
+/** 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
-  t: ChatViewSlotProps['t']
+  labels: MessageImageLabels
 }) {
   const [src, setSrc] = useState<string | null>(null)
   const [error, setError] = useState(false)
@@ -35,37 +59,37 @@ export function MessageImage({ attachment, load, t }: {
     return () => { live = false }
   }, [attachment, load])
 
-  const label = attachment.name ?? t('image.label')
-  if (error) return <button type="button" className={css.error} onClick={request}>{t('image.loadFailed')}</button>
+  const label = attachment.name ?? labels.image
+  if (error) return <button type="button" className={css.error} onClick={request}>{labels.loadFailed}</button>
   return (
     <>
       <button
         type="button"
         className={css.frame}
         style={size}
-        title={t('image.openOriginal')}
-        aria-label={t('image.openOriginalLabel', { label })}
-        onDoubleClick={() => { if (src !== null) setOpen(true) }}
+        title={labels.open}
+        aria-label={labels.openNamed(label)}
+        onClick={() => { if (src !== null) setOpen(true) }}
       >
-        {src === null ? <span className={css.loading}>{t('image.loading')}</span> : <img src={src} alt={label} />}
+        {src === null ? <span className={css.loading}>{labels.loading}</span> : <img src={src} alt={label} />}
       </button>
-      {open && src !== null && <ImageLightbox src={src} alt={label} onClose={close} t={t} />}
+      {open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
     </>
   )
 }
 
 /** Wrapping image group shared by user and assistant history. */
-export function ImageGallery({ images, load, align, t }: {
+export function ImageGallery({ images, load, align, labels }: {
   images: readonly { attachment: ImageAttachmentRef }[]
   load: ImageLoader
   align: 'start' | 'end'
-  t: ChatViewSlotProps['t']
+  labels: MessageImageLabels
 }) {
   if (images.length === 0) return null
   return (
     <div className={css.gallery} data-align={align}>
       {images.map((image, index) => (
-        <MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} t={t} />
+        <MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} labels={labels} />
       ))}
     </div>
   )

+ 6 - 0
packages/client/ui-attachment/src/css-modules.d.ts

@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}
+
+declare module '*.css'

+ 14 - 0
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'

+ 31 - 0
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 */

+ 128 - 0
packages/client/ui-attachment/tests/attachment-rail.spec.tsx

@@ -0,0 +1,128 @@
+// @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.
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { AttachmentRail } from '../src/AttachmentRail.tsx'
+import type { AttachmentRailItem, AttachmentRailLabels } from '../src/AttachmentRail.tsx'
+
+afterEach(cleanup)
+
+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(<AttachmentRail items={items} labels={labels} onOpen={onOpen} onRemove={onRemove} />)
+    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(
+      <AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+    )
+    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 on window resize', () => {
+    const view = render(
+      <AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+    )
+    const rail = view.getByRole('group', { name: '待发送图片' })
+    const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
+    setScrollLeft(100)
+    fireEvent(window, new Event('resize'))
+    expect(view.getByLabelText('向左滚动图片')).toBeTruthy()
+    expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
+  })
+
+  it('pans horizontally on a vertical wheel with clamped travel', () => {
+    const view = render(
+      <AttachmentRail items={[item('a'), item('b')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+    )
+    const rail = view.getByRole('group', { name: '待发送图片' })
+    const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
+    fireEvent.wheel(rail, { deltaY: 30 })
+    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' })
+    // A trackpad pan (deltaX) and a zero-delta wheel keep native behavior.
+    fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })
+    fireEvent.wheel(rail, { deltaY: 0 })
+    expect(scrollBy).toHaveBeenCalledTimes(3)
+  })
+
+  it('reveals the rail end when an item is added, not when one is removed', () => {
+    const first = [item('a'), item('b')]
+    const view = render(
+      <AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+    )
+    const rail = view.getByRole('group', { name: '待发送图片' })
+    stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
+    view.rerender(
+      <AttachmentRail items={[...first, item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+    )
+    expect(rail.scrollLeft).toBe(200)
+    view.rerender(
+      <AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+    )
+    // Removal keeps the position; only growth jumps to the end.
+    expect(rail.scrollLeft).toBe(200)
+  })
+})

+ 50 - 0
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(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
+    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(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={vi.fn()} />)
+      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(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
+    fireEvent.mouseDown(view.getByRole('img'))
+    expect(onClose).not.toHaveBeenCalled()
+    fireEvent.mouseDown(view.getByRole('dialog', { name: '原图预览' }))
+    expect(onClose).toHaveBeenCalledTimes(1)
+  })
+})

+ 12 - 0
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()
+  })
+})

+ 103 - 0
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(<MessageImage attachment={attachment} load={load} labels={labels} />)
+    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<string>(() => {}))
+    const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
+    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(<MessageImage attachment={unnamed} load={load} labels={labels} />)
+    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(<MessageImage attachment={attachment} load={load} labels={labels} />)
+    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<string>((r) => { resolve = r }))
+    const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
+    view.unmount()
+    resolve?.('blob:late')
+    await Promise.resolve()
+    let reject: ((error: Error) => void) | undefined
+    const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
+    const second = render(<MessageImage attachment={attachment} load={failing} labels={labels} />)
+    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(<ImageGallery images={[]} load={load} align="start" labels={labels} />)
+    expect(empty.container.firstChild).toBeNull()
+    const view = render(
+      <ImageGallery images={[{ attachment }, { attachment }]} load={load} align="end" labels={labels} />,
+    )
+    expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
+    await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
+  })
+})

+ 21 - 0
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"
+    }
+  ]
+}

+ 31 - 0
packages/client/ui-attachment/tsdown.config.ts

@@ -0,0 +1,31 @@
+import { clientOnly } from '../tsdown.client.ts'
+
+/**
+ * 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 {};'
+    },
+  }],
+}])

+ 2 - 0
packages/client/ui-conversation/package.json

@@ -57,6 +57,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-slash": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
@@ -82,6 +83,7 @@
     "@deepseek-ai/dsh-goal": "workspace:^",
     "@deepseek-ai/dsh-plan-mode": "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-slash": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",

+ 3 - 2
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 <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
-            case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" t={t} />
+            case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" labels={messageImageLabels(t)} />
             // Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
             case 'tool-call': return null
             default: return (

+ 3 - 2
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<UserMessageNode['content'][number], { type: 'image' }>
@@ -177,7 +178,7 @@ function UserStyleBubble({
   return (
     <div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
       <div className={css.userStack}>
-        <ImageGallery images={images} load={imageLoader} align="end" t={t} />
+        <ImageGallery images={images} load={imageLoader} align="end" labels={messageImageLabels(t)} />
         {showBubble && <div className={css.bubble}>
           {projectUserText(text)}
           {rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}

+ 48 - 0
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<ConversationKey>): 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<ConversationKey>): 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<ConversationKey>): AttachmentRailLabels {
+  return {
+    group: t('image.pending'),
+    open: t('image.openOriginal'),
+    scrollLeft: t('image.scrollLeft'),
+    scrollRight: t('image.scrollRight'),
+  }
+}

+ 8 - 4
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',

+ 5 - 68
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

+ 60 - 49
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,7 +70,22 @@ export function InputBar({
   const empty = draft.trim() === '' && attachments.length === 0
   const [preview, setPreview] = useState<ComposerAttachment | null>(null)
   const [dragActive, setDragActive] = useState(false)
-  const [dropError, setDropError] = useState<string | null>(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.
+  useEffect(() => {
+    if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
+  }, [promptError, showToast])
   const inputRef = useRef<HTMLTextAreaElement | null>(null)
   const dragDepthRef = useRef(0)
   const scrollRef = useRef<HTMLDivElement | null>(null)
@@ -369,7 +381,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 +408,6 @@ export function InputBar({
     event.preventDefault()
     if (locked || machineBusy || addImages === undefined) return
     dragDepthRef.current += 1
-    setDropError(null)
     setDragActive(true)
   }
 
@@ -416,11 +430,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<ComposerRailItem[]>(() => 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<HTMLTextAreaElement>): 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 +570,13 @@ export function InputBar({
 
   return (
     <div className={clsx(css.root, variant === 'hero' && css.hero)}>
-      {error !== null && (
-        <div className={css.error} role="alert">
-          {error.message}
-        </div>
+      {toast !== null && (
+        <Toast
+          key={toast.seq}
+          text={toast.text}
+          icon={<IconWarningOutline16 />}
+          onDone={dismissToast}
+        />
       )}
       {notice !== null && (
         <div className={clsx(css.notice, notice.level === 'error' && css.noticeError)} role="status">
@@ -558,7 +588,6 @@ 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 && <div className={css.error} role="alert">{dropError}</div>}
       <div
         className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
         data-composer-card
@@ -572,29 +601,14 @@ export function InputBar({
         {dragActive && <div className={css.dropHint} role="status">{t('image.dropHint')}</div>}
         {overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
         {accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
-        {attachments.length > 0 && (
-          <div className={css.attachments} role="group" aria-label={t('image.pending')}>
-            {attachments.map(attachment => (
-              <div key={attachment.id} className={css.attachment}>
-                <button
-                  type="button"
-                  className={css.thumbnail}
-                  title={t('image.openOriginal')}
-                  onDoubleClick={() => { setPreview(attachment) }}
-                >
-                  <img src={attachment.previewUrl} alt={attachment.file.name || t('image.pending')} />
-                </button>
-                <button
-                  type="button"
-                  className={css.remove}
-                  aria-label={t('image.remove', { name: attachment.file.name })}
-                  onClick={() => {
-                    setDropError(null)
-                    removeImage?.(attachment.id)
-                  }}
-                >×</button>
-              </div>
-            ))}
+        {railItems.length > 0 && (
+          <div className={css.attachments}>
+            <AttachmentRail
+              items={railItems}
+              labels={attachmentRailLabels(t)}
+              onOpen={(item) => { setPreview(item.attachment) }}
+              onRemove={(item) => { removeImage?.(item.attachment.id) }}
+            />
           </div>
         )}
         {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
@@ -628,10 +642,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 +723,8 @@ export function InputBar({
         <ImageLightbox
           src={preview.previewUrl}
           alt={preview.file.name || t('image.original')}
+          labels={lightboxLabels(t)}
           onClose={closePreview}
-          t={t}
         />
       )}
       {footer}

+ 82 - 0
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(
+      <AssistantMarkdown
+        t={t}
+        blocks={[{ kind: 'image', attachment }]}
+        streaming={false}
+        loadImage={() => 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(
+      <AssistantMarkdown
+        t={enT}
+        blocks={[{ kind: 'image', attachment }]}
+        streaming={false}
+        loadImage={() => 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(
+      <AssistantMarkdown
+        t={t}
+        blocks={[
+          { kind: 'text', text: 'before' },
+          { kind: 'image', attachment },
+          { kind: 'text', text: 'after' },
+        ]}
+        streaming={false}
+        loadImage={() => 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)
+  })
+})

+ 47 - 6
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,17 @@ 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' } } } })
+      expect(send.view.container.querySelector('[role="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()
+    } finally {
+      vi.useRealTimers()
+    }
   })
 
   it('renders the notice strip from the machine notice store', () => {

+ 0 - 81
packages/client/ui-conversation/tests/message-image.spec.tsx

@@ -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(<MessageImage attachment={attachment} load={load} t={t} />)
-    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(<MessageImage attachment={attachment} load={load} t={t} />)
-    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(<MessageImage attachment={attachment} load={load} t={enT} />)
-    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(
-      <AssistantMarkdown
-        t={t}
-        blocks={[
-          { kind: 'text', text: 'before' },
-          { kind: 'image', attachment },
-          { kind: 'text', text: 'after' },
-        ]}
-        streaming={false}
-        loadImage={() => 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)
-  })
-})

+ 3 - 0
packages/client/ui-conversation/tsconfig.json

@@ -23,6 +23,9 @@
     {
       "path": "../ui-slots"
     },
+    {
+      "path": "../ui-attachment"
+    },
     {
       "path": "../ui-primitives"
     },

+ 2 - 2
packages/client/ui-primitives/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-primitives/README.md
-README.md: a9c802c1e43cf06aa0492b39d5052e882a72d9e6
-README.zh.md: 9e67488ccc1d70263bd0b91d167c4c251ee95926
+README.md: 6e2cfed2578a59eec10f6d50b2bb5da3c7019764
+README.zh.md: d651d9fac1c597b33ec95ffe9a0b69b3a69455a1

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 5 - 1
packages/client/ui-primitives/README.md


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 5 - 1
packages/client/ui-primitives/README.zh.md


+ 58 - 0
packages/client/ui-primitives/src/Toast.module.css

@@ -0,0 +1,58 @@
+/* Transient top-center banner (DeepSeek Chat toast look): contrast fill,
+   inverted label, slide-in, then hold-and-fade. The fade delay/duration MUST
+   agree with HOLD_MS/FADE_MS in Toast.tsx: the component unmounts at their
+   sum, so a mismatched sheet either cuts the fade or leaves an invisible
+   banner blocking nothing. */
+
+.toast {
+  position: fixed;
+  top: 80px;
+  left: 50%;
+  /* Above the 1000 the image lightbox backdrop uses: a failure reported while
+     a preview is open must stay readable. */
+  z-index: 1100;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  max-width: min(560px, calc(100vw - 48px));
+  padding: 12px 16px;
+  border-radius: 14px;
+  background: var(--dsw-alias-button-contrast-fill);
+  color: var(--dsw-alias-label-primary-inverted);
+  font-size: 14px;
+  line-height: 22px;
+  box-shadow: var(--dsw-shadow-lv3);
+  transform: translateX(-50%);
+  animation:
+    dsh-toast-in 160ms ease-out,
+    dsh-toast-fade 1000ms ease 3000ms forwards;
+}
+
+.icon {
+  display: grid;
+  place-items: center;
+  flex: none;
+  color: var(--dsw-alias-state-warn-label);
+}
+
+.text {
+  min-width: 0;
+}
+
+@keyframes dsh-toast-in {
+  from {
+    opacity: 0;
+    transform: translate(-50%, -6px);
+  }
+
+  to {
+    opacity: 1;
+    transform: translate(-50%, 0);
+  }
+}
+
+@keyframes dsh-toast-fade {
+  to {
+    opacity: 0;
+  }
+}

+ 37 - 0
packages/client/ui-primitives/src/Toast.tsx

@@ -0,0 +1,37 @@
+import { useEffect } from 'react'
+import type { ReactNode } from 'react'
+import css from './Toast.module.css'
+
+/** Full-opacity hold before the fade starts. Must agree with the stylesheet's
+ * toast-fade delay (Toast.module.css) or the banner unmounts mid-fade. */
+const HOLD_MS = 3000
+/** Fade duration. Must agree with the stylesheet's toast-fade duration. */
+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).
+ *
+ * @param props.text - resolved banner copy; the owner passes localized text.
+ * @param props.icon - optional leading glyph (e.g. a warning icon).
+ * @param props.onDone - called once the fade completes; unmount the toast here.
+ * @returns the floating banner.
+ */
+export function Toast({ text, icon, onDone }: {
+  text: string
+  icon?: ReactNode
+  onDone: () => void
+}) {
+  useEffect(() => {
+    const timer = setTimeout(onDone, HOLD_MS + FADE_MS)
+    return () => { clearTimeout(timer) }
+  }, [onDone])
+  return (
+    <div className={css.toast} role="alert">
+      {icon !== undefined && <span className={css.icon} aria-hidden>{icon}</span>}
+      <span className={css.text}>{text}</span>
+    </div>
+  )
+}

+ 1 - 0
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'

+ 40 - 0
packages/client/ui-primitives/tests/toast.spec.tsx

@@ -0,0 +1,40 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, 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(<Toast text="最多添加 50 张图片" icon={<svg data-testid="icon" />} 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('renders without an icon and cancels its timer on unmount', () => {
+    vi.useFakeTimers()
+    try {
+      const onDone = vi.fn()
+      const view = render(<Toast text="plain" onDone={onDone} />)
+      expect(view.getByRole('alert').querySelector('[aria-hidden]')).toBeNull()
+      view.unmount()
+      vi.advanceTimersByTime(10_000)
+      expect(onDone).not.toHaveBeenCalled()
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+})

+ 1 - 0
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:^",

+ 1 - 0
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
 

+ 2 - 0
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<string, unknown> {
     '@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<PlatformModule, unknown>
 }

+ 3 - 0
packages/client/web/tsconfig.json

@@ -20,6 +20,9 @@
     {
       "path": "../ui-slots"
     },
+    {
+      "path": "../ui-attachment"
+    },
     {
       "path": "../ui-primitives"
     },

+ 37 - 6
pnpm-lock.yaml

@@ -1937,6 +1937,31 @@ 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
+    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
+
   packages/client/ui-command:
     dependencies:
       clsx:
@@ -2016,6 +2041,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
@@ -2859,15 +2887,15 @@ importers:
         specifier: ^9.0.0
         version: 9.0.0
     devDependencies:
-      '@deepseek-ai/dsh-client-locale':
-        specifier: workspace:^
-        version: link:../locale
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
       '@deepseek-ai/dsh-agent':
         specifier: workspace:^
         version: link:../../core/agent
+      '@deepseek-ai/dsh-client-locale':
+        specifier: workspace:^
+        version: link:../locale
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime
@@ -2950,6 +2978,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
@@ -4495,12 +4526,12 @@ importers:
       '@deepseek-ai/dsh-workspace':
         specifier: workspace:^
         version: link:../../workspace/workspace
-      fflate:
-        specifier: ^0.8.2
-        version: 0.8.3
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
+      fflate:
+        specifier: ^0.8.2
+        version: 0.8.3
       zod:
         specifier: ^4.4.3
         version: 4.4.3

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -61,6 +61,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   '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.' },

+ 1 - 0
tsconfig.base.json

@@ -154,6 +154,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"],

+ 1 - 0
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" },

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác