Merge branch 'worktree/schedule-explicit-at' into worktree/schedule-fixed-rate

This commit is contained in:
Tianyi Cui
2026-08-11 20:07:51 +08:00
66 changed files with 1750 additions and 366 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/README.md
README.md: 42258a961b522271cf61367856c162a7312c79f2
README.zh.md: 5ecebbfd1e3c8aaa294e3bfd1bf943600ab3366e
README.md: a349b79783fae8fcfc9b3af7d3e96de779514d01
README.zh.md: 342442001bd268048de901233fc1913027b6d70f

View File

@@ -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. |

View File

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

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md
README.md: 9fab9c23b958606030b1e87fcbfa45130c980947
README.zh.md: 668dba11154538f52a9a87692020868c1b8a63d5

View File

@@ -0,0 +1,27 @@
# @deepseek-ai/dsh-client-ui-attachment
English | [中文](README.zh.md)
Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), and the original-image lightbox (`ImageLightbox`). Every string arrives through label props resolved by the owning plugin's own locale namespace, and nothing here reads application state; `@deepseek-ai/dsh-client-ui-conversation` is the current consumer, bridging its `conversation` dictionary through its `image-labels` module.
## Attachment rail
`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling (instant under `prefers-reduced-motion: reduce`), and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and rail size changes (a ResizeObserver on the rail element, so sidebar and panel resizes count, not only window resizes). The rail scrolls horizontally only: a non-passive listener consumes every wheel tick with a vertical component — nothing scrolls the conversation behind the composer — converting a pure vertical wheel to a horizontal step (LINE/PAGE deltas normalized to pixels, per-tick travel clamped to 60px) and keeping a diagonal pan's horizontal intent, while purely horizontal pans stay native. A newly added item is revealed at the rail's end; removal keeps the scroll position, and a rail that mounts over an already-populated draft keeps its start position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist.
## Message images and the lightbox
`MessageImage` renders one durable history image bounded to 240px on its longer edge, loading a session-authorized URL through the owner's `ImageLoader`; a failed load renders an explicit retry control, and a settled load answers a single click by opening `ImageLightbox` (clicks during loading are ignored). `ImageGallery` wraps a message's images in one aligned flex group (`end` for user messages, `start` for assistant messages) and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview that closes on Escape, a backdrop press, or its close control, and restores focus to its opener on unmount.
## Model Experience
None, as the package renders pure React atoms in the browser; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Images only** — non-image files have no rail card or history renderer yet; DeepSeek Chat-style file cards and upload-progress states wait until the composer accepts non-image attachments.
- **No zoom or download in the lightbox** — the preview renders the original at fit-to-viewport size only.
- **The lightbox does not trap focus** — it sets `aria-modal` and restores focus on close, but Tab can reach the page behind it (behavior carried over from the pre-package component).

View File

@@ -0,0 +1,27 @@
# @deepseek-ai/dsh-client-ui-attachment
[English](README.md) | 中文
纯 React 附件原子组件(零 cordis输入框草稿图片栏`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)与原图灯箱(`ImageLightbox`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
## 附件栏
`AttachmentRail` 将待发送草稿图片渲染为固定 64px16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px并平滑滚动`prefers-reduced-motion: reduce` 下瞬时完成箭头的显隐在滚动、条目数量变化和栏自身尺寸变化时依据滚动几何重算rail 元素上的 ResizeObserver因此侧栏、面板的宽度变化也计入不只是窗口尺寸变化。附件栏只允许横向滚动非 passive 监听器消费所有带纵向分量的滚轮事件——不会滚动输入框背后的会话记录——纯纵向滚轮转为横向步进LINE/PAGE 单位先归一化为像素,单次行程钳制在 60px 内),对角平移保留其横向分量,纯横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位,带着已有草稿重新挂载的栏保持起始位置。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。
## 消息图片与灯箱
`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px经持有方的 `ImageLoader` 加载会话授权 URL加载失败渲染显式重试按钮加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭卸载时将焦点还给打开者。
## 模型体验
无。该包package在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **仅支持图片** — 非图片文件尚无附件栏卡片与历史渲染DeepSeek Chat 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。
- **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。
- **灯箱不锁定焦点** — 它设置 `aria-modal` 并在关闭时归还焦点,但 Tab 仍可移动到背后的页面(沿袭入包前组件的行为)。

View File

@@ -0,0 +1,51 @@
{
"name": "@deepseek-ai/dsh-client-ui-attachment",
"description": "Pure React attachment atoms for the dsh web UI: draft-image rail, message image gallery, and original-image lightbox (zero cordis)",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-attachment"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
}
}

View File

@@ -0,0 +1,118 @@
/* Thumbnail geometry mirrors DeepSeek Chat's composer rail: 64px cards with a
16px radius, remove control fully inside the card, arrows overlaid at the
edges instead of a scrollbar. */
.root {
position: relative;
min-width: 0;
}
.rail {
display: flex;
gap: 10px;
overflow-x: auto;
overflow-y: hidden;
/* Edge arrows page the overflow; the scrollbar stays hidden (both engines). */
scrollbar-width: none;
/* The rail scrolls on the composer's elevated input surface: bind the l2
pair (ui-theme styles/scrollbar.css rebinding contract) so anything that
does draw a thumb here matches the surface. */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.rail::-webkit-scrollbar {
display: none;
}
.item {
position: relative;
flex: 0 0 64px;
width: 64px;
height: 64px;
}
.thumbnail {
width: 64px;
height: 64px;
padding: 0;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 16px;
background: var(--dsw-alias-interactive-bg-hover);
cursor: zoom-in;
}
.thumbnail img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.remove {
position: absolute;
top: 4px;
right: 4px;
z-index: 1;
display: grid;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--dsw-alias-button-contrast-fill);
color: var(--dsw-alias-label-primary-inverted);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.item:hover .remove,
.remove:focus-visible {
opacity: 1;
}
/* Touch surfaces have no hover to reveal the control. */
@media (pointer: coarse) {
.remove {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.remove {
transition: none;
}
}
.arrow {
position: absolute;
top: 50%;
z-index: 2;
display: grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 999px;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-secondary);
box-shadow: var(--dsw-shadow-lv2);
cursor: pointer;
transform: translateY(-50%);
}
.arrow:hover {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
.arrowLeft {
left: 4px;
}
.arrowRight {
right: 4px;
}

View File

@@ -0,0 +1,200 @@
/** Draft-attachment thumbnail rail: scrollbar-less horizontal overflow paged
* by edge arrows, hover-revealed per-item remove, single-click open. */
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import {
IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import css from './AttachmentRail.module.css'
/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */
export interface AttachmentRailItem {
/** Stable identity for the React key. */
id: string
/** Object or data URL rendered as the thumbnail. */
previewUrl: string
/** Image alt text (display name with the owner's fallback applied). */
alt: string
/** Accessible label of the item's remove control. */
removeLabel: string
}
/** Rail-level strings the owner resolves from its own locale namespace. */
export interface AttachmentRailLabels {
/** Accessible name of the rail group. */
group: string
/** Thumbnail tooltip inviting the original-image preview. */
open: string
/** Accessible label of the left paging arrow. */
scrollLeft: string
/** Accessible label of the right paging arrow. */
scrollRight: string
}
/** Approximate pixels per wheel step for `deltaMode` LINE deltas (Firefox
* notch wheels report lines, not pixels). */
const WHEEL_LINE_PX = 16
/** Smooth paging unless the user asked for reduced motion. */
function pageBehavior(): ScrollBehavior {
// jsdom (the unit lane) implements no matchMedia despite lib.dom's
// non-optional typing; the optional call keeps that lane on the default.
// oxlint-disable-next-line typescript/no-unnecessary-condition
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
}
/**
* Horizontal thumbnail rail over the caller's draft attachments.
*
* The rail scrolls with its scrollbar hidden; overflow is announced by edge
* arrows recomputed from scroll geometry on scroll, item-count changes, and
* rail size changes (a ResizeObserver on the rail element, so sidebar or
* panel resizes count, not only window resizes). A vertical wheel pans the
* rail horizontally and is consumed exclusively (non-passive listener), a
* newly added item is revealed at the rail's end while a rail that mounts
* over an existing draft keeps its start position, and each thumbnail opens
* on a single click while its remove control sits inside the card and
* reveals on hover or focus. The owner decides mounting; it renders the rail
* only while items exist.
*
* @param props.items - resolved thumbnails in draft order.
* @param props.labels - rail-level strings (group name, open tooltip, arrows).
* @param props.onOpen - single-click open of one item's original image.
* @param props.onRemove - remove one item from the draft.
* @returns the rail group with its paging arrows.
*/
export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, onOpen, onRemove }: {
items: readonly T[]
labels: AttachmentRailLabels
onOpen: (item: T) => void
onRemove: (item: T) => void
}) {
const railRef = useRef<HTMLDivElement | null>(null)
// null marks the first layout pass: a rail that MOUNTS over an existing
// draft (session switch back to held images) is initial display, not
// growth, and must not jump to the end.
const countRef = useRef<number | null>(null)
const [edges, setEdges] = useState({ left: false, right: false })
const updateEdges = useCallback(() => {
const el = railRef.current
/* v8 ignore next -- defensive: every caller runs while the rail element is mounted. */
if (el === null) return
// 1px slack: engines report fractional scroll positions at the edges.
const left = el.scrollLeft > 1
const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right })
}, [])
useLayoutEffect(() => {
const grew = countRef.current !== null && items.length > countRef.current
countRef.current = items.length
const el = railRef.current
/* v8 ignore next -- defensive: the rail div renders unconditionally, so the layout effect always finds it. */
if (el === null) return
// A newly added attachment lands at the rail's end: reveal it.
if (grew) el.scrollLeft = el.scrollWidth - el.clientWidth
updateEdges()
}, [items.length, updateEdges])
useEffect(() => {
const el = railRef.current
/* v8 ignore next -- defensive: the rail div renders unconditionally, so the mount effect always finds it. */
if (el === null) return
// The rail's width follows the composer, which resizes with sidebars and
// panels, not only the window — observe the element itself. jsdom (the
// unit lane) implements no ResizeObserver; every browser gets the
// subscription.
let disconnect = (): void => {}
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(updateEdges)
observer.observe(el)
disconnect = () => { observer.disconnect() }
}
// The rail scrolls horizontally ONLY: any wheel tick with a vertical
// component is consumed — without preventDefault it would also scroll the
// conversation behind the composer, and React's root wheel listener is
// passive, so the exclusion needs this manually attached non-passive
// listener. A diagonal trackpad pan keeps its horizontal intent; a pure
// vertical wheel converts to a horizontal step, with LINE and PAGE deltas
// (Firefox notch wheels) normalized to pixels before the per-tick clamp
// that keeps a fast wheel followable. A purely horizontal pan stays
// native.
const onWheel = (event: globalThis.WheelEvent): void => {
if (event.deltaY === 0) return
const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
? WHEEL_LINE_PX
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1
event.preventDefault()
el.scrollBy({
left: event.deltaX !== 0
? event.deltaX * scale
: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
behavior: 'auto',
})
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => {
disconnect()
el.removeEventListener('wheel', onWheel)
}
}, [updateEdges])
const page = (direction: -1 | 1): void => {
const el = railRef.current
/* v8 ignore next -- defensive: the arrows render only while the rail is mounted, so a click cannot find a null ref. */
if (el === null) return
// One viewport minus a card keeps the last visible thumbnail as context;
// the floor keeps narrow rails paging a useful distance.
el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: pageBehavior() })
}
return (
<div className={css.root}>
{edges.left && (
<button
type="button"
className={clsx(css.arrow, css.arrowLeft)}
aria-label={labels.scrollLeft}
onClick={() => { page(-1) }}
>
<IconChevronLeftOutline14 />
</button>
)}
<div
ref={railRef}
className={css.rail}
role="group"
aria-label={labels.group}
onScroll={updateEdges}
>
{items.map(item => (
<div key={item.id} className={css.item}>
<button
type="button"
className={css.thumbnail}
title={labels.open}
onClick={() => { onOpen(item) }}
>
<img src={item.previewUrl} alt={item.alt} />
</button>
<button
type="button"
className={css.remove}
aria-label={item.removeLabel}
onClick={() => { onRemove(item) }}
>
<IconCloseFill14 size={12} />
</button>
</div>
))}
</div>
{edges.right && (
<button
type="button"
className={clsx(css.arrow, css.arrowRight)}
aria-label={labels.scrollRight}
onClick={() => { page(1) }}
>
<IconChevronRightOutline14 />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import css from './ImageLightbox.module.css'
/** Lightbox strings the owner resolves from its own locale namespace. */
export interface ImageLightboxLabels {
/** Accessible name of the preview dialog. */
dialog: string
/** Accessible label of the close control. */
close: string
}
/**
* Document-level original-image preview opened by clicking a thumbnail.
* Closes on Escape, backdrop press, or the close control, and restores focus
* to the opener on unmount. Rendered through a body portal: an opener inside
* a transformed or filtered ancestor would otherwise trap the fixed backdrop
* in that ancestor's box instead of covering the viewport.
*
* @param props.src - the original image URL.
* @param props.alt - the image's alt text.
* @param props.labels - dialog and close-control strings.
* @param props.onClose - dismiss callback owned by the opener.
* @returns the modal preview dialog.
*/
export function ImageLightbox({ src, alt, labels, onClose }: {
src: string
alt: string
labels: ImageLightboxLabels
onClose: () => void
}) {
const closeRef = useRef<HTMLButtonElement | null>(null)
const restoreRef = useRef<HTMLElement | null>(null)
useEffect(() => {
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
closeRef.current?.focus()
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
restoreRef.current?.focus()
}
}, [onClose])
return createPortal(
<div
className={css.backdrop}
role="dialog"
aria-modal="true"
aria-label={labels.dialog}
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
>
<img className={css.image} src={src} alt={alt} />
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
</div>,
document.body,
)
}

View File

@@ -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;
}

View File

@@ -0,0 +1,95 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { ImageLightbox } from './ImageLightbox.tsx'
import type { ImageLightboxLabels } from './ImageLightbox.tsx'
import css from './MessageImage.module.css'
/** Loads a session-authorized durable image URL. */
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
/** Message-image strings the owner resolves from its own locale namespace. */
export interface MessageImageLabels {
/** Fallback display name for an unnamed image. */
image: string
/** Thumbnail tooltip inviting the original-image preview. */
open: string
/** Accessible thumbnail label; receives the image's display name. */
openNamed: (label: string) => string
/** Loading placeholder shown until bytes resolve. */
loading: string
/** Retry-control label shown when the load fails. */
loadFailed: string
/** Lightbox strings forwarded to the opened preview. */
lightbox: ImageLightboxLabels
}
/**
* Compact history renderer with retryable loading and click-to-open original
* preview.
*
* @param props.attachment - the durable image reference to load and bound.
* @param props.load - session-authorized URL loader.
* @param props.labels - resolved strings (tooltip, loading, retry, lightbox).
* @returns the bounded thumbnail button, or the retry control on failure.
*/
export function MessageImage({ attachment, load, labels }: {
attachment: ImageAttachmentRef
load: ImageLoader
labels: MessageImageLabels
}) {
const [src, setSrc] = useState<string | null>(null)
const [error, setError] = useState(false)
const [open, setOpen] = useState(false)
// Retry re-arms the one load effect below, so every attempt — first load or
// retry — runs under the same liveness guard and the same reset.
const [attempt, setAttempt] = useState(0)
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
const close = useCallback(() => { setOpen(false) }, [])
const size = useMemo(() => {
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
}, [attachment.height, attachment.width])
useEffect(() => {
let live = true
setError(false)
setSrc(null)
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
return () => { live = false }
}, [attachment, load, attempt])
const label = attachment.name ?? labels.image
if (error) return <button type="button" className={css.error} onClick={request}>{labels.loadFailed}</button>
return (
<>
<button
type="button"
className={css.frame}
style={size}
title={labels.open}
aria-label={labels.openNamed(label)}
onClick={() => { if (src !== null) setOpen(true) }}
>
{src === null ? <span className={css.loading}>{labels.loading}</span> : <img src={src} alt={label} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
</>
)
}
/** Wrapping image group shared by user and assistant history. */
export function ImageGallery({ images, load, align, labels }: {
images: readonly { attachment: ImageAttachmentRef }[]
load: ImageLoader
align: 'start' | 'end'
labels: MessageImageLabels
}) {
if (images.length === 0) return null
return (
<div className={css.gallery} data-align={align}>
{images.map((image, index) => (
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} labels={labels} />
))}
</div>
)
}

View File

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

View File

@@ -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'

View File

@@ -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 */

View File

@@ -0,0 +1,173 @@
// @vitest-environment jsdom
// AttachmentRail behavior in the jsdom lane: item rendering and callbacks,
// arrow paging over stubbed scroll geometry (jsdom lays nothing out), the
// exclusive vertical-wheel pan, and the new-item end reveal.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { AttachmentRail } from '../src/AttachmentRail.tsx'
import type { AttachmentRailItem, AttachmentRailLabels } from '../src/AttachmentRail.tsx'
afterEach(cleanup)
// jsdom implements no ResizeObserver; the stub records instances so a test
// can drive the size-change recompute path.
const observers: { callback: ResizeObserverCallback; observed: Element[] }[] = []
beforeEach(() => {
observers.length = 0
vi.stubGlobal('ResizeObserver', class {
observed: Element[] = []
constructor(callback: ResizeObserverCallback) {
observers.push({ callback, observed: this.observed })
}
observe(el: Element) { this.observed.push(el) }
disconnect() { this.observed.length = 0 }
})
})
afterEach(() => { vi.unstubAllGlobals() })
const labels: AttachmentRailLabels = {
group: '待发送图片',
open: '查看原图',
scrollLeft: '向左滚动图片',
scrollRight: '向右滚动图片',
}
function item(id: string): AttachmentRailItem {
return { id, previewUrl: `blob:${id}`, alt: `${id}.png`, removeLabel: `移除图片 ${id}.png` }
}
/** Stub the rail's scroll geometry (jsdom reports 0 for every metric). */
function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) {
Object.defineProperty(rail, 'scrollWidth', { value: scrollWidth, configurable: true })
Object.defineProperty(rail, 'clientWidth', { value: clientWidth, configurable: true })
let scrollLeft = 0
Object.defineProperty(rail, 'scrollLeft', {
configurable: true,
get: () => scrollLeft,
set: (value: number) => { scrollLeft = value },
})
const scrollBy = vi.fn((options: { left: number }) => {
scrollLeft = Math.max(0, Math.min(scrollWidth - clientWidth, scrollLeft + options.left))
})
rail.scrollBy = scrollBy as unknown as typeof rail.scrollBy
return { scrollBy, setScrollLeft: (value: number) => { scrollLeft = value } }
}
describe('AttachmentRail', () => {
it('renders thumbnails in order and routes open and remove clicks', () => {
const onOpen = vi.fn()
const onRemove = vi.fn()
const items = [item('a'), item('b')]
const view = render(<AttachmentRail items={items} labels={labels} onOpen={onOpen} onRemove={onRemove} />)
const rail = view.getByRole('group', { name: '待发送图片' })
expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['a.png', 'b.png'])
fireEvent.click(view.getAllByTitle('查看原图')[0]!)
expect(onOpen).toHaveBeenCalledWith(items[0])
fireEvent.click(view.getByRole('button', { name: '移除图片 b.png' }))
expect(onRemove).toHaveBeenCalledWith(items[1])
})
it('shows edge arrows from scroll geometry and pages a viewport at a time', () => {
const view = render(
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
// No arrows until geometry is observed (mount saw jsdom's zero metrics).
expect(view.queryByLabelText('向右滚动图片')).toBeNull()
fireEvent.scroll(rail)
// Same-edges scroll takes the memoized-state path.
fireEvent.scroll(rail)
expect(view.queryByLabelText('向左滚动图片')).toBeNull()
const right = view.getByLabelText('向右滚动图片')
// clientWidth 200 - 64 < the 200 floor: pages by the floor.
fireEvent.click(right)
expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' })
fireEvent.scroll(rail)
// Scrolled to the far edge: only the left arrow remains.
expect(view.queryByLabelText('向右滚动图片')).toBeNull()
fireEvent.click(view.getByLabelText('向左滚动图片'))
expect(scrollBy).toHaveBeenCalledWith({ left: -200, behavior: 'smooth' })
fireEvent.scroll(rail)
expect(view.queryByLabelText('向左滚动图片')).toBeNull()
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
})
it('shows both arrows mid-scroll and recomputes when the rail itself resizes', () => {
const view = render(
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
setScrollLeft(100)
// The component observes the rail element, not the window: a sidebar or
// panel resize reaches it through the ResizeObserver callback.
expect(observers.at(-1)?.observed).toContain(rail)
act(() => { observers.at(-1)!.callback([], undefined as never) })
expect(view.getByLabelText('向左滚动图片')).toBeTruthy()
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
})
it('pans horizontally on a vertical wheel, consuming the event, with clamped normalized travel', () => {
const view = render(
<AttachmentRail items={[item('a'), item('b')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
// Converted ticks are consumed (preventDefault): fireEvent returns false.
expect(fireEvent.wheel(rail, { deltaY: 30 })).toBe(false)
expect(scrollBy).toHaveBeenCalledWith({ left: 30, behavior: 'auto' })
fireEvent.wheel(rail, { deltaY: 500 })
expect(scrollBy).toHaveBeenCalledWith({ left: 60, behavior: 'auto' })
fireEvent.wheel(rail, { deltaY: -500 })
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
// Firefox notch wheels report lines; a page-mode wheel reports viewports.
fireEvent.wheel(rail, { deltaY: 2, deltaMode: WheelEvent.DOM_DELTA_LINE })
expect(scrollBy).toHaveBeenCalledWith({ left: 32, behavior: 'auto' })
fireEvent.wheel(rail, { deltaY: -1, deltaMode: WheelEvent.DOM_DELTA_PAGE })
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
// A diagonal pan is consumed too — nothing vertical may escape the rail —
// and keeps its horizontal intent.
expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(false)
expect(scrollBy).toHaveBeenCalledWith({ left: 12, behavior: 'auto' })
// A purely horizontal pan and a zero-delta wheel keep native behavior.
expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 0 })).toBe(true)
fireEvent.wheel(rail, { deltaY: 0 })
expect(scrollBy).toHaveBeenCalledTimes(6)
})
it('pages instantly under a reduced-motion preference, smoothly otherwise', () => {
for (const [matches, behavior] of [[true, 'auto'], [false, 'smooth']] as const) {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList))
const view = render(
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
fireEvent.scroll(rail)
fireEvent.click(view.getByLabelText('向右滚动图片'))
expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior })
view.unmount()
}
})
it('reveals the rail end when an item is added, not when one is removed', () => {
const first = [item('a'), item('b')]
const view = render(
<AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
view.rerender(
<AttachmentRail items={[...first, item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
expect(rail.scrollLeft).toBe(200)
view.rerender(
<AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
// Removal keeps the position; only growth jumps to the end.
expect(rail.scrollLeft).toBe(200)
})
})

View File

@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { ImageLightbox } from '../src/ImageLightbox.tsx'
afterEach(cleanup)
const labels = { dialog: '原图预览', close: '关闭原图预览' }
describe('ImageLightbox', () => {
it('focuses its close control, closes by button and Escape, and restores focus', () => {
const opener = document.createElement('button')
document.body.appendChild(opener)
opener.focus()
const onClose = vi.fn()
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
const close = view.getByRole('button', { name: '关闭原图预览' })
expect(document.activeElement).toBe(close)
fireEvent.keyDown(window, { key: 'a' })
expect(onClose).not.toHaveBeenCalled()
fireEvent.keyDown(window, { key: 'Escape' })
fireEvent.click(close)
expect(onClose).toHaveBeenCalledTimes(2)
view.unmount()
expect(document.activeElement).toBe(opener)
opener.remove()
})
it('tolerates a focus owner it cannot restore (no active element at mount)', () => {
// jsdom always reports body as the fallback active element; stub the
// element-less state a detached focus can leave.
Object.defineProperty(document, 'activeElement', { configurable: true, get: () => null })
try {
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={vi.fn()} />)
view.unmount()
} finally {
delete (document as { activeElement?: unknown }).activeElement
}
})
it('closes on a backdrop press but not on a press over the image', () => {
const onClose = vi.fn()
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
fireEvent.mouseDown(view.getByRole('img'))
expect(onClose).not.toHaveBeenCalled()
fireEvent.mouseDown(view.getByRole('dialog', { name: '原图预览' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@@ -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()
})
})

View File

@@ -0,0 +1,103 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { ImageGallery, MessageImage } from '../src/MessageImage.tsx'
import type { MessageImageLabels } from '../src/MessageImage.tsx'
afterEach(cleanup)
const labels: MessageImageLabels = {
image: '图片',
open: '查看原图',
openNamed: label => `${label},点击查看原图`,
loading: '图片加载中…',
loadFailed: '图片加载失败,点击重试',
lightbox: { dialog: '原图预览', close: '关闭原图预览' },
}
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 68,
width: 640,
height: 320,
name: 'history.png',
}
describe('MessageImage', () => {
it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png点击查看原图' })
expect(frame.getAttribute('style')).toContain('width: 240px')
expect(frame.getAttribute('style')).toContain('height: 120px')
expect(frame.getAttribute('title')).toBe('查看原图')
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledWith(attachment)
fireEvent.click(frame)
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
it('ignores a click while the thumbnail is still loading', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png点击查看原图' })
expect(view.getByText('图片加载中…')).toBeTruthy()
fireEvent.click(frame)
expect(view.queryByRole('dialog')).toBeNull()
})
it('falls back to the image label for an unnamed attachment', async () => {
const { name: _named, ...unnamed } = attachment
const load = vi.fn().mockResolvedValue('blob:unnamed')
const view = render(<MessageImage attachment={unnamed} load={load} labels={labels} />)
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
})
it('surfaces a retry control when durable bytes cannot be read, including a failed retry', async () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockRejectedValueOnce(new Error('still offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retryAgain)
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledTimes(3)
})
it('ignores a load settling after unmount', async () => {
let resolve: ((url: string) => void) | undefined
const load = vi.fn(() => new Promise<string>((r) => { resolve = r }))
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
view.unmount()
resolve?.('blob:late')
await Promise.resolve()
let reject: ((error: Error) => void) | undefined
const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
const second = render(<MessageImage attachment={attachment} load={failing} labels={labels} />)
second.unmount()
reject?.(new Error('late failure'))
await Promise.resolve()
})
})
describe('ImageGallery', () => {
it('renders nothing without images and an aligned wrapping group with them', async () => {
const load = vi.fn().mockResolvedValue('blob:gallery')
const empty = render(<ImageGallery images={[]} load={load} align="start" labels={labels} />)
expect(empty.container.firstChild).toBeNull()
const view = render(
<ImageGallery images={[{ attachment }, { attachment }]} load={load} align="end" labels={labels} />,
)
expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
})
})

View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,35 @@
import { clientOnly } from '../tsdown.client.ts'
// TODO(client-atoms): verbatim copy of ui-primitives/tsdown.config.ts (only
// the package differs). On a third atoms package, extract a shared css-stub
// client-library preset in packages/client/tsdown.client.ts instead of a
// fourth copy.
/**
* ui-attachment is browser-only, but its lib bundle IS imported under plain
* Node because the web shell is a lib (dsh-client-web's lib chain reaches
* this package). CSS imports are therefore stubbed to empty modules instead
* of externalized — the hashed class maps only matter in bundler contexts
* (loader module table / vite source paths), which compile src directly and
* never read lib.
*/
export default clientOnly([{
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'neutral',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
plugins: [{
name: 'dsh-css-stub',
resolveId(source: string) {
if (!source.endsWith('.css')) return null
return `\0dsh-css-stub:${source}.mjs`
},
load(id: string) {
if (!id.startsWith('\0dsh-css-stub:')) return null
return 'export default {};'
},
}],
}])

View File

@@ -61,6 +61,7 @@
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-attachment": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
@@ -84,6 +85,7 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-attachment": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",

View File

@@ -13,8 +13,9 @@ import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
import { messageImageLabels } from '../image-labels.ts'
import { ReasoningRow } from './ReasoningRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -62,7 +63,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
/>
)
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" t={t} />
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" labels={messageImageLabels(t)} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return (

View File

@@ -1,72 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ImageLightbox } from '../skeleton/ImageLightbox.tsx'
import css from './MessageImage.module.css'
/** Loads a session-authorized durable image URL. */
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
/** Compact history renderer with retryable loading and double-click original preview. */
export function MessageImage({ attachment, load, t }: {
attachment: ImageAttachmentRef
load: ImageLoader
t: ChatViewSlotProps['t']
}) {
const [src, setSrc] = useState<string | null>(null)
const [error, setError] = useState(false)
const [open, setOpen] = useState(false)
const close = useCallback(() => { setOpen(false) }, [])
const size = useMemo(() => {
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
}, [attachment.height, attachment.width])
const request = useCallback(() => {
setError(false)
setSrc(null)
void load(attachment).then(setSrc).catch(() => { setError(true) })
}, [attachment, load])
useEffect(() => {
let live = true
setError(false)
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
return () => { live = false }
}, [attachment, load])
const label = attachment.name ?? t('image.label')
if (error) return <button type="button" className={css.error} onClick={request}>{t('image.loadFailed')}</button>
return (
<>
<button
type="button"
className={css.frame}
style={size}
title={t('image.openOriginal')}
aria-label={t('image.openOriginalLabel', { label })}
onDoubleClick={() => { if (src !== null) setOpen(true) }}
>
{src === null ? <span className={css.loading}>{t('image.loading')}</span> : <img src={src} alt={label} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} onClose={close} t={t} />}
</>
)
}
/** Wrapping image group shared by user and assistant history. */
export function ImageGallery({ images, load, align, t }: {
images: readonly { attachment: ImageAttachmentRef }[]
load: ImageLoader
align: 'start' | 'end'
t: ChatViewSlotProps['t']
}) {
if (images.length === 0) return null
return (
<div className={css.gallery} data-align={align}>
{images.map((image, index) => (
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} t={t} />
))}
</div>
)
}

View File

@@ -10,10 +10,11 @@ import type {
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment'
import { messageImageLabels } from '../image-labels.ts'
import { CompactionItem } from './CompactionItem.tsx'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
import css from './MessageItem.module.css'
type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
@@ -177,7 +178,7 @@ function UserStyleBubble({
return (
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
<div className={css.userStack}>
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
<ImageGallery images={images} load={imageLoader} align="end" labels={messageImageLabels(t)} />
{showBubble && <div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}

View File

@@ -0,0 +1,48 @@
/** Bridges the `conversation` locale namespace to the zero-cordis attachment
* atoms' label props (`@deepseek-ai/dsh-client-ui-attachment` reads no
* application state; owners resolve every string). */
import type {
AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels,
} from '@deepseek-ai/dsh-client-ui-attachment'
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationKey } from './locales.ts'
/**
* Resolve the original-image lightbox strings.
* @param t - the conversation-namespace translate.
* @returns the lightbox dialog and close-control labels.
*/
export function lightboxLabels(t: Translate<ConversationKey>): ImageLightboxLabels {
return { dialog: t('image.preview'), close: t('image.closePreview') }
}
/**
* Resolve the chat-history image strings.
* @param t - the conversation-namespace translate.
* @returns the message-image labels including the forwarded lightbox strings.
*/
export function messageImageLabels(t: Translate<ConversationKey>): MessageImageLabels {
return {
image: t('image.label'),
open: t('image.openOriginal'),
openNamed: label => t('image.openOriginalLabel', { label }),
loading: t('image.loading'),
loadFailed: t('image.loadFailed'),
lightbox: lightboxLabels(t),
}
}
/**
* Resolve the composer draft-image rail strings.
* @param t - the conversation-namespace translate.
* @returns the rail group, open-tooltip, and paging-arrow labels.
*/
export function attachmentRailLabels(t: Translate<ConversationKey>): AttachmentRailLabels {
return {
group: t('image.pending'),
open: t('image.openOriginal'),
scrollLeft: t('image.scrollLeft'),
scrollRight: t('image.scrollRight'),
}
}

View File

@@ -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',

View File

@@ -1,40 +0,0 @@
import { useEffect, useRef } from 'react'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import css from './ImageLightbox.module.css'
/** Document-level original-image preview opened by an explicit double-click. */
export function ImageLightbox({ src, alt, onClose, t }: {
src: string
alt: string
onClose: () => void
t: ChatViewSlotProps['t']
}) {
const closeRef = useRef<HTMLButtonElement | null>(null)
const restoreRef = useRef<HTMLElement | null>(null)
useEffect(() => {
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
closeRef.current?.focus()
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
restoreRef.current?.focus()
}
}, [onClose])
return (
<div
className={css.backdrop}
role="dialog"
aria-modal="true"
aria-label={t('image.preview')}
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
>
<img className={css.image} src={src} alt={alt} />
<button ref={closeRef} type="button" className={css.close} aria-label={t('image.closePreview')} onClick={onClose}>×</button>
</div>
)
}

View File

@@ -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

View File

@@ -9,7 +9,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment'
// Type-only: the `plan` projection key merge (the TodoDock posture — the
// composer reads a host-computed value; the domain owns the key).
import type {} from '@deepseek-ai/dsh-plan-mode/client'
@@ -19,18 +23,17 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
import { attachmentRailLabels, lightboxLabels } from '../image-labels.ts'
import { ContextMeter } from './ContextMeter.tsx'
import { ImageLightbox } from './ImageLightbox.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
/** Decoration product of the no-session state (no machine, empty draft). */
const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
/** Prompt failure surface (derived from promptError). */
export interface InputBarError {
op: 'send' | 'stop'
message: string
/** Rail thumbnail carrying its source attachment for the open/remove callbacks. */
interface ComposerRailItem extends AttachmentRailItem {
attachment: ComposerAttachment
}
export type InputBarProps = ComposerBarProps
@@ -56,12 +59,6 @@ export function InputBar({
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
// Absent (undefined: no frame yet) and cleared (null) both mean no goal.
const hasGoal = useProjection('goal', goal => goal != null)
// Prompt failures are ordinary failures (no create/attach transaction
// exists anymore): the strip renders promptError, the draft stays in the
// machine, and the user resubmits.
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
// Session-maybe: the machine faces are absent together while no session is
// current; the bar renders the same DOM inert instead of a parallel tree.
const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
@@ -73,8 +70,26 @@ export function InputBar({
const empty = draft.trim() === '' && attachments.length === 0
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
const [dragActive, setDragActive] = useState(false)
const [dropError, setDropError] = useState<string | null>(null)
// Transient error banner (image-intake rejections and prompt failures): the
// seq keys the Toast so an identical repeated message restarts the
// hold-then-fade cycle instead of silently reusing the faded one.
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
const toastSeq = useRef(0)
const showToast = useCallback((text: string) => {
toastSeq.current += 1
setToast({ seq: toastSeq.current, text })
}, [])
const dismissToast = useCallback(() => { setToast(null) }, [])
// Prompt failures are ordinary failures (no create/attach transaction exists
// anymore): the toast announces promptError, the draft stays in the machine,
// and the user resubmits. A remount over a session whose machine still holds
// an unresolved promptError deliberately re-announces it once — the failure
// is still pending, and a transient banner is its only surface.
useEffect(() => {
if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
}, [promptError, showToast])
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const cardRef = useRef<HTMLDivElement | null>(null)
const dragDepthRef = useRef(0)
const scrollRef = useRef<HTMLDivElement | null>(null)
const mirrorRef = useRef<HTMLDivElement | null>(null)
@@ -369,7 +384,10 @@ export function InputBar({
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
if (files.length > 0 && addImages !== undefined) setDropError(addImages(files))
if (files.length > 0 && addImages !== undefined) {
const rejected = addImages(files)
if (rejected !== null) showToast(rejected)
}
const text = e.clipboardData.getData('text/plain')
if (text === '') {
if (files.length > 0) e.preventDefault()
@@ -393,7 +411,6 @@ export function InputBar({
event.preventDefault()
if (locked || machineBusy || addImages === undefined) return
dragDepthRef.current += 1
setDropError(null)
setDragActive(true)
}
@@ -416,11 +433,24 @@ export function InputBar({
setDragActive(false)
if (locked || machineBusy || addImages === undefined) return
const dropped = [...event.dataTransfer.files]
if (dropped.length > 0) setDropError(addImages(dropped))
if (dropped.length > 0) {
const rejected = addImages(dropped)
if (rejected !== null) showToast(rejected)
}
}
const closePreview = useCallback(() => { setPreview(null) }, [])
// Rail thumbnails with their strings resolved here: the attachment atoms are
// zero-cordis and read no locale.
const railItems = useMemo<ComposerRailItem[]>(() => attachments.map(attachment => ({
id: attachment.id,
previewUrl: attachment.previewUrl,
alt: attachment.file.name || t('image.pending'),
removeLabel: t('image.remove', { name: attachment.file.name }),
attachment,
})), [attachments, t])
const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
// Any caret/selection gesture ends a live paste attempt (the machine
// cannot observe DOM selection). Cheap no-op when none is live.
@@ -543,10 +573,14 @@ export function InputBar({
return (
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
{error !== null && (
<div className={css.error} role="alert">
{error.message}
</div>
{toast !== null && (
<Toast
key={toast.seq}
text={toast.text}
icon={<IconWarningOutline16 />}
anchor={cardRef.current}
onDone={dismissToast}
/>
)}
{notice !== null && (
<div className={clsx(css.notice, notice.level === 'error' && css.noticeError)} role="status">
@@ -558,8 +592,8 @@ export function InputBar({
their pointer events), so the WHOLE capsule is the pick target.
pointerdown stops here so the Menu's outside-close cannot race the
click's reopen (close-then-open flickers the chip's open echo). */}
{dropError !== null && <div className={css.error} role="alert">{dropError}</div>}
<div
ref={cardRef}
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
data-composer-card
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
@@ -572,29 +606,14 @@ export function InputBar({
{dragActive && <div className={css.dropHint} role="status">{t('image.dropHint')}</div>}
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{attachments.length > 0 && (
<div className={css.attachments} role="group" aria-label={t('image.pending')}>
{attachments.map(attachment => (
<div key={attachment.id} className={css.attachment}>
<button
type="button"
className={css.thumbnail}
title={t('image.openOriginal')}
onDoubleClick={() => { setPreview(attachment) }}
>
<img src={attachment.previewUrl} alt={attachment.file.name || t('image.pending')} />
</button>
<button
type="button"
className={css.remove}
aria-label={t('image.remove', { name: attachment.file.name })}
onClick={() => {
setDropError(null)
removeImage?.(attachment.id)
}}
>×</button>
</div>
))}
{railItems.length > 0 && (
<div className={css.attachments}>
<AttachmentRail
items={railItems}
labels={attachmentRailLabels(t)}
onOpen={(item) => { setPreview(item.attachment) }}
onRemove={(item) => { removeImage?.(item.attachment.id) }}
/>
</div>
)}
{/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
@@ -628,10 +647,7 @@ export function InputBar({
? t('placeholder.steerQueue')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={(event) => {
setDropError(null)
onChange(event)
}}
onChange={onChange}
onKeyDown={onKeyDown}
onSelect={onSelect}
onCopy={(e) => { onCopyOrCut(e, false) }}
@@ -712,8 +728,8 @@ export function InputBar({
<ImageLightbox
src={preview.previewUrl}
alt={preview.file.name || t('image.original')}
labels={lightboxLabels(t)}
onClose={closePreview}
t={t}
/>
)}
{footer}

View File

@@ -0,0 +1,82 @@
// @vitest-environment jsdom
// The conversation-side bridge to the ui-attachment atoms: dictionary strings
// flow through image-labels into the gallery, and assistant images keep their
// block position between text blocks.
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const t = makeTranslate(zh, commonZh)
const enT = makeTranslate(en, commonZh)
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 68,
width: 640,
height: 320,
name: 'history.png',
}
describe('assistant images through the label bridge', () => {
it('resolves zh dictionary strings and opens the lightbox on a single click', async () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'image', attachment }]}
streaming={false}
loadImage={() => Promise.resolve('blob:history')}
/>,
)
const frame = await view.findByRole('button', { name: 'history.png点击查看原图' })
expect(frame.getAttribute('title')).toBe('查看原图')
await view.findByAltText('history.png')
fireEvent.click(frame)
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
it('resolves the active English dictionary', async () => {
const view = render(
<AssistantMarkdown
t={enT}
blocks={[{ kind: 'image', attachment }]}
streaming={false}
loadImage={() => Promise.resolve('blob:history')}
/>,
)
const frame = await view.findByRole('button', { name: 'history.png, click to view original' })
await view.findByAltText('history.png')
fireEvent.click(frame)
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[
{ kind: 'text', text: 'before' },
{ kind: 'image', attachment },
{ kind: 'text', text: 'after' },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:middle')}
/>,
)
const image = await view.findByAltText('history.png')
const before = view.getByText('before')
const after = view.getByText('after')
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
})
})

View File

@@ -237,15 +237,49 @@ describe('image draft rail', () => {
expect(removeImage).toHaveBeenCalledWith('draft-1')
})
it('opens the original image on double-click and closes it with Escape', () => {
it('opens the original image on a single click and closes it with Escape', () => {
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }
const { view } = bench({ attachments: [attachment] })
fireEvent.doubleClick(view.getByTitle('双击查看原图'))
fireEvent.click(view.getByTitle('查看原图'))
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.keyDown(window, { key: 'Escape' })
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
vi.useFakeTimers()
try {
const addImages = vi.fn(() => '不支持的图片格式text/plain')
const { view, textarea } = bench({ addImages })
const paste = () => {
fireEvent.paste(textarea, {
clipboardData: {
items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'note.txt', { type: 'text/plain' }) }],
getData: () => '',
},
})
}
paste()
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式text/plain')
act(() => { vi.advanceTimersByTime(4000) })
expect(view.queryByRole('alert')).toBeNull()
// The identical rejection re-announces: the toast is keyed per show.
paste()
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式text/plain')
} finally {
vi.useRealTimers()
}
})
it('announces a rejected drop through the same toast', () => {
const addImages = vi.fn(() => '图片读取服务不可用')
const { view } = bench({ addImages })
const card = view.container.querySelector('[class*="card"]')!
const dataTransfer = { types: ['Files'], files: [new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' })], dropEffect: 'none' }
fireEvent.drop(card, { dataTransfer })
expect(view.getByRole('alert').textContent).toContain('图片读取服务不可用')
})
})
describe('Enter semantics', () => {
@@ -941,10 +975,19 @@ describe('insertText (scoped event body)', () => {
})
describe('strips and variants', () => {
it('derives the failure strip from promptError (ordinary failure — no transaction UI, no Retry)', () => {
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom (agent-busy)')
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
it('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => {
vi.useFakeTimers()
try {
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
// The toast body-portals (transformed ancestors must not trap it), so
// queries go through the view's document-bound helpers.
expect(send.view.getByRole('alert').textContent).toContain('boom (agent-busy)')
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
act(() => { vi.advanceTimersByTime(4000) })
expect(send.view.queryByRole('alert')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('renders the notice strip from the machine notice store', () => {

View File

@@ -1,81 +0,0 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { MessageImage } from '../src/client/chat/MessageImage.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const t = makeTranslate(zh, commonZh)
const enT = makeTranslate(en, commonZh)
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 68,
width: 640,
height: 320,
name: 'history.png',
}
describe('MessageImage', () => {
it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
const frame = view.getByRole('button', { name: 'history.png双击查看原图' })
expect(frame.getAttribute('style')).toContain('width: 240px')
expect(frame.getAttribute('style')).toContain('height: 120px')
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledWith(attachment)
fireEvent.doubleClick(frame)
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
it('surfaces a retry control when durable bytes cannot be read', async () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledTimes(2)
})
it('renders image controls from the active English dictionary', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} t={enT} />)
const frame = view.getByRole('button', { name: 'history.png, double-click to view original' })
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
fireEvent.doubleClick(frame)
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[
{ kind: 'text', text: 'before' },
{ kind: 'image', attachment },
{ kind: 'text', text: 'after' },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:middle')}
/>,
)
const image = await view.findByAltText('history.png')
const before = view.getByText('before')
const after = view.getByText('after')
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
})
})

View File

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

View File

@@ -7,7 +7,9 @@
* ToggleButton) shows both: model name + effort in the caption tone.
* Data and submission ride the SAME per-session ModelDirectory as the
* /model popup; exact-model reasoning metadata and the selected effort come
* from the Host rather than a client-owned vocabulary.
* from the Host rather than a client-owned vocabulary. A rejected selection
* announces through the shared transient Toast anchored to the composer
* card; the in-menu strip with Retry remains the catalog-load surface.
*/
import {
useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
@@ -17,6 +19,7 @@ import clsx from 'clsx'
import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-client-connection/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
IconWarningOutline16, Toast,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelSelectInjected } from './slots.ts'
@@ -49,6 +52,13 @@ export function ModelSelect(
)
const [open, setOpen] = useState(false)
const [pane, setPane] = useState<Pane>('root')
// The in-menu error strip serves catalog loads (its Retry re-runs the
// load); a rejected SELECTION announces through the transient toast
// instead, so the strip renders only while the latest failure-capable
// action was a load.
const lastActionRef = useRef<'load' | 'select'>('load')
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
const toastSeq = useRef(0)
const rootRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
@@ -92,9 +102,17 @@ export function ModelSelect(
], [reasoning, t])
const busy = state.status === 'selecting'
const reload = (): void => {
lastActionRef.current = 'load'
load()
}
// Mount-time load resolves the trigger label; every open refreshes.
useEffect(() => {
if (available) load()
if (available) {
lastActionRef.current = 'load'
load()
}
}, [available, load])
useEffect(() => {
@@ -111,7 +129,7 @@ export function ModelSelect(
const show = (): void => {
setPane('root')
setOpen(true)
load()
reload()
}
const close = (restoreFocus = false): void => {
@@ -148,14 +166,25 @@ export function ModelSelect(
close()
}
const settleSelection = (accepted: boolean): void => {
if (accepted) {
if (rootRef.current !== null) close(true)
return
}
const message = directory.getSnapshot().error
if (message !== null) {
toastSeq.current += 1
setToast({ seq: toastSeq.current, text: t('error.action', { message }) })
}
}
const choose = (selection: ModelSelection): void => {
if (state.current?.provider === selection.provider && state.current.model === selection.model) {
close(true)
return
}
void select(selection).then((accepted) => {
if (accepted && rootRef.current !== null) close(true)
})
lastActionRef.current = 'select'
void select(selection).then(settleSelection)
}
const chooseEffort = (effort: string | undefined): void => {
@@ -169,9 +198,8 @@ export function ModelSelect(
model: state.current.model,
...effort === undefined ? {} : { reasoningEffort: effort },
}
void select(selection).then((accepted) => {
if (accepted && rootRef.current !== null) close(true)
})
lastActionRef.current = 'select'
void select(selection).then(settleSelection)
}
const modelLabel = currentChoice?.model.name ?? t('trigger.fallback')
@@ -243,16 +271,16 @@ export function ModelSelect(
{state.status === 'loading' && (
<div className={css.status}>{t('status.loading')}</div>
)}
{state.error !== null && (
{state.error !== null && lastActionRef.current === 'load' && (
<div className={css.error}>
<span>{t('error.action', { message: state.error })}</span>
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
<button type="button" className={css.retry} onClick={reload}>{t('retry')}</button>
</div>
)}
{state.failures.map(failure => (
<div className={css.warning} key={failure.id}>
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
<button type="button" className={css.retry} onClick={reload}>{t('retry')}</button>
</div>
))}
<div className={clsx(css.groups, 'scrollable')}>
@@ -299,10 +327,10 @@ export function ModelSelect(
{pane === 'effort' && (
<>
{state.error !== null && (
{state.error !== null && lastActionRef.current === 'load' && (
<div className={css.error}>
<span>{t('error.action', { message: state.error })}</span>
<button type="button" className={css.retry} onClick={() => { load() }}>{t('action.reload')}</button>
<button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
</div>
)}
{effortChoices.length === 0
@@ -333,6 +361,15 @@ export function ModelSelect(
)}
</div>
)}
{toast !== null && (
<Toast
key={toast.seq}
text={toast.text}
icon={<IconWarningOutline16 />}
anchor={rootRef.current?.closest<HTMLElement>('[data-composer-card]') ?? null}
onDone={() => { setToast(null) }}
/>
)}
</div>
)
}

View File

@@ -135,6 +135,38 @@ describe('ModelSelect reasoning effort', () => {
expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
})
it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => {
const groups = [{
id: 'deepseek-official',
name: 'DeepSeek',
models: [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
],
}]
const directory = createSnapshotStore<ModelDirectoryState>(state({ groups }))
const select = vi.fn(async () => {
directory.set(state({ groups, status: 'error', error: 'model-unavailable: session already contains images' }))
return false
})
render(<ModelSelect
locked={false}
available
directory={directory}
load={vi.fn()}
select={select}
t={t}
/>)
fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
const toast = await screen.findByRole('alert')
expect(toast.textContent).toContain('模型操作失败model-unavailable: session already contains images')
// The selection failure does not render the in-menu load strip (no Retry).
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
})
it('renders no Agent-bound control for an addressed subagent session', () => {
const load = vi.fn()
render(<ModelSelect

View File

@@ -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: f96e931472a0946bd023b97b034643f079a67e44
README.zh.md: 0db2ac1bccbdc4793c488e1ce621ee5c674d9895

View File

@@ -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 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). It body-portals with `pointer-events: none`, sits 120px from the viewport top, and centers horizontally over the optional `anchor` element (re-measured on window resizes) — the composer passes its card so the banner centers over the chat column rather than the whole window — falling back to the viewport center without one. 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. Under `prefers-reduced-motion: reduce` the slide-in is dropped and only the delayed fade remains. 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).

View File

@@ -2,12 +2,16 @@
[English](README.md) | 中文
纯 React 原子组件(零 cordisStateDot、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 原子组件(零 cordisStateDot、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由持有方本地化。它经 body portal 渲染且 `pointer-events: none`,距视口顶部 120px水平中心跟随可选的 `anchor` 元素窗口尺寸变化时重测——composer 传入自己的卡片,横幅因此在聊天列而非整个窗口上居中——不传则回退到视口居中。重复展示同一条消息需要重新挂载,持有方用每次展示递增的序号作为 key让相同文案重新走完停留与淡出而不是静默复用已淡出的横幅。`prefers-reduced-motion: reduce` 下去掉滑入,只保留延迟淡出。它的层级高于 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

View File

@@ -0,0 +1,70 @@
/* 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: 120px;
left: 50%;
/* Above the 1000 the image lightbox backdrop uses: a failure reported while
a preview is open must stay readable. */
z-index: 1100;
/* Purely an announcement: it must never intercept clicks — in particular
after the CSS fade finished while a throttled background-tab timer has
not yet unmounted the still-hit-testable fixed element. */
pointer-events: none;
display: flex;
align-items: center;
gap: 10px;
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;
}
}
/* Reduced motion drops the slide-in; the delayed fade (an opacity change,
not movement) still ends the banner before the timed unmount. */
@media (prefers-reduced-motion: reduce) {
.toast {
animation: dsh-toast-fade 1000ms ease 3000ms forwards;
}
}

View File

@@ -0,0 +1,59 @@
import { useEffect, useLayoutEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import css from './Toast.module.css'
/** Full-opacity hold before the fade starts. Must agree with the stylesheet's
* 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). Rendered through a body portal so an owner inside a
* transformed or filtered ancestor cannot trap the fixed banner in that
* ancestor's box.
*
* @param props.text - resolved banner copy; the owner passes localized text.
* @param props.icon - optional leading glyph (e.g. a warning icon).
* @param props.anchor - optional element whose horizontal center the banner
* follows (e.g. the composer card, so the banner centers over the chat column
* rather than the whole window); omitted, it centers on the viewport.
* @param props.onDone - called once the fade completes; unmount the toast here.
* @returns the floating banner.
*/
export function Toast({ text, icon, anchor, onDone }: {
text: string
icon?: ReactNode
anchor?: HTMLElement | null
onDone: () => void
}) {
useEffect(() => {
const timer = setTimeout(onDone, HOLD_MS + FADE_MS)
return () => { clearTimeout(timer) }
}, [onDone])
// Anchor-centered placement re-measures on window resizes; the banner lives
// four seconds, so sub-window layout drift within that span stays out of
// scope.
const [left, setLeft] = useState<number | null>(null)
useLayoutEffect(() => {
if (anchor == null) return
const measure = (): void => {
const rect = anchor.getBoundingClientRect()
setLeft(rect.left + rect.width / 2)
}
measure()
window.addEventListener('resize', measure)
return () => { window.removeEventListener('resize', measure) }
}, [anchor])
return createPortal(
<div className={css.toast} role="alert" style={left === null ? undefined : { left }}>
{icon !== undefined && <span className={css.icon} aria-hidden>{icon}</span>}
<span className={css.text}>{text}</span>
</div>,
document.body,
)
}

View File

@@ -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'

View File

@@ -0,0 +1,57 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { Toast } from '../src/Toast.tsx'
afterEach(cleanup)
describe('Toast', () => {
it('announces its text and reports done after the hold-and-fade lifetime', () => {
vi.useFakeTimers()
try {
const onDone = vi.fn()
const view = render(<Toast text="最多添加 50 张图片" icon={<svg data-testid="icon" />} onDone={onDone} />)
const banner = view.getByRole('alert')
expect(banner.textContent).toContain('最多添加 50 张图片')
expect(view.getByTestId('icon')).toBeTruthy()
vi.advanceTimersByTime(3999)
expect(onDone).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(onDone).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('centers over its anchor and re-measures on window resize', () => {
vi.useFakeTimers()
try {
const anchor = document.createElement('div')
document.body.appendChild(anchor)
anchor.getBoundingClientRect = () => ({ left: 100, width: 400 }) as DOMRect
const view = render(<Toast text="anchored" anchor={anchor} onDone={vi.fn()} />)
expect(view.getByRole('alert').style.left).toBe('300px')
anchor.getBoundingClientRect = () => ({ left: 200, width: 400 }) as DOMRect
fireEvent(window, new Event('resize'))
expect(view.getByRole('alert').style.left).toBe('400px')
anchor.remove()
} finally {
vi.useRealTimers()
}
})
it('renders without an icon and cancels its timer on unmount', () => {
vi.useFakeTimers()
try {
const onDone = vi.fn()
const view = render(<Toast text="plain" onDone={onDone} />)
expect(view.getByRole('alert').querySelector('[aria-hidden]')).toBeNull()
view.unmount()
vi.advanceTimersByTime(10_000)
expect(onDone).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})

View File

@@ -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:^",

View File

@@ -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

View File

@@ -14,6 +14,7 @@ import * as Cordis from '@deepseek-ai/cordis'
import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots'
import * as WebReact from '@deepseek-ai/dsh-client-web-react'
import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import * as UiAttachment from '@deepseek-ai/dsh-client-ui-attachment'
import * as SchemaForm from '@deepseek-ai/dsh-client-schema-form'
import type { PlatformModule } from './platform.ts'
@@ -34,6 +35,7 @@ export function getStaticModules(): Record<string, unknown> {
'@deepseek-ai/dsh-client-ui-slots': UiSlots,
'@deepseek-ai/dsh-client-web-react': WebReact,
'@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,
'@deepseek-ai/dsh-client-ui-attachment': UiAttachment,
'@deepseek-ai/dsh-client-schema-form': SchemaForm,
} satisfies Record<PlatformModule, unknown>
}

View File

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