Ver código fonte

feat(ui-attachment): unify mixed attachment presentation

creatixchu 3 semanas atrás
pai
commit
a1144c4950
29 arquivos alterados com 1323 adições e 462 exclusões
  1. 2 58
      packages/client/ui-attachment/src/AttachmentRail.module.css
  2. 15 44
      packages/client/ui-attachment/src/AttachmentRail.tsx
  3. 151 0
      packages/client/ui-attachment/src/FileCard.module.css
  4. 89 0
      packages/client/ui-attachment/src/FileCard.tsx
  5. 4 3
      packages/client/ui-attachment/src/MessageImage.tsx
  6. 60 0
      packages/client/ui-attachment/src/client/ComposerAttachments.module.css
  7. 51 14
      packages/client/ui-attachment/src/client/ComposerAttachments.tsx
  8. 10 2
      packages/client/ui-attachment/src/client/MessageImages.tsx
  9. 24 8
      packages/client/ui-attachment/src/client/labels.ts
  10. 54 0
      packages/client/ui-chat/src/client/chat/MessageItem.module.css
  11. 67 23
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  12. 54 18
      packages/client/ui-conversation/src/client/apply.ts
  13. 33 25
      packages/client/ui-conversation/src/client/contract/input.ts
  14. 43 13
      packages/client/ui-conversation/src/client/contract/slots.ts
  15. 3 0
      packages/client/ui-conversation/src/client/image-labels.ts
  16. 4 2
      packages/client/ui-conversation/src/client/index.ts
  17. 82 81
      packages/client/ui-conversation/src/client/input/facade.ts
  18. 17 14
      packages/client/ui-conversation/src/client/input/hub.ts
  19. 2 2
      packages/client/ui-conversation/src/client/input/machine.ts
  20. 36 12
      packages/client/ui-conversation/src/client/locales.ts
  21. 42 1
      packages/client/ui-conversation/src/client/queue/QueueDock.module.css
  22. 80 37
      packages/client/ui-conversation/src/client/queue/QueueDock.tsx
  23. 248 66
      packages/client/ui-conversation/src/client/service.ts
  24. 79 36
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  25. 41 0
      packages/client/ui-primitives/src/DocumentFileIcon.tsx
  26. 16 0
      packages/client/ui-primitives/src/file-size.ts
  27. 2 0
      packages/client/ui-primitives/src/index.ts
  28. 12 3
      packages/client/ui-trajectory/src/client/layout.ts
  29. 2 0
      packages/client/ui-trajectory/src/client/locales.ts

+ 2 - 58
packages/client/ui-attachment/src/AttachmentRail.module.css

