refactor(client): make attachment UI a client plugin

This commit is contained in:
imccyu 2026-08-16 23:48:05 +08:00
parent 56dff07c4e
commit 3e4ad10d05
28 changed files with 426 additions and 337 deletions

View file

@ -1,6 +1,6 @@
{
"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)",
"description": "Dynamic attachment presentation plugin for conversation input and message-image slots",
"version": "0.1.0-rc.7",
"publishConfig": {
"access": "public"
@ -22,30 +22,53 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "MIT",
"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"
"clsx": "^2.0.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0"
"@types/react-dom": "~18.3.0",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@deepseek-ai/dsh-attachment": "workspace:^"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
],
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^"
}
}

View file

@ -0,0 +1,4 @@
.rail {
min-width: 0;
padding: 4px 12px 0;
}

View file

@ -0,0 +1,112 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
ComposerAttachment, ComposerAttachmentsProps,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { AttachmentRail } from '../AttachmentRail.tsx'
import type { AttachmentRailItem } from '../AttachmentRail.tsx'
import { DropOverlay } from '../DropOverlay.tsx'
import { ImageLightbox } from '../ImageLightbox.tsx'
import { attachmentRailLabels, dropOverlayLabels, lightboxLabels } from './labels.ts'
import css from './ComposerAttachments.module.css'
/** Rail item retaining its browser-owned attachment for callbacks. */
interface ComposerRailItem extends AttachmentRailItem {
attachment: ComposerAttachment
}
/** Draft-image rail, document drop target, and original-image preview slot entry. */
export function ComposerAttachments({
attachments, canAcceptDrop, onAddImages, onRemoveImage, dropLimits, t,
}: ComposerAttachmentsProps) {
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
const [dragActive, setDragActive] = useState(false)
const dragDepth = useRef(0)
const closePreview = useCallback(() => { setPreview(null) }, [])
useEffect(() => {
if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null)
}, [attachments, preview])
useEffect(() => {
const hasFiles = (event: globalThis.DragEvent): boolean =>
event.dataTransfer?.types.includes('Files') ?? false
const reset = (): void => {
dragDepth.current = 0
setDragActive(false)
}
const onDragEnter = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
event.preventDefault()
dragDepth.current += 1
setDragActive(true)
}
const onDragOver = (event: globalThis.DragEvent): void => {
if (!hasFiles(event) || event.dataTransfer === null) return
event.preventDefault()
event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
}
const onDragLeave = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
dragDepth.current = Math.max(0, dragDepth.current - 1)
if (dragDepth.current === 0) setDragActive(false)
const leftViewport = event.clientX <= 0 || event.clientY <= 0
|| event.clientX >= window.innerWidth || event.clientY >= window.innerHeight
if ((event.target === document.documentElement || event.target === document.body) && leftViewport) reset()
}
const onDrop = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
event.preventDefault()
reset()
if (canAcceptDrop) onAddImages([...(event.dataTransfer?.files ?? [])])
}
document.addEventListener('dragenter', onDragEnter)
document.addEventListener('dragover', onDragOver)
document.addEventListener('dragleave', onDragLeave)
document.addEventListener('drop', onDrop)
window.addEventListener('dragend', reset)
return () => {
document.removeEventListener('dragenter', onDragEnter)
document.removeEventListener('dragover', onDragOver)
document.removeEventListener('dragleave', onDragLeave)
document.removeEventListener('drop', onDrop)
window.removeEventListener('dragend', reset)
}
}, [canAcceptDrop, onAddImages])
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])
return (
<>
{dragActive && (
<DropOverlay
disabled={!canAcceptDrop}
labels={dropOverlayLabels(t, canAcceptDrop, dropLimits)}
/>
)}
{railItems.length > 0 && (
<div className={css.rail}>
<AttachmentRail
items={railItems}
labels={attachmentRailLabels(t)}
onOpen={(item) => { setPreview(item.attachment) }}
onRemove={(item) => { onRemoveImage(item.attachment.id) }}
/>
</div>
)}
{preview !== null && (
<ImageLightbox
src={preview.previewUrl}
alt={preview.file.name || t('image.original')}
labels={lightboxLabels(t)}
onClose={closePreview}
/>
)}
</>
)
}

