1
0
Эх сурвалжийг харах

refactor(client): make attachment UI a client plugin

imccyu 3 долоо хоног өмнө
parent
commit
3e4ad10d05
28 өөрчлөгдсөн 426 нэмэгдсэн , 337 устгасан
  1. 31 8
      packages/client/ui-attachment/package.json
  2. 4 0
      packages/client/ui-attachment/src/client/ComposerAttachments.module.css
  3. 112 0
      packages/client/ui-attachment/src/client/ComposerAttachments.tsx
  4. 8 0
      packages/client/ui-attachment/src/client/MessageImages.tsx
  5. 20 0
      packages/client/ui-attachment/src/client/index.ts
  6. 45 0
      packages/client/ui-attachment/src/client/labels.ts
  7. 3 15
      packages/client/ui-attachment/src/index.ts
  8. 2 3
      packages/client/ui-attachment/src/invariant.ts
  9. 9 0
      packages/client/ui-attachment/tsconfig.json
  10. 5 34
      packages/client/ui-attachment/tsdown.config.ts
  11. 8 7
      packages/client/ui-conversation/package.json
  12. 2 0
      packages/client/ui-conversation/src/client/apply.ts
  13. 13 9
      packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
  14. 2 2
      packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx
  15. 5 3
      packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx
  16. 13 4
      packages/client/ui-conversation/src/client/chat/ChatView.tsx
  17. 9 12
      packages/client/ui-conversation/src/client/chat/MessageItem.tsx
  18. 49 4
      packages/client/ui-conversation/src/client/contract/slots.ts
  19. 1 64
      packages/client/ui-conversation/src/client/image-labels.ts
  20. 2 2
      packages/client/ui-conversation/src/client/index.ts
  21. 0 9
      packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
  22. 12 113
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  23. 8 2
      packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx
  24. 13 2
      packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx
  25. 8 1
      packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx
  26. 35 39
      packages/client/ui-conversation/tests/image-labels.client.spec.tsx
  27. 7 1
      packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx
  28. 0 3
      packages/client/ui-conversation/tsconfig.json

+ 31 - 8
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:^"
   }
 }

+ 4 - 0
packages/client/ui-attachment/src/client/ComposerAttachments.module.css

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

+ 112 - 0
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<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}
+        />
+      )}
+    </>
+  )
+}

+ 8 - 0
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 <ImageGallery images={images} load={loadImage} align={align} labels={messageImageLabels(t)} />
+}

+ 20 - 0
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))
+}

+ 45 - 0
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'),
+  }
+}

+ 3 - 15
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 {}

+ 2 - 3
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 = () => {}
 

+ 9 - 0
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"
     }

+ 5 - 34
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'],
+)

+ 8 - 7
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",

+ 2 - 0
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<typeof chatStore>): ChatViewInjected => {

+ 13 - 9
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(<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.

+ 2 - 2
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}
     />

+ 5 - 3
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

+ 13 - 4
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<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 - 12
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 (
     <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

+ 49 - 4
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -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 - 64
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<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'),
-  }
-}

+ 2 - 2
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.
 

+ 0 - 9
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 {

+ 12 - 113
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<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>
   )

+ 8 - 2
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<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 - 2
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(
-      <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()
   })
 

+ 8 - 1
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(
-      <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()
   })

+ 35 - 39
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<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')}
-      />,
-    )
-    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()
-  })
-
-  it('resolves the active English dictionary', async () => {
-    const view = render(
-      <AssistantMarkdown
-        t={enT}
-        blocks={[{ kind: 'image', attachment }]}
-        streaming={false}
-        loadImage={() => Promise.resolve('blob:history')}
+        renderMessageImages={imageRenderer(calls)}
       />,
     )
-    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()
+    expect(view.getByTestId('message-images').getAttribute('data-align')).toBe('start')
+    expect(calls).toHaveLength(1)
+    expect(calls[0]?.images).toEqual([{ attachment }])
   })
 
-  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)

+ 7 - 1
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'))

+ 0 - 3
packages/client/ui-conversation/tsconfig.json

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