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