refactor(client): make attachment UI a client plugin
This commit is contained in:
parent
56dff07c4e
commit
3e4ad10d05
28 changed files with 426 additions and 337 deletions
|
|
@ -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:^"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
.rail {
|
||||
min-width: 0;
|
||||
padding: 4px 12px 0;
|
||||
}
|
||||
112
packages/client/ui-attachment/src/client/ComposerAttachments.tsx
Normal file
112
packages/client/ui-attachment/src/client/ComposerAttachments.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)} />
|
||||
}
|
||||
20
packages/client/ui-attachment/src/client/index.ts
Normal file
20
packages/client/ui-attachment/src/client/index.ts
Normal 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))
|
||||
}
|
||||
45
packages/client/ui-attachment/src/client/labels.ts
Normal file
45
packages/client/ui-attachment/src/client/labels.ts
Normal 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'),
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 = () => {}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,15 @@
|
|||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -20,9 +20,6 @@
|
|||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../ui-attachment"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue