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

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

The attachment atoms move to a new zero-cordis package
@deepseek-ai/dsh-client-ui-attachment (rail, message gallery, lightbox),
seeded as a platform module; the toast is a ui-primitives atom. Strings
arrive as label props bridged from the conversation dictionary.
This commit is contained in:
creatixchu
2026-08-11 17:01:29 +08:00
parent 5d591e55c1
commit e611e825b1
56 changed files with 1366 additions and 251 deletions

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

View File

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

View File

@@ -0,0 +1,26 @@
# @deepseek-ai/dsh-client-ui-attachment
[English](README.md) | 中文
纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)与原图灯箱(`ImageLightbox`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。
## 附件栏
`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动,箭头的显隐在滚动、条目数量变化和窗口尺寸变化时依据滚动几何重算。纵向滚轮转为横向平移,单次行程钳制在 60px 内,触控板的横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。
## 消息图片与灯箱
`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。
## Model Experience
None, as the package renders pure React atoms in the browser; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **仅支持图片** — 非图片文件尚无附件栏卡片与历史渲染;DeepSeek Chat 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。
- **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。

View File

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

View File

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

View File

@@ -0,0 +1,152 @@
/** Draft-attachment thumbnail rail: scrollbar-less horizontal overflow paged
* by edge arrows, hover-revealed per-item remove, single-click open. */
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { WheelEvent } from 'react'
import clsx from 'clsx'
import {
IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import css from './AttachmentRail.module.css'
/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */
export interface AttachmentRailItem {
/** Stable identity for the React key. */
id: string
/** Object or data URL rendered as the thumbnail. */
previewUrl: string
/** Image alt text (display name with the owner's fallback applied). */
alt: string
/** Accessible label of the item's remove control. */
removeLabel: string
}
/** Rail-level strings the owner resolves from its own locale namespace. */
export interface AttachmentRailLabels {
/** Accessible name of the rail group. */
group: string
/** Thumbnail tooltip inviting the original-image preview. */
open: string
/** Accessible label of the left paging arrow. */
scrollLeft: string
/** Accessible label of the right paging arrow. */
scrollRight: string
}
/**
* Horizontal thumbnail rail over the caller's draft attachments.
*
* The rail scrolls with its scrollbar hidden; overflow is announced by edge
* arrows recomputed from scroll geometry on scroll, item-count changes, and
* window resizes. A vertical wheel pans horizontally, a newly added item is
* revealed at the rail's end, and each thumbnail opens on a single click while
* its remove control sits inside the card and reveals on hover or focus.
* The owner decides mounting; it renders the rail only while items exist.
*
* @param props.items - resolved thumbnails in draft order.
* @param props.labels - rail-level strings (group name, open tooltip, arrows).
* @param props.onOpen - single-click open of one item's original image.
* @param props.onRemove - remove one item from the draft.
* @returns the rail group with its paging arrows.
*/
export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, onOpen, onRemove }: {
items: readonly T[]
labels: AttachmentRailLabels
onOpen: (item: T) => void
onRemove: (item: T) => void
}) {
const railRef = useRef<HTMLDivElement | null>(null)
const countRef = useRef(0)
const [edges, setEdges] = useState({ left: false, right: false })
const updateEdges = useCallback(() => {
const el = railRef.current
/* v8 ignore next -- defensive: every caller runs while the rail element is mounted. */
if (el === null) return
// 1px slack: engines report fractional scroll positions at the edges.
const left = el.scrollLeft > 1
const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right })
}, [])
useLayoutEffect(() => {
const grew = items.length > countRef.current
countRef.current = items.length
const el = railRef.current
// A newly added attachment lands at the rail's end: reveal it.
if (grew && el !== null) el.scrollLeft = el.scrollWidth - el.clientWidth
updateEdges()
}, [items.length, updateEdges])
useEffect(() => {
window.addEventListener('resize', updateEdges)
return () => { window.removeEventListener('resize', updateEdges) }
}, [updateEdges])
const page = (direction: -1 | 1): void => {
const el = railRef.current
/* v8 ignore next -- defensive: the arrows render only while the rail is mounted, so a click cannot find a null ref. */
if (el === null) return
// One viewport minus a card keeps the last visible thumbnail as context;
// the floor keeps narrow rails paging a useful distance.
el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: 'smooth' })
}
// A vertical wheel pans the rail horizontally (trackpads pan natively via
// deltaX); per-tick travel is clamped so a fast notch wheel stays followable.
const onWheel = (event: WheelEvent<HTMLDivElement>): void => {
if (event.deltaX !== 0 || event.deltaY === 0) return
event.currentTarget.scrollBy({
left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY), 60),
behavior: 'auto',
})
}
return (
<div className={css.root}>
{edges.left && (
<button
type="button"
className={clsx(css.arrow, css.arrowLeft)}
aria-label={labels.scrollLeft}
onClick={() => { page(-1) }}
>
<IconChevronLeftOutline14 />
</button>
)}
<div
ref={railRef}
className={css.rail}
role="group"
aria-label={labels.group}
onScroll={updateEdges}
onWheel={onWheel}
>
{items.map(item => (
<div key={item.id} className={css.item}>
<button
type="button"
className={css.thumbnail}
title={labels.open}
onClick={() => { onOpen(item) }}
>
<img src={item.previewUrl} alt={item.alt} />
</button>
<button
type="button"
className={css.remove}
aria-label={item.removeLabel}
onClick={() => { onRemove(item) }}
>
<IconCloseFill14 size={12} />
</button>
</div>
))}
</div>
{edges.right && (
<button
type="button"
className={clsx(css.arrow, css.arrowRight)}
aria-label={labels.scrollRight}
onClick={() => { page(1) }}
>
<IconChevronRightOutline14 />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,34 @@
.backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 40px;
background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent);
}
.image {
max-width: min(100%, 1600px);
max-height: calc(100vh - 80px);
object-fit: contain;
border-radius: 12px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv3);
}
.close {
position: fixed;
top: 20px;
right: 20px;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 999px;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-primary);
font-size: 24px;
cursor: pointer;
}