View file

@ -0,0 +1,8 @@
import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ImageGallery } from '../MessageImage.tsx'
import { messageImageLabels } from './labels.ts'
/** Historical message-image slot entry. */
export function MessageImages({ images, loadImage, align, t }: MessageImagesProps) {
return <ImageGallery images={images} load={loadImage} align={align} labels={messageImageLabels(t)} />
}

View file

@ -0,0 +1,20 @@
/** Browser attachment plugin: fills conversation's composer and message-image slots. */
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ComposerAttachments } from './ComposerAttachments.tsx'
import { MessageImages } from './MessageImages.tsx'
/** Slot registry required by this presentation plugin. */
export const inject = ['slots']
/** Register attachment presentation without exporting React components as package values. */
export function apply(ctx: ClientContext): void {
ctx.slots.inject('conversation.input.attachments', () => ctx.slots.register({
name: 'conversation.input.attachments',
locale: 'conversation',
}, ComposerAttachments))
ctx.slots.inject('conversation.message.images', () => ctx.slots.register({
name: 'conversation.message.images',
locale: 'conversation',
}, MessageImages))
}

View file

@ -0,0 +1,45 @@
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import type { AttachmentRailLabels } from '../AttachmentRail.tsx'
import type { DropOverlayLabels } from '../DropOverlay.tsx'
import type { ImageLightboxLabels } from '../ImageLightbox.tsx'
import type { MessageImageLabels } from '../MessageImage.tsx'
/** Resolve original-image lightbox strings from the conversation namespace. */
export function lightboxLabels(t: TranslateNS<'conversation'>): ImageLightboxLabels {
return { dialog: t('image.preview'), close: t('image.closePreview') }
}
/** Resolve historical message-image strings from the conversation namespace. */
export function messageImageLabels(t: TranslateNS<'conversation'>): 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 document-level drop invitation and its optional limits line. */
export function dropOverlayLabels(
t: TranslateNS<'conversation'>,
accepting: boolean,
limits?: { readonly count: number; readonly size: string },
): DropOverlayLabels {
if (!accepting) return { title: t('image.dropBlocked') }
return {
title: t('image.dropTitle'),
desc: limits === undefined ? undefined : t('image.dropDesc', limits),
}
}
/** Resolve draft-image rail strings from the conversation namespace. */
export function attachmentRailLabels(t: TranslateNS<'conversation'>): AttachmentRailLabels {
return {
group: t('image.pending'),
open: t('image.openOriginal'),
scrollLeft: t('image.scrollLeft'),
scrollRight: t('image.scrollRight'),
}
}

View file

@ -1,16 +1,4 @@
/**
* Pure React attachment atoms (zero cordis): the composer draft-image rail,
* the chat-history image gallery, the original-image lightbox, and the
* full-page drop overlay. 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
*/
/** Host half of the browser-only attachment presentation plugin. */
export { AttachmentRail } from './AttachmentRail.tsx'
export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx'
export { DropOverlay } from './DropOverlay.tsx'
export type { DropOverlayLabels } from './DropOverlay.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'
/** No host-side behavior; the client half registers the React slot entries. */
export function apply(): void {}

View file

@ -15,9 +15,8 @@ export const name = 'client-ui-attachment-invariant'
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.
* No runtime invariant: the package contributes only effect-owned slot entries;
* the slot registry owns their lifecycle and validates their declarations.
*/
const install: InvariantInstaller = () => {}

View file

@ -14,6 +14,15 @@
{
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-slots"
},
{
"path": "../ui-primitives"
}

View file

@ -1,35 +1,6 @@
import { clientOnly } from '../tsdown.client.ts'
import { clientBundle } 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 {};'
},
}],
}])
export default clientBundle(
'@deepseek-ai/dsh-client-ui-attachment',
['lib/types/index.js', 'lib/types/invariant.js'],
)

View file

