From 3e4ad10d0527440188cc02a5af4c4e08a2ef680d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:48:05 +0800 Subject: [PATCH] refactor(client): make attachment UI a client plugin --- packages/client/ui-attachment/package.json | 39 ++++-- .../src/client/ComposerAttachments.module.css | 4 + .../src/client/ComposerAttachments.tsx | 112 ++++++++++++++++ .../src/client/MessageImages.tsx | 8 ++ .../client/ui-attachment/src/client/index.ts | 20 +++ .../client/ui-attachment/src/client/labels.ts | 45 +++++++ packages/client/ui-attachment/src/index.ts | 18 +-- .../client/ui-attachment/src/invariant.ts | 5 +- packages/client/ui-attachment/tsconfig.json | 9 ++ .../client/ui-attachment/tsdown.config.ts | 39 +----- packages/client/ui-conversation/package.json | 15 ++- .../ui-conversation/src/client/apply.ts | 2 + .../src/client/chat/AssistantMarkdown.tsx | 22 +-- .../src/client/chat/AssistantNodeView.tsx | 4 +- .../src/client/chat/ChatNodeSeat.tsx | 8 +- .../src/client/chat/ChatView.tsx | 17 ++- .../src/client/chat/MessageItem.tsx | 21 ++- .../src/client/contract/slots.ts | 53 +++++++- .../src/client/image-labels.ts | 65 +-------- .../ui-conversation/src/client/index.ts | 4 +- .../src/client/skeleton/InputBar.module.css | 9 -- .../src/client/skeleton/InputBar.tsx | 125 ++---------------- .../tests/chat-branch-tails.client.spec.tsx | 10 +- .../tests/coverage-tails.client.spec.tsx | 15 ++- .../tests/gate-branch-tails.client.spec.tsx | 9 +- .../tests/image-labels.client.spec.tsx | 74 +++++------ .../tests/reasoning-row.client.spec.tsx | 8 +- packages/client/ui-conversation/tsconfig.json | 3 - 28 files changed, 426 insertions(+), 337 deletions(-) create mode 100644 packages/client/ui-attachment/src/client/ComposerAttachments.module.css create mode 100644 packages/client/ui-attachment/src/client/ComposerAttachments.tsx create mode 100644 packages/client/ui-attachment/src/client/MessageImages.tsx create mode 100644 packages/client/ui-attachment/src/client/index.ts create mode 100644 packages/client/ui-attachment/src/client/labels.ts diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 5a1b81e978..8e22c28e57 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -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:^" } } diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css new file mode 100644 index 0000000000..770a64cef5 --- /dev/null +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -0,0 +1,4 @@ +.rail { + min-width: 0; + padding: 4px 12px 0; +} diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx new file mode 100644 index 0000000000..0525c74ce2 --- /dev/null +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -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(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(() => 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 && ( + + )} + {railItems.length > 0 && ( +
+ { setPreview(item.attachment) }} + onRemove={(item) => { onRemoveImage(item.attachment.id) }} + /> +
+ )} + {preview !== null && ( + + )} + + ) +} diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx new file mode 100644 index 0000000000..0d0dac02f8 --- /dev/null +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -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 +} diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts new file mode 100644 index 0000000000..616e9c7292 --- /dev/null +++ b/packages/client/ui-attachment/src/client/index.ts @@ -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)) +} diff --git a/packages/client/ui-attachment/src/client/labels.ts b/packages/client/ui-attachment/src/client/labels.ts new file mode 100644 index 0000000000..cc83d5791b --- /dev/null +++ b/packages/client/ui-attachment/src/client/labels.ts @@ -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'), + } +} diff --git a/packages/client/ui-attachment/src/index.ts b/packages/client/ui-attachment/src/index.ts index bef6c900a6..4bb65a79cc 100644 --- a/packages/client/ui-attachment/src/index.ts +++ b/packages/client/ui-attachment/src/index.ts @@ -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 {} diff --git a/packages/client/ui-attachment/src/invariant.ts b/packages/client/ui-attachment/src/invariant.ts index 47d18f97b8..5358704929 100644 --- a/packages/client/ui-attachment/src/invariant.ts +++ b/packages/client/ui-attachment/src/invariant.ts @@ -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 = () => {} diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index c9ccd04a95..2f5cd3a73e 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -14,6 +14,15 @@ { "path": "../../runtime-diagnostics/invariants" }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-slots" + }, { "path": "../ui-primitives" } diff --git a/packages/client/ui-attachment/tsdown.config.ts b/packages/client/ui-attachment/tsdown.config.ts index d8c37d8a2c..e70803de17 100644 --- a/packages/client/ui-attachment/tsdown.config.ts +++ b/packages/client/ui-attachment/tsdown.config.ts @@ -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'], +) diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 12e5c7f6c2..8fd8c44d4c 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -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", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index f57caea9e5..463c481eef 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index bb766da778..c758827317 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -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() + rendered.push( + + {renderMessageImages({ + images: group.map(({ attachment }) => ({ attachment })), + align: 'start', + })} + , + ) break } // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. diff --git a/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx index 72e8a6ae28..850036f0cd 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx @@ -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} /> diff --git a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx index f3343a183f..bc9c96fd41 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx @@ -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 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 58e63b312f..4d9df510e1 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -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( + owner => renderSlot('conversation.message.images', { ...owner, loadImage }), + [loadImage, renderSlot], + ) const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline]) const listRef = useRef(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 && } {pendingSteering.map(item => ( - + ))} {!atBottom && ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 00b5110d68..ecb0d11caa 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -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 (
- + {renderMessageImages({ images, align: 'end' })} {showBubble &&
{projectUserText(text)} {rest.map((block, i) => )} @@ -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 ( ( @@ -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 ( ( 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 + /** Message-side alignment. */ + align: 'start' | 'end' +} + +/** Slot-backed renderer used by chat nodes without importing an attachment implementation. */ +export type RenderMessageImages = (owner: Omit) => 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 + /** 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 & 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 & 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. diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts index bed6f5c89a..e322755473 100644 --- a/packages/client/ui-conversation/src/client/image-labels.ts +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -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): ImageLightboxLabels { - return { dialog: t('image.preview'), close: t('image.closePreview') } -} - -/** - * Resolve the chat-history image strings. - * @param t - the conversation-namespace translate. - * @returns the message-image labels including the forwarded lightbox strings. - */ -export function messageImageLabels(t: Translate): MessageImageLabels { - return { - image: t('image.label'), - open: t('image.openOriginal'), - openNamed: label => t('image.openOriginalLabel', { label }), - loading: t('image.loading'), - loadFailed: t('image.loadFailed'), - lightbox: lightboxLabels(t), - } -} - -/** - * Resolve the 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, - 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): AttachmentRailLabels { - return { - group: t('image.pending'), - open: t('image.openOriginal'), - scrollLeft: t('image.scrollLeft'), - scrollRight: t('image.scrollRight'), - } -} diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 0574aa8375..4a8b27acbb 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -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. diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 6a2eb5fdf4..6635322a5a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -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 { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 000174f513..501001215d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -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(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(null) const cardRef = useRef(null) - const dragDepthRef = useRef(0) const scrollRef = useRef(null) const mirrorRef = useRef(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(() => attachments.map(attachment => ({ - id: attachment.id, - previewUrl: attachment.previewUrl, - alt: attachment.file.name || t('image.pending'), - removeLabel: t('image.remove', { name: attachment.file.name }), - attachment, - })), [attachments, t]) const onSelect = (e: React.SyntheticEvent): void => { // Any caret/selection gesture ends a live paste attempt (the machine @@ -655,15 +571,6 @@ export function InputBar({ return (
- {dragActive && ( - - )} {toast !== null && ( {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} - {railItems.length > 0 && ( -
- { setPreview(item.attachment) }} - onRemove={(item) => { removeImage?.(item.attachment.id) }} - /> -
- )} + {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({
- {preview !== null && ( - - )} {footer}
) diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx index 67a213a98e..2164fcd059 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx @@ -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['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( - , + , ) expect(view.getByText('one-liner')).toBeTruthy() }) diff --git a/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx index b0ce994f44..c6e3563e3e 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx @@ -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( - , + , ) 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() + const blank = render( + , + ) expect(blank.container.firstChild).toBeNull() }) diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx index 588ca52c43..a934efb727 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx @@ -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( - , + , ) expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() }) diff --git a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx b/packages/client/ui-conversation/tests/image-labels.client.spec.tsx index bec1fa8ebe..5b01fc5712 100644 --- a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-conversation/tests/image-labels.client.spec.tsx @@ -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[0] + +function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages { + return (owner) => { + calls.push(owner) + return ( +
+ {owner.images.map(({ attachment: image }, index) => ( + {image.name} + ))} +
+ ) + } +} + 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( 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( - 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( { { 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( { { 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) diff --git a/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx b/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx index 62e6ac7848..551a286a88 100644 --- a/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx @@ -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')) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 79e9e3dc7c..a88ba5c8c1 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../ui-slots" }, - { - "path": "../ui-attachment" - }, { "path": "../ui-primitives" },