From e611e825b10ffffe419c39038c5e1c2cb647003e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 11 Aug 2026 17:01:29 +0800 Subject: [PATCH] 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. --- ...web-attachment-display-alignment.i18n.yaml | 6 + ...-08-11-web-attachment-display-alignment.md | 31 ++++ ...-11-web-attachment-display-alignment.zh.md | 31 ++++ apps/web/tests/image-display.snapshot.ts | 6 +- apps/web/vite.config.ts | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + .../client/ui-attachment/README.i18n.yaml | 6 + packages/client/ui-attachment/README.md | 26 +++ packages/client/ui-attachment/README.zh.md | 26 +++ packages/client/ui-attachment/package.json | 49 ++++++ .../src/AttachmentRail.module.css | 112 +++++++++++++ .../ui-attachment/src/AttachmentRail.tsx | 152 ++++++++++++++++++ .../src}/ImageLightbox.module.css | 0 .../src}/ImageLightbox.tsx | 29 +++- .../src}/MessageImage.module.css | 2 +- .../src}/MessageImage.tsx | 54 +++++-- .../client/ui-attachment/src/css-modules.d.ts | 6 + packages/client/ui-attachment/src/index.ts | 14 ++ .../client/ui-attachment/src/invariant.ts | 31 ++++ .../tests/attachment-rail.spec.tsx | 128 +++++++++++++++ .../tests/image-lightbox.spec.tsx | 50 ++++++ .../ui-attachment/tests/invariant.spec.ts | 12 ++ .../tests/message-image.spec.tsx | 103 ++++++++++++ packages/client/ui-attachment/tsconfig.json | 21 +++ .../client/ui-attachment/tsdown.config.ts | 31 ++++ packages/client/ui-conversation/package.json | 2 + .../src/client/chat/AssistantMarkdown.tsx | 5 +- .../src/client/chat/MessageItem.tsx | 5 +- .../src/client/image-labels.ts | 48 ++++++ .../ui-conversation/src/client/locales.ts | 12 +- .../src/client/skeleton/InputBar.module.css | 73 +-------- .../src/client/skeleton/InputBar.tsx | 109 +++++++------ .../tests/image-labels.spec.tsx | 82 ++++++++++ .../ui-conversation/tests/input-bar.spec.tsx | 53 +++++- .../tests/message-image.spec.tsx | 81 ---------- packages/client/ui-conversation/tsconfig.json | 3 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 6 +- packages/client/ui-primitives/README.zh.md | 6 +- .../client/ui-primitives/src/Toast.module.css | 58 +++++++ packages/client/ui-primitives/src/Toast.tsx | 37 +++++ packages/client/ui-primitives/src/index.ts | 1 + .../client/ui-primitives/tests/toast.spec.tsx | 40 +++++ packages/client/web/package.json | 1 + packages/client/web/src/platform.ts | 1 + packages/client/web/src/seed.ts | 2 + packages/client/web/tsconfig.json | 3 + pnpm-lock.yaml | 43 ++++- .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + 56 files changed, 1366 insertions(+), 251 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md create mode 100644 .agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md create mode 100644 packages/client/ui-attachment/README.i18n.yaml create mode 100644 packages/client/ui-attachment/README.md create mode 100644 packages/client/ui-attachment/README.zh.md create mode 100644 packages/client/ui-attachment/package.json create mode 100644 packages/client/ui-attachment/src/AttachmentRail.module.css create mode 100644 packages/client/ui-attachment/src/AttachmentRail.tsx rename packages/client/{ui-conversation/src/client/skeleton => ui-attachment/src}/ImageLightbox.module.css (100%) rename packages/client/{ui-conversation/src/client/skeleton => ui-attachment/src}/ImageLightbox.tsx (54%) rename packages/client/{ui-conversation/src/client/chat => ui-attachment/src}/MessageImage.module.css (97%) rename packages/client/{ui-conversation/src/client/chat => ui-attachment/src}/MessageImage.tsx (52%) create mode 100644 packages/client/ui-attachment/src/css-modules.d.ts create mode 100644 packages/client/ui-attachment/src/index.ts create mode 100644 packages/client/ui-attachment/src/invariant.ts create mode 100644 packages/client/ui-attachment/tests/attachment-rail.spec.tsx create mode 100644 packages/client/ui-attachment/tests/image-lightbox.spec.tsx create mode 100644 packages/client/ui-attachment/tests/invariant.spec.ts create mode 100644 packages/client/ui-attachment/tests/message-image.spec.tsx create mode 100644 packages/client/ui-attachment/tsconfig.json create mode 100644 packages/client/ui-attachment/tsdown.config.ts create mode 100644 packages/client/ui-conversation/src/client/image-labels.ts create mode 100644 packages/client/ui-conversation/tests/image-labels.spec.tsx delete mode 100644 packages/client/ui-conversation/tests/message-image.spec.tsx create mode 100644 packages/client/ui-primitives/src/Toast.module.css create mode 100644 packages/client/ui-primitives/src/Toast.tsx create mode 100644 packages/client/ui-primitives/tests/toast.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml new file mode 100644 index 0000000000..e967409825 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md +2026-08-11-web-attachment-display-alignment.md: 84d51aada0a463145115f0cebfaf73e9b2bd9e9b +2026-08-11-web-attachment-display-alignment.zh.md: 2676fcea333b54017c37cb4ee85f59ad7c76cfac diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md new file mode 100644 index 0000000000..84d51aada0 --- /dev/null +++ b/.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). diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md new file mode 100644 index 0000000000..2676fcea33 --- /dev/null +++ b/.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 的限制一节)。 diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index df286ec1c3..9f22cb55f7 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -4,7 +4,7 @@ // Opens the fixture history session whose turn 72 carries an image in BOTH a // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized -// sessions.attachment route, the double-click ImageLightbox, and the composer +// sessions.attachment route, the single-click ImageLightbox, and the composer // intake chain (paste → ordered thumbnail rail → image-only send enablement → remove). import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { expect, it } from 'vitest' @@ -66,10 +66,10 @@ it('renders the history image pair through the authorized attachment route and o `) const userImage = document.querySelector('[data-align="end"] img')! - // Double-click opens the original-size lightbox; Escape/close dismisses it. + // A single click opens the original-size lightbox; Escape/close dismisses it. const frame = userImage.closest('button') if (frame === null) throw new Error('image frame button missing') - fireEvent.doubleClick(frame) + fireEvent.click(frame) const lightbox = await screen.findByRole('dialog') expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob') fireEvent.click(within(lightbox).getByRole('button', { name: /Close/ })) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 66076c1171..5d77e13e03 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -143,6 +143,7 @@ export default defineConfig({ { find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') }, + { find: /^@deepseek-ai\/dsh-client-ui-attachment$/, replacement: src('../../packages/client/ui-attachment/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-schema-form$/, replacement: src('../../packages/client/schema-form/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') }, ], diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 098c91c804..099444a5cc 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 911255077833354351b08bd2800f2116510ca3c0 -config-catalog.zh.md: d3141ab389cb1b8f60b88d504e2598ab1938decc +config-catalog.md: 59e426a484d7ed1db67bc3d48490b069e04bc1e3 +config-catalog.zh.md: e64d9f0bdb9bcedc2084c3552c6b5e71c561c75c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9112550778..59e426a484 100644 --- a/docs/config-catalog.md +++ b/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)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d3141ab389..e64d9f0bdb 100644 --- a/docs/config-catalog.zh.md +++ b/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)) diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index c1f21425d6..e8c6113e2b 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: bbc32fb3944dcb3b7aa48ef1f8e24e5c93ff7a67 -README.zh.md: 5bfbd1ce6b41a44d3ef421ea59ecc29e1c329b3c +README.md: 40005f982e4003a6ea211b958002940ef73d77ef +README.zh.md: 4d51d7a425fe531a558d8f61f048412572ed153d diff --git a/packages/client/README.md b/packages/client/README.md index bbc32fb394..40005f982e 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -18,6 +18,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots. | | [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme. | | [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers. | +| [`ui-attachment/`](ui-attachment/README.md) | Provides attachment display atoms: draft-image rail, message gallery, and lightbox. | | [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions. | | [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. | | [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 5bfbd1ce6b..4d51d7a425 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -18,6 +18,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-slots/`](ui-slots/README.md) | 定义 UI 功能注册和组合扩展 slot 的方式。 | | [`ui-theme/`](ui-theme/README.md) | 应用所选颜色主题。 | | [`ui-primitives/`](ui-primitives/README.md) | 提供共享 React 控件、图标和内容渲染器。 | +| [`ui-attachment/`](ui-attachment/README.md) | 提供附件展示原子组件:草稿图片栏、消息画廊与灯箱。 | | [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 | | [`ui-sidebar/`](ui-sidebar/README.md) | 展示 Workspace 与会话导航。 | | [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 | diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml new file mode 100644 index 0000000000..4a66c5ebe6 --- /dev/null +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md +README.md: a0a410532a631f243c3dfef7debfc6ce0bb0699e +README.zh.md: 4d8ae638a30dc37d89531f15cf3c541d8e09f8f6 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md new file mode 100644 index 0000000000..a0a410532a --- /dev/null +++ b/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. diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md new file mode 100644 index 0000000000..4d8ae638a3 --- /dev/null +++ b/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 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。 +- **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。 diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json new file mode 100644 index 0000000000..3174f4c8b9 --- /dev/null +++ b/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:^" + } +} diff --git a/packages/client/ui-attachment/src/AttachmentRail.module.css b/packages/client/ui-attachment/src/AttachmentRail.module.css new file mode 100644 index 0000000000..5e53374af2 --- /dev/null +++ b/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; +} diff --git a/packages/client/ui-attachment/src/AttachmentRail.tsx b/packages/client/ui-attachment/src/AttachmentRail.tsx new file mode 100644 index 0000000000..db83184efc --- /dev/null +++ b/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({ items, labels, onOpen, onRemove }: { + items: readonly T[] + labels: AttachmentRailLabels + onOpen: (item: T) => void + onRemove: (item: T) => void +}) { + const railRef = useRef(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): 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 ( +
+ {edges.left && ( + + )} +
+ {items.map(item => ( +
+ + +
+ ))} +
+ {edges.right && ( + + )} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css b/packages/client/ui-attachment/src/ImageLightbox.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css rename to packages/client/ui-attachment/src/ImageLightbox.module.css diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx b/packages/client/ui-attachment/src/ImageLightbox.tsx similarity index 54% rename from packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx rename to packages/client/ui-attachment/src/ImageLightbox.tsx index 43cbc441a5..dcf01bbc41 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx +++ b/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(null) const restoreRef = useRef(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() }} > {alt} - + ) } diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css similarity index 97% rename from packages/client/ui-conversation/src/client/chat/MessageImage.module.css rename to packages/client/ui-attachment/src/MessageImage.module.css index e05a6fc625..17ac423640 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -24,7 +24,7 @@ padding: 0; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 12px; + border-radius: 16px; background: var(--dsw-alias-interactive-bg-hover); cursor: zoom-in; } diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx similarity index 52% rename from packages/client/ui-conversation/src/client/chat/MessageImage.tsx rename to packages/client/ui-attachment/src/MessageImage.tsx index 3f22ff72b8..943d1fd158 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx +++ b/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 -/** 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(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 + const label = attachment.name ?? labels.image + if (error) return return ( <> - {open && src !== null && } + {open && src !== null && } ) } /** 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 (
{images.map((image, index) => ( - + ))}
) diff --git a/packages/client/ui-attachment/src/css-modules.d.ts b/packages/client/ui-attachment/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-attachment/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-attachment/src/index.ts b/packages/client/ui-attachment/src/index.ts new file mode 100644 index 0000000000..8757915fee --- /dev/null +++ b/packages/client/ui-attachment/src/index.ts @@ -0,0 +1,14 @@ +/** + * Pure React attachment atoms (zero cordis): the composer draft-image rail, + * the chat-history image gallery, and the original-image lightbox. Owners + * resolve every string through their own locale namespace and pass it down; + * nothing here reads application state. + * @module @deepseek-ai/dsh-client-ui-attachment + */ + +export { AttachmentRail } from './AttachmentRail.tsx' +export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx' +export { ImageLightbox } from './ImageLightbox.tsx' +export type { ImageLightboxLabels } from './ImageLightbox.tsx' +export { ImageGallery, MessageImage } from './MessageImage.tsx' +export type { ImageLoader, MessageImageLabels } from './MessageImage.tsx' diff --git a/packages/client/ui-attachment/src/invariant.ts b/packages/client/ui-attachment/src/invariant.ts new file mode 100644 index 0000000000..47d18f97b8 --- /dev/null +++ b/packages/client/ui-attachment/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-attachment`. + * @module @deepseek-ai/dsh-client-ui-attachment/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-attachment' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-attachment-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: pure props-in React atoms with no Cordis API — + * no events, no services, no mutable cross-plugin state; rendering contracts + * are asserted directly by this package's component specs. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-attachment/tests/attachment-rail.spec.tsx b/packages/client/ui-attachment/tests/attachment-rail.spec.tsx new file mode 100644 index 0000000000..de468bd6ce --- /dev/null +++ b/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() + const rail = view.getByRole('group', { name: '待发送图片' }) + expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['a.png', 'b.png']) + fireEvent.click(view.getAllByTitle('查看原图')[0]!) + expect(onOpen).toHaveBeenCalledWith(items[0]) + fireEvent.click(view.getByRole('button', { name: '移除图片 b.png' })) + expect(onRemove).toHaveBeenCalledWith(items[1]) + }) + + it('shows edge arrows from scroll geometry and pages a viewport at a time', () => { + const view = render( + , + ) + const rail = view.getByRole('group', { name: '待发送图片' }) + const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) + // No arrows until geometry is observed (mount saw jsdom's zero metrics). + expect(view.queryByLabelText('向右滚动图片')).toBeNull() + fireEvent.scroll(rail) + // Same-edges scroll takes the memoized-state path. + fireEvent.scroll(rail) + expect(view.queryByLabelText('向左滚动图片')).toBeNull() + const right = view.getByLabelText('向右滚动图片') + // clientWidth 200 - 64 < the 200 floor: pages by the floor. + fireEvent.click(right) + expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' }) + fireEvent.scroll(rail) + // Scrolled to the far edge: only the left arrow remains. + expect(view.queryByLabelText('向右滚动图片')).toBeNull() + fireEvent.click(view.getByLabelText('向左滚动图片')) + expect(scrollBy).toHaveBeenCalledWith({ left: -200, behavior: 'smooth' }) + fireEvent.scroll(rail) + expect(view.queryByLabelText('向左滚动图片')).toBeNull() + expect(view.getByLabelText('向右滚动图片')).toBeTruthy() + }) + + it('shows both arrows mid-scroll and recomputes on window resize', () => { + const view = render( + , + ) + 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( + , + ) + 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( + , + ) + const rail = view.getByRole('group', { name: '待发送图片' }) + stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) + view.rerender( + , + ) + expect(rail.scrollLeft).toBe(200) + view.rerender( + , + ) + // Removal keeps the position; only growth jumps to the end. + expect(rail.scrollLeft).toBe(200) + }) +}) diff --git a/packages/client/ui-attachment/tests/image-lightbox.spec.tsx b/packages/client/ui-attachment/tests/image-lightbox.spec.tsx new file mode 100644 index 0000000000..6152fc5ec2 --- /dev/null +++ b/packages/client/ui-attachment/tests/image-lightbox.spec.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { ImageLightbox } from '../src/ImageLightbox.tsx' + +afterEach(cleanup) + +const labels = { dialog: '原图预览', close: '关闭原图预览' } + +describe('ImageLightbox', () => { + it('focuses its close control, closes by button and Escape, and restores focus', () => { + const opener = document.createElement('button') + document.body.appendChild(opener) + opener.focus() + const onClose = vi.fn() + const view = render() + const close = view.getByRole('button', { name: '关闭原图预览' }) + expect(document.activeElement).toBe(close) + fireEvent.keyDown(window, { key: 'a' }) + expect(onClose).not.toHaveBeenCalled() + fireEvent.keyDown(window, { key: 'Escape' }) + fireEvent.click(close) + expect(onClose).toHaveBeenCalledTimes(2) + view.unmount() + expect(document.activeElement).toBe(opener) + opener.remove() + }) + + it('tolerates a focus owner it cannot restore (no active element at mount)', () => { + // jsdom always reports body as the fallback active element; stub the + // element-less state a detached focus can leave. + Object.defineProperty(document, 'activeElement', { configurable: true, get: () => null }) + try { + const view = render() + view.unmount() + } finally { + delete (document as { activeElement?: unknown }).activeElement + } + }) + + it('closes on a backdrop press but not on a press over the image', () => { + const onClose = vi.fn() + const view = render() + fireEvent.mouseDown(view.getByRole('img')) + expect(onClose).not.toHaveBeenCalled() + fireEvent.mouseDown(view.getByRole('dialog', { name: '原图预览' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/client/ui-attachment/tests/invariant.spec.ts b/packages/client/ui-attachment/tests/invariant.spec.ts new file mode 100644 index 0000000000..4e76b3776f --- /dev/null +++ b/packages/client/ui-attachment/tests/invariant.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import * as AttachmentInvariant from '@deepseek-ai/dsh-client-ui-attachment/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(AttachmentInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/client/ui-attachment/tests/message-image.spec.tsx b/packages/client/ui-attachment/tests/message-image.spec.tsx new file mode 100644 index 0000000000..6dbf4cd746 --- /dev/null +++ b/packages/client/ui-attachment/tests/message-image.spec.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { ImageGallery, MessageImage } from '../src/MessageImage.tsx' +import type { MessageImageLabels } from '../src/MessageImage.tsx' + +afterEach(cleanup) + +const labels: MessageImageLabels = { + image: '图片', + open: '查看原图', + openNamed: label => `${label},点击查看原图`, + loading: '图片加载中…', + loadFailed: '图片加载失败,点击重试', + lightbox: { dialog: '原图预览', close: '关闭原图预览' }, +} + +const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 640, + height: 320, + name: 'history.png', +} + +describe('MessageImage', () => { + it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { + const load = vi.fn().mockResolvedValue('blob:history') + const view = render() + const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) + expect(frame.getAttribute('style')).toContain('width: 240px') + expect(frame.getAttribute('style')).toContain('height: 120px') + expect(frame.getAttribute('title')).toBe('查看原图') + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + expect(load).toHaveBeenCalledWith(attachment) + fireEvent.click(frame) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) + expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + }) + + it('ignores a click while the thumbnail is still loading', () => { + const load = vi.fn(() => new Promise(() => {})) + const view = render() + const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) + expect(view.getByText('图片加载中…')).toBeTruthy() + fireEvent.click(frame) + expect(view.queryByRole('dialog')).toBeNull() + }) + + it('falls back to the image label for an unnamed attachment', async () => { + const { name: _named, ...unnamed } = attachment + const load = vi.fn().mockResolvedValue('blob:unnamed') + const view = render() + await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() }) + expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy() + }) + + it('surfaces a retry control when durable bytes cannot be read, including a failed retry', async () => { + const load = vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce(new Error('still offline')) + .mockResolvedValueOnce('blob:retry') + const view = render() + const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) + fireEvent.click(retry) + const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' }) + fireEvent.click(retryAgain) + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + expect(load).toHaveBeenCalledTimes(3) + }) + + it('ignores a load settling after unmount', async () => { + let resolve: ((url: string) => void) | undefined + const load = vi.fn(() => new Promise((r) => { resolve = r })) + const view = render() + view.unmount() + resolve?.('blob:late') + await Promise.resolve() + let reject: ((error: Error) => void) | undefined + const failing = vi.fn(() => new Promise((_r, rej) => { reject = rej })) + const second = render() + second.unmount() + reject?.(new Error('late failure')) + await Promise.resolve() + }) +}) + +describe('ImageGallery', () => { + it('renders nothing without images and an aligned wrapping group with them', async () => { + const load = vi.fn().mockResolvedValue('blob:gallery') + const empty = render() + expect(empty.container.firstChild).toBeNull() + const view = render( + , + ) + expect(view.container.querySelector('[data-align="end"]')).not.toBeNull() + await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) }) + }) +}) diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json new file mode 100644 index 0000000000..e034eef4e3 --- /dev/null +++ b/packages/client/ui-attachment/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../attachment/attachment" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../ui-primitives" + } + ] +} diff --git a/packages/client/ui-attachment/tsdown.config.ts b/packages/client/ui-attachment/tsdown.config.ts new file mode 100644 index 0000000000..2ffa80a8d1 --- /dev/null +++ b/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 {};' + }, + }], +}]) diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index e80432f79f..bb1be514bb 100644 --- a/packages/client/ui-conversation/package.json +++ b/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:^", diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 7a8590c2b8..4365f4c453 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -13,8 +13,9 @@ import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { ImageGallery, type ImageLoader } from './MessageImage.tsx' +import { messageImageLabels } from '../image-labels.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' @@ -62,7 +63,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ /> ) case 'reasoning': return - case 'image': return + case 'image': return // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. case 'tool-call': return null default: return ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index f7c451ce8a..d2bfe8868f 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -10,10 +10,11 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' +import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment' +import { messageImageLabels } from '../image-labels.ts' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' -import { ImageGallery, type ImageLoader } from './MessageImage.tsx' import css from './MessageItem.module.css' type UserImage = Extract @@ -177,7 +178,7 @@ function UserStyleBubble({ return (
- + {showBubble &&
{projectUserText(text)} {rest.map((block, i) => )} diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts new file mode 100644 index 0000000000..493ddbbbbe --- /dev/null +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -0,0 +1,48 @@ +/** Bridges the `conversation` locale namespace to the zero-cordis attachment + * atoms' label props (`@deepseek-ai/dsh-client-ui-attachment` reads no + * application state; owners resolve every string). */ + +import type { + AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels, +} from '@deepseek-ai/dsh-client-ui-attachment' +import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' +import type { ConversationKey } from './locales.ts' + +/** + * Resolve the original-image lightbox strings. + * @param t - the conversation-namespace translate. + * @returns the lightbox dialog and close-control labels. + */ +export function lightboxLabels(t: Translate): ImageLightboxLabels { + return { dialog: t('image.preview'), close: t('image.closePreview') } +} + +/** + * Resolve the chat-history image strings. + * @param t - the conversation-namespace translate. + * @returns the message-image labels including the forwarded lightbox strings. + */ +export function messageImageLabels(t: Translate): MessageImageLabels { + return { + image: t('image.label'), + open: t('image.openOriginal'), + openNamed: label => t('image.openOriginalLabel', { label }), + loading: t('image.loading'), + loadFailed: t('image.loadFailed'), + lightbox: lightboxLabels(t), + } +} + +/** + * Resolve the composer draft-image rail strings. + * @param t - the conversation-namespace translate. + * @returns the rail group, open-tooltip, and paging-arrow labels. + */ +export function attachmentRailLabels(t: Translate): AttachmentRailLabels { + return { + group: t('image.pending'), + open: t('image.openOriginal'), + scrollLeft: t('image.scrollLeft'), + scrollRight: t('image.scrollRight'), + } +} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index dcf04264e8..080bb9b67b 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -27,9 +27,11 @@ export const zh = { 'input.accessMode': '访问模式,当前:{name}', 'image.dropHint': '松开以添加图片', 'image.pending': '待发送图片', - 'image.openOriginal': '双击查看原图', - 'image.openOriginalLabel': '{label},双击查看原图', + 'image.openOriginal': '查看原图', + 'image.openOriginalLabel': '{label},点击查看原图', 'image.remove': '移除图片 {name}', + 'image.scrollLeft': '向左滚动图片', + 'image.scrollRight': '向右滚动图片', 'image.original': '原图', 'image.label': '图片', 'image.loadFailed': '图片加载失败,点击重试', @@ -184,9 +186,11 @@ export const en = { 'input.accessMode': 'Access mode, current: {name}', 'image.dropHint': 'Drop to add images', 'image.pending': 'Pending images', - 'image.openOriginal': 'Double-click to view original', - 'image.openOriginalLabel': '{label}, double-click to view original', + 'image.openOriginal': 'View original', + 'image.openOriginalLabel': '{label}, click to view original', 'image.remove': 'Remove image {name}', + 'image.scrollLeft': 'Scroll images left', + 'image.scrollRight': 'Scroll images right', 'image.original': 'Original image', 'image.label': 'Image', 'image.loadFailed': 'Image failed to load; click to retry', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index ad1a6ed275..751e253e22 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -35,22 +35,6 @@ padding: 0 var(--dsh-composer-side-clearance); } -.error, -.status { - width: 100%; - max-width: var(--dsh-composer-card-max-width); - margin-bottom: 6px; - padding: 4px 8px; - border-radius: 8px; - font-size: 12px; - line-height: 18px; -} - -.status { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-secondary); -} - .notice { width: 100%; max-width: var(--dsh-composer-card-max-width); @@ -68,11 +52,6 @@ color: var(--dsw-alias-state-error-primary); } -.error { - background: var(--dsw-alias-interactive-bg-hover-danger); - color: var(--dsw-alias-state-error-primary); -} - .card { box-sizing: border-box; position: relative; /* overlay anchor positioning context */ @@ -162,55 +141,13 @@ padding: 10px 12px 0; } +/* Rail seat: the card's top padding (10px) plus this 4px matches DeepSeek + Chat's spacing above the thumbnails; the card's 12px flex gap owns the space + below. The rail itself (arrows, hidden scrollbar, card geometry) is the + ui-attachment atom's. */ .attachments { - display: flex; - gap: 8px; min-width: 0; - padding: 12px 12px 0; - overflow-x: auto; - overflow-y: hidden; -} - -.attachment { - position: relative; - flex: 0 0 72px; - width: 72px; - height: 72px; -} - -.thumbnail { - width: 72px; - height: 72px; - padding: 0; - overflow: hidden; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 12px; - background: var(--dsw-alias-interactive-bg-hover); - cursor: zoom-in; -} - -.thumbnail img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.remove { - position: absolute; - top: -6px; - right: -6px; - display: grid; - place-items: center; - width: 22px; - height: 22px; - padding: 0; - border: 1px solid var(--dsw-specific-input-major); - border-radius: 999px; - background: var(--dsw-alias-label-primary); - color: var(--dsw-specific-input-major); - font-size: 16px; - line-height: 1; - cursor: pointer; + padding: 4px 12px 0; } /* Floating overlay anchor (menu / popupSelect shell): entries position diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 5423a2bac6..555177e6f6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -9,7 +9,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' -import { IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment' +import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). import type {} from '@deepseek-ai/dsh-plan-mode/client' @@ -19,18 +23,17 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import type { DraftDecorations } from '../input/decorations.ts' +import { attachmentRailLabels, lightboxLabels } from '../image-labels.ts' import { ContextMeter } from './ContextMeter.tsx' -import { ImageLightbox } from './ImageLightbox.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import css from './InputBar.module.css' /** Decoration product of the no-session state (no machine, empty draft). */ const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null } -/** Prompt failure surface (derived from promptError). */ -export interface InputBarError { - op: 'send' | 'stop' - message: string +/** Rail thumbnail carrying its source attachment for the open/remove callbacks. */ +interface ComposerRailItem extends AttachmentRailItem { + attachment: ComposerAttachment } export type InputBarProps = ComposerBarProps @@ -56,12 +59,6 @@ export function InputBar({ const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active)) // Absent (undefined: no frame yet) and cleared (null) both mean no goal. const hasGoal = useProjection('goal', goal => goal != null) - // Prompt failures are ordinary failures (no create/attach transaction - // exists anymore): the strip renders promptError, the draft stays in the - // machine, and the user resubmits. - const error: InputBarError | null = promptError === null - ? null - : { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` } // Session-maybe: the machine faces are absent together while no session is // current; the bar renders the same DOM inert instead of a parallel tree. const live = input !== undefined && keyboard !== undefined && inputActions !== undefined @@ -73,7 +70,22 @@ export function InputBar({ const empty = draft.trim() === '' && attachments.length === 0 const [preview, setPreview] = useState(null) const [dragActive, setDragActive] = useState(false) - const [dropError, setDropError] = useState(null) + // Transient error banner (image-intake rejections and prompt failures): the + // seq keys the Toast so an identical repeated message restarts the + // hold-then-fade cycle instead of silently reusing the faded one. + const [toast, setToast] = useState<{ seq: number; text: string } | null>(null) + const toastSeq = useRef(0) + const showToast = useCallback((text: string) => { + toastSeq.current += 1 + setToast({ seq: toastSeq.current, text }) + }, []) + const dismissToast = useCallback(() => { setToast(null) }, []) + // Prompt failures are ordinary failures (no create/attach transaction exists + // anymore): the toast announces promptError, the draft stays in the machine, + // and the user resubmits. + useEffect(() => { + if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`) + }, [promptError, showToast]) const inputRef = useRef(null) const dragDepthRef = useRef(0) const scrollRef = useRef(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(() => attachments.map(attachment => ({ + id: attachment.id, + previewUrl: attachment.previewUrl, + alt: attachment.file.name || t('image.pending'), + removeLabel: t('image.remove', { name: attachment.file.name }), + attachment, + })), [attachments, t]) + const onSelect = (e: React.SyntheticEvent): void => { // Any caret/selection gesture ends a live paste attempt (the machine // cannot observe DOM selection). Cheap no-op when none is live. @@ -543,10 +570,13 @@ export function InputBar({ return (
- {error !== null && ( -
- {error.message} -
+ {toast !== null && ( + } + onDone={dismissToast} + /> )} {notice !== null && (
@@ -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 &&
{dropError}
}
{t('image.dropHint')}
} {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} - {attachments.length > 0 && ( -
- {attachments.map(attachment => ( -
- - -
- ))} + {railItems.length > 0 && ( +
+ { setPreview(item.attachment) }} + onRemove={(item) => { removeImage?.(item.attachment.id) }} + />
)} {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the @@ -628,10 +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({ )} {footer} diff --git a/packages/client/ui-conversation/tests/image-labels.spec.tsx b/packages/client/ui-conversation/tests/image-labels.spec.tsx new file mode 100644 index 0000000000..6aa5810e38 --- /dev/null +++ b/packages/client/ui-conversation/tests/image-labels.spec.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +// The conversation-side bridge to the ui-attachment atoms: dictionary strings +// flow through image-labels into the gallery, and assistant images keep their +// block position between text blocks. + +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = makeTranslate(zh, commonZh) +const enT = makeTranslate(en, commonZh) + +const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 640, + height: 320, + name: 'history.png', +} + +describe('assistant images through the label bridge', () => { + it('resolves zh dictionary strings and opens the lightbox on a single click', async () => { + const view = render( + Promise.resolve('blob:history')} + />, + ) + const frame = await view.findByRole('button', { name: 'history.png,点击查看原图' }) + expect(frame.getAttribute('title')).toBe('查看原图') + await view.findByAltText('history.png') + fireEvent.click(frame) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) + expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + }) + + it('resolves the active English dictionary', async () => { + const view = render( + Promise.resolve('blob:history')} + />, + ) + const frame = await view.findByRole('button', { name: 'history.png, click to view original' }) + await view.findByAltText('history.png') + fireEvent.click(frame) + expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy() + expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy() + }) + + it('keeps assistant images at their original position between text blocks', async () => { + const view = render( + Promise.resolve('blob:middle')} + />, + ) + const image = await view.findByAltText('history.png') + const before = view.getByText('before') + const after = view.getByText('after') + expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + }) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index f7b0669e3e..084a3a48e5 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -237,15 +237,49 @@ describe('image draft rail', () => { expect(removeImage).toHaveBeenCalledWith('draft-1') }) - it('opens the original image on double-click and closes it with Escape', () => { + it('opens the original image on a single click and closes it with Escape', () => { const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' } const { view } = bench({ attachments: [attachment] }) - fireEvent.doubleClick(view.getByTitle('双击查看原图')) + fireEvent.click(view.getByTitle('查看原图')) expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() fireEvent.keyDown(window, { key: 'Escape' }) expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() }) + + it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => { + vi.useFakeTimers() + try { + const addImages = vi.fn(() => '不支持的图片格式:text/plain') + const { view, textarea } = bench({ addImages }) + const paste = () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'note.txt', { type: 'text/plain' }) }], + getData: () => '', + }, + }) + } + paste() + expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain') + act(() => { vi.advanceTimersByTime(4000) }) + expect(view.queryByRole('alert')).toBeNull() + // The identical rejection re-announces: the toast is keyed per show. + paste() + expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain') + } finally { + vi.useRealTimers() + } + }) + + it('announces a rejected drop through the same toast', () => { + const addImages = vi.fn(() => '图片读取服务不可用') + const { view } = bench({ addImages }) + const card = view.container.querySelector('[class*="card"]')! + const dataTransfer = { types: ['Files'], files: [new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' })], dropEffect: 'none' } + fireEvent.drop(card, { dataTransfer }) + expect(view.getByRole('alert').textContent).toContain('图片读取服务不可用') + }) }) describe('Enter semantics', () => { @@ -941,10 +975,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', () => { diff --git a/packages/client/ui-conversation/tests/message-image.spec.tsx b/packages/client/ui-conversation/tests/message-image.spec.tsx deleted file mode 100644 index 6da4d42f12..0000000000 --- a/packages/client/ui-conversation/tests/message-image.spec.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// @vitest-environment jsdom - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { MessageImage } from '../src/client/chat/MessageImage.tsx' -import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' -import { en, zh } from '../src/client/locales.ts' - -afterEach(cleanup) - -const t = makeTranslate(zh, commonZh) -const enT = makeTranslate(en, commonZh) - -const attachment = { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png' as const, - bytes: 68, - width: 640, - height: 320, - name: 'history.png', -} - -describe('MessageImage', () => { - it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => { - const load = vi.fn().mockResolvedValue('blob:history') - const view = render() - const frame = view.getByRole('button', { name: 'history.png,双击查看原图' }) - expect(frame.getAttribute('style')).toContain('width: 240px') - expect(frame.getAttribute('style')).toContain('height: 120px') - await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) - expect(load).toHaveBeenCalledWith(attachment) - fireEvent.doubleClick(frame) - expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() - fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) - expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() - }) - - it('surfaces a retry control when durable bytes cannot be read', async () => { - const load = vi.fn() - .mockRejectedValueOnce(new Error('offline')) - .mockResolvedValueOnce('blob:retry') - const view = render() - const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) - fireEvent.click(retry) - await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) - expect(load).toHaveBeenCalledTimes(2) - }) - - it('renders image controls from the active English dictionary', async () => { - const load = vi.fn().mockResolvedValue('blob:history') - const view = render() - const frame = view.getByRole('button', { name: 'history.png, double-click to view original' }) - await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) - fireEvent.doubleClick(frame) - expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy() - expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy() - }) - - it('keeps assistant images at their original position between text blocks', async () => { - const view = render( - Promise.resolve('blob:middle')} - />, - ) - const image = await view.findByAltText('history.png') - const before = view.getByText('before') - const after = view.getByText('after') - expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) - expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) - }) -}) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 49763e5fb8..e28e9fdb3f 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../ui-slots" }, + { + "path": "../ui-attachment" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 06dfe45bd8..703a133632 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index a9c802c1e4..6e2cfed257 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -2,12 +2,16 @@ English | [中文](README.zh.md) -Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the OnboardingSurface first-run takeover (body-portaled mask + opaque stage that holds `#root` inert for exactly its own lifetime), the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock. +Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the Toast transient banner, the OnboardingSurface first-run takeover (body-portaled mask + opaque stage that holds `#root` inert for exactly its own lifetime), the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock. ## Hover cards `HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Historical rationale: [the archived hover-card copy note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md). +## Toast + +`Toast` is the transient top-center banner: it slides in, holds at full opacity for three seconds, fades over one second, then calls `onDone` so the owner can unmount it. It renders `role="alert"` with an optional leading icon slot and takes its copy as a required prop (zero-cordis: the owner localizes). Re-showing the same message requires a remount — owners key the element by a per-show sequence so an identical repeated message restarts the hold-and-fade cycle instead of silently reusing the faded banner. It layers above the ui-attachment image lightbox so a failure reported during a preview stays readable. + ## Markdown rendering `MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 9e67488ccc..d651d9fac1 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -2,12 +2,16 @@ [English](README.md) | 中文 -纯 React 原子组件(零 cordis):StateDot、DisclosureRow、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、OnboardingSurface 首次使用接管层(portal 到 body 的遮罩加不透明展示层,在且仅在自身生命周期内保持 `#root` 为 `inert`)、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` 钩子(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。 +纯 React 原子组件(零 cordis):StateDot、DisclosureRow、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、Toast 短时横幅、OnboardingSurface 首次使用接管层(portal 到 body 的遮罩加不透明展示层,在且仅在自身生命周期内保持 `#root` 为 `inert`)、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` 钩子(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。 ## 悬浮卡片 `HoverCard` 通过指针离开宽限期,使采用 portal 渲染的预览在跨过与锚点之间的间隙时仍可触及。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。历史依据见[已归档的悬浮卡片复制 Agent Note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md)。 +## Toast + +`Toast` 是顶部居中的短时横幅:滑入后满不透明度停留三秒,再用一秒淡出,随后调用 `onDone` 由持有方卸载。它渲染 `role="alert"`,带可选的前置图标插槽,文案是必填 prop(零 cordis,由持有方本地化)。重复展示同一条消息需要重新挂载,持有方用每次展示递增的序号作为 key,让相同文案重新走完停留与淡出,而不是静默复用已淡出的横幅。它的层级高于 ui-attachment 的图片灯箱,预览打开时报出的失败仍然可读。 + ## Markdown 渲染 `MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 diff --git a/packages/client/ui-primitives/src/Toast.module.css b/packages/client/ui-primitives/src/Toast.module.css new file mode 100644 index 0000000000..e4dbdf6bea --- /dev/null +++ b/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; + } +} diff --git a/packages/client/ui-primitives/src/Toast.tsx b/packages/client/ui-primitives/src/Toast.tsx new file mode 100644 index 0000000000..37352cb460 --- /dev/null +++ b/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 ( +
+ {icon !== undefined && {icon}} + {text} +
+ ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 18e5b2c7e9..05baf26f8d 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -23,6 +23,7 @@ export { FishLogo } from './FishLogo.tsx' export { BrandWordmark } from './BrandWordmark.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' +export { Toast } from './Toast.tsx' export { writeClipboard } from './clipboard.ts' export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' diff --git a/packages/client/ui-primitives/tests/toast.spec.tsx b/packages/client/ui-primitives/tests/toast.spec.tsx new file mode 100644 index 0000000000..5fdd4d2f48 --- /dev/null +++ b/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(} 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() + expect(view.getByRole('alert').querySelector('[aria-hidden]')).toBeNull() + view.unmount() + vi.advanceTimersByTime(10_000) + expect(onDone).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/packages/client/web/package.json b/packages/client/web/package.json index df5192e070..d23c343b3c 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -29,6 +29,7 @@ "dependencies": { "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-schema-form": "workspace:^", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", diff --git a/packages/client/web/src/platform.ts b/packages/client/web/src/platform.ts index e7997cf728..dd7248c117 100644 --- a/packages/client/web/src/platform.ts +++ b/packages/client/web/src/platform.ts @@ -10,6 +10,7 @@ export const PLATFORM_MODULES = [ '@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-web-react', '@deepseek-ai/dsh-client-ui-primitives', + '@deepseek-ai/dsh-client-ui-attachment', '@deepseek-ai/dsh-client-schema-form', ] as const diff --git a/packages/client/web/src/seed.ts b/packages/client/web/src/seed.ts index 78ed31099e..8299f225c7 100644 --- a/packages/client/web/src/seed.ts +++ b/packages/client/web/src/seed.ts @@ -14,6 +14,7 @@ import * as Cordis from '@deepseek-ai/cordis' import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots' import * as WebReact from '@deepseek-ai/dsh-client-web-react' import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' +import * as UiAttachment from '@deepseek-ai/dsh-client-ui-attachment' import * as SchemaForm from '@deepseek-ai/dsh-client-schema-form' import type { PlatformModule } from './platform.ts' @@ -34,6 +35,7 @@ export function getStaticModules(): Record { '@deepseek-ai/dsh-client-ui-slots': UiSlots, '@deepseek-ai/dsh-client-web-react': WebReact, '@deepseek-ai/dsh-client-ui-primitives': UiPrimitives, + '@deepseek-ai/dsh-client-ui-attachment': UiAttachment, '@deepseek-ai/dsh-client-schema-form': SchemaForm, } satisfies Record } diff --git a/packages/client/web/tsconfig.json b/packages/client/web/tsconfig.json index 9240c34891..db2e3b8ed8 100644 --- a/packages/client/web/tsconfig.json +++ b/packages/client/web/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../ui-slots" }, + { + "path": "../ui-attachment" + }, { "path": "../ui-primitives" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 816737ec3f..bb80f707d2 100644 --- a/pnpm-lock.yaml +++ b/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 diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 41f0923ad1..99531843ce 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -61,6 +61,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers nothing model-facing.' }, 'packages/client/test-runtime': { kind: 'none', reason: 'Browser-side test infrastructure (jsdom bench); registers nothing model-facing.' }, 'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-attachment': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers nothing model-facing.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index a08b438f3a..6bffbed82e 100644 --- a/tsconfig.base.json +++ b/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"], diff --git a/tsconfig.client.json b/tsconfig.client.json index fce7d45b7b..6f6c0e3533 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -46,6 +46,7 @@ { "path": "./packages/compact/compact" }, { "path": "./packages/client/ui-slots" }, { "path": "./packages/client/schema-form" }, + { "path": "./packages/client/ui-attachment" }, { "path": "./packages/client/ui-primitives" }, { "path": "./packages/client/web-react" }, { "path": "./packages/client/modules" },