@@ -5,6 +5,7 @@
 
 .rail {
   display: flex;
+  align-items: stretch;
   gap: 10px;
   overflow-x: auto;
   overflow-y: hidden;
@@ -22,65 +23,8 @@
 }
 
 .item {
-  position: relative;
-  flex: 0 0 64px;
-  width: 64px;
-  height: 64px;
-}
-
-.thumbnail {
-  width: 64px;
+  flex: none;
   height: 64px;
-  padding: 0;
-  overflow: hidden;
-  border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
-  border-radius: 16px;
-  background: var(--dsw-alias-interactive-bg-hover);
-  cursor: zoom-in;
-}
-
-.thumbnail img {
-  display: block;
-  width: 100%;
-  height: 100%;
-  object-fit: cover;
-}
-
-.remove {
-  position: absolute;
-  top: 4px;
-  right: 4px;
-  z-index: 1;
-  display: grid;
-  place-items: center;
-  width: 18px;
-  height: 18px;
-  padding: 0;
-  border: none;
-  border-radius: 50%;
-  background: var(--dsw-alias-button-contrast-fill);
-  color: var(--dsw-alias-label-primary-inverted);
-  cursor: pointer;
-  opacity: 0;
-  transition: opacity 0.2s ease-in-out;
-}
-
-.item:hover .remove,
-.remove:focus-visible {
-  opacity: 1;
-}
-
-/* Touch surfaces have no hover to reveal the control. */
-@media (pointer: coarse) {
-  .remove {
-    opacity: 1;
-  }
-}
-
-@media (prefers-reduced-motion: reduce) {
-  .remove {
-    transition: none;
-  }
 }
 
 .arrow {

+ 15 - 44
packages/client/ui-attachment/src/AttachmentRail.tsx

@@ -1,31 +1,23 @@
-/** Draft-attachment thumbnail rail: scrollbar-less horizontal overflow paged
- * by edge arrows, hover-revealed per-item remove, single-click open. */
+/** Draft-attachment rail: scrollbar-less horizontal overflow paged by edge arrows. */
 
 import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
+import type { ReactNode } from 'react'
 import clsx from 'clsx'
 import {
-  IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14,
+  IconChevronLeftOutline14, IconChevronRightOutline14,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import css from './AttachmentRail.module.css'
 
-/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */
+/** One ordered draft attachment rendered by the rail owner. */
 export interface AttachmentRailItem {
   /** Stable identity for the React key. */
   id: string
-  /** Object or data URL rendered as the thumbnail. */
-  previewUrl: string
-  /** Image alt text (display name with the owner's fallback applied). */
-  alt: string
-  /** Accessible label of the item's remove control. */
-  removeLabel: string
 }
 
 /** Rail-level strings the owner resolves from its own locale namespace. */
 export interface AttachmentRailLabels {
   /** Accessible name of the rail group. */
   group: string
-  /** Thumbnail tooltip inviting the original-image preview. */
-  open: string
   /** Accessible label of the left paging arrow. */
   scrollLeft: string
   /** Accessible label of the right paging arrow. */
@@ -40,12 +32,12 @@ const WHEEL_LINE_PX = 16
 function pageBehavior(): ScrollBehavior {
   // jsdom (the unit lane) implements no matchMedia despite lib.dom's
   // non-optional typing; the optional call keeps that lane on the default.
-  // oxlint-disable-next-line typescript/no-unnecessary-condition
-  return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
+  const matchMedia = (window as unknown as { matchMedia?: Window['matchMedia'] }).matchMedia
+  return matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
 }
 
 /**
- * Horizontal thumbnail rail over the caller's draft attachments.
+ * Horizontal rail over the caller's ordered draft attachments.
  *
  * The rail scrolls with its scrollbar hidden; overflow is announced by edge
  * arrows recomputed from scroll geometry on scroll, item-count changes, and
@@ -53,22 +45,18 @@ function pageBehavior(): ScrollBehavior {
  * panel resizes count, not only window resizes). A vertical wheel pans the
  * rail horizontally and is consumed exclusively (non-passive listener), a
  * newly added item is revealed at the rail's end while a rail that mounts
- * over an existing draft keeps its start position, and each thumbnail opens
- * on a single click while its remove control sits inside the card and
- * reveals on hover or focus. The owner decides mounting; it renders the rail
- * only while items exist.
+ * over an existing draft keeps its start position. The owner renders each
+ * item and decides mounting; it renders the rail only while items exist.
  *
- * @param props.items - resolved thumbnails in draft order.
- * @param props.labels - rail-level strings (group name, open tooltip, arrows).
- * @param props.onOpen - single-click open of one item's original image.
- * @param props.onRemove - remove one item from the draft.
+ * @param props.items - attachments in draft order.
+ * @param props.labels - rail-level strings (group name and paging arrows).
+ * @param props.renderItem - render one attachment card in draft order.
  * @returns the rail group with its paging arrows.
  */
-export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, onOpen, onRemove }: {
+export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, renderItem }: {
   items: readonly T[]
   labels: AttachmentRailLabels
-  onOpen: (item: T) => void
-  onRemove: (item: T) => void
+  renderItem: (item: T) => ReactNode
 }) {
   const railRef = useRef<HTMLDivElement | null>(null)
   // null marks the first layout pass: a rail that MOUNTS over an existing
@@ -165,24 +153,7 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
         onScroll={updateEdges}
       >
         {items.map(item => (
-          <div key={item.id} className={css.item}>
-            <button
-              type="button"
-              className={css.thumbnail}
-              title={labels.open}
-              onClick={() => { onOpen(item) }}
-            >
-              <img src={item.previewUrl} alt={item.alt} />
-            </button>
-            <button
-              type="button"
-              className={css.remove}
-              aria-label={item.removeLabel}
-              onClick={() => { onRemove(item) }}
-            >
-              <IconCloseFill14 size={12} />
-            </button>
-          </div>
+          <div key={item.id} className={css.item}>{renderItem(item)}</div>
         ))}
       </div>
       {edges.right && (

+ 151 - 0
packages/client/ui-attachment/src/FileCard.module.css

@@ -0,0 +1,151 @@
+.card {
+  position: relative;
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+  width: 240px;
+  height: 64px;
+  padding: 0 12px;
+  border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
+  border-radius: 16px;
+  background: var(--dsw-specific-input-major, transparent);
+  box-sizing: border-box;
+  text-align: left;
+}
+
+.failed {
+  border-color: var(--dsw-alias-state-error-primary, #d54941);
+}
+
+.icon {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+}
+
+.spinner {
+  width: 20px;
+  height: 20px;
+  border: 2px solid currentColor;
+  border-top-color: transparent;
+  border-radius: 50%;
+  animation: file-card-spin 0.8s linear infinite;
+}
+
+@keyframes file-card-spin {
+  to { transform: rotate(360deg); }
+}
+
+.body {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  min-width: 0;
+  padding: 8px 0;
+}
+
+.retry {
+  padding: 0;
+  border: 0;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  text-align: left;
+  cursor: pointer;
+}
+
+.name {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  color: var(--dsw-alias-label-primary);
+  font-size: 14px;
+  font-weight: 500;
+  line-height: 22px;
+}
+
+.meta {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
+  font-size: 12px;
+  line-height: 15px;
+}
+
+.metaFailed {
+  color: var(--dsw-alias-state-error-primary, #d54941);
+}
+
+.remove {
+  position: absolute;
+  top: 6px;
+  right: 6px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 18px;
+  height: 18px;
+  padding: 0;
+  border: none;
+  border-radius: 50%;
+  background: var(--dsw-alias-button-contrast-fill, rgba(0, 0, 0, 0.72));
+  color: var(--dsw-alias-label-primary-inverted, #fff);
+  opacity: 0;
+  cursor: pointer;
+  transition: opacity 0.2s ease-in-out;
+}
+
+.card:hover .remove,
+.remove:focus-visible {
+  opacity: 1;
+}
+
+.card:hover .name,
+.card:focus-within .name {
+  padding-right: 18px;
+}
+
+.removeFailed {
+  background: var(--dsw-alias-state-error-primary, #d54941);
+  color: #fff;
+  opacity: 1;
+}
+
+.progressTrack {
+  position: absolute;
+  right: 12px;
+  bottom: 5px;
+  left: 12px;
+  overflow: hidden;
+  height: 2px;
+  border-radius: 1px;
+  background: var(--dsw-alias-fill-tertiary, rgba(0, 0, 0, 0.08));
+}
+
+.progressBar {
+  display: block;
+  width: 35%;
+  height: 100%;
+  border-radius: inherit;
+  background: var(--dsw-alias-brand-primary, #4d6bfe);
+  animation: file-card-progress 1.2s ease-in-out infinite alternate;
+}
+
+.progressBar[style] {
+  animation: none;
+}
+
+@keyframes file-card-progress {
+  from { transform: translateX(-70%); }
+  to { transform: translateX(220%); }
+}
+
+@media (pointer: coarse) {
+  .remove {
+    opacity: 1;
+  }
+}

+ 89 - 0
packages/client/ui-attachment/src/FileCard.tsx

@@ -0,0 +1,89 @@
+import { DocumentFileIcon, fileSizeText } from '@deepseek-ai/dsh-client-ui-primitives'
+import css from './FileCard.module.css'
+
+/** Localized strings consumed by one pending-file card. */
+export interface FileCardLabels {
+  /** Card body announcement, e.g. "Pending file {name}". */
+  readonly label: string
+  /** Remove-button label. */
+  readonly remove: string
+  /** Status line while the upload is in flight. */
+  readonly uploading: string
+  /** Status line and retry affordance after a failed upload. */
+  readonly failed: string
+  /** Retry-button label. */
+  readonly retry: string
+}
+
+/** Upload display state resolved by the owner. */
+export type FileCardState = 'uploading' | 'ready' | 'error'
+
+function extensionOf(name: string): string {
+  const dot = name.lastIndexOf('.')
+  if (dot <= 0 || dot === name.length - 1) return ''
+  return name.slice(dot + 1).toUpperCase().slice(0, 8)
+}
+
+/** One pending generic-file card: name, size or upload status, remove, retry. */
+export function FileCard({
+  name, bytes, state, progress, labels, onRemove, onRetry,
+}: {
+  name: string
+  bytes: number
+  state: FileCardState
+  progress?: number
+  labels: FileCardLabels
+  onRemove: () => void
+  onRetry: () => void
+}) {
+  const extension = extensionOf(name)
+  const meta = state === 'uploading'
+    ? labels.uploading
+    : state === 'error'
+      ? labels.failed
+      : [extension, fileSizeText(bytes)].filter(part => part !== '').join(' ')
+  const retryable = state === 'error'
+  return (
+    <div
+      className={`${css.card}${retryable ? ` ${css.failed}` : ''}`}
+      title={name}
+    >
+      <span className={css.icon} aria-hidden>
+        {state === 'uploading'
+          ? <span className={css.spinner} />
+          : <DocumentFileIcon />}
+      </span>
+      {retryable
+        ? (
+          <button type="button" className={`${css.body} ${css.retry}`} aria-label={labels.retry} onClick={onRetry}>
+            <span className={css.name}>{name}</span>
+            <span className={`${css.meta} ${css.metaFailed}`}>{meta}</span>
+          </button>
+        )
+        : (
+          <span className={css.body} aria-label={labels.label}>
+            <span className={css.name}>{name}</span>
+            <span className={css.meta}>{meta}</span>
+          </span>
+        )}
+      <button
+        type="button"
+        className={retryable ? `${css.remove} ${css.removeFailed}` : css.remove}
+        aria-label={labels.remove}
+        onClick={onRemove}
+      >
+        <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
+          <path d="M3 3L13 13M13 3L3 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
+        </svg>
+      </button>
+      {state === 'uploading' && (
+        <span className={css.progressTrack} aria-hidden>
+          <span
+            className={css.progressBar}
+            style={progress === undefined ? undefined : { width: `${String(Math.min(1, Math.max(0, progress)) * 100)}%` }}
+          />
+        </span>
+      )}
+    </div>
+  )
+}

+ 4 - 3
packages/client/ui-attachment/src/MessageImage.tsx

@@ -139,15 +139,16 @@ export function MessageImage({ image, load, variant, labels }: {
 }
 
 /** Wrapping image group shared by user and assistant history: a lone image
- * renders large, several render as 64px square tiles (DeepSeek Chat rule). */
-export function ImageGallery({ images, load, align, labels }: {
+ * renders large unless its owning mixed-attachment row requests compact tiles. */
+export function ImageGallery({ images, load, align, compact = false, labels }: {
   images: readonly MessageImageSpec[]
   load: ImageLoader
   align: 'start' | 'end'
+  compact?: boolean
   labels: MessageImageLabels
 }) {
   if (images.length === 0) return null
-  const variant = images.length === 1 ? 'single' : 'tile'
+  const variant = compact || images.length > 1 ? 'tile' : 'single'
   return (
     <div className={css.gallery} data-align={align}>
       {images.map((image, index) => (

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

@@ -2,3 +2,63 @@
   min-width: 0;
   padding: 4px 12px 0;
 }
+
+.imageItem {
+  position: relative;
+  width: 64px;
+  height: 64px;
+}
+
+.thumbnail {
+  width: 64px;
+  height: 64px;
+  padding: 0;
+  overflow: hidden;
+  border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
+  border-radius: 16px;
+  background: var(--dsw-alias-interactive-bg-hover);
+  cursor: zoom-in;
+}
+
+.thumbnail img {
+  display: block;
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.remove {
+  position: absolute;
+  top: 4px;
+  right: 4px;
+  z-index: 1;
+  display: grid;
+  place-items: center;
+  width: 18px;
+  height: 18px;
+  padding: 0;
+  border: none;
+  border-radius: 50%;
+  background: var(--dsw-alias-button-contrast-fill);
+  color: var(--dsw-alias-label-primary-inverted);
+  cursor: pointer;
+  opacity: 0;
+  transition: opacity 0.2s ease-in-out;
+}
+
+.imageItem:hover .remove,
+.remove:focus-visible {
+  opacity: 1;
+}
+
+@media (pointer: coarse) {
+  .remove {
+    opacity: 1;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .remove {
+    transition: none;
+  }
+}

+ 51 - 14
packages/client/ui-attachment/src/client/ComposerAttachments.tsx

@@ -1,12 +1,14 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import type {
-  ComposerAttachment, ComposerAttachmentsProps,
+  ComposerAttachment, ComposerAttachmentsProps, ComposerImageAttachment,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import { AttachmentRail } from '../AttachmentRail.tsx'
 import type { AttachmentRailItem } from '../AttachmentRail.tsx'
 import { DropOverlay } from '../DropOverlay.tsx'
+import { FileCard } from '../FileCard.tsx'
 import { ImageLightbox } from '../ImageLightbox.tsx'
-import { attachmentRailLabels, dropOverlayLabels, lightboxLabels } from './labels.ts'
+import { attachmentRailLabels, dropOverlayLabels, fileCardLabels, lightboxLabels } from './labels.ts'
 import css from './ComposerAttachments.module.css'
 
 /** Rail item retaining its browser-owned attachment for callbacks. */
@@ -14,15 +16,14 @@ interface ComposerRailItem extends AttachmentRailItem {
   attachment: ComposerAttachment
 }
 
-/** Draft-image rail, document drop target, and original-image preview slot entry. */
+/** Draft image previews, pending-file cards, drop target, and original-image preview. */
 export function ComposerAttachments({
-  attachments, canAcceptDrop, onAddImages, onRemoveImage, dropLimits, t,
+  attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, uploads, onRetryFile, dropLimits, t,
 }: ComposerAttachmentsProps) {
-  const [preview, setPreview] = useState<ComposerAttachment | null>(null)
+  const [preview, setPreview] = useState<ComposerImageAttachment | 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])
@@ -62,7 +63,7 @@ export function ComposerAttachments({
       if (dataTransfer === null) return
       event.preventDefault()
       reset()
-      if (canAcceptDrop) onAddImages([...dataTransfer.files])
+      if (canAcceptDrop) onAddFiles([...dataTransfer.files])
     }
     document.addEventListener('dragenter', onDragEnter)
     document.addEventListener('dragover', onDragOver)
@@ -76,15 +77,12 @@ export function ComposerAttachments({
       document.removeEventListener('drop', onDrop)
       window.removeEventListener('dragend', reset)
     }
-  }, [canAcceptDrop, onAddImages])
+  }, [canAcceptDrop, onAddFiles])
 
   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])
+  })), [attachments])
 
   return (
     <>
@@ -99,8 +97,47 @@ export function ComposerAttachments({
           <AttachmentRail
             items={railItems}
             labels={attachmentRailLabels(t)}
-            onOpen={(item) => { setPreview(item.attachment) }}
-            onRemove={(item) => { onRemoveImage(item.attachment.id) }}
+            renderItem={(item) => {
+              const attachment = item.attachment
+              if (attachment.kind === 'file') {
+                const upload = uploads[attachment.id]
+                return (
+                  <FileCard
+                    name={attachment.file.name || t('file.label')}
+                    bytes={attachment.file.size}
+                    state={upload === undefined || upload.status === 'uploading'
+                      ? 'uploading'
+                      : upload.status === 'ready' ? 'ready' : 'error'}
+                    {...upload?.status === 'uploading' && upload.total !== undefined && upload.total > 0
+                      ? { progress: upload.loaded / upload.total }
+                      : {}}
+                    labels={fileCardLabels(t, attachment.file.name)}
+                    onRemove={() => { onRemoveAttachment(attachment.id) }}
+                    onRetry={() => { onRetryFile(attachment.id) }}
+                  />
+                )
+              }
+              return (
+                <div className={css.imageItem}>
+                  <button
+                    type="button"
+                    className={css.thumbnail}
+                    title={t('image.openOriginal')}
+                    onClick={() => { setPreview(attachment) }}
+                  >
+                    <img src={attachment.previewUrl} alt={attachment.file.name || t('image.pending')} />
+                  </button>
+                  <button
+                    type="button"
+                    className={css.remove}
+                    aria-label={t('image.remove', { name: attachment.file.name })}
+                    onClick={() => { onRemoveAttachment(attachment.id) }}
+                  >
+                    <IconCloseFill14 size={12} />
+                  </button>
+                </div>
+              )
+            }}
           />
         </div>
       )}

+ 10 - 2
packages/client/ui-attachment/src/client/MessageImages.tsx

@@ -3,6 +3,14 @@ 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)} />
+export function MessageImages({ images, loadImage, align, compact = false, t }: MessageImagesProps) {
+  return (
+    <ImageGallery
+      images={images}
+      load={loadImage}
+      align={align}
+      compact={compact}
+      labels={messageImageLabels(t)}
+    />
+  )
 }

+ 24 - 8
packages/client/ui-attachment/src/client/labels.ts

@@ -1,6 +1,7 @@
 import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
 import type { AttachmentRailLabels } from '../AttachmentRail.tsx'
 import type { DropOverlayLabels } from '../DropOverlay.tsx'
+import type { FileCardLabels } from '../FileCard.tsx'
 import type { ImageLightboxLabels } from '../ImageLightbox.tsx'
 import type { MessageImageLabels } from '../MessageImage.tsx'
 
@@ -41,23 +42,38 @@ export function dropOverlayLabels(
   accepting: boolean,
   limits?: { readonly count: number; readonly size: string },
 ): DropOverlayLabels {
-  if (!accepting) return { title: t('image.dropBlocked') }
+  if (!accepting) return { title: t('attachment.dropBlocked') }
   return {
-    title: t('image.dropTitle'),
-    desc: limits === undefined ? undefined : t('image.dropDesc', limits),
+    title: t('attachment.dropTitle'),
+    desc: limits === undefined ? undefined : t('attachment.dropDesc', limits),
   }
 }
 
 /**
- * Resolve draft-image rail strings from the conversation namespace.
+ * Resolve pending-file card strings from the conversation namespace.
+ * @param t - conversation namespace translator.
+ * @param name - browser file name interpolated into remove/retry labels.
+ * @returns translated file-card labels.
+ */
+export function fileCardLabels(t: TranslateNS<'conversation'>, name: string): FileCardLabels {
+  return {
+    label: t('file.pending'),
+    remove: t('file.remove', { name }),
+    uploading: t('file.uploading'),
+    failed: t('file.uploadFailed'),
+    retry: t('file.retry', { name }),
+  }
+}
+
+/**
+ * Resolve the mixed draft-attachment rail strings from the conversation namespace.
  * @param t - conversation namespace translator.
  * @returns translated attachment-rail labels.
  */
 export function attachmentRailLabels(t: TranslateNS<'conversation'>): AttachmentRailLabels {
   return {
-    group: t('image.pending'),
-    open: t('image.openOriginal'),
-    scrollLeft: t('image.scrollLeft'),
-    scrollRight: t('image.scrollRight'),
+    group: t('attachment.pending'),
+    scrollLeft: t('attachment.scrollLeft'),
+    scrollRight: t('attachment.scrollRight'),
   }
 }

+ 54 - 0
packages/client/ui-chat/src/client/chat/MessageItem.module.css

@@ -292,3 +292,57 @@
     animation: none;
   }
 }
+
+.attachmentRow {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  max-width: 100%;
+  gap: 8px;
+}
+
+.fileCard {
+  display: inline-flex;
+  flex: 0 0 240px;
+  align-items: center;
+  gap: 10px;
+  width: 240px;
+  min-height: 64px;
+  padding: 8px 12px;
+  border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
+  border-radius: 16px;
+  background: var(--dsw-specific-input-major, transparent);
+  box-sizing: border-box;
+}
+
+.fileIcon {
+  flex: none;
+  width: 24px;
+  height: 28px;
+}
+
+.fileContent {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  min-width: 0;
+}
+
+.fileName {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  color: var(--dsw-alias-label-primary);
+  font-size: 14px;
+  font-weight: 500;
+  line-height: 22px;
+}
+
+.fileMeta {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
+  font-size: 12px;
+  line-height: 15px;
+}

+ 67 - 23
packages/client/ui-chat/src/client/chat/MessageItem.tsx

@@ -1,8 +1,8 @@
-import { memo, useEffect, useMemo, useState } from 'react'
+import { Fragment, memo, useEffect, useMemo, useState } from 'react'
 import type { ReactNode } from 'react'
 import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
+import { DocumentFileIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
 import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts'
 import { CompactionItem } from './CompactionItem.tsx'
@@ -11,24 +11,37 @@ import { MessageIconActions } from './MessageIconActions.tsx'
 import css from './MessageItem.module.css'
 
 type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
+type UserFile = Extract<UserMessageNode['content'][number], { type: 'file' }>
+type PresentedAttachment =
+  | { readonly type: 'image'; readonly image: MessageImageSource }
+  | { readonly type: 'file'; readonly file: UserFile['attachment'] }
+
+function extensionOf(name: string): string {
+  const dot = name.lastIndexOf('.')
+  if (dot <= 0 || dot === name.length - 1) return ''
+  return name.slice(dot + 1).toUpperCase().slice(0, 8)
+}
 
 function contentParts(content: readonly unknown[]): {
   text: string
-  images: { attachment: UserImage['attachment'] }[]
+  attachments: PresentedAttachment[]
   rest: unknown[]
 } {
   const texts: string[] = []
-  const images: { attachment: UserImage['attachment'] }[] = []
+  const attachments: PresentedAttachment[] = []
   const rest: unknown[] = []
   for (const block of content) {
     const b = block as { type?: string; text?: string; attachment?: unknown }
     if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text)
     else if (b.type === 'image' && b.attachment !== undefined) {
-      images.push({ attachment: (b as UserImage).attachment })
+      attachments.push({ type: 'image', image: { attachment: (b as UserImage).attachment } })
+    }
+    else if (b.type === 'file' && b.attachment !== undefined) {
+      attachments.push({ type: 'file', file: (b as UserFile).attachment })
     }
     else rest.push(block)
   }
-  return { text: texts.join(''), images, rest }
+  return { text: texts.join(''), attachments, rest }
 }
 
 function retrySeconds(milliseconds: number): number {
@@ -148,7 +161,7 @@ function TurnMaxTokensItem({ t }: {
 
 /** Right-aligned bubble shared by user and steering rows. */
 function UserStyleBubble({
-  content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, reveal = 'always', t,
+  content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewAttachments, reveal = 'always', t,
 }: {
   content: readonly unknown[]
   renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
@@ -160,14 +173,15 @@ function UserStyleBubble({
   echo?: boolean
   /** Exact session mention labels associated by the adjacent recall node. */
   referenceLabels?: readonly string[]
-  /** Local submission-echo previews replacing the content-derived image group. */
-  previewImages?: readonly MessageImageSource[]
+  /** Local submission-echo attachments replacing the content-derived attachment sequence. */
+  previewAttachments?: readonly PresentedAttachment[]
   /** Whole actions-row visibility: earlier rows reveal on hover, the latest stays shown (turn tails' gate). */
   reveal?: 'always' | 'hover'
   t: ChatViewSlotProps['t']
 }): ReactNode {
-  const { text, images: contentImages, rest } = contentParts(content)
-  const images = previewImages ?? contentImages
+  const { text, attachments: contentAttachments, rest } = contentParts(content)
+  const attachments = previewAttachments ?? contentAttachments
+  const compactImages = attachments.length > 1
   const truncated = (total: number): string => t('json.truncated', { total })
   const showBubble = text !== '' || rest.length > 0
   return (
@@ -178,7 +192,32 @@ function UserStyleBubble({
       data-actions-reveal={reveal}
     >
       <div className={css.userStack}>
-        {renderMessageImages({ images, align: 'end' })}
+        {attachments.length > 0 && (
+          <div className={css.attachmentRow} data-message-attachments>
+            {attachments.map((attachment, index) => attachment.type === 'image'
+              ? (
+                <Fragment key={`image:${index}`}>
+                  {renderMessageImages({
+                    images: [attachment.image],
+                    align: 'end',
+                    compact: compactImages,
+                  })}
+                </Fragment>
+              )
+              : (
+                <span key={`file:${index}`} className={css.fileCard} title={attachment.file.name}>
+                  <DocumentFileIcon className={css.fileIcon} />
+                  <span className={css.fileContent}>
+                    <span className={css.fileName}>{attachment.file.name}</span>
+                    <span className={css.fileMeta}>
+                      {[extensionOf(attachment.file.name), fileSizeText(attachment.file.bytes)]
+                        .filter(Boolean).join(' ')}
+                    </span>
+                  </span>
+                </span>
+              ))}
+          </div>
+        )}
         {showBubble && <div className={css.bubble}>
           {projectUserText(text, referenceLabels)}
           {rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
@@ -240,21 +279,26 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }:
     () => (submission.text === '' ? [] : [{ type: 'text', text: submission.text }]),
     [submission.text],
   )
-  const previewImages = useMemo<readonly MessageImageSource[]>(
-    () => submission.images.map(image => ({
-      preview: {
-        url: image.previewUrl,
-        ...(image.name === undefined ? {} : { name: image.name }),
-        ...(image.width === undefined ? {} : { width: image.width }),
-        ...(image.height === undefined ? {} : { height: image.height }),
-      },
-    })),
-    [submission.images],
+  const previewAttachments = useMemo<readonly PresentedAttachment[]>(
+    () => submission.attachments.map(attachment => attachment.type === 'image'
+      ? {
+        type: 'image',
+        image: {
+          preview: {
+            url: attachment.previewUrl,
+            ...(attachment.name === undefined ? {} : { name: attachment.name }),
+            ...(attachment.width === undefined ? {} : { width: attachment.width }),
+            ...(attachment.height === undefined ? {} : { height: attachment.height }),
+          },
+        },
+      }
+      : { type: 'file', file: attachment.attachment }),
+    [submission.attachments],
   )
   return (
     <UserStyleBubble
       content={content}
-      previewImages={previewImages}
+      previewAttachments={previewAttachments}
       renderMessageImages={renderMessageImages}
       pending={submission.placement === 'steering'}
       echo

+ 54 - 18
packages/client/ui-conversation/src/client/apply.ts

@@ -1,5 +1,6 @@
 /** Registers the target-neutral Conversation assembly, shell, input, and docks. */
 import type { Context } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
 import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
 import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
@@ -13,7 +14,7 @@ import { UiConversation } from './conversation/assembly.ts'
 import type { ViewTab } from './contract/views.ts'
 import type {
   ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected,
-  ConversationSessionInjected,
+  ConversationSessionInjected, DraftFileUploads,
 } from './contract/slots.ts'
 import type { InputNotice } from './contract/input.ts'
 import { createConversationStore, readConversationViewPreference } from './stores.ts'
@@ -46,6 +47,17 @@ export const inject = [
   'slots', 'sessions', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope',
 ]
 
+/** Conversation runtime configuration. */
+export interface Config {
+  /** Maximum generic-file uploads allowed to run concurrently in browser Workers. */
+  maxConcurrentFileUploads?: number
+}
+
+/** Validated Conversation runtime configuration. */
+export const Config: z<Config> = z.object({
+  maxConcurrentFileUploads: z.natural().min(1).default(2),
+})
+
 // Stable no-session sources keep the renderer's observable-hook cache and
 // hook order unchanged across current-Session transitions.
 const ABSENT_NOTICES = {
@@ -65,6 +77,11 @@ const ABSENT_MENU_LAUNCHER = {
   getSnapshot: (): string | null => null,
   subscribe: () => () => {},
 }
+const EMPTY_FILE_UPLOADS: DraftFileUploads = {}
+const ABSENT_FILE_UPLOADS = {
+  getSnapshot: () => EMPTY_FILE_UPLOADS,
+  subscribe: () => () => {},
+}
 
 interface WorkspaceNavigation {
   connectWorkspace(
@@ -94,9 +111,11 @@ function concreteConversation(ctx: Context): ConversationController {
  * Mount the Conversation core and target-neutral presentation.
  * @param ctx - Client root context.
  */
-export function apply(ctx: Context): void {
+export function apply(ctx: Context, config: Config = Config({})): void {
   const sessions = ctx.sessions
   const slots = ctx.slots
+  // Schemastery's field default is materialized before Cordis calls apply.
+  const maxConcurrentFileUploads = config.maxConcurrentFileUploads as number
   const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation
   const uiConversation = new UiConversation(ctx, sessions)
 
@@ -220,15 +239,20 @@ export function apply(ctx: Context): void {
         if (sessionId !== undefined && nextId !== sessionId) {
           const from = inputHub.shell(sessionId)
           const draft = from.snapshot.draft
-          const imageIds = from.snapshot.imageIds
+          const attachmentIds = from.snapshot.attachmentIds
           const next = inputHub.shell(nextId)
-          if (imageIds.length === 0 || next.addImages(imageIds)) {
+          if (attachmentIds.length === 0 || next.addAttachments(attachmentIds)) {
+            const target = sessions.binding(nextId)?.session
+            if (target === undefined) {
+              throw new Error(`ui-conversation: session "${nextId}" resolved no binding`)
+            }
+            concreteConversation(ctx).rebindDraftFiles(target, attachmentIds)
             if (draft !== '') {
               next.setDraft(draft)
               from.setDraft('')
             }
-            if (imageIds.length > 0) {
-              for (const id of imageIds) from.removeImage(id)
+            if (attachmentIds.length > 0) {
+              for (const id of attachmentIds) from.removeAttachment(id)
             }
           }
         }
@@ -284,15 +308,17 @@ export function apply(ctx: Context): void {
       if (sessionId === undefined) {
         return {
           keyboard: undefined,
-          addImages: undefined,
-          removeImage: undefined,
-          draftImages: undefined,
+          addFiles: undefined,
+          removeAttachment: undefined,
+          resolveDraftAttachments: undefined,
+          retryFileUpload: undefined,
           resolveSubmitMode: (running, gesture, steeringAvailable) =>
             submissionPolicy.resolve(running, gesture, steeringAvailable),
           toggleCommandMenu: undefined,
           stop: undefined,
           command: undefined,
           hooks: {
+            fileUploads: ABSENT_FILE_UPLOADS,
             notices: ABSENT_NOTICES,
             lexicon: ABSENT_LEXICON,
             menuLauncher: ABSENT_MENU_LAUNCHER,
@@ -304,11 +330,13 @@ export function apply(ctx: Context): void {
       const inputTriggers = inputHub.inputTriggers(sessionId)
       return {
         keyboard: shell,
-        addImages: (files) => {
+        addFiles: (files) => {
+          const session = sessions.binding(sessionId)?.session
+          if (session === undefined) return t('file.sessionUnavailable')
           try {
-            const images = conversation.createDraftImages(files)
-            if (!shell.addImages(images.map(image => image.id))) {
-              conversation.releaseDraftImages(images)
+            const drafts = conversation.createDrafts(session, files)
+            if (!shell.addAttachments(drafts.map(draft => draft.id))) {
+              conversation.releaseDraftAttachments(drafts)
             }
             return null
           } catch (error: unknown) {
@@ -316,11 +344,14 @@ export function apply(ctx: Context): void {
             return error instanceof Error ? error.message : String(error)
           }
         },
-        removeImage: (id) => {
-          conversation.releaseDraftImage(id)
-          shell.removeImage(id)
+        removeAttachment: (id) => {
+          if (shell.removeAttachment(id)) conversation.releaseDraftAttachment(id)
+        },
+        resolveDraftAttachments: ids => conversation.resolveDraftAttachments(ids),
+        retryFileUpload: (id) => {
+          const session = sessions.binding(sessionId)?.session
+          if (session !== undefined) conversation.retryFileUpload(session, id)
         },
-        draftImages: ids => conversation.draftImages(ids),
         resolveSubmitMode: (running, gesture, steeringAvailable) =>
           submissionPolicy.resolve(running, gesture, steeringAvailable),
         toggleCommandMenu: inputTriggers === undefined
@@ -348,6 +379,7 @@ export function apply(ctx: Context): void {
           return result.ok && result.value.matched
         },
         hooks: {
+          fileUploads: conversation.fileUploads,
           notices: shell.notices,
           lexicon: shell.lexicon,
           menuLauncher: inputTriggers?.launcher ?? ABSENT_MENU_LAUNCHER,
@@ -363,7 +395,11 @@ export function apply(ctx: Context): void {
     yield registerComposerBar()
   })
 
-  ctx.plugin(ConversationController, { input: inputHub, blocks: composerBlocks })
+  ctx.plugin(ConversationController, {
+    input: inputHub,
+    blocks: composerBlocks,
+    maxConcurrentFileUploads,
+  })
   ctx.plugin(todoDockEntry)
   ctx.plugin(queueDockEntry)
 }

+ 33 - 25
packages/client/ui-conversation/src/client/contract/input.ts

@@ -20,11 +20,19 @@ export interface TokenSpan {
   readonly draftRev: number
 }
 
-/** Base64 image payload passed to a claimed command submission. */
-export interface SubmitImageAttachment {
-  readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
-  readonly data: string
-  readonly name?: string
+/** Attachment payload passed to a claimed command submission. */
+export type SubmitAttachment =
+  | {
+    readonly type: 'image'
+    readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
+    readonly data: string
+    readonly name?: string
+  }
+  | { readonly type: 'file'; readonly receiptId: string }
+
+/** Command serialization result for one ordered attachment draft. */
+export interface DraftAttachmentSerializationResult {
+  readonly attachments: readonly SubmitAttachment[]
 }
 
 /** Settled result of a command or default composer submission. */
@@ -37,15 +45,15 @@ export interface SubmitOutcome {
 export interface CommandClaim {
   readonly token: string
   readonly hint?: string
-  readonly images?: boolean
+  readonly attachments?: boolean
   /**
    * Submit the claimed command.
    * @param args - command text after the claimed token.
    * @param actx - current Session scope.
-   * @param images - serialized draft images accepted by the claim.
+   * @param attachments - serialized draft attachments accepted by the claim.
    * @returns command settlement.
    */
-  submit(args: string, actx: Context, images: readonly SubmitImageAttachment[]): Promise<SubmitOutcome>
+  submit(args: string, actx: Context, attachments: readonly SubmitAttachment[]): Promise<SubmitOutcome>
 }
 
 /** Structured reference inserted by an input-trigger source. */
@@ -127,7 +135,7 @@ export interface InputTriggerController {
   adjudicate(
     line: string,
     signal: AbortSignal,
-    envelope: { readonly images: number },
+    envelope: { readonly attachments: number },
   ): Promise<PickOutcome>
   /** @param source - source name. @param hit - synthetic trigger hit. */
   toggleSource(source: string, hit: InputTriggerHit): void
@@ -162,7 +170,7 @@ declare module '@deepseek-ai/cordis' {
   }
 }
 
-/** Browser-runtime identity of one unsent image draft. */
+/** Browser-runtime identity of one unsent attachment draft. */
 export type DraftAttachmentId = Branded<'DraftAttachmentId'>
 
 /**
@@ -181,12 +189,12 @@ export interface InputTarget {
 export interface SessionInput extends InputTarget {
   /** Replace the whole draft (persisted-draft seed and programmatic writes). */
   setDraft(text: string): void
-  /** Append ordered browser-owned image ids; busy admission phases refuse. */
-  addImages(ids: readonly DraftAttachmentId[]): boolean
-  /** Remove one browser-owned image id; busy admission phases refuse. */
-  removeImage(id: DraftAttachmentId): void
+  /** Append ordered browser-owned attachment ids; busy admission phases refuse. */
+  addAttachments(ids: readonly DraftAttachmentId[]): boolean
+  /** Remove one browser-owned attachment id; busy admission phases refuse. @returns whether the id was removed. */
+  removeAttachment(id: DraftAttachmentId): boolean
   /** Drop ids whose browser-owned objects no longer exist. */
-  pruneImages(ids: readonly DraftAttachmentId[]): void
+  pruneAttachments(ids: readonly DraftAttachmentId[]): void
   /**
    * THE complexity sink: enter adjudication, submit transaction, and the default sink live inside.
    * @param mode - delivery intent retained through asynchronous adjudication and serialization.
@@ -221,12 +229,12 @@ export interface SessionInputResolver {
 export interface InputActions {
   /** Replace the whole draft (persisted-draft seed and programmatic writes). */
   setDraft(text: string): void
-  /** Append ordered browser-owned image ids; busy admission phases refuse. */
-  addImages(ids: readonly DraftAttachmentId[]): boolean
-  /** Remove one browser-owned image id; busy admission phases refuse. */
-  removeImage(id: DraftAttachmentId): void
+  /** Append ordered browser-owned attachment ids; busy admission phases refuse. */
+  addAttachments(ids: readonly DraftAttachmentId[]): boolean
+  /** Remove one browser-owned attachment id; busy admission phases refuse. */
+  removeAttachment(id: DraftAttachmentId): void
   /** Drop ids whose browser-owned objects no longer exist. */
-  pruneImages(ids: readonly DraftAttachmentId[]): void
+  pruneAttachments(ids: readonly DraftAttachmentId[]): void
   /** Enter submission (adjudication / claim transaction / default sink inside). */
   submit(): void
 }
@@ -321,13 +329,13 @@ export interface Occurrence {
 export interface InputState {
   /** Clipboard-text projection of the editor document (chips expanded to their clipboard form). */
   readonly draft: string
-  /** Ordered runtime-only image ids; bytes and URLs stay in ConversationController. */
-  readonly imageIds: readonly DraftAttachmentId[]
+  /** Ordered runtime-only attachment ids; browser objects stay in ConversationController. */
+  readonly attachmentIds: readonly DraftAttachmentId[]
   /** Monotonic editor revision (span CAS compares against this). */
   readonly draftRev: number
   readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
   /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */
-  readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean }
+  readonly claim?: { readonly token: string; readonly hint?: string; readonly attachments?: boolean }
   /** Reference occurrence view of the editor's chips, sorted by offset. */
   readonly occurrences: readonly Occurrence[]
   /** Read-only transient inbox projection from Session control, including pending steering. */
@@ -369,7 +377,7 @@ export type InputEvent =
   | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly draft: string; readonly outcome?: SubmitOutcome; readonly message?: string }
   /** Settlement of one optimistic default send, independent of the frozen command slot. */
   | { readonly type: 'sink-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
-  /** Commit an image-only send whose empty draft did not need an attempt. */
+  /** Commit an attachment-only send whose empty draft did not need an attempt. */
   | { readonly type: 'send-committed' }
   | { readonly type: 'release' }
 
@@ -392,7 +400,7 @@ export type InputEffect =
    * Clear the committed draft in the editor and cut undo history. A string
    * snapshot keeps a pure suffix typed during the Host round-trip (content
    * appended after the sent snapshot survives; interleaved edits cannot be
-   * separated and clear whole); null clears unconditionally (image-only
+   * separated and clear whole); null clears unconditionally (attachment-only
    * sends have no draft to retain).
    */
   | { readonly type: 'commit-draft'; readonly retainSuffixOf: string | null }

+ 43 - 13
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -1,7 +1,8 @@
 /** Target-neutral Conversation slot declarations and composed component props. */
 import type { ReactNode, RefObject } from 'react'
-import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { FileUploadReceiptId } from '@deepseek-ai/dsh-api-session-controller/types'
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type {
   MaybeSnapshotSelectorHook, ObservableSnapshot, SnapshotSelectorHook,
@@ -22,8 +23,11 @@ import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submissi
 import type { ConversationSnapshot } from './snapshot.ts'
 import type { ViewTab } from './views.ts'
 
-/** Browser-owned image that has not crossed the durable Host boundary. */
-export interface ComposerAttachment {
+/** Browser-owned draft attachment that has not crossed the durable Host boundary. */
+export type ComposerAttachment = ComposerImageAttachment | ComposerFileAttachment
+
+/** Browser-owned image, base64-encoded into the prompt at send time. */
+export interface ComposerImageAttachment {
   kind: 'image'
   id: DraftAttachmentId
   file: File
@@ -34,16 +38,36 @@ export interface ComposerAttachment {
   height?: number
 }
 
+/** Browser-owned generic file whose bytes upload to the Host as soon as it is picked. */
+export interface ComposerFileAttachment {
+  kind: 'file'
+  id: DraftAttachmentId
+  file: File
+}
+
+/** Upload lifecycle of one picked file draft (files upload on pick, not on send). */
+export type DraftFileUpload =
+  | { readonly status: 'uploading'; readonly loaded: number; readonly total?: number }
+  | { readonly status: 'ready'; readonly receiptId: FileUploadReceiptId; readonly file: FileAttachmentRef }
+  | { readonly status: 'error'; readonly message: string }
+
+/** Per-draft upload states keyed by draft attachment id. */
+export type DraftFileUploads = Readonly<Record<string, DraftFileUpload>>
+
 /** Input state handed to the optional attachment presentation plugin. */
 export interface ComposerAttachmentsOwnerProps {
-  /** Browser-owned draft images in input order. */
+  /** Browser-owned draft attachments in input order. */
   attachments: readonly ComposerAttachment[]
-  /** Whether a document-level file drop may add images now. */
+  /** Whether a document-level file drop may add attachments 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
+  onAddFiles: (files: readonly File[]) => void
+  /** Remove one draft attachment through the Conversation service. */
+  onRemoveAttachment: (id: DraftAttachmentId) => void
+  /** Current per-draft upload states for file-kind attachments. */
+  uploads: DraftFileUploads
+  /** Restart one failed file upload. */
+  onRetryFile: (id: DraftAttachmentId) => void
   /** Display-ready limits for the drop invitation. */
   dropLimits?: { readonly count: number; readonly size: string } | undefined
 }
@@ -79,6 +103,8 @@ export interface MessageImagesOwnerProps {
   loadImage: MessageImageLoader
   /** Horizontal placement inside the owning record. */
   align: 'start' | 'end'
+  /** Force every image into the compact message-attachment tile size. */
+  compact?: boolean
 }
 
 /** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */
@@ -135,7 +161,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone }
     /** Resident composer body, including the no-Session inert state. */
     'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
-    /** Optional draft-image rail and drop target. */
+    /** Optional draft-attachment rail and drop target. */
     'conversation.input.attachments': {
       kind: 'single'
       scope: 'session-maybe'
@@ -268,9 +294,11 @@ export interface ComposerBarOwnerProps {
 /** Package-private operations injected into the resident composer bar. */
 export interface ComposerBarInjected {
   keyboard: ComposerKeyboard | undefined
-  addImages: ((files: readonly File[]) => string | null) | undefined
-  removeImage: ((id: DraftAttachmentId) => void) | undefined
-  draftImages: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined
+  addFiles: ((files: readonly File[]) => string | null) | undefined
+  removeAttachment: ((id: DraftAttachmentId) => void) | undefined
+  resolveDraftAttachments: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined
+  /** Restart one failed file upload; absent without a session. */
+  retryFileUpload: ((id: DraftAttachmentId) => void) | undefined
   resolveSubmitMode: (
     running: boolean,
     gesture: ComposerSubmitGesture,
@@ -280,6 +308,8 @@ export interface ComposerBarInjected {
   stop: (() => void) | undefined
   command: ((line: string) => Promise<boolean>) | undefined
   hooks: {
+    /** Live per-draft upload states for file-kind drafts. */
+    fileUploads: ObservableSnapshot<DraftFileUploads>
     notices: ObservableSnapshot<InputNotice | null>
     lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
     menuLauncher: ObservableSnapshot<string | null>
@@ -357,7 +387,7 @@ export type ConversationSessionHeaderSlotProps =
   & InjectFace<ConversationSessionHeaderInjected>
   & PropsLocale<'conversation'>
 
-/** Full props of the draft-image attachment renderer. */
+/** Full props of the draft-attachment renderer. */
 export type ComposerAttachmentsProps =
   PropsRuntime<'conversation.input.attachments'> & PropsLocale<'conversation'>
 

+ 3 - 0
packages/client/ui-conversation/src/client/image-labels.ts

@@ -32,6 +32,9 @@ export function attachmentErrorText(
 ): string {
   switch (reason) {
     case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported')
+    // A prompt cited a file the Host has no staged upload for (expired
+    // process, foreign id): solvable by re-adding the file.
+    case 'FILE_NOT_STAGED': return t('file.notStaged')
     case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels')
     case 'IMAGE_DIMENSION_TOO_LARGE':
       if (limits !== undefined) return t('image.dimensionTooLarge', { size: limits.maxImageDimension })

+ 4 - 2
packages/client/ui-conversation/src/client/index.ts

@@ -1,5 +1,6 @@
 /** Browser Conversation assemble core, React adapter, shell, and input plugin. */
-export { apply, inject } from './apply.ts'
+export { apply, Config, inject } from './apply.ts'
+export type { Config as ConversationConfig } from './apply.ts'
 export { UiConversation } from './conversation/assembly.ts'
 export type { ConversationBinding } from './conversation/assembly.ts'
 export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts'
@@ -47,6 +48,7 @@ export { ConversationViewRegistry } from './conversation/view-registry.ts'
 export type { ConversationKey } from './locales.ts'
 export type {
   ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps,
+  ComposerFileAttachment, ComposerImageAttachment, DraftFileUpload, DraftFileUploads,
   ComposerBarInjected, ComposerBarOwnerProps, ComposerBarProps, ComposerChainProps,
   ConversationHeaderActionOwnerProps, ConversationHeaderLineageOwnerProps,
   ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionHeaderSlotProps,
@@ -59,7 +61,7 @@ export type {
 export type {
   ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest,
   DraftAttachmentId, InputActions, InputState, InsertReferenceRequest, InsertTextRequest,
-  PickOutcome, ReferenceInsert, SessionInput, SessionInputResolver, SubmitImageAttachment,
+  PickOutcome, ReferenceInsert, SessionInput, SessionInputResolver, SubmitAttachment,
   SubmitOutcome, TokenSpan,
 } from './contract/input.ts'
 export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts'

+ 82 - 81
packages/client/ui-conversation/src/client/input/facade.ts

@@ -23,7 +23,7 @@ import { mergeRegister } from '@lexical/utils'
 import type {
   ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId,
   InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome,
-  Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment,
+  Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment,
   SubmitOutcome, TokenSpan,
 } from '../contract/input.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
@@ -63,17 +63,17 @@ export interface SessionInputDeps {
   /** The plain-message sink (send choreography / materialize fork — the hub owns it). */
   defaultSink(
     text: string,
-    imageIds: readonly DraftAttachmentId[],
+    attachmentIds: readonly DraftAttachmentId[],
     mode: InputSubmitMode,
     signal: AbortSignal,
   ): Promise<SubmitOutcome>
-  /** Command-plane image plumbing (the hub owns the conversation face and the copy). */
-  commandImages: {
+  /** Command-plane attachment plumbing (the hub owns the conversation face and the copy). */
+  commandAttachments: {
     /** Resolve ordered draft ids to wire payloads without sending them; rejects when an id no longer resolves. */
-    serialize(ids: readonly DraftAttachmentId[]): Promise<readonly SubmitImageAttachment[]>
-    /** Free consumed draft images after a successful command submit. */
+    serialize(ids: readonly DraftAttachmentId[]): Promise<readonly SubmitAttachment[]>
+    /** Free consumed draft attachments after a successful command submit. */
     release(ids: readonly DraftAttachmentId[]): void
-    /** Localized composer notice for a claimed command that does not accept images. */
+    /** Localized composer notice for a claimed command that does not accept attachments. */
     unsupportedNotice(token: string): string
   }
 }
@@ -117,7 +117,7 @@ const HISTORY_MERGE_DELAY_MS = 1000
 interface DetachedDraft {
   readonly draft: string
   readonly occurrences: readonly Occurrence[]
-  readonly imageIds: readonly DraftAttachmentId[]
+  readonly attachmentIds: readonly DraftAttachmentId[]
 }
 
 /**
@@ -135,9 +135,9 @@ export class SessionInputShell implements SessionInput {
   /** The public provide-channel action face (one stable identity per session). */
   readonly actions: InputActions = {
     setDraft: (text) => { this.setDraft(text) },
-    addImages: ids => this.addImages(ids),
-    removeImage: (id) => { this.removeImage(id) },
-    pruneImages: (ids) => { this.pruneImages(ids) },
+    addAttachments: ids => this.addAttachments(ids),
+    removeAttachment: (id) => { this.removeAttachment(id) },
+    pruneAttachments: (ids) => { this.pruneAttachments(ids) },
     submit: () => { this.submit('queue') },
   }
 
@@ -150,24 +150,24 @@ export class SessionInputShell implements SessionInput {
   private readonly unregister: () => void
   private noticeSeq = 0
   private lastMirroredDraft = ''
-  private imageIds: readonly DraftAttachmentId[] = []
+  private attachmentIds: readonly DraftAttachmentId[] = []
   private disposed = false
   /** Draft persistence mirror (Conversation store write; receives the clipboard projection). */
   private mirrorFn: ((text: string) => void) | undefined
   /** Live lexicon subscription disposer; undefined until the controller resolves. */
   private lexiconOff: (() => void) | undefined
-  /** Default sends retained until admission settles or scope disposal releases their images. */
+  /** Default sends retained until admission settles or scope disposal releases their attachments. */
   private readonly detachedDrafts = new Map<number, DetachedDraft>()
   /** Failed default sends waiting to be restored together in submission order. */
   private readonly failedDetached = new Map<number, DetachedDraft>()
   /** Revision of the last automatic failure restoration. */
   private failedRestoreRev: number | undefined
   private restoringFailures = false
-  private imageFlightSeq = 0
-  /** Image-only sends retained until admission settles or scope disposal releases their images. */
-  private readonly imageFlights = new Map<number, {
+  private attachmentFlightSeq = 0
+  /** Attachment-only sends retained until admission settles or scope disposal releases their attachments. */
+  private readonly attachmentFlights = new Map<number, {
     readonly controller: AbortController
-    readonly imageIds: readonly DraftAttachmentId[]
+    readonly attachmentIds: readonly DraftAttachmentId[]
   }>()
 
   constructor(private readonly deps: SessionInputDeps) {
@@ -281,37 +281,38 @@ export class SessionInputShell implements SessionInput {
     }, { discrete: true, tag: HISTORY_MERGE_TAG })
   }
 
-  /** Append ordered image ids unless an admission transaction is locked. */
-  addImages(ids: readonly DraftAttachmentId[]): boolean {
+  /** Append ordered attachment ids unless an admission transaction is locked. */
+  addAttachments(ids: readonly DraftAttachmentId[]): boolean {
     if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false
     if (ids.length === 0) return true
-    this.imageIds = [...this.imageIds, ...ids]
+    this.attachmentIds = [...this.attachmentIds, ...ids]
     this.publish()
     return true
   }
 
   /**
-   * Remove one image id from this draft. Busy admission phases refuse, like
-   * {@link addImages}: a removal landing while a command submit serializes
+   * Remove one attachment id from this draft. Busy admission phases refuse, like
+   * {@link addAttachments}: a removal landing while a command submit serializes
    * would otherwise vanish from the rail yet still ride the in-flight send.
    */
-  removeImage(id: DraftAttachmentId): void {
-    if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return
-    const next = this.imageIds.filter(candidate => candidate !== id)
-    if (next.length === this.imageIds.length) return
-    this.imageIds = next
+  removeAttachment(id: DraftAttachmentId): boolean {
+    if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false
+    const next = this.attachmentIds.filter(candidate => candidate !== id)
+    if (next.length === this.attachmentIds.length) return false
+    this.attachmentIds = next
     this.publish()
+    return true
   }
 
   /**
-   * Keep only image ids that still resolve in the browser attachment registry.
+   * Keep only ids that still resolve in the browser attachment registry.
    * @param available - live registry ids.
    */
-  pruneImages(available: readonly DraftAttachmentId[]): void {
+  pruneAttachments(available: readonly DraftAttachmentId[]): void {
     const keep = new Set(available)
-    const next = this.imageIds.filter(id => keep.has(id))
-    if (next.length === this.imageIds.length) return
-    this.imageIds = next
+    const next = this.attachmentIds.filter(id => keep.has(id))
+    if (next.length === this.attachmentIds.length) return
+    this.attachmentIds = next
     this.publish()
   }
 
@@ -319,11 +320,11 @@ export class SessionInputShell implements SessionInput {
    * Clear the draft as a successful-send commit: the editor empties (no undo
    * unit) and the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent
    * content (the command path gets the same discipline from submit-settled).
-   * @param imageIds - admitted image ids to remove from this draft.
+   * @param attachmentIds - admitted attachment ids to remove from this draft.
    */
-  commitSend(imageIds: readonly DraftAttachmentId[]): void {
-    const submitted = new Set(imageIds)
-    this.imageIds = this.imageIds.filter(id => !submitted.has(id))
+  commitSend(attachmentIds: readonly DraftAttachmentId[]): void {
+    const submitted = new Set(attachmentIds)
+    this.attachmentIds = this.attachmentIds.filter(id => !submitted.has(id))
     this.dispatchRun(({ type: 'send-committed' }))
   }
 
@@ -358,34 +359,34 @@ export class SessionInputShell implements SessionInput {
    * dismisses and the menu tracks frozen.
    */
   submit(mode: InputSubmitMode = 'queue'): void {
-    if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) {
+    if (this.snapshot.draft.trim() === '' && this.attachmentIds.length > 0) {
       if (this.snapshot.phase === 'plain') {
-        const imageIds = [...this.imageIds]
+        const attachmentIds = [...this.attachmentIds]
         const controller = new AbortController()
-        this.imageFlightSeq += 1
-        const flight = this.imageFlightSeq
-        this.imageFlights.set(flight, { controller, imageIds })
-        this.commitSend(imageIds)
-        void this.deps.defaultSink('', imageIds, mode, controller.signal).then((outcome) => {
-          if (this.disposed || !this.imageFlights.delete(flight)) return
+        this.attachmentFlightSeq += 1
+        const flight = this.attachmentFlightSeq
+        this.attachmentFlights.set(flight, { controller, attachmentIds })
+        this.commitSend(attachmentIds)
+        void this.deps.defaultSink('', attachmentIds, mode, controller.signal).then((outcome) => {
+          if (this.disposed || !this.attachmentFlights.delete(flight)) return
           if (outcome.kind === 'success') return
-          this.restoreImages(imageIds)
+          this.restoreAttachments(attachmentIds)
           if (outcome.text !== undefined) this.notify('error', outcome.text)
         }, (error: unknown) => {
-          if (this.disposed || !this.imageFlights.delete(flight)) return
-          this.restoreImages(imageIds)
+          if (this.disposed || !this.attachmentFlights.delete(flight)) return
+          this.restoreAttachments(attachmentIds)
           this.notify('error', error instanceof Error ? error.message : String(error))
         })
       }
       return
     }
-    // Claimed pre-gate: a claim that does not declare image acceptance never
-    // submits while images are attached — one notice, everything retained.
+    // Claimed pre-gate: a claim that does not declare attachment acceptance never
+    // submits while attachments are present — one notice, everything retained.
     // Enter-time adjudication applies the same policy for unclaimed lines
     // inside the command source itself.
     const before = this.snapshot
-    if (before.phase === 'claimed' && this.imageIds.length > 0 && before.claim?.images !== true) {
-      this.notify('error', this.deps.commandImages.unsupportedNotice(before.claim?.token ?? before.draft))
+    if (before.phase === 'claimed' && this.attachmentIds.length > 0 && before.claim?.attachments !== true) {
+      this.notify('error', this.deps.commandAttachments.unsupportedNotice(before.claim?.token ?? before.draft))
       return
     }
     this.dispatchRun(({ type: 'enter', mode, draft: this.projection.clipboardText }))
@@ -562,18 +563,18 @@ export class SessionInputShell implements SessionInput {
   // ---- wiring-layer extras (not on the frozen SessionInput face) ----
 
   /**
-   * Teardown the shell and return every browser-owned image still retained by
+   * Teardown the shell and return every browser-owned attachment still retained by
    * the draft or an unsettled default send.
-   * @returns image ids the scope disposer must release.
+   * @returns attachment ids the scope disposer must release.
    */
   dispose(): readonly DraftAttachmentId[] {
     if (this.disposed) return []
-    const retained = new Set(this.imageIds)
+    const retained = new Set(this.attachmentIds)
     for (const record of this.detachedDrafts.values()) {
-      for (const imageId of record.imageIds) retained.add(imageId)
+      for (const attachmentId of record.attachmentIds) retained.add(attachmentId)
     }
-    for (const flight of this.imageFlights.values()) {
-      for (const imageId of flight.imageIds) retained.add(imageId)
+    for (const flight of this.attachmentFlights.values()) {
+      for (const attachmentId of flight.attachmentIds) retained.add(attachmentId)
       flight.controller.abort()
     }
     this.disposed = true
@@ -582,7 +583,7 @@ export class SessionInputShell implements SessionInput {
     this.editor.setRootElement(null)
     this.detachedDrafts.clear()
     this.failedDetached.clear()
-    this.imageFlights.clear()
+    this.attachmentFlights.clear()
     return [...retained]
   }
 
@@ -687,17 +688,17 @@ export class SessionInputShell implements SessionInput {
     draft: string,
     mode: InputSubmitMode,
   ): void {
-    const imageIds = [...this.imageIds]
-    this.imageIds = []
+    const attachmentIds = [...this.attachmentIds]
+    this.attachmentIds = []
     const occurrences = this.projection.occurrences
-    const record = { draft, occurrences, imageIds }
+    const record = { draft, occurrences, attachmentIds }
     this.detachedDrafts.set(attempt.seq, record)
     if (this.failedRestoreRev === this.rev) {
       this.failedDetached.clear()
       this.failedRestoreRev = undefined
     }
     if (occurrences.length === 0) {
-      this.settleSink(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal))
+      this.settleSink(attempt, this.deps.defaultSink(draft.trim(), attachmentIds, mode, attempt.signal))
       return
     }
     const inputTriggers = this.deps.inputTriggers?.()
@@ -721,7 +722,7 @@ export class SessionInputShell implements SessionInput {
           cursor = part.offset + part.length
         }
         out += draft.slice(cursor)
-        this.settleSink(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal))
+        this.settleSink(attempt, this.deps.defaultSink(out.trim(), attachmentIds, mode, attempt.signal))
       },
       (error: unknown) => {
         if (this.dead(attempt)) return
@@ -758,7 +759,7 @@ export class SessionInputShell implements SessionInput {
     const record = this.detachedDrafts.get(attempt.seq)
     if (record === undefined) return
     this.detachedDrafts.delete(attempt.seq)
-    this.restoreImages(record.imageIds)
+    this.restoreAttachments(record.attachmentIds)
     this.failedDetached.set(attempt.seq, record)
     if (this.projection.clipboardText === '' || this.failedRestoreRev === this.rev) {
       this.restoreFailedDrafts()
@@ -821,13 +822,13 @@ export class SessionInputShell implements SessionInput {
     }
   }
 
-  /** Return failed-send images to the head of the rail (ids still resolve — release happens only after success). */
-  private restoreImages(imageIds: readonly DraftAttachmentId[]): void {
-    if (imageIds.length === 0) return
-    const current = new Set(this.imageIds)
-    const restored = imageIds.filter(id => !current.has(id))
+  /** Return failed-send attachments to the head of the rail; release happens only after success. */
+  private restoreAttachments(attachmentIds: readonly DraftAttachmentId[]): void {
+    if (attachmentIds.length === 0) return
+    const current = new Set(this.attachmentIds)
+    const restored = attachmentIds.filter(id => !current.has(id))
     if (restored.length === 0) return
-    this.imageIds = [...restored, ...this.imageIds]
+    this.attachmentIds = [...restored, ...this.attachmentIds]
     this.publish()
   }
 
@@ -839,7 +840,7 @@ export class SessionInputShell implements SessionInput {
       this.dispatchRun(({ type: 'adjudicated', attempt, outcome: undefined }))
       return
     }
-    inputTriggers.adjudicate(draft.trim(), attempt.signal, { images: this.imageIds.length }).then(
+    inputTriggers.adjudicate(draft.trim(), attempt.signal, { attachments: this.attachmentIds.length }).then(
       (outcome: PickOutcome) => {
         if (this.dead(attempt)) return
         this.dispatchRun(({ type: 'adjudicated', attempt, outcome }))
@@ -855,27 +856,27 @@ export class SessionInputShell implements SessionInput {
   /**
    * The submit transaction: claim.submit against the session scope; ok maps
    * from the outcome kind. An accepting claim receives the serialized draft
-   * images, which are cleared and released only on a success outcome; a
-   * failure (serialize, transport, or handler error) keeps draft and images
+   * attachments, which are cleared and released only on a success outcome; a
+   * failure (serialize, transport, or handler error) keeps draft and attachments
    * for correction.
    */
   private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void {
-    const imageIds = claim.images === true ? [...this.imageIds] : []
+    const attachmentIds = claim.attachments === true ? [...this.attachmentIds] : []
     Promise.resolve()
       .then(async () => {
-        const images = imageIds.length > 0 ? await this.deps.commandImages.serialize(imageIds) : []
+        const attachments = attachmentIds.length > 0 ? await this.deps.commandAttachments.serialize(attachmentIds) : []
         // Serialization may outlive the attempt (large files, session
         // teardown); a dead attempt must not reach the Host executor.
         if (this.dead(attempt)) return undefined
-        return claim.submit(args, this.deps.actx, images)
+        return claim.submit(args, this.deps.actx, attachments)
       })
       .then(
         (outcome) => {
           if (outcome === undefined || this.dead(attempt)) return
-          if (outcome.kind === 'success' && imageIds.length > 0) {
-            const submitted = new Set(imageIds)
-            this.imageIds = this.imageIds.filter(id => !submitted.has(id))
-            this.deps.commandImages.release(imageIds)
+          if (outcome.kind === 'success' && attachmentIds.length > 0) {
+            const submitted = new Set(attachmentIds)
+            this.attachmentIds = this.attachmentIds.filter(id => !submitted.has(id))
+            this.deps.commandAttachments.release(attachmentIds)
           }
           this.dispatchRun(({
             type: 'submit-settled', attempt, ok: outcome.kind === 'success',
@@ -903,7 +904,7 @@ export class SessionInputShell implements SessionInput {
     const core = this.core.state
     return {
       draft: this.projection.clipboardText,
-      imageIds: this.imageIds,
+      attachmentIds: this.attachmentIds,
       draftRev: this.rev,
       phase: core.phase,
       ...(core.claim !== undefined ? { claim: core.claim } : {}),

+ 17 - 14
packages/client/ui-conversation/src/client/input/hub.ts

@@ -15,8 +15,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
 import { queueReadFaceOf } from './queue-store.ts'
 import type {
-  ComposerKeyboard, DraftAttachmentId, InputTriggerController, SessionInputResolver, SessionInput,
-  SubmitImageAttachment, SubmitOutcome,
+  ComposerKeyboard, DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController,
+  SessionInputResolver, SessionInput, SubmitOutcome,
 } from '../contract/input.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
 import type { PopupDismissFace } from './facade.ts'
@@ -38,12 +38,12 @@ interface ConversationAttachmentFace {
   sendSession(
     session: SessionFace,
     text: string,
-    imageIds: readonly DraftAttachmentId[],
+    attachmentIds: readonly DraftAttachmentId[],
     mode: InputSubmitMode,
     signal?: AbortSignal,
   ): Promise<SubmitOutcome>
-  serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise<readonly SubmitImageAttachment[]>
-  releaseDraftImage(id: DraftAttachmentId): void
+  serializeDraftAttachments(attachmentIds: readonly DraftAttachmentId[]): Promise<DraftAttachmentSerializationResult>
+  releaseDraftAttachment(id: DraftAttachmentId): void
 }
 
 /** Session-addressed input facade registry (SessionInputResolver face + composer-layer extras). */
@@ -88,19 +88,22 @@ export class InputHub implements SessionInputResolver {
       inputTriggers: () => this.controller(actx),
       popup: () => this.popup(actx),
       queue: queueReadFaceOf(session),
-      defaultSink: (text, imageIds, mode, signal) => this.sink(session, text, imageIds, mode, signal),
+      defaultSink: (text, attachmentIds, mode, signal) => this.sink(session, text, attachmentIds, mode, signal),
       steerQueue: () => { void this.steerQueue(session, shell) },
-      commandImages: {
-        serialize: ids => this.conversation().serializeDraftImages(ids),
+      commandAttachments: {
+        serialize: async (ids) => {
+          const result = await this.conversation().serializeDraftAttachments(ids)
+          return result.attachments
+        },
         // Asymmetric with serialize on purpose: release settles AFTER the
         // submit RPC, where session teardown may already have unloaded the
         // conversation service (the same tolerance as the scope disposer
         // above); leaked preview URLs then die with the document.
         release: (ids) => {
           const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
-          for (const imageId of ids) conversation?.releaseDraftImage(imageId)
+          for (const attachmentId of ids) conversation?.releaseDraftAttachment(attachmentId)
         },
-        unsupportedNotice: token => this.t('command.imagesUnsupported', {
+        unsupportedNotice: token => this.t('command.attachmentsUnsupported', {
           command: token.trim().replace(/^\//u, ''),
         }),
       },
@@ -124,7 +127,7 @@ export class InputHub implements SessionInputResolver {
         const drafts = shell.dispose()
         this.shells.delete(id)
         const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
-        for (const imageId of drafts) conversation?.releaseDraftImage(imageId)
+        for (const attachmentId of drafts) conversation?.releaseDraftAttachment(attachmentId)
       }
     }, 'conversation.input: session shell')
     return shell
@@ -175,12 +178,12 @@ export class InputHub implements SessionInputResolver {
   private sink(
     session: SessionFace,
     text: string,
-    imageIds: readonly DraftAttachmentId[],
+    attachmentIds: readonly DraftAttachmentId[],
     mode: InputSubmitMode,
     signal: AbortSignal,
   ): Promise<SubmitOutcome> {
-    if (text === '' && imageIds.length === 0) return Promise.resolve({ kind: 'success' })
-    return this.conversation().sendSession(session, text, imageIds, mode, signal)
+    if (text === '' && attachmentIds.length === 0) return Promise.resolve({ kind: 'success' })
+    return this.conversation().sendSession(session, text, attachmentIds, mode, signal)
   }
 
   /**

+ 2 - 2
packages/client/ui-conversation/src/client/input/machine.ts

@@ -55,7 +55,7 @@ export class SubmitMachine {
           claim: {
             token: c.token,
             ...(c.hint !== undefined ? { hint: c.hint } : {}),
-            ...(c.images === true ? { images: true } : {}),
+            ...(c.attachments === true ? { attachments: true } : {}),
           },
         }
         : {}),
@@ -216,7 +216,7 @@ export class SubmitMachine {
     return [{ type: 'notice', level: ev.ok && ev.outcome?.kind !== 'error' ? 'info' : 'error', text }]
   }
 
-  /** Clear after an accepted image-only send; it has no text suffix to retain. */
+  /** Clear after an accepted attachment-only send; it has no text suffix to retain. */
   private onSendCommitted(): readonly InputEffect[] {
     if (this.phase !== 'plain') return []
     this.claim = undefined

+ 36 - 12
packages/client/ui-conversation/src/client/locales.ts

@@ -23,15 +23,16 @@ export const zh = {
   'input.stop': '停止生成',
   'input.send': '发送消息',
   'input.accessMode': '访问模式,当前:{name}',
-  'image.dropTitle': '图片拖动到此处即可添加',
-  'image.dropDesc': '最多 {count} 张,每张 {size}',
-  'image.dropBlocked': '当前无法添加图片',
+  'attachment.pending': '待发送附件',
+  'attachment.scrollLeft': '向左滚动附件',
+  'attachment.scrollRight': '向右滚动附件',
+  'attachment.dropTitle': '文件或图片拖动到此处即可添加',
+  'attachment.dropDesc': '图片限制:最多 {count} 张,每张 {size}',
+  'attachment.dropBlocked': '当前无法添加文件或图片',
   'image.pending': '待发送图片',
   'image.openOriginal': '查看原图',
   'image.openOriginalLabel': '{label},点击查看原图',
   'image.remove': '移除图片 {name}',
-  'image.scrollLeft': '向左滚动图片',
-  'image.scrollRight': '向右滚动图片',
   'image.original': '原图',
   'image.label': '图片',
   'image.loadFailed': '图片加载失败,点击重试',
@@ -46,6 +47,16 @@ export const zh = {
   'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试',
   'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
   'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
+  'file.attach': '添加附件',
+  'file.pending': '待发送文件',
+  'file.remove': '移除文件 {name}',
+  'file.uploading': '上传中…',
+  'file.uploadFailed': '上传失败,点击重试',
+  'file.retry': '重试上传 {name}',
+  'file.stillUploading': '文件还在上传,请等待上传完成后发送',
+  'file.sessionUnavailable': '会话不可用,无法上传文件',
+  'file.notStaged': '文件尚未上传成功,请重新添加后再试',
+  'file.label': '文件',
   'context.aria': '上下文已用 {percent}',
   'context.used': '上下文已用',
   'context.system': '系统提示词',
@@ -73,7 +84,7 @@ export const zh = {
   'todo.progress.pending': '{pending} 待处理',
   'todo.rowTitle': '更新任务清单',
   'todo.completed': '{done}/{total} 已完成',
-  'command.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片',
+  'command.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件',
   'ask.rowTitle': '提问',
   'ask.waiting': '等待回答',
   'ask.cancelled': '已取消',
@@ -131,6 +142,7 @@ export const zh = {
   'details.running': '运行中…',
   'queue.count': '{n} 条排队消息',
   'queue.image': '排队消息图片',
+  'queue.file': '排队文件 {name}',
   'queue.edit': '编辑排队消息',
   'queue.edit.unsupported': '包含非文本内容,暂不支持编辑',
   'queue.save': '保存排队消息',
@@ -173,15 +185,16 @@ export const en = {
   'input.stop': 'Stop generating',
   'input.send': 'Send message',
   'input.accessMode': 'Access mode, current: {name}',
-  'image.dropTitle': 'Drag images here to add them',
-  'image.dropDesc': 'Up to {count} images, {size} each',
-  'image.dropBlocked': 'Images cannot be added right now',
+  'attachment.pending': 'Pending attachments',
+  'attachment.scrollLeft': 'Scroll attachments left',
+  'attachment.scrollRight': 'Scroll attachments right',
+  'attachment.dropTitle': 'Drag files or images here to add them',
+  'attachment.dropDesc': 'Image limit: up to {count} images, {size} each',
+  'attachment.dropBlocked': 'Files and images cannot be added right now',
   'image.pending': 'Pending images',
   'image.openOriginal': 'View original',
   'image.openOriginalLabel': '{label}, click to view original',
   'image.remove': 'Remove image {name}',
-  'image.scrollLeft': 'Scroll images left',
-  'image.scrollRight': 'Scroll images right',
   'image.original': 'Original image',
   'image.label': 'Image',
   'image.loadFailed': 'Image failed to load; click to retry',
@@ -196,6 +209,16 @@ export const en = {
   'image.dimensionTooLarge': 'Image sides must be at most {size}px; downscale it and try again',
   'image.modelUnsupported': 'The current model does not support images; switch to a model that does',
   'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again',
+  'file.attach': 'Add attachment',
+  'file.pending': 'Pending files',
+  'file.remove': 'Remove file {name}',
+  'file.uploading': 'Uploading…',
+  'file.uploadFailed': 'Upload failed; click to retry',
+  'file.retry': 'Retry uploading {name}',
+  'file.stillUploading': 'Files are still uploading; send after they finish',
+  'file.sessionUnavailable': 'Session unavailable; files cannot be uploaded',
+  'file.notStaged': 'The file has not finished uploading; re-add it and try again',
+  'file.label': 'File',
   'context.aria': '{percent} of context used',
   'context.used': 'of context used',
   'context.system': 'System prompt',
@@ -223,7 +246,7 @@ export const en = {
   'todo.progress.pending': '{pending} pending',
   'todo.rowTitle': 'Update to-do list',
   'todo.completed': '{done}/{total} completed',
-  'command.imagesUnsupported': '/{command} does not accept image attachments; remove them first',
+  'command.attachmentsUnsupported': '/{command} does not accept attachments; remove them first',
   'ask.rowTitle': 'Ask question',
   'ask.waiting': 'waiting',
   'ask.cancelled': 'cancelled',
@@ -281,6 +304,7 @@ export const en = {
   'details.running': 'Running…',
   'queue.count': '{n} queued messages',
   'queue.image': 'Queued message image',
+  'queue.file': 'Queued file {name}',
   'queue.edit': 'Edit queued message',
   'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet',
   'queue.save': 'Save queued message',

+ 42 - 1
packages/client/ui-conversation/src/client/queue/QueueDock.module.css

@@ -122,10 +122,51 @@
   box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1);
 }
 
-.thumbs {
+.attachments {
   display: flex;
   flex: none;
   gap: 4px;
+  min-width: 0;
+  max-width: 55%;
+  overflow: hidden;
+}
+
+.file {
+  display: inline-flex;
+  flex: 0 1 180px;
+  align-items: center;
+  gap: 4px;
+  min-width: 74px;
+  height: 24px;
+  padding: 0 6px;
+  overflow: hidden;
+  border: 1px solid var(--dsw-alias-border-l1);
+  border-radius: 6px;
+  background: var(--dsw-alias-bg-base);
+  box-sizing: border-box;
+}
+
+.fileIcon {
+  display: inline-flex;
+  flex: none;
+  width: 16px;
+  height: 16px;
+}
+
+.fileName {
+  min-width: 0;
+  overflow: hidden;
+  color: var(--dsw-alias-label-primary-dimmed);
+  font: var(--dsw-font-xs-13);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.fileSize {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 10px;
+  white-space: nowrap;
 }
 
 .thumb {

+ 80 - 37
packages/client/ui-conversation/src/client/queue/QueueDock.tsx

@@ -1,11 +1,12 @@
 import type { Context } from '@deepseek-ai/cordis'
 import { useEffect, useId, useMemo, useState } from 'react'
-import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import {
   IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16,
-  IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip,
+  DocumentFileIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14,
+  IconTrashOutline16, projectUserText, Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { QueueAction, QueueItemId, QueueRow } from '../contract/queue.ts'
 import { NS } from '../locales.ts'
@@ -26,12 +27,36 @@ export interface QueueDockInjected {
  * @param content - the row's wire content blocks.
  * @returns the row's durable image references in block order.
  */
-function queueImageRefs(content: QueueRow['content']): ImageAttachmentRef[] {
-  return content.flatMap((block) => {
-    if (block.type !== 'image') return []
-    const { attachment } = block as { attachment?: ImageAttachmentRef }
-    return attachment === undefined ? [] : [attachment]
-  })
+function queueAttachments(content: QueueRow['content']): Array<
+  | { readonly type: 'image'; readonly attachment: ImageAttachmentRef }
+  | { readonly type: 'file'; readonly attachment: FileAttachmentRef }
+> {
+  const attachments: Array<
+    | { readonly type: 'image'; readonly attachment: ImageAttachmentRef }
+    | { readonly type: 'file'; readonly attachment: FileAttachmentRef }
+  > = []
+  for (const block of content) {
+    if (block.type === 'image') {
+      const { attachment } = block as { attachment?: ImageAttachmentRef }
+      if (attachment !== undefined) attachments.push({ type: 'image', attachment })
+    }
+    if (block.type === 'file') {
+      const { attachment } = block as { attachment?: FileAttachmentRef }
+      if (attachment !== undefined) attachments.push({ type: 'file', attachment })
+    }
+  }
+  return attachments
+}
+
+/** Compact file identity used beside queue thumbnails. */
+function QueueFile({ attachment, label }: { attachment: FileAttachmentRef; label: string }) {
+  return (
+    <span className={css.file} aria-label={label} title={attachment.name}>
+      <span className={css.fileIcon} aria-hidden><DocumentFileIcon /></span>
+      <span className={css.fileName}>{attachment.name}</span>
+      <span className={css.fileSize}>{fileSizeText(attachment.bytes)}</span>
+    </span>
+  )
 }
 
 /** One durable queued image as a fixed-size thumbnail; a load failure keeps the empty placeholder. */
@@ -137,7 +162,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
         )}
         <ul id={listId} className={css.list} hidden={!listVisible}>
           {listVisible && queue.map((row) => {
-            const imageRefs = queueImageRefs(row.content)
+            const attachments = queueAttachments(row.content)
             return (
               <li key={row.id} className={css.row}>
                 {/* Single-item strip has no count header, so the row itself carries the queue glyph. */}
@@ -164,16 +189,24 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
                   )
                   : (
                     <>
-                      {imageRefs.length > 0 && (
-                        <span className={css.thumbs}>
-                          {imageRefs.map((attachment, index) => (
-                            <QueueThumb
-                              key={`${attachment.attachmentId}:${index}`}
-                              attachment={attachment}
-                              loadImage={loadImage}
-                              label={t('queue.image')}
-                            />
-                          ))}
+                      {attachments.length > 0 && (
+                        <span className={css.attachments}>
+                          {attachments.map((item, index) => item.type === 'image'
+                            ? (
+                              <QueueThumb
+                                key={`${item.attachment.attachmentId}:${index}`}
+                                attachment={item.attachment}
+                                loadImage={loadImage}
+                                label={t('queue.image')}
+                              />
+                            )
+                            : (
+                              <QueueFile
+                                key={`${item.attachment.attachmentId}:${item.attachment.name}:${index}`}
+                                attachment={item.attachment}
+                                label={t('queue.file', { name: item.attachment.name })}
+                              />
+                            ))}
                         </span>
                       )}
                       <span className={css.preview}>{projectUserText(row.preview, [])}</span>
@@ -266,24 +299,34 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
               </li>
             )
           })}
-          {listVisible && pendingQueue.map(submission => (
-            <li key={submission.requestId} className={css.row} data-submission-echo="">
-              {rowCount === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
-              {submission.images.length > 0 && (
-                <span className={css.thumbs}>
-                  {submission.images.map((image, index) => (
-                    <img
-                      key={`${image.previewUrl}:${index}`}
-                      className={css.thumb}
-                      src={image.previewUrl}
-                      alt={t('queue.image')}
-                    />
-                  ))}
-                </span>
-              )}
-              <span className={css.preview}>{projectUserText(submission.text, [])}</span>
-            </li>
-          ))}
+          {listVisible && pendingQueue.map((submission) => {
+            return (
+              <li key={submission.requestId} className={css.row} data-submission-echo="">
+                {rowCount === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
+                {submission.attachments.length > 0 && (
+                  <span className={css.attachments}>
+                    {submission.attachments.map((attachment, index) => attachment.type === 'image'
+                      ? (
+                        <img
+                          key={`${attachment.previewUrl}:${index}`}
+                          className={css.thumb}
+                          src={attachment.previewUrl}
+                          alt={t('queue.image')}
+                        />
+                      )
+                      : (
+                        <QueueFile
+                          key={`${attachment.attachment.attachmentId}:${attachment.attachment.name}:${index}`}
+                          attachment={attachment.attachment}
+                          label={t('queue.file', { name: attachment.attachment.name })}
+                        />
+                      ))}
+                  </span>
+                )}
+                <span className={css.preview}>{projectUserText(submission.text, [])}</span>
+              </li>
+            )
+          })}
         </ul>
       </div>
     </div>

+ 248 - 66
packages/client/ui-conversation/src/client/service.ts

@@ -18,11 +18,15 @@ import type {
 } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
-import type { ComposerAttachment } from './contract/slots.ts'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type {
+  ComposerAttachment, ComposerFileAttachment, ComposerImageAttachment, DraftFileUpload,
+} from './contract/slots.ts'
 import type { QueueAction, QueueItemId } from './contract/queue.ts'
 import type { ComposerBlocks } from './contract/composer-blocks.ts'
 import type {
-  DraftAttachmentId, SessionInputResolver, SubmitImageAttachment, SubmitOutcome,
+  DraftAttachmentId, DraftAttachmentSerializationResult, SessionInputResolver, SubmitAttachment, SubmitOutcome,
 } from './contract/input.ts'
 import type { InputSubmitMode } from './contract/composer-submission.ts'
 
@@ -64,8 +68,8 @@ export interface IConversation {
   loadOlder(): Promise<void>
 }
 
-/** Create one browser-only draft descriptor; only its id enters input state. */
-function browserDraftAttachment(file: File): ComposerAttachment {
+/** Create one browser-only image draft descriptor; only its id enters input state. */
+function browserDraftAttachment(file: File): ComposerImageAttachment {
   return {
     kind: 'image',
     id: randomUUID() as DraftAttachmentId,
@@ -82,7 +86,7 @@ function browserDraftAttachment(file: File): ComposerAttachment {
  * reads the dimensions into an immutable echo snapshot, so this late write
  * does not require a store notification.
  */
-function probeDimensions(attachment: ComposerAttachment): void {
+function probeDimensions(attachment: ComposerImageAttachment): void {
   if (typeof Image !== 'function') return
   const probe = new Image()
   probe.onload = () => {
@@ -115,8 +119,8 @@ function nextPaint(): Promise<void> {
   })
 }
 
-/** Native canonical base64 of one browser file (FileReader data-URL encode; no main-thread byte loop). */
-function base64Of(file: File): Promise<string> {
+/** Native canonical base64 of one browser image (FileReader data-URL encode; no main-thread byte loop). */
+function base64ImageOf(file: File): Promise<string> {
   return new Promise((resolve, reject) => {
     const reader = new FileReader()
     reader.onload = () => {
@@ -149,7 +153,20 @@ export class ConversationController extends Service implements IConversation {
   readonly input: SessionInputResolver
   /** The per-session composer-block registry. */
   readonly blocks: ComposerBlocks
+  /** Live upload state per file-kind draft; images never appear here. */
+  readonly fileUploads: SnapshotStore<Record<string, DraftFileUpload>> = createSnapshotStore<Record<string, DraftFileUpload>>({})
   private readonly draftAttachments = new Map<DraftAttachmentId, ComposerAttachment>()
+  private readonly fileUploadOperations = new Map<DraftAttachmentId, {
+    readonly controller: AbortController
+    readonly done: Promise<void>
+  }>()
+  private readonly pendingFileUploads = new Set<Promise<void>>()
+  private readonly fileUploadQueue: Array<{
+    readonly run: () => Promise<void>
+    readonly settle: () => void
+  }> = []
+  private activeFileUploads = 0
+  private readonly maxConcurrentFileUploads: number
 
   /**
    * @param ctx - owning root context (the plugin apply context; the service
@@ -158,15 +175,26 @@ export class ConversationController extends Service implements IConversation {
    * constructed by the plugin apply (the same instances the slot inject
    * factories close over).
    */
-  constructor(ctx: Context, config: { input: SessionInputResolver; blocks: ComposerBlocks }) {
+  constructor(ctx: Context, config: {
+    input: SessionInputResolver
+    blocks: ComposerBlocks
+    maxConcurrentFileUploads: number
+  }) {
     super(ctx, 'conversation')
     this.input = config.input
     this.blocks = config.blocks
-    ctx.effect(() => () => {
+    this.maxConcurrentFileUploads = config.maxConcurrentFileUploads
+    ctx.effect(() => async () => {
+      const operations = [...this.fileUploadOperations.values()]
+      for (const operation of operations) operation.controller.abort()
+      await Promise.allSettled([...this.pendingFileUploads])
+      this.fileUploadOperations.clear()
+      this.fileUploadQueue.length = 0
       for (const attachment of this.draftAttachments.values()) {
-        revokePreview(attachment.previewUrl)
+        if (attachment.kind === 'image') revokePreview(attachment.previewUrl)
       }
       this.draftAttachments.clear()
+      this.fileUploads.set({})
     }, 'conversation draft attachments')
   }
 
@@ -183,15 +211,15 @@ export class ConversationController extends Service implements IConversation {
   }
 
   /**
-   * Submit ordered draft images with text through one host admission. A local
+   * Submit ordered draft attachments with text through one host admission. A local
    * submission echo enters the session snapshot synchronously; serialization
    * and the prompt round-trip start after the browser can paint it. On the
-   * echo's observed retirement the draft images hand their preview URLs to
-   * the durable image cache and leave the registry; on failure they stay
-   * registered so the composer can restore them.
+   * echo's observed retirement seeds admitted image previews into the durable
+   * cache and removes every attachment from the draft registry. On failure,
+   * every attachment remains registered so the composer can restore it.
    * @param session - target session.
    * @param text - serialized prompt text.
-   * @param imageIds - ordered draft-local attachment ids.
+   * @param attachmentIds - ordered draft-local attachment ids.
    * @param mode - queue or steer delivery selected by composer policy.
    * @param signal - optional cancellation for the complete Host admission.
    * @returns the Host admission outcome; local attachment preparation failures reject.
@@ -199,17 +227,39 @@ export class ConversationController extends Service implements IConversation {
   async sendSession(
     session: SessionFace,
     text: string,
-    imageIds: readonly DraftAttachmentId[],
+    attachmentIds: readonly DraftAttachmentId[],
     mode: InputSubmitMode,
     signal?: AbortSignal,
   ): Promise<SubmitOutcome> {
-    const attachments = this.draftImages(imageIds)
-    if (attachments.length !== imageIds.length) {
-      throw new Error('conversation.sendSession: one or more draft images are no longer available')
+    const attachments = this.resolveDraftAttachments(attachmentIds)
+    if (attachments.length !== attachmentIds.length) {
+      throw new Error('conversation.sendSession: one or more draft attachments are no longer available')
+    }
+    const uploads = this.fileUploads.getSnapshot()
+    const uploadFor = (attachment: ComposerFileAttachment): Extract<DraftFileUpload, { status: 'ready' }> => {
+      const upload = uploads[attachment.id]
+      if (upload === undefined || upload.status !== 'ready') {
+        throw new Error('conversation.sendSession: one or more files have not finished uploading')
+      }
+      return upload
     }
+    const pendingAttachments = attachments.map(attachment => attachment.kind === 'image'
+      ? {
+        type: 'image' as const,
+        previewUrl: attachment.previewUrl,
+        ...(attachment.file.name === '' ? {} : { name: attachment.file.name }),
+        ...(attachment.width === undefined ? {} : { width: attachment.width }),
+        ...(attachment.height === undefined ? {} : { height: attachment.height }),
+      }
+      : { type: 'file' as const, attachment: uploadFor(attachment).file })
+    const serializeAttachments = (): Promise<Parameters<SessionFace['prompt']>[0]> => Promise.all(
+      attachments.map(async attachment => attachment.kind === 'image'
+        ? { type: 'image' as const, ...await this.encodeImage(attachment.file) }
+        : { type: 'file' as const, receiptId: uploadFor(attachment).receiptId }),
+    )
     const snapshot = session.getSnapshot()
     if (snapshot.subagent !== null) {
-      const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file))
+      const uploaded = await serializeAttachments()
       const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
       const result = await session.prompt(content, mode, signal)
       return result.ok ? { kind: 'success' } : { kind: 'error' }
@@ -221,21 +271,16 @@ export class ConversationController extends Service implements IConversation {
     const submission = session.beginSubmission({
       mode,
       text,
-      images: attachments.map(attachment => ({
-        previewUrl: attachment.previewUrl,
-        ...(attachment.file.name === '' ? {} : { name: attachment.file.name }),
-        ...(attachment.width === undefined ? {} : { width: attachment.width }),
-        ...(attachment.height === undefined ? {} : { height: attachment.height }),
-      })),
+      attachments: pendingAttachments,
       onRetire: (settlement) => {
-        this.settleSubmittedImages(session.sessionId, attachments, settlement)
+        this.settleSubmittedAttachments(session.sessionId, attachments, settlement)
         finishRetirement?.(settlement)
       },
     })
     let content: Parameters<SessionFace['prompt']>[0]
     try {
       await nextPaint()
-      const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file))
+      const uploaded = await serializeAttachments()
       content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
     } catch (error) {
       submission.abandon()
@@ -248,26 +293,135 @@ export class ConversationController extends Service implements IConversation {
   }
 
   /**
-   * Create runtime-only draft images and their object URLs.
-   * @param files - browser files to register after MIME validation.
+   * Create runtime-only draft attachments. Files whose browser MIME is an
+   * accepted image type become image drafts (object URL preview, bytes sent
+   * with the prompt); every other file becomes a file draft whose background
+   * upload starts immediately and remains owned by this service across Session
+   * navigation until completion or explicit removal.
+   * @param session - target session owning staged file uploads.
+   * @param files - browser files to register.
    * @returns ordered draft descriptors.
    */
-  createDraftImages(files: readonly File[]): readonly ComposerAttachment[] {
-    for (const file of files) imageMediaType(file.type)
+  createDrafts(session: SessionFace, files: readonly File[]): readonly ComposerAttachment[] {
     return files.map((file) => {
-      const attachment = browserDraftAttachment(file)
+      if (isImageMediaType(file.type)) {
+        const attachment = browserDraftAttachment(file)
+        this.draftAttachments.set(attachment.id, attachment)
+        probeDimensions(attachment)
+        return attachment
+      }
+      const attachment: ComposerFileAttachment = {
+        kind: 'file',
+        id: randomUUID() as DraftAttachmentId,
+        file,
+      }
       this.draftAttachments.set(attachment.id, attachment)
-      probeDimensions(attachment)
+      this.beginFileUpload(session, attachment)
       return attachment
     })
   }
 
   /**
-   * Resolve ordered input-state ids to runtime-owned draft images.
+   * Restart one failed file upload.
+   * @param session - target session owning staged file uploads.
+   * @param id - draft attachment id whose upload previously failed.
+   */
+  retryFileUpload(session: SessionFace, id: DraftAttachmentId): void {
+    const attachment = this.draftAttachments.get(id)
+    if (attachment === undefined || attachment.kind !== 'file') return
+    if (this.fileUploads.getSnapshot()[id]?.status !== 'error') return
+    this.beginFileUpload(session, attachment)
+  }
+
+  /**
+   * Stage carried file drafts again for a new Session.
+   * @param session - target Session after a Workspace switch.
+   * @param ids - carried draft attachment ids.
+   */
+  rebindDraftFiles(session: SessionFace, ids: readonly DraftAttachmentId[]): void {
+    for (const id of ids) {
+      const attachment = this.draftAttachments.get(id)
+      if (attachment?.kind === 'file') this.beginFileUpload(session, attachment)
+    }
+  }
+
+  private beginFileUpload(session: SessionFace, attachment: ComposerFileAttachment): void {
+    this.fileUploadOperations.get(attachment.id)?.controller.abort()
+    const controller = new AbortController()
+    this.fileUploads.update((draft) => {
+      draft[attachment.id] = { status: 'uploading', loaded: 0 }
+    })
+    let settle!: () => void
+    const done = new Promise<void>((resolve) => { settle = resolve })
+    this.fileUploadOperations.set(attachment.id, { controller, done })
+    this.pendingFileUploads.add(done)
+    void done.then(() => { this.pendingFileUploads.delete(done) })
+    const run = async (): Promise<void> => {
+      try {
+        if (controller.signal.aborted
+          || this.fileUploadOperations.get(attachment.id)?.controller !== controller) return
+        const result = await session.uploadFile(
+          attachment.file,
+          attachment.file.name === '' ? undefined : attachment.file.name,
+          controller.signal,
+          (progress) => {
+            if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return
+            this.fileUploads.update((draft) => {
+              if (!(attachment.id in draft)) return
+              draft[attachment.id] = {
+                status: 'uploading',
+                loaded: progress.loaded,
+                ...(progress.total === undefined ? {} : { total: progress.total }),
+              }
+            })
+          },
+        )
+        if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return
+        this.fileUploads.update((draft) => {
+          if (!(attachment.id in draft)) return
+          draft[attachment.id] = result.ok
+            ? { status: 'ready', receiptId: result.value.receiptId, file: result.value.file }
+            : { status: 'error', message: result.error.message }
+        })
+      } catch (error) {
+        if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return
+        this.fileUploads.update((draft) => {
+          if (!(attachment.id in draft)) return
+          draft[attachment.id] = {
+            status: 'error',
+            message: error instanceof Error ? error.message : String(error),
+          }
+        })
+      } finally {
+        if (this.fileUploadOperations.get(attachment.id)?.controller === controller) {
+          this.fileUploadOperations.delete(attachment.id)
+        }
+      }
+    }
+    this.fileUploadQueue.push({ run, settle })
+    this.pumpFileUploads()
+  }
+
+  /** Start queued upload Workers until the configured concurrency is occupied. */
+  private pumpFileUploads(): void {
+    while (this.activeFileUploads < this.maxConcurrentFileUploads) {
+      const task = this.fileUploadQueue.shift()
+      if (task === undefined) return
+      this.activeFileUploads += 1
+      void task.run().finally(() => {
+        this.activeFileUploads -= 1
+        task.settle()
+        this.pumpFileUploads()
+      })
+    }
+  }
+
+  /**
+   * Resolve ordered input-state ids to runtime-owned draft attachments.
    * @param ids - draft attachment ids.
    * @returns descriptors that remain live, in requested order.
    */
-  draftImages(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] {
+  resolveDraftAttachments(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] {
     const attachments: ComposerAttachment[] = []
     for (const id of ids) {
       const attachment = this.draftAttachments.get(id)
@@ -277,37 +431,59 @@ export class ConversationController extends Service implements IConversation {
   }
 
   /**
-   * Serialize ordered draft images to command-submit wire payloads without
-   * sending or releasing them (the composer releases only after the command
-   * settles successfully).
-   * @param imageIds - ordered draft-local attachment ids.
-   * @returns base64 payloads in id order.
+   * Serialize ordered draft attachments to command-submit wire payloads without
+   * sending or releasing them. Images are encoded; generic files cite receipts
+   * from their completed background uploads and never reread browser bytes.
+   * @param attachmentIds - ordered draft-local attachment ids.
+   * @returns wire payloads in id order.
    */
-  async serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise<readonly SubmitImageAttachment[]> {
-    const attachments = this.draftImages(imageIds)
-    if (attachments.length !== imageIds.length) {
-      throw new Error('conversation.serializeDraftImages: one or more draft images are no longer available')
+  async serializeDraftAttachments(
+    attachmentIds: readonly DraftAttachmentId[],
+  ): Promise<DraftAttachmentSerializationResult> {
+    const attachments = this.resolveDraftAttachments(attachmentIds)
+    if (attachments.length !== attachmentIds.length) {
+      throw new Error('conversation.serializeDraftAttachments: one or more draft attachments are no longer available')
+    }
+    const uploads = this.fileUploads.getSnapshot()
+    return {
+      attachments: await Promise.all(attachments.map(async (attachment) => {
+        if (attachment.kind === 'image') return { type: 'image' as const, ...await this.encodeImage(attachment.file) }
+        const upload = uploads[attachment.id]
+        if (upload === undefined || upload.status !== 'ready') {
+          throw new Error('conversation.serializeDraftAttachments: one or more files have not finished uploading')
+        }
+        return { type: 'file' as const, receiptId: upload.receiptId }
+      })),
     }
-    return Promise.all(attachments.map(attachment => this.encodeImage(attachment.file)))
   }
 
   /**
-   * Release one browser-owned draft image and preview URL.
+   * Release one browser-owned draft attachment, aborting its active upload.
    * @param id - draft attachment id.
    */
-  releaseDraftImage(id: DraftAttachmentId): void {
+  releaseDraftAttachment(id: DraftAttachmentId): void {
     const attachment = this.draftAttachments.get(id)
     if (attachment === undefined) return
+    const operation = this.fileUploadOperations.get(id)
+    this.fileUploadOperations.delete(id)
+    operation?.controller.abort()
     this.draftAttachments.delete(id)
-    revokePreview(attachment.previewUrl)
+    if (attachment.kind === 'image') {
+      revokePreview(attachment.previewUrl)
+      return
+    }
+    // The stored Host object stays durable; only the draft's upload state ends.
+    this.fileUploads.set(Object.fromEntries(
+      Object.entries(this.fileUploads.getSnapshot()).filter(([key]) => key !== id),
+    ))
   }
 
   /**
-   * Release a set of browser-owned draft images.
+   * Release a set of browser-owned draft attachments.
    * @param attachments - descriptors to release.
    */
-  releaseDraftImages(attachments: readonly ComposerAttachment[]): void {
-    for (const attachment of attachments) this.releaseDraftImage(attachment.id)
+  releaseDraftAttachments(attachments: readonly ComposerAttachment[]): void {
+    for (const attachment of attachments) this.releaseDraftAttachment(attachment.id)
   }
 
   /** Apply one operation to a pending queue occurrence. */
@@ -361,40 +537,41 @@ export class ConversationController extends Service implements IConversation {
   }
 
   /**
-   * Settle one submission's draft images when its echo retires. Observed:
+   * Settle one submission's draft attachments when its echo retires. Observed:
    * each image leaves the registry, handing its preview URL to the durable
    * image cache (seeded under the admitted reference so the transcript node
    * renders immediately while the cache reads canonical bytes) or revoking it
    * when the cache already holds that reference. Failed: nothing changes;
    * the ids stay registered for the composer's rail restore.
    */
-  private settleSubmittedImages(
+  private settleSubmittedAttachments(
     sessionId: SessionId,
     attachments: readonly ComposerAttachment[],
     retirement: PendingSubmissionRetirement,
   ): void {
     if (retirement.reason !== 'observed') return
     const uiConversation = this.ctx.get('uiConversation')
-    attachments.forEach((attachment, index) => {
+    let observedIndex = 0
+    for (const attachment of attachments) {
       const live = this.draftAttachments.get(attachment.id)
-      if (live === undefined) return
+      const ref = retirement.attachments[observedIndex++]
+      if (live === undefined) continue
+      if (attachment.kind === 'file') {
+        this.releaseDraftAttachment(attachment.id)
+        continue
+      }
       this.draftAttachments.delete(attachment.id)
-      const ref = retirement.attachments[index]
-      if (ref !== undefined && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) return
+      if (ref !== undefined && 'mediaType' in ref
+        && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) continue
       revokePreview(attachment.previewUrl)
-    })
-  }
-
-  /** Convert browser files to canonical base64 prompt parts. */
-  private serializeImages(images: readonly File[]): Promise<Parameters<SessionFace['prompt']>[0]> {
-    return Promise.all(images.map(async file => ({ type: 'image' as const, ...await this.encodeImage(file) })))
+    }
   }
 
   /** Canonical base64 wire form of one browser image file. */
-  private async encodeImage(file: File): Promise<SubmitImageAttachment> {
+  private async encodeImage(file: File): Promise<Omit<Extract<SubmitAttachment, { type: 'image' }>, 'type'>> {
     return {
       mediaType: imageMediaType(file.type),
-      data: await base64Of(file),
+      data: await base64ImageOf(file),
       ...(file.name === '' ? {} : { name: file.name }),
     }
   }
@@ -412,6 +589,11 @@ function imageMediaType(value: string): ImageMediaType {
   }
 }
 
+/** Whether a browser-declared MIME selects the image draft path (all other files upload verbatim). */
+function isImageMediaType(value: string): boolean {
+  return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif'
+}
+
 function revokePreview(url: string): void {
   if (url.startsWith('blob:')) URL.revokeObjectURL(url)
 }

+ 79 - 36
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -14,10 +14,10 @@
  */
 
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import type { CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react'
+import type { CSSProperties, ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
 import clsx from 'clsx'
 import {
-  IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
+  IconPaperclipOutline16, IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 // Type-only: the `plan` projection key merge (the TodoDock posture — the
 // composer reads a host-computed value; the domain owns the key).
@@ -40,9 +40,10 @@ import css from './InputBar.module.css'
 export type InputBarProps = ComposerBarProps
 
 export function InputBar({
-  useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages,
+  useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments,
+  retryFileUpload,
   resolveSubmitMode, toggleCommandMenu, stop, command, t,
-  renderSlot, useNotices, useLexicon, useMenuLauncher,
+  renderSlot, useFileUploads, useNotices, useLexicon, useMenuLauncher,
   useProjection, sessionId, variant, disabled: inert = false, blocked,
   workspacePickerOpen = false, onRequestWorkspace,
   placeholder, accessory, overlay, leftItems, rightItems, footer,
@@ -66,10 +67,16 @@ export function InputBar({
   const draft = input?.draft ?? ''
   const editor = keyboard?.editor ?? null
   const attachments = useMemo(
-    () => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds),
-    [draftImages, input?.imageIds],
+    () => input === undefined || resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds),
+    [resolveDraftAttachments, input?.attachmentIds],
   )
   const empty = draft.trim() === '' && attachments.length === 0
+  const uploads = useFileUploads(snapshot => snapshot)
+  // Send waits for every picked file: uploading and failed drafts both hold
+  // the gate (a failed upload is retried or removed, never silently dropped).
+  const uploadsPending = attachments.some(
+    attachment => attachment.kind === 'file' && uploads[attachment.id]?.status !== 'ready',
+  )
   // Transient error banner (machine notices, image-intake rejections, and
   // prompt failures): the seq keys the Toast so an identical repeated message
   // restarts the hold-then-fade cycle instead of reusing the faded one.
@@ -136,10 +143,10 @@ export function InputBar({
 
   useEffect(() => {
     if (input === undefined || inputActions === undefined) return
-    if (attachments.length !== input.imageIds.length) {
-      inputActions.pruneImages(attachments.map(attachment => attachment.id))
+    if (attachments.length !== input.attachmentIds.length) {
+      inputActions.pruneAttachments(attachments.map(attachment => attachment.id))
     }
-  }, [attachments, input?.imageIds, inputActions])
+  }, [attachments, input?.attachmentIds, inputActions])
 
   // Scroll the draft scrollport the minimum that brings the selection focus
   // into view — the browser's own behavior for typing, performed for the
@@ -212,46 +219,56 @@ export function InputBar({
     return () => { el.removeEventListener('wheel', onWheel) }
   }, [])
 
-  // Intake pre-check: an addition that would break
-  // a projected limit is refused as a whole batch, announced immediately, and
-  // never enters the rail — no more submit-time failure rolling the rail
-  // back. The host enforces the same limits at submit for callers that bypass
+  // Intake pre-check: an addition that would break a projected image limit is
+  // refused as a whole batch, announced immediately, and never enters the
+  // rail. Only the image subset is limit-checked: generic files carry no
+  // client-side size or count limit and upload as soon as they are picked.
+  // The host enforces the same image limits at submit for callers that bypass
   // this composer.
-  const intakeImages = useCallback((files: readonly File[]): void => {
-    if (addImages === undefined || files.length === 0) return
+  const intakeFiles = useCallback((files: readonly File[]): void => {
+    if (subagent !== null || addFiles === undefined || files.length === 0) return
     const rejected = ((): string | null => {
       if (imageLimits !== undefined) {
-        // Format precedes limits: a batch with
-        // a non-image must announce the format problem, not a count or size
-        // it could never pass anyway — addImages rejects it authoritatively.
-        if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) {
-          return addImages(files)
-        }
-        if (attachments.length + files.length > imageLimits.maxImagesPerMessage) {
+        const mediaTypes = imageLimits.mediaTypes as readonly string[]
+        const images = files.filter(file => mediaTypes.includes(file.type))
+        const imageAttachments = attachments.filter(attachment => attachment.kind === 'image')
+        if (imageAttachments.length + images.length > imageLimits.maxImagesPerMessage) {
           return t('image.tooMany', { count: imageLimits.maxImagesPerMessage })
         }
-        if (files.some(file => file.size > imageLimits.maxImageBytes)) {
+        if (images.some(file => file.size > imageLimits.maxImageBytes)) {
           return t('image.fileTooLarge', { size: imageSizeText(imageLimits.maxImageBytes) })
         }
-        const total = attachments.reduce((sum, attachment) => sum + attachment.file.size, 0)
-          + files.reduce((sum, file) => sum + file.size, 0)
+        const total = imageAttachments.reduce((sum, attachment) => sum + attachment.file.size, 0)
+          + images.reduce((sum, file) => sum + file.size, 0)
         if (total > imageLimits.maxMessageImageBytes) {
           return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) })
         }
       }
-      return addImages(files)
+      return addFiles(files)
     })()
     if (rejected !== null) showToast(rejected)
-  }, [addImages, attachments, imageLimits, showToast, t])
+  }, [subagent, addFiles, attachments, imageLimits, showToast, t])
 
-  const canAcceptDrop = !locked && !machineBusy && addImages !== undefined
+  const canAcceptDrop = subagent === null && !locked && !machineBusy && addFiles !== undefined
+
+  const fileInputRef = useRef<HTMLInputElement | null>(null)
+  const onPickFiles = (e: ChangeEvent<HTMLInputElement>): void => {
+    const picked = e.target.files === null ? [] : [...e.target.files]
+    // Reset so picking the same file again re-fires the change event.
+    e.target.value = ''
+    if (picked.length > 0) intakeFiles(picked)
+  }
 
   // The keymap handlers read live bar state through this ref so the editor
   // registration survives re-renders without re-arming per keystroke.
   const gate = useRef({
-    locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages,
+    locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode,
+    intakeFiles, uploadsPending, showToast, t,
   })
-  gate.current = { locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages }
+  gate.current = {
+    locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode,
+    intakeFiles, uploadsPending, showToast, t,
+  }
 
   useEffect(() => {
     if (editor === null || keyboard === undefined) return
@@ -272,13 +289,17 @@ export function InputBar({
           keyboard.steerQueue()
           return
         }
+        if (g.uploadsPending) {
+          g.showToast(g.t('file.stillUploading'))
+          return
+        }
         keyboard.submit(g.resolveSubmitMode(
           g.running,
           accelerated ? 'accelerated' : 'enter',
           g.subagent === null,
         ))
       },
-      intakeFiles: (files) => { gate.current.intakeImages(files) },
+      intakeFiles: (files) => { gate.current.intakeFiles(files) },
       pasteText: (text) => {
         if (gate.current.machineBusy || gate.current.locked) return
         keyboard.paste(text)
@@ -321,8 +342,8 @@ export function InputBar({
       return
     }
     if (inputActions === undefined) return // absent machine: the button is disabled
-    /* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
-    if (!empty && !disabled && !machineBusy) inputActions.submit()
+    /* v8 ignore next -- defensive: the primary button is disabled for empty, disabled, and pending-upload states. */
+    if (!empty && !disabled && !machineBusy && !uploadsPending) inputActions.submit()
   }
 
   // The Access seat: the projection-fed permission chip (renders nothing
@@ -396,8 +417,10 @@ export function InputBar({
         {renderSlot('conversation.input.attachments', {
           attachments,
           canAcceptDrop,
-          onAddImages: intakeImages,
-          onRemoveImage: (id) => { removeImage?.(id) },
+          onAddFiles: intakeFiles,
+          onRemoveAttachment: (id) => { removeAttachment?.(id) },
+          uploads,
+          onRetryFile: (id) => { retryFileUpload?.(id) },
           dropLimits: imageLimits === undefined ? undefined : {
             count: imageLimits.maxImagesPerMessage,
             size: imageSizeText(imageLimits.maxImageBytes),
@@ -450,6 +473,26 @@ export function InputBar({
                 <IconPlusOutline16 size={14} />
               </button>
             </Tooltip>
+            <Tooltip label={t('file.attach')} side="top" delayMs={500}>
+              <button
+                type="button"
+                className={css.add}
+                aria-label={t('file.attach')}
+                disabled={subagent !== null || locked || machineBusy || addFiles === undefined}
+                onMouseDown={keepFocus}
+                onClick={() => { fileInputRef.current?.click() }}
+              >
+                <IconPaperclipOutline16 size={14} />
+              </button>
+            </Tooltip>
+            <input
+              ref={fileInputRef}
+              type="file"
+              multiple
+              disabled={subagent !== null}
+              hidden
+              onChange={onPickFiles}
+            />
             <div className={css.modes}>
               {accessSelect}
               {sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })}
@@ -481,7 +524,7 @@ export function InputBar({
                 type="button"
                 className={css.primary}
                 aria-label={primaryLabel}
-                disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy}
+                disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending}
                 onMouseDown={keepFocus}
                 onClick={onPrimary}
               >

+ 41 - 0
packages/client/ui-primitives/src/DocumentFileIcon.tsx

@@ -0,0 +1,41 @@
+import { useId } from 'react'
+
+/**
+ * Render the DeepSeek Web document glyph used by generic-file cards.
+ * @param props - optional CSS class for sizing and placement.
+ * @returns a decorative document SVG with an instance-safe gradient id.
+ */
+export function DocumentFileIcon({ className }: { readonly className?: string | undefined }) {
+  const gradientId = useId()
+  return (
+    <svg
+      className={className}
+      xmlns="http://www.w3.org/2000/svg"
+      width="24"
+      height="28"
+      viewBox="0 0 24 28"
+      fill="none"
+      aria-hidden
+    >
+      <path
+        d="M16.5 0l7 7v15.6c0 2.25 0 3.375-.573 4.164a3 3 0 0 1-.663.663C21.475 28 20.349 28 18.1 28H5.9c-2.25 0-3.375 0-4.164-.573a3 3 0 0 1-.663-.663C.5 25.975.5 24.849.5 22.6V5.4c0-2.25 0-3.375.573-4.164a3 3 0 0 1 .663-.663C2.525 0 3.651 0 5.9 0h10.6z"
+        fill={`url(#${gradientId})`}
+      />
+      <path
+        d="M16.5 0l7 7h-3.8c-1.12 0-1.68 0-2.108-.218a2 2 0 0 1-.874-.874C16.5 5.48 16.5 4.92 16.5 3.8V0z"
+        fill="#fff"
+        fillOpacity=".55"
+      />
+      <path
+        d="M6 11.784c0-.433.351-.784.784-.784h10.432a.784.784 0 1 1 0 1.568H6.784A.784.784 0 0 1 6 11.784zM6 15.784c0-.433.351-.784.784-.784h10.432a.784.784 0 1 1 0 1.568H6.784A.784.784 0 0 1 6 15.784zM6.114 19.817c0-.433.35-.784.784-.784h6.318a.784.784 0 1 1 0 1.568H6.898a.784.784 0 0 1-.784-.784z"
+        fill="#fff"
+      />
+      <defs>
+        <linearGradient id={gradientId} x1="1.5" y1="-1" x2="23.5" y2="28" gradientUnits="userSpaceOnUse">
+          <stop stopColor="#6D93FF" />
+          <stop offset="1" stopColor="#5A71F0" />
+        </linearGradient>
+      </defs>
+    </svg>
+  )
+}

+ 16 - 0
packages/client/ui-primitives/src/file-size.ts

@@ -0,0 +1,16 @@
+/** Compact human-readable byte counts shared by attachment presenters. @module @deepseek-ai/dsh-client-ui-primitives/file-size */
+
+/**
+ * Byte count as compact user-facing size text (`312B`, `4.2KB`, `1.5MB`, `2.4GB`).
+ * @param bytes - exact byte count.
+ * @returns whole-unit text with one decimal below ten of the chosen unit.
+ */
+export function fileSizeText(bytes: number): string {
+  if (bytes < 1024) return `${bytes}B`
+  const kb = bytes / 1024
+  if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)}KB`
+  const mb = kb / 1024
+  if (mb < 1024) return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)}MB`
+  const gb = mb / 1024
+  return `${gb < 10 ? gb.toFixed(1) : Math.round(gb)}GB`
+}

+ 2 - 0
packages/client/ui-primitives/src/index.ts

@@ -32,6 +32,8 @@ export { projectUserText } from './user-text.tsx'
 export { Tooltip } from './Tooltip.tsx'
 export type { TooltipSide } from './Tooltip.tsx'
 export { Toast } from './Toast.tsx'
+export { fileSizeText } from './file-size.ts'
+export { DocumentFileIcon } from './DocumentFileIcon.tsx'
 export { writeClipboard } from './clipboard.ts'
 export { relativeTime } from './relative-time.ts'
 export type { RelativeTime, RelativeTimeUnit } from './relative-time.ts'

+ 12 - 3
packages/client/ui-trajectory/src/client/layout.ts

@@ -125,10 +125,15 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick<
   const preview = previewContent(node.content)
   const previewMarkdown = preview === '' ? undefined : preview
   const images = imageBlockCount(node.content)
-  return {
-    text: previewMarkdown === undefined && images > 0
+  const files = fileBlockCount(node.content)
+  const attachmentSummary = [
+    previewMarkdown === undefined && images > 0
       ? t('layout.imageOnly', { count: images })
-      : '',
+      : undefined,
+    files > 0 ? t('layout.fileAttachments', { count: files }) : undefined,
+  ].filter((value): value is string => value !== undefined).join(' · ')
+  return {
+    text: attachmentSummary,
     ...(previewMarkdown === undefined ? {} : { previewMarkdown }),
     sourceSeq: node.seq,
     messageSource: node.source,
@@ -848,6 +853,10 @@ function imageBlockCount(content: readonly { type: string }[]): number {
   return content.filter(block => block.type === 'image').length
 }
 
+function fileBlockCount(content: readonly { type: string }[]): number {
+  return content.filter(block => block.type === 'file').length
+}
+
 function stringifySourceValue(value: unknown): string {
   const json = JSON.stringify(value, null, 2)
   return json || String(value)

+ 2 - 0
packages/client/ui-trajectory/src/client/locales.ts

@@ -174,6 +174,7 @@ export const zh = {
   'layout.compacted': '上下文已压缩',
   'layout.toolCallOnly': '仅工具调用',
   'layout.imageOnly': '图片 ×{count}',
+  'layout.fileAttachments': '文件 ×{count}',
   'layout.initialSystemPrompt': '初始系统提示词',
   'layout.systemPromptUpdated': '系统提示词已更新',
   'layout.toolsUpdated': '工具已更新',
@@ -366,6 +367,7 @@ export const en: Record<TrajectoryKey, string> = {
   'layout.compacted': 'Context compacted',
   'layout.toolCallOnly': 'Tool call only',
   'layout.imageOnly': 'Images ×{count}',
+  'layout.fileAttachments': 'Files ×{count}',
   'layout.initialSystemPrompt': 'Initial System Prompt',
   'layout.systemPromptUpdated': 'System Prompt Updated',
   'layout.toolsUpdated': 'Tools Updated',