View File

@@ -0,0 +1,57 @@
import { useEffect, useRef } from 'react'
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.
*
* @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 (
<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>
)
}

View File

@@ -0,0 +1,53 @@
.gallery {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: min(240px, 100%);
}
.gallery[data-align='end'] {
justify-content: flex-end;
align-self: flex-end;
}
.gallery[data-align='start'] {
justify-content: flex-start;
align-self: flex-start;
}
.frame {
display: grid;
flex: 0 0 auto;
place-items: center;
min-width: 44px;
min-height: 44px;
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;
}
.frame img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.loading,
.error {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.error {
max-width: 240px;
padding: 10px 12px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 10px;
background: var(--dsw-alias-interactive-bg-hover-danger);
cursor: pointer;
}

View File

@@ -0,0 +1,96 @@
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)
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 ?? 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,128 @@
// @vitest-environment jsdom
// AttachmentRail behavior in the jsdom lane: item rendering and callbacks,
// arrow paging over stubbed scroll geometry (jsdom lays nothing out), the
// vertical-wheel pan, and the new-item end reveal.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { AttachmentRail } from '../src/AttachmentRail.tsx'
import type { AttachmentRailItem, AttachmentRailLabels } from '../src/AttachmentRail.tsx'
afterEach(cleanup)
const labels: AttachmentRailLabels = {
group: '待发送图片',
open: '查看原图',
scrollLeft: '向左滚动图片',
scrollRight: '向右滚动图片',
}
function item(id: string): AttachmentRailItem {
return { id, previewUrl: `blob:${id}`, alt: `${id}.png`, removeLabel: `移除图片 ${id}.png` }
}
/** Stub the rail's scroll geometry (jsdom reports 0 for every metric). */
function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) {
Object.defineProperty(rail, 'scrollWidth', { value: scrollWidth, configurable: true })
Object.defineProperty(rail, 'clientWidth', { value: clientWidth, configurable: true })
let scrollLeft = 0
Object.defineProperty(rail, 'scrollLeft', {
configurable: true,
get: () => scrollLeft,
set: (value: number) => { scrollLeft = value },
})
const scrollBy = vi.fn((options: { left: number }) => {
scrollLeft = Math.max(0, Math.min(scrollWidth - clientWidth, scrollLeft + options.left))
})
rail.scrollBy = scrollBy as unknown as typeof rail.scrollBy
return { scrollBy, setScrollLeft: (value: number) => { scrollLeft = value } }
}
describe('AttachmentRail', () => {
it('renders thumbnails in order and routes open and remove clicks', () => {
const onOpen = vi.fn()
const onRemove = vi.fn()
const items = [item('a'), item('b')]
const view = render(<AttachmentRail items={items} labels={labels} onOpen={onOpen} onRemove={onRemove} />)
const rail = view.getByRole('group', { name: '待发送图片' })
expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['a.png', 'b.png'])
fireEvent.click(view.getAllByTitle('查看原图')[0]!)
expect(onOpen).toHaveBeenCalledWith(items[0])
fireEvent.click(view.getByRole('button', { name: '移除图片 b.png' }))
expect(onRemove).toHaveBeenCalledWith(items[1])
})
it('shows edge arrows from scroll geometry and pages a viewport at a time', () => {
const view = render(
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
// No arrows until geometry is observed (mount saw jsdom's zero metrics).
expect(view.queryByLabelText('向右滚动图片')).toBeNull()
fireEvent.scroll(rail)
// Same-edges scroll takes the memoized-state path.
fireEvent.scroll(rail)
expect(view.queryByLabelText('向左滚动图片')).toBeNull()
const right = view.getByLabelText('向右滚动图片')
// clientWidth 200 - 64 < the 200 floor: pages by the floor.
fireEvent.click(right)
expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' })
fireEvent.scroll(rail)
// Scrolled to the far edge: only the left arrow remains.
expect(view.queryByLabelText('向右滚动图片')).toBeNull()
fireEvent.click(view.getByLabelText('向左滚动图片'))
expect(scrollBy).toHaveBeenCalledWith({ left: -200, behavior: 'smooth' })
fireEvent.scroll(rail)
expect(view.queryByLabelText('向左滚动图片')).toBeNull()
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
})
it('shows both arrows mid-scroll and recomputes on window resize', () => {
const view = render(
<AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
setScrollLeft(100)
fireEvent(window, new Event('resize'))
expect(view.getByLabelText('向左滚动图片')).toBeTruthy()
expect(view.getByLabelText('向右滚动图片')).toBeTruthy()
})
it('pans horizontally on a vertical wheel with clamped travel', () => {
const view = render(
<AttachmentRail items={[item('a'), item('b')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
fireEvent.wheel(rail, { deltaY: 30 })
expect(scrollBy).toHaveBeenCalledWith({ left: 30, behavior: 'auto' })
fireEvent.wheel(rail, { deltaY: 500 })
expect(scrollBy).toHaveBeenCalledWith({ left: 60, behavior: 'auto' })
fireEvent.wheel(rail, { deltaY: -500 })
expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
// A trackpad pan (deltaX) and a zero-delta wheel keep native behavior.
fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })
fireEvent.wheel(rail, { deltaY: 0 })
expect(scrollBy).toHaveBeenCalledTimes(3)
})
it('reveals the rail end when an item is added, not when one is removed', () => {
const first = [item('a'), item('b')]
const view = render(
<AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
const rail = view.getByRole('group', { name: '待发送图片' })
stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
view.rerender(
<AttachmentRail items={[...first, item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
expect(rail.scrollLeft).toBe(200)
view.rerender(
<AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
)
// Removal keeps the position; only growth jumps to the end.
expect(rail.scrollLeft).toBe(200)
})
})

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