@ -48,7 +48,6 @@
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-settings": "workspace:^",
"clsx": "^2.0.0",
"@deepseek-ai/schemastery": "workspace:^"
},
@ -61,11 +60,8 @@
"@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-input-trigger": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compaction": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
@ -73,7 +69,12 @@
"@deepseek-ai/dsh-session-stats": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-permission-presets": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
@ -86,7 +87,6 @@
"@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-input-trigger": "workspace:^",
@ -104,7 +104,8 @@
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
"react": "^18.2.0",
"@deepseek-ai/dsh-settings": "workspace:^"
},
"files": [
"lib/index.js",

View file

@ -282,6 +282,7 @@ export function apply(ctx: Context): void {
// access control, model right); empty until their owning plugins
// register.
children: {
'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' },
'conversation.input.plan': { kind: 'single', scope: 'session' },
'conversation.input.model': { kind: 'single', scope: 'session' },
},
@ -381,6 +382,7 @@ export function apply(ctx: Context): void {
locale: NS,
children: {
'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT },
'conversation.message.images': { kind: 'single', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {

View file

@ -9,14 +9,12 @@
// their branch action is enabled only when the node is also the completed
// turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
import { memo, useMemo } from 'react'
import { Fragment, memo, useMemo } from 'react'
import type { ReactNode } 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 { messageImageLabels } from '../image-labels.ts'
import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
import { ReasoningRow } from './ReasoningRow.tsx'
import css from './AssistantMarkdown.module.css'
@ -25,8 +23,8 @@ export interface AssistantMarkdownProps {
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
interrupted?: boolean | undefined
/** Session-authorized durable image loader. */
loadImage?: ImageLoader
/** Render consecutive image blocks through the attachment slot. */
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
/** Resolved prose file mentions for this Assistant's closing turn. */
mentions?: MarkdownFileMentions | undefined
/** The owning view's locale seat, passed down as a plain prop. */
@ -35,9 +33,8 @@ export interface AssistantMarkdownProps {
/** Reasoning block as the Think variant summary row (figma 39:28304). */
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, loadImage, mentions, t,
blocks, streaming, interrupted, renderMessageImages, mentions, t,
}: AssistantMarkdownProps) {
const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable'))))
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
@ -82,7 +79,14 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
group.push(next)
i += 1
}
rendered.push(<ImageGallery key={start} images={group} load={imageLoader} align="start" labels={messageImageLabels(t)} />)
rendered.push(
<Fragment key={start}>
{renderMessageImages({
images: group.map(({ attachment }) => ({ attachment })),
align: 'start',
})}
</Fragment>,
)
break
}
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.

View file

@ -4,7 +4,7 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx'
/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
export const AssistantNodeView = memo(function AssistantNodeView({
node, useTurnData, openFile, loadImage, fileMentions, t,
node, useTurnData, openFile, renderMessageImages, fileMentions, t,
}: ChatNodeViewProps<'assistant-step'>) {
const data = node.data
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
@ -25,7 +25,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({
blocks={data.blocks}
streaming={data.status === 'running'}
interrupted={data.status === 'interrupted'}
loadImage={loadImage}
renderMessageImages={renderMessageImages}
mentions={mentions}
t={t}
/>

View file

@ -18,7 +18,7 @@ type RoutedChatNodeOwner = {
/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */
export const ChatNodeSeat = memo(function ChatNodeSeat({
nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt,
loadImage, fileMentions, useSession, renderSlot, t,
renderMessageImages, fileMentions, useSession, renderSlot, t,
}: ChatNodeSeatProps) {
const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey))
const routedNode = node as ChatNode | undefined
@ -30,9 +30,11 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
openFile,
inspectCall,
forkAt,
loadImage,
renderMessageImages,
fileMentions,
}, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions])
}, [
node, selectedCallId, cwd, openFile, inspectCall, forkAt, renderMessageImages, fileMentions,
])
if (routedNode === undefined || owner === null) return null
// Runtime dispatch owns the correlation: every Node's discriminant is the
// keyed-slot entry passed alongside that same Node. TypeScript does not

View file

@ -12,10 +12,10 @@
// ChatNodeSeat subscribes to one Node key, so Assistant deltas and Tool
// lifecycle updates replace only their own row without remounting it.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts'
import { PendingSteeringBubble } from './MessageItem.tsx'
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
import { formatRunDuration } from './message-chrome.ts'
@ -164,6 +164,10 @@ export function ChatView({
() => inbox.filter(item => item.placement === 'steering'),
[inbox],
)
const renderMessageImages = useCallback<RenderMessageImages>(
owner => renderSlot('conversation.message.images', { ...owner, loadImage }),
[loadImage, renderSlot],
)
const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline])
const listRef = useRef<HTMLDivElement | null>(null)
@ -389,7 +393,7 @@ export function ChatView({
openFile={openFile}
inspectCall={inspectCall}
forkAt={forkAt}
loadImage={loadImage}
renderMessageImages={renderMessageImages}
fileMentions={fileMentions}
renderSlot={renderSlot}
t={t}
@ -402,7 +406,12 @@ export function ChatView({
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnStatus startTime={runningTurnStart} t={t} />}
{pendingSteering.map(item => (
<PendingSteeringBubble key={item.id} content={item.content} loadImage={loadImage} t={t} />
<PendingSteeringBubble
key={item.id}
content={item.content}
renderMessageImages={renderMessageImages}
t={t}
/>
))}
</div>
{!atBottom && (

View file

@ -9,9 +9,7 @@ import type {
ModelRetryNode, TurnErrorNode, UserMessageNode,
} 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 type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
import { CompactionItem } from './CompactionItem.tsx'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
@ -177,10 +175,10 @@ function projectUserText(text: string): ReactNode {
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, imageLoader, actions, pending = false, t,
content, renderMessageImages, actions, pending = false, t,
}: {
content: readonly unknown[]
imageLoader: ImageLoader
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
actions?: (text: string) => ReactNode
/** Whether this is the Host-authoritative pre-admission steering projection. */
@ -193,7 +191,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" labels={messageImageLabels(t)} />
{renderMessageImages({ images, align: 'end' })}
{showBubble && <div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
@ -210,16 +208,15 @@ function UserStyleBubble({
* @param props - Pending message content and conversation translator.
* @returns the pending steering bubble.
*/
export function PendingSteeringBubble({ content, loadImage, t }: {
export function PendingSteeringBubble({ content, renderMessageImages, t }: {
content: readonly unknown[]
loadImage?: ImageLoader
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
t: ChatViewSlotProps['t']
}): ReactNode {
const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable'))))
return (
<UserStyleBubble
content={content}
imageLoader={imageLoader}
renderMessageImages={renderMessageImages}
pending
t={t}
actions={text => (
@ -236,13 +233,13 @@ export function PendingSteeringBubble({ content, loadImage, t }: {
/** User and admitted-steering keyed Chat renderer. */
export const UserMessageNodeView = memo(function UserMessageNodeView({
node, loadImage, t,
node, renderMessageImages, t,
}: ChatNodeViewProps<'user' | 'steering'>) {
const data = node.data
return (
<UserStyleBubble
content={data.content}
imageLoader={loadImage}
renderMessageImages={renderMessageImages}
t={t}
actions={text => (
<MessageIconActions

View file

@ -30,6 +30,33 @@ export interface ComposerAttachment {
previewUrl: string
}
/** Input state handed to the optional attachment presentation plugin. */
export interface ComposerAttachmentsOwnerProps {
/** Browser-owned draft images in input order. */
attachments: readonly ComposerAttachment[]
/** Whether a document-level file drop may add images now. */
canAcceptDrop: boolean
/** Add one dropped batch through the composer's validation path. */
onAddImages: (files: readonly File[]) => void
/** Remove one draft image through the conversation service. */
onRemoveImage: (id: DraftAttachmentId) => void
/** Display-ready limits for the drop invitation. */
dropLimits?: { readonly count: number; readonly size: string } | undefined
}
/** Historical image group handed to the optional attachment presentation plugin. */
export interface MessageImagesOwnerProps {
/** Consecutive image blocks rendered as one gallery. */
images: readonly { readonly attachment: ImageAttachmentRef }[]
/** Session-authorized durable image loader. */
loadImage: (attachment: ImageAttachmentRef) => Promise<string>
/** Message-side alignment. */
align: 'start' | 'end'
}
/** Slot-backed renderer used by chat nodes without importing an attachment implementation. */
export type RenderMessageImages = (owner: Omit<MessageImagesOwnerProps, 'loadImage'>) => ReactNode
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
@ -83,6 +110,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
hookContext: string
inject: ChatNodeTurnDataInjected
}
/** Optional renderer for one consecutive group of durable message images. */
'conversation.message.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps }
/**
* The chat view's per-command row hole: keyed dispatch on the command
* name (`command/run.name`; a run-less cross-window node has none and
@ -199,6 +228,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* command face through its own inject.
*/
'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
/** Optional draft-image rail, drop target, and preview surface inside the composer. */
'conversation.input.attachments': {
kind: 'single'
scope: 'session-maybe'
owner: ComposerAttachmentsOwnerProps
}
/**
* The named plan-status seat in the composer tool row, immediately right
* of the access-mode control — one occupant, so taking it means rendering
@ -361,8 +396,8 @@ export interface ChatNodeOwnerProps {
openFile: (path: string) => void
inspectCall: (callId: CallId) => void
forkAt: (seq: number) => void
/** Resolve a session-authorized historical image for inline display. */
loadImage: (attachment: ImageAttachmentRef) => Promise<string>
/** Render a historical image group through the attachment slot. */
renderMessageImages: RenderMessageImages
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
}
@ -544,7 +579,9 @@ export interface InputControlOwnerProps {
/** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */
export type ComposerBarProps =
PropsRuntime<'conversation.composer.bar'>
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
& PropsRenderSlots<
'conversation.input.attachments' | 'conversation.input.plan' | 'conversation.input.model'
>
& InjectFace<ComposerBarInjected>
& PropsLocale<'conversation'>
@ -709,9 +746,17 @@ export interface ChatViewInjected {
/** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.node'>
PropsRuntime<'conversation.view'>
& PropsRenderSlots<'conversation.chat.node' | 'conversation.message.images'>
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
/** Full props of the attachment plugin's composer entry. */
export type ComposerAttachmentsProps =
PropsRuntime<'conversation.input.attachments'> & PropsLocale<'conversation'>
/** Full props of the attachment plugin's message-gallery entry. */
export type MessageImagesProps = PropsRuntime<'conversation.message.images'> & PropsLocale<'conversation'>
/**
* Injected share of the details slot: the panel is otherwise a pure reader of
* the shared chat store, but its close button is a layout orchestration call.

View file

@ -1,10 +1,5 @@
/** 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). */
/** Attachment error and limit copy owned by the conversation input flow. */
import type {
AttachmentRailLabels, DropOverlayLabels, ImageLightboxLabels, MessageImageLabels,
} from '@deepseek-ai/dsh-client-ui-attachment'
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationKey } from './locales.ts'
@ -56,61 +51,3 @@ export function attachmentErrorText(
}
return t('image.sendFailed', { reason })
}
/**
* 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 full-page drop overlay strings.
* @param t - the conversation-namespace translate.
* @param accepting - whether drops are currently accepted.
* @param limits - per-message limits for the desc line, when known.
* @returns the overlay title, with the limits desc while accepting.
*/
export function dropOverlayLabels(
t: Translate<ConversationKey>,
accepting: boolean,
limits?: { count: number; size: string },
): DropOverlayLabels {
if (!accepting) return { title: t('image.dropBlocked') }
return {
title: t('image.dropTitle'),
desc: limits === undefined ? undefined : t('image.dropDesc', { count: limits.count, size: limits.size }),
}
}
/**
* 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

@ -30,10 +30,10 @@ export type {
export type {
ChatFileMentions, ChatNodeOwnerProps, ChatNodeViewProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerAttachment, ComposerChainProps, ConversationInjected,
ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps, ComposerChainProps, ConversationInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
TurnTailOwnerProps, UseChatNodeTurnData,
MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages, TurnTailOwnerProps, UseChatNodeTurnData,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View file

@ -122,15 +122,6 @@
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 {
min-width: 0;
padding: 4px 12px 0;
}
/* Floating overlay anchor (menu / popupSelect shell): entries position
themselves against the card (bottom: 100% + gap); closed entries render null. */
.overlayAnchor {

View file

@ -12,8 +12,6 @@ import clsx from 'clsx'
import {
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { AttachmentRail, DropOverlay, 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'
@ -23,12 +21,10 @@ import type {} from '@deepseek-ai/dsh-goal/client'
// wire types: apiproxy's sessions contract declares it, and client-runtime's
// api-remotes import already places it in every client program.
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
import {
attachmentErrorText, attachmentRailLabels, dropOverlayLabels, imageSizeText, lightboxLabels,
} from '../image-labels.ts'
import { attachmentErrorText, imageSizeText } from '../image-labels.ts'
import { ContextMeter } from './ContextMeter.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts'
@ -37,11 +33,6 @@ 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 }
/** Rail thumbnail carrying its source attachment for the open/remove callbacks. */
interface ComposerRailItem extends AttachmentRailItem {
attachment: ComposerAttachment
}
export type InputBarProps = ComposerBarProps
export function InputBar({
@ -74,8 +65,6 @@ export function InputBar({
[draftImages, input?.imageIds],
)
const empty = draft.trim() === '' && attachments.length === 0
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
const [dragActive, setDragActive] = useState(false)
// 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.
@ -104,7 +93,6 @@ export function InputBar({
}, [promptError, showToast, t, imageLimits])
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)
const safari = useMemo(() => isSafariBrowser(navigator), [])
@ -168,11 +156,6 @@ export function InputBar({
safariNativeShrinkRef.current = false
if (safari && nativeShrink) repairSafariTextareaLayout(inputRef.current)
}, [draft, safari])
useEffect(() => {
if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null)
}, [attachments, preview])
// Scroll the draft scrollport the minimum that brings `caret` into view — the
// browser's own behavior for typing, performed for the paths where it does
// not act.
@ -464,74 +447,7 @@ export function InputBar({
if (rejected !== null) showToast(rejected)
}, [addImages, attachments, imageLimits, showToast, t])
// Whole-page file-drop intake (DeepSeek Chat behavior): the listeners live
// on the document so a drop anywhere over the window adds images, not only
// over the composer card. Safe as document-level state: the composer-bar
// slot is `kind: 'single'`, so at most one bar is mounted to bind these.
// Text drags carry no 'Files' type and pass through untouched, keeping the
// native drop-text-into-textarea path. The overlay layer itself is
// pointer-inert, so it never disturbs the enter/leave count.
const canAcceptDrop = !locked && !machineBusy && addImages !== undefined
useEffect(() => {
const hasFiles = (event: globalThis.DragEvent): boolean =>
event.dataTransfer?.types.includes('Files') ?? false
const reset = (): void => {
dragDepthRef.current = 0
setDragActive(false)
}
const onDragEnter = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
event.preventDefault()
dragDepthRef.current += 1
setDragActive(true)
}
const onDragOver = (event: globalThis.DragEvent): void => {
if (!hasFiles(event) || event.dataTransfer === null) return
event.preventDefault()
event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
}
const onDragLeave = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragActive(false)
// Leaving through the viewport edge does not balance the count on every
// engine; a page-root leave at the border means the drag left the window.
const leavingViewport = event.clientX <= 0 || event.clientY <= 0
|| event.clientX >= window.innerWidth || event.clientY >= window.innerHeight
if ((event.target === document.documentElement || event.target === document.body) && leavingViewport) reset()
}
const onDrop = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
event.preventDefault()
reset()
if (!canAcceptDrop) return
intakeImages([...(event.dataTransfer?.files ?? [])])
}
document.addEventListener('dragenter', onDragEnter)
document.addEventListener('dragover', onDragOver)
document.addEventListener('dragleave', onDragLeave)
document.addEventListener('drop', onDrop)
window.addEventListener('dragend', reset)
return () => {
document.removeEventListener('dragenter', onDragEnter)
document.removeEventListener('dragover', onDragOver)
document.removeEventListener('dragleave', onDragLeave)
document.removeEventListener('drop', onDrop)
window.removeEventListener('dragend', reset)
}
}, [canAcceptDrop, intakeImages])
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
@ -655,15 +571,6 @@ export function InputBar({
return (
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
{dragActive && (
<DropOverlay
disabled={!canAcceptDrop}
labels={dropOverlayLabels(t, canAcceptDrop, imageLimits === undefined ? undefined : {
count: imageLimits.maxImagesPerMessage,
size: imageSizeText(imageLimits.maxImageBytes),
})}
/>
)}
{toast !== null && (
<Toast
key={toast.seq}
@ -692,16 +599,16 @@ export function InputBar({
>
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</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>
)}
{renderSlot('conversation.input.attachments', {
attachments,
canAcceptDrop,
onAddImages: intakeImages,
onRemoveImage: (id) => { removeImage?.(id) },
dropLimits: imageLimits === undefined ? undefined : {
count: imageLimits.maxImagesPerMessage,
size: imageSizeText(imageLimits.maxImageBytes),
},
})}
{/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the
absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14
@ -810,14 +717,6 @@ export function InputBar({
</div>
</div>
</div>
{preview !== null && (
<ImageLightbox
src={preview.previewUrl}
alt={preview.file.name || t('image.original')}
labels={lightboxLabels(t)}
onClose={closePreview}
/>
)}
{footer}
</div>
)

View file

@ -21,7 +21,7 @@ import {
CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView,
UserMessageNodeView,
} from '../src/client/chat/MessageItem.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { zh } from '../src/client/locales.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
@ -42,6 +42,7 @@ afterEach(() => {
// Mirrors the real lookup chain (conversation namespace, then common).
const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh)
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
const RETRY_ID = 'retry-fixture' as Extract<ConversationNode, { kind: 'model-retry' }>['retryId']
interface MessageItemProps {
@ -949,7 +950,12 @@ describe('useCalendarDay boundary refresh', () => {
describe('small branch tails', () => {
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
const view = render(
<AssistantMarkdown t={t} blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'one-liner' }]}
streaming={false}
renderMessageImages={renderMessageImages}
/>,
)
expect(view.getByText('one-liner')).toBeTruthy()
})

View file

@ -13,6 +13,7 @@ import { zh } from '../src/client/locales.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
afterEach(cleanup)
@ -31,13 +32,20 @@ describe('tails', () => {
{ kind: 'other', block: { type: 'mystery' } },
]}
streaming
renderMessageImages={renderMessageImages}
/>,
)
expect(view.getByText('Think')).toBeTruthy()
expect(view.getByText('thinking hard')).toBeTruthy()
expect(view.getByText(/未知内容块/)).toBeTruthy()
const stopped = render(
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial words' }]} streaming={false} interrupted />,
<AssistantMarkdown
t={t}
blocks={[{ kind: 'text', text: 'partial words' }]}
streaming={false}
interrupted
renderMessageImages={renderMessageImages}
/>,
)
expect(stopped.getByText('已停止')).toBeTruthy()
})
@ -50,10 +58,13 @@ describe('tails', () => {
t={t}
blocks={[{ kind: 'tool-call', callId: 'c', name: 'todo_write', argsRaw: '{}' }]}
streaming={false}
renderMessageImages={renderMessageImages}
/>,
)
expect(empty.container.firstChild).toBeNull()
const blank = render(<AssistantMarkdown t={t} blocks={[]} streaming={false} />)
const blank = render(
<AssistantMarkdown t={t} blocks={[]} streaming={false} renderMessageImages={renderMessageImages} />,
)
expect(blank.container.firstChild).toBeNull()
})

View file

@ -21,6 +21,7 @@ import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
// Mirrors the real lookup chain (conversation namespace, then common).
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
class ResizeObserverStub {
@ -64,6 +65,7 @@ describe('render branch tails', () => {
t={t}
blocks={[{ kind: 'reasoning', text: 'done thinking' }, { kind: 'text', text: 'answer' }]}
streaming
renderMessageImages={renderMessageImages}
/>,
)
// reasoning at index 0 with a later block: running is false → ok state.
@ -100,7 +102,12 @@ describe('render branch tails', () => {
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
const view = render(
<AssistantMarkdown t={t} blocks={[{ kind: 'reasoning', text: 'still thinking' }]} streaming />,
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'still thinking' }]}
streaming
renderMessageImages={renderMessageImages}
/>,
)
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})

View file

@ -1,14 +1,13 @@
// @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.
// Conversation-owned attachment errors and the message-image slot handoff.
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { cleanup, 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 type { RenderMessageImages } from '../src/client/contract/slots.ts'
import { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts'
import { en, zh } from '../src/client/locales.ts'
@ -26,6 +25,21 @@ const attachment = {
name: 'history.png',
}
type MessageImagesRenderOwner = Parameters<RenderMessageImages>[0]
function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages {
return (owner) => {
calls.push(owner)
return (
<div data-testid="message-images" data-align={owner.align} data-count={owner.images.length}>
{owner.images.map(({ attachment: image }, index) => (
<span key={`${image.attachmentId}:${String(index)}`}>{image.name}</span>
))}
</div>
)
}
}
describe('attachment rejection copy', () => {
const limits = {
maxImageBytes: 5 * 1024 * 1024,
@ -60,42 +74,24 @@ describe('attachment rejection copy', () => {
})
})
describe('assistant images through the label bridge', () => {
it('resolves zh dictionary strings and opens the lightbox on a single click', async () => {
describe('assistant image slot handoff', () => {
it('passes one image group and its message alignment to the renderer', () => {
const calls: MessageImagesRenderOwner[] = []
const view = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'image', attachment }]}
streaming={false}
loadImage={() => Promise.resolve('blob:history')}
renderMessageImages={imageRenderer(calls)}
/>,
)
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()
expect(view.getByTestId('message-images').getAttribute('data-align')).toBe('start')
expect(calls).toHaveLength(1)
expect(calls[0]?.images).toEqual([{ attachment }])
})
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('merges consecutive image blocks into one tiled gallery, split by text', async () => {
it('merges consecutive image blocks into one group and splits groups at text', () => {
const calls: MessageImagesRenderOwner[] = []
const view = render(
<AssistantMarkdown
t={t}
@ -106,17 +102,17 @@ describe('assistant images through the label bridge', () => {
{ kind: 'image', attachment },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:grouped')}
renderMessageImages={imageRenderer(calls)}
/>,
)
await view.findAllByAltText('history.png')
const galleries = view.container.querySelectorAll('[data-align="start"]')
const galleries = view.getAllByTestId('message-images')
expect(galleries).toHaveLength(2)
expect(galleries[0]?.querySelectorAll('[data-variant="tile"]')).toHaveLength(2)
expect(galleries[1]?.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
expect(galleries.map(gallery => gallery.getAttribute('data-count'))).toEqual(['2', '1'])
expect(calls.map(call => call.images.length)).toEqual([2, 1])
})
it('keeps assistant images at their original position between text blocks', async () => {
it('keeps the renderer output at the image block position between text blocks', () => {
const calls: MessageImagesRenderOwner[] = []
const view = render(
<AssistantMarkdown
t={t}
@ -126,10 +122,10 @@ describe('assistant images through the label bridge', () => {
{ kind: 'text', text: 'after' },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:middle')}
renderMessageImages={imageRenderer(calls)}
/>,
)
const image = await view.findByAltText('history.png')
const image = view.getByTestId('message-images')
const before = view.getByText('before')
const after = view.getByText('after')
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)

View file

@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
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 { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { zh } from '../src/client/locales.ts'
let nextAnimationFrameId = 1
@ -37,6 +37,7 @@ afterEach(() => {
})
const t = makeTranslate(zh, commonZh)
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
describe('ReasoningRow', () => {
it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
@ -45,6 +46,7 @@ describe('ReasoningRow', () => {
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
streaming
renderMessageImages={renderMessageImages}
/>,
)
expect(view.getByText('运行中')).toBeTruthy()
@ -59,6 +61,7 @@ describe('ReasoningRow', () => {
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
streaming
renderMessageImages={renderMessageImages}
/>,
)
expect(summary.scrollLeft).toBe(0)
@ -73,6 +76,7 @@ describe('ReasoningRow', () => {
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
streaming={false}
renderMessageImages={renderMessageImages}
/>,
)
flushAnimationFrames(3)
@ -88,6 +92,7 @@ describe('ReasoningRow', () => {
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
renderMessageImages={renderMessageImages}
/>,
)
const row = view.getByRole('button')
@ -106,6 +111,7 @@ describe('ReasoningRow', () => {
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
renderMessageImages={renderMessageImages}
/>,
)
fireEvent.click(view.getByText('Think'))

View file

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