فهرست منبع

feat(ui-conversation): 默认发送改为乐观提交并接入提交回显

enter 即清空草稿并解冻输入框,默认发送作为 detached attempt 并发运行;
sink-settled 失败时仅还原未被覆盖的空草稿与图片;sendSession 在序列化前注册
提交回显并在绘制让步后再编码(FileReader 原生 base64);观察退休时把预览 URL
移交 HistoricalImageCache,正式消息节点零往返显示。
creatixchu 1 ماه پیش
والد
کامیت
390dad6138

+ 22 - 4
packages/client/ui-conversation/src/client/contract/input.ts

@@ -375,9 +375,11 @@ export interface InputState {
 
 /**
  * One in-flight submission attempt: the ONLY id concept in the submit plane.
- * Created on enter; carried by adjudicated/submit-settled events; stale
- * attempts are dropped (anti-backwash). release/session teardown aborts the
- * current attempt, keeping the promise bounded.
+ * Created on enter; carried by adjudicated/submit-settled/sink-settled
+ * events; stale attempts are dropped (anti-backwash). Command attempts hold
+ * the single frozen in-flight slot; default-sink attempts run detached and
+ * concurrently. release/session teardown aborts them all, keeping every
+ * promise bounded.
  */
 export interface SubmitAttempt {
   readonly seq: number
@@ -421,6 +423,13 @@ export type InputEvent =
   | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
   | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
   | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
+  /**
+   * Settlement of one detached default-sink send. Independent of phase and of
+   * the command-plane in-flight slot: the composer committed optimistically at
+   * enter, so failure restores the enter-time draft and occurrences only while
+   * the composer is still untouched (empty plain draft).
+   */
+  | { 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. */
   | { readonly type: 'send-committed' }
   | { readonly type: 'release' }
@@ -433,5 +442,14 @@ export type InputEvent =
 export type InputEffect =
   | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
   | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
-  | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode }
+  /** Detached default send. The machine committed the composer clear at enter;
+   *  `occurrences` snapshots the reference table serialization needs (the live
+   *  table was cleared with the draft). */
+  | {
+    readonly type: 'default-sink'
+    readonly attempt: SubmitAttempt
+    readonly draft: string
+    readonly occurrences: readonly Occurrence[]
+    readonly mode: InputSubmitMode
+  }
   | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }

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

@@ -28,6 +28,10 @@ export interface ComposerAttachment {
   id: DraftAttachmentId
   file: File
   previewUrl: string
+  /** Intrinsic pixel width, filled asynchronously by the intake header probe. */
+  width?: number
+  /** Intrinsic pixel height, filled asynchronously by the intake header probe. */
+  height?: number
 }
 
 /** Input state handed to the optional attachment presentation plugin. */
@@ -44,11 +48,29 @@ export interface ComposerAttachmentsOwnerProps {
   dropLimits?: { readonly count: number; readonly size: string } | undefined
 }
 
-/** Durable image group handed to the optional attachment presentation plugin. */
+/**
+ * One image inside a message record: a durable admitted reference, or the
+ * local preview of a submission echo whose admission is still in flight.
+ */
+export type MessageImageSource =
+  | { readonly attachment: ImageAttachmentRef }
+  | {
+    readonly preview: {
+      /** Browser-owned preview URL (lifecycle stays with the submitter). */
+      readonly url: string
+      readonly name?: string
+      /** Intrinsic pixel width, when the intake probe has resolved it. */
+      readonly width?: number
+      /** Intrinsic pixel height, when the intake probe has resolved it. */
+      readonly height?: number
+    }
+  }
+
+/** Message image group handed to the optional attachment presentation plugin. */
 export interface MessageImagesOwnerProps {
-  /** Durable image references in source order. */
-  images: readonly { readonly attachment: ImageAttachmentRef }[]
-  /** Session-authorized image URL loader. */
+  /** Durable references or submission-echo previews in source order. */
+  images: readonly MessageImageSource[]
+  /** Session-authorized image URL loader for the durable arm. */
   loadImage: (attachment: ImageAttachmentRef) => Promise<string>
   /** Horizontal placement inside the owning record. */
   align: 'start' | 'end'

+ 13 - 0
packages/client/ui-conversation/src/client/conversation/assembly.ts

@@ -214,6 +214,19 @@ export class UiConversation extends Service {
     return this.images.resolve(sessionId, attachment)
   }
 
+  /**
+   * Adopt an already-displayable URL for one durable reference (see
+   * HistoricalImageCache.seed): the transcript node then renders it without a
+   * byte round-trip.
+   * @param sessionId - Session authorization and lifetime scope.
+   * @param attachment - Durable image reference the URL displays.
+   * @param url - browser URL to adopt.
+   * @returns whether the cache took URL ownership.
+   */
+  seedImageUrl(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean {
+    return this.images.seed(sessionId, attachment, url)
+  }
+
   /**
    * Canonicalize one `request/header` event against the previous prompt state.
    *

+ 23 - 0
packages/client/ui-conversation/src/client/conversation/historical-images.ts

@@ -67,6 +67,29 @@ export class HistoricalImageCache {
     return pending
   }
 
+  /**
+   * Adopt an already-displayable URL for one durable reference (a submission
+   * echo's preview whose bytes are the just-admitted image). Ownership moves
+   * to this cache: the URL is revoked with the Session scope like a fetched
+   * one, and later resolve() calls reuse it without a byte round-trip.
+   * @param sessionId - Session authorization and lifetime scope.
+   * @param attachment - Durable image reference the URL displays.
+   * @param url - browser URL to adopt.
+   * @returns whether the cache took ownership (false: entry already present or unknown session — the caller keeps the URL).
+   */
+  seed(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean {
+    if (this.disposed) return false
+    const key = `${sessionId}:${attachment.attachmentId}`
+    if (this.entries.has(key)) return false
+    const binding = this.sessions.binding(sessionId)
+    if (binding === undefined) return false
+    this.bindScope(sessionId, binding.ctx)
+    const generation = this.generations.get(sessionId) ?? 0
+    this.urls.add(url)
+    this.entries.set(key, { sessionId, generation, pending: Promise.resolve(url) })
+    return true
+  }
+
   private bindScope(sessionId: SessionId, scope: Context): void {
     if (this.scopeDisposers.has(sessionId)) return
     const dispose = scope.effect(() => () => {

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

@@ -53,7 +53,8 @@ export type {
   ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps,
   ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps,
   HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone,
-  MessageImagesOwnerProps, RenderMessageImages, UseConversation, UseConversationViews,
+  MessageImageSource, MessageImagesOwnerProps, RenderMessageImages, UseConversation,
+  UseConversationViews,
 } from './contract/slots.ts'
 export type {
   ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest,

+ 44 - 29
packages/client/ui-conversation/src/client/input/facade.ts

@@ -13,7 +13,7 @@ import {
 import type {
   ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId,
   EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
-  InputTriggerController, PasteComponent, PickOutcome, QueuedMessage, ReferenceInsert,
+  InputTriggerController, Occurrence, PasteComponent, PickOutcome, QueuedMessage, ReferenceInsert,
   SessionInput, SubmitAttempt, SubmitImageAttachment, SubmitOutcome, TokenSpan,
 } from '../contract/input.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
@@ -100,8 +100,6 @@ export class SessionInputShell implements SessionInput {
   private noticeSeq = 0
   private lastMirroredDraft = ''
   private imageIds: readonly DraftAttachmentId[] = []
-  /** One image-only send at a time: Enter during the Host round-trip is a no-op. */
-  private imageSendInFlight = false
   private disposed = false
   /** Draft persistence mirror (Conversation store write; receives the clipboard projection, never display-only ranges). */
   private mirrorFn: ((text: string) => void) | undefined
@@ -208,17 +206,19 @@ export class SessionInputShell implements SessionInput {
    */
   submit(mode: InputSubmitMode = 'queue'): void {
     if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) {
-      if (this.snapshot.phase === 'plain' && !this.imageSendInFlight) {
+      if (this.snapshot.phase === 'plain') {
+        // Optimistic image-only send: the rail clears now; a failed admission
+        // restores the same ids (they stay registered until release).
         const imageIds = [...this.imageIds]
-        this.imageSendInFlight = true
+        this.commitSend(imageIds)
         void this.deps.defaultSink('', imageIds, mode, new AbortController().signal).then((outcome) => {
-          this.imageSendInFlight = false
-          if (this.disposed) return
-          if (outcome.kind === 'success') this.commitSend(imageIds)
-          else if (outcome.text !== undefined) this.notify('error', outcome.text)
+          if (this.disposed || outcome.kind === 'success') return
+          this.restoreImages(imageIds)
+          if (outcome.text !== undefined) this.notify('error', outcome.text)
         }, (error: unknown) => {
-          this.imageSendInFlight = false
-          if (!this.disposed) this.notify('error', error instanceof Error ? error.message : String(error))
+          if (this.disposed) return
+          this.restoreImages(imageIds)
+          this.notify('error', error instanceof Error ? error.message : String(error))
         })
       }
       return
@@ -438,7 +438,7 @@ export class SessionInputShell implements SessionInput {
         return
       }
       case 'default-sink': {
-        this.sinkSerialized(fx.attempt, fx.draft, fx.mode)
+        this.sinkSerialized(fx.attempt, fx.draft, fx.occurrences, fx.mode)
         return
       }
       default:
@@ -449,15 +449,21 @@ export class SessionInputShell implements SessionInput {
   /**
    * Prompt serialization before the sink: expand each
    * inline reference range to its owner's model form via the session controller's
-   * codec routing. Owner missing / serialize failure / disposal blocks the
-   * send — notice + draft and chips retained, never a silent downgrade to
-   * the clipboard text. Chip-free drafts skip the async detour.
+   * codec routing. The composer committed at enter, so the draft images clear
+   * here (captured for the send) and a failure — owner missing, serialize
+   * rejection, transport, or admission — restores them beside the machine's
+   * untouched-draft restore. Chip-free drafts skip the async detour.
    */
-  private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void {
+  private sinkSerialized(
+    attempt: SubmitAttempt,
+    draft: string,
+    occurrences: readonly Occurrence[],
+    mode: InputSubmitMode,
+  ): void {
     const imageIds = [...this.imageIds]
-    const occurrences = this.core.state.occurrences
+    this.imageIds = []
     if (occurrences.length === 0) {
-      this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds)
+      this.settleSink(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds)
       return
     }
     const inputTriggers = this.deps.inputTriggers?.()
@@ -481,32 +487,30 @@ export class SessionInputShell implements SessionInput {
           cursor = part.offset + part.length
         }
         out += draft.slice(cursor)
-        this.settleSubmit(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds)
+        this.settleSink(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds)
       },
       (error: unknown) => {
         controller.abort()
         if (this.dead(attempt)) return
+        this.restoreImages(imageIds)
         const message = error instanceof Error ? error.message : String(error)
-        this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message }))
+        this.run(this.core.dispatch({ type: 'sink-settled', attempt, ok: false, message }))
       },
     )
   }
 
-  /** Settle one admission attempt; successful sends consume only their captured images. */
-  private settleSubmit(
+  /** Settle one detached default send; a failure returns its captured images to the rail. */
+  private settleSink(
     attempt: SubmitAttempt,
     pending: Promise<SubmitOutcome>,
-    imageIds: readonly DraftAttachmentId[] = [],
+    imageIds: readonly DraftAttachmentId[],
   ): void {
     pending.then(
       (outcome) => {
         if (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))
-        }
+        if (outcome.kind !== 'success') this.restoreImages(imageIds)
         this.run(this.core.dispatch({
-          type: 'submit-settled',
+          type: 'sink-settled',
           attempt,
           ok: outcome.kind === 'success',
           outcome,
@@ -514,8 +518,9 @@ export class SessionInputShell implements SessionInput {
       },
       (error: unknown) => {
         if (this.dead(attempt)) return
+        this.restoreImages(imageIds)
         this.run(this.core.dispatch({
-          type: 'submit-settled',
+          type: 'sink-settled',
           attempt,
           ok: false,
           message: error instanceof Error ? error.message : String(error),
@@ -524,6 +529,16 @@ 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))
+    if (restored.length === 0) return
+    this.imageIds = [...restored, ...this.imageIds]
+    this.publish()
+  }
+
   /** Enter adjudication: poll the session controller; failure = notice + draft retained (never a silent downgrade). */
   private adjudicate(attempt: SubmitAttempt, draft: string): void {
     const inputTriggers = this.deps.inputTriggers?.()

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

@@ -125,6 +125,12 @@ export class InputMachine {
     readonly attempt: SubmitAttempt
     readonly controller: AbortController
   } | undefined
+  /** Detached default-sink sends by attempt seq: the composer already committed; settlement only restores on failure. */
+  private readonly detached = new Map<number, {
+    readonly controller: AbortController
+    readonly draftSnapshot: string
+    readonly occurrences: readonly Occurrence[]
+  }>()
   private log: Transaction[] = []
   private redoStack: Transaction[] = []
   /** Open single-char typing run: the next contiguous char within the window coalesces. */
@@ -186,6 +192,7 @@ export class InputMachine {
       case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
       case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
       case 'submit-settled': return this.onSubmitSettled(ev)
+      case 'sink-settled': return this.onSinkSettled(ev)
       case 'send-committed': return this.onSendCommitted()
       case 'release': return this.onRelease()
       default: return unreachable(ev)
@@ -480,6 +487,30 @@ export class InputMachine {
     return attempt
   }
 
+  /**
+   * Detach one default send and commit the composer clear in the same
+   * transaction: the draft, occurrence table, and undo history go now (a sent
+   * draft must not resurrect through Ctrl/Cmd-Z), while the snapshots ride
+   * the detached record so a failed settlement can restore an untouched
+   * composer. The phase stays 'plain' — typing and further sends continue
+   * during the flight.
+   */
+  private detachSink(attempt: SubmitAttempt, controller: AbortController): InputEffect {
+    const occurrences = this.occurrences
+    this.detached.set(attempt.seq, { controller, draftSnapshot: attempt.draftSnapshot, occurrences })
+    this.phase = 'plain'
+    this.claim = undefined
+    if (this.draft === attempt.draftSnapshot) {
+      this.occurrences = []
+      this.adopt('')
+      this.log = []
+      this.redoStack = []
+    }
+    this.typingRun = undefined
+    this.paste = undefined
+    return { type: 'default-sink', attempt, draft: attempt.draftSnapshot, occurrences, mode: attempt.mode }
+  }
+
   private onEnter(mode: InputSubmitMode): InputEffect[] {
     if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
     if (this.phase === 'claimed' && this.claim !== undefined) {
@@ -496,9 +527,10 @@ export class InputMachine {
       this.phase = 'adjudicating'
       return [{ type: 'adjudicate', attempt, draft: this.draft }]
     }
-    const attempt = this.beginAttempt(mode)
-    this.phase = 'submitting'
-    return [{ type: 'default-sink', attempt, draft: this.draft, mode }]
+    const controller = new AbortController()
+    this.seq += 1
+    const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft, mode }
+    return [this.detachSink(attempt, controller)]
   }
 
   private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
@@ -517,13 +549,8 @@ export class InputMachine {
     // 'handled' (source dealt internally), {insert} (no enter-time span
     // semantics), or a miss: all land plain; only the miss flows to the sink.
     if (outcome === undefined) {
-      this.phase = 'submitting'
-      return [{
-        type: 'default-sink',
-        attempt,
-        draft: attempt.draftSnapshot,
-        mode: attempt.mode,
-      }]
+      this.inflight = undefined
+      return [this.detachSink(attempt, flight.controller)]
     }
     this.inflight = undefined
     this.phase = 'plain'
@@ -577,6 +604,31 @@ export class InputMachine {
     return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
   }
 
+  /**
+   * Settle one detached default send. Success has nothing left to commit (the
+   * clear happened at enter); failure restores the enter-time draft and
+   * occurrence table, but only into a still-untouched composer — an empty
+   * plain draft — so content typed during the flight always wins.
+   */
+  private onSinkSettled(ev: Extract<InputEvent, { type: 'sink-settled' }>): InputEffect[] {
+    const record = this.detached.get(ev.attempt.seq)
+    if (record === undefined) return []
+    this.detached.delete(ev.attempt.seq)
+    if (ev.ok) {
+      return ev.outcome?.text !== undefined
+        ? [{ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }]
+        : []
+    }
+    if (this.phase === 'plain' && this.draft === '') {
+      this.occurrences = record.occurrences
+      this.adopt(record.draftSnapshot)
+      this.typingRun = undefined
+      this.paste = undefined
+    }
+    const text = ev.message ?? ev.outcome?.text
+    return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
+  }
+
   /** Cut undo state after an accepted image-only send. */
   private onSendCommitted(): InputEffect[] {
     if (this.phase !== 'plain') return []
@@ -595,6 +647,8 @@ export class InputMachine {
       this.inflight.controller.abort()
       this.inflight = undefined
     }
+    for (const record of this.detached.values()) record.controller.abort()
+    this.detached.clear()
     this.phase = 'plain'
     this.claim = undefined
     this.typingRun = undefined

+ 100 - 10
packages/client/ui-conversation/src/client/service.ts

@@ -9,11 +9,13 @@
  */
 import { Service } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
-import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto'
+import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
 // Type-only imports: a plugin-to-plugin value import is a bundle purity
 // error, so scope resolution goes through the sessions service (scopeOf
 // method) instead of the standalone helper.
-import type { ISessions, SessionFace } from '@deepseek-ai/dsh-api-session-controller/client'
+import type {
+  ISessions, PendingSubmissionRetirement, SessionFace,
+} 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'
@@ -72,6 +74,48 @@ function browserDraftAttachment(file: File): ComposerAttachment {
   }
 }
 
+/**
+ * Fill the draft's intrinsic dimensions once the browser parses the image
+ * header (a metadata read off the preview URL, not a full decode). Failures
+ * and non-browser runtimes leave them absent — consumers size those images
+ * from CSS constraints instead.
+ */
+function probeDimensions(attachment: ComposerAttachment): void {
+  if (typeof Image !== 'function') return
+  const probe = new Image()
+  probe.onload = () => {
+    attachment.width = probe.naturalWidth
+    attachment.height = probe.naturalHeight
+  }
+  probe.src = attachment.previewUrl
+}
+
+/** Resolve after the browser paints the frame in which a just-published submission echo renders. */
+function nextPaint(): Promise<void> {
+  return new Promise((resolve) => {
+    if (typeof requestAnimationFrame === 'function') {
+      requestAnimationFrame(() => { setTimeout(resolve, 0) })
+    } else {
+      setTimeout(resolve, 0)
+    }
+  })
+}
+
+/** Native canonical base64 of one browser file (FileReader data-URL encode; no main-thread byte loop). */
+function base64Of(file: File): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onload = () => {
+      const url = reader.result as string
+      resolve(url.slice(url.indexOf(',') + 1))
+    }
+    reader.onerror = () => {
+      reject(reader.error ?? new Error('conversation: image read failed'))
+    }
+    reader.readAsDataURL(file)
+  })
+}
+
 /** Unsupported browser-declared image type, localized by the UI boundary. */
 export class UnsupportedImageMediaTypeError extends Error {
   /** Browser-declared MIME value, possibly empty. */
@@ -125,7 +169,12 @@ export class ConversationController extends Service implements IConversation {
   }
 
   /**
-   * Submit ordered draft images with text through one host admission.
+   * Submit ordered draft images 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.
    * @param session - target session.
    * @param text - serialized prompt text.
    * @param imageIds - ordered draft-local attachment ids.
@@ -144,12 +193,27 @@ export class ConversationController extends Service implements IConversation {
     if (attachments.length !== imageIds.length) {
       throw new Error('conversation.sendSession: one or more draft images are no longer available')
     }
-    const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file))
-    const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
-    const result = await session.prompt(content, mode, signal)
-    if (!result.ok) return { kind: 'error' }
-    this.releaseDraftImages(attachments)
-    return { kind: 'success' }
+    const submission = session.beginSubmission({
+      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 }),
+      })),
+      onRetire: (retirement) => { this.settleSubmittedImages(session.sessionId, attachments, retirement) },
+    })
+    let content: Parameters<SessionFace['prompt']>[0]
+    try {
+      await nextPaint()
+      const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file))
+      content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
+    } catch (error) {
+      submission.abandon()
+      throw error
+    }
+    const result = await session.prompt(content, mode, signal, submission.requestId)
+    return result.ok ? { kind: 'success' } : { kind: 'error' }
   }
 
   /**
@@ -162,6 +226,7 @@ export class ConversationController extends Service implements IConversation {
     return files.map((file) => {
       const attachment = browserDraftAttachment(file)
       this.draftAttachments.set(attachment.id, attachment)
+      probeDimensions(attachment)
       return attachment
     })
   }
@@ -264,6 +329,31 @@ export class ConversationController extends Service implements IConversation {
     return sessions
   }
 
+  /**
+   * Settle one submission's draft images 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 without a byte round-trip) 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(
+    sessionId: SessionId,
+    attachments: readonly ComposerAttachment[],
+    retirement: PendingSubmissionRetirement,
+  ): void {
+    if (retirement.reason !== 'observed') return
+    const uiConversation = this.ctx.get('uiConversation')
+    attachments.forEach((attachment, index) => {
+      const live = this.draftAttachments.get(attachment.id)
+      if (live === undefined) return
+      this.draftAttachments.delete(attachment.id)
+      const ref = retirement.attachments[index]
+      if (ref !== undefined && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) return
+      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) })))
@@ -273,7 +363,7 @@ export class ConversationController extends Service implements IConversation {
   private async encodeImage(file: File): Promise<SubmitImageAttachment> {
     return {
       mediaType: imageMediaType(file.type),
-      data: bytesToBase64(new Uint8Array(await file.arrayBuffer())),
+      data: await base64Of(file),
       ...(file.name === '' ? {} : { name: file.name }),
     }
   }

+ 8 - 4
packages/client/ui-conversation/tests/apply-inject.client.spec.tsx

@@ -102,10 +102,14 @@ describe('Conversation inject API', () => {
 
     actions.setDraft('hello')
     actions.submit()
-    await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('') })
-    expect(b.sessionFake.prompt).toHaveBeenCalledWith(
-      [{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal),
-    )
+    // Optimistic commit clears the draft at enter; the prompt lands after the
+    // paint-yield inside the send pipeline.
+    expect(state.getSnapshot().draft).toBe('')
+    await vi.waitFor(() => {
+      expect(b.sessionFake.prompt).toHaveBeenCalledWith(
+        [{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal), expect.any(String),
+      )
+    })
 
     b.sessionFake.prompt.mockResolvedValueOnce({
       ok: false, error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } },

+ 1 - 0
packages/client/ui-conversation/tests/conversation-registry.client.spec.ts

@@ -21,6 +21,7 @@ function sessionSnapshot(): SessionSnapshot {
   return {
     sessionId: SESSION_ID,
     queue: [],
+    pendingSubmissions: [],
     running: false,
     subagent: null,
     removed: false,

+ 19 - 2
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -367,11 +367,28 @@ describe('image draft rail', () => {
     sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
     fireEvent.keyDown(textarea, { key: 'Enter' })
     expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal))
-    expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]])
+    // Optimistic commit: the rail clears at submit, before the admission settles.
+    expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
     await act(async () => { settle({ kind: 'success' }) })
+    expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
+  })
+
+  it('returns an image-only draft to the rail when its admission fails', async () => {
+    const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
+    const attachments = [
+      { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' },
+    ]
+    const result = bench({ attachments })
+    const { textarea, sink } = result
+    let fail!: (outcome: SubmitOutcome) => void
+    sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { fail = resolve }))
+    fireEvent.keyDown(textarea, { key: 'Enter' })
+    expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
+    await act(async () => { fail({ kind: 'error', text: '图片发送失败' }) })
     await vi.waitFor(() => {
-      expect(attachmentOwner(result.slotCalls).attachments).toEqual([])
+      expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]])
     })
+    expect(result.view.getByRole('alert').textContent).toContain('图片发送失败')
   })
 
   it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {

+ 32 - 11
packages/client/ui-conversation/tests/input-machine.client.spec.ts

@@ -71,13 +71,18 @@ describe('input-machine: plain × enter', () => {
     expect(m.state.phase).toBe('plain')
   })
 
-  it('non-command text falls to the default sink', () => {
+  it('non-command text falls to the default sink and commits the composer clear at enter', () => {
     const m = new InputMachine()
     m.dispatch({ type: 'draft-changed', draft: 'hello world' })
     const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
     expect(effect).toMatchObject({ draft: 'hello world', mode: 'queue' })
     expect(effect.attempt.draftSnapshot).toBe('hello world')
-    expect(m.state.phase).toBe('submitting')
+    // Optimistic commit: the send is detached — the composer is already
+    // cleared, unlocked, and un-undoable while the flight runs.
+    expect(m.state.phase).toBe('plain')
+    expect(m.state.draft).toBe('')
+    expect(m.dispatch({ type: 'undo' })).toEqual([])
+    expect(m.state.draft).toBe('')
   })
 
   it('retains an explicit steer mode on the default sink effect', () => {
@@ -134,7 +139,7 @@ describe('input-machine: adjudication outcomes', () => {
     expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x')
   })
 
-  it('undefined outcome falls back to the default sink', () => {
+  it('undefined outcome falls back to the default sink and commits the clear', () => {
     const m = new InputMachine()
     const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
     expect(effectAt(
@@ -142,7 +147,8 @@ describe('input-machine: adjudication outcomes', () => {
       0,
       'default-sink',
     )).toMatchObject({ attempt, draft: '/unknown thing', mode: 'steer' })
-    expect(m.state.phase).toBe('submitting')
+    expect(m.state.phase).toBe('plain')
+    expect(m.state.draft).toBe('')
   })
 
   it("'handled' lands plain with zero effects (popup shell path)", () => {
@@ -525,20 +531,35 @@ describe('input-machine: undo / redo', () => {
     expect(m.state.draft).toBe('')
   })
 
-  it('keeps a suffix typed during the round-trip and drops interleaved edits with the commit', () => {
+  it('text typed during the detached flight is the next draft and survives both settlements', () => {
     const m = new InputMachine()
     m.dispatch({ type: 'draft-changed', draft: 'hello' })
     const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
-    m.dispatch({ type: 'draft-changed', draft: 'hello world' })
-    m.dispatch({ type: 'submit-settled', attempt: effect.attempt, ok: true })
-    expect(m.state.draft).toBe(' world')
+    expect(m.state.draft).toBe('')
+    m.dispatch({ type: 'draft-changed', draft: 'world' })
+    m.dispatch({ type: 'sink-settled', attempt: effect.attempt, ok: true })
+    expect(m.state.draft).toBe('world')
 
+    // Failure with a non-empty composer keeps the typed content: the sent
+    // draft is NOT restored over it.
     const n = new InputMachine()
     n.dispatch({ type: 'draft-changed', draft: 'hello' })
     const second = effectAt(n.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
-    n.dispatch({ type: 'draft-changed', draft: 'hXello' })
-    n.dispatch({ type: 'submit-settled', attempt: second.attempt, ok: true })
-    expect(n.state.draft).toBe('')
+    n.dispatch({ type: 'draft-changed', draft: 'typed during flight' })
+    n.dispatch({ type: 'sink-settled', attempt: second.attempt, ok: false, message: 'boom' })
+    expect(n.state.draft).toBe('typed during flight')
+  })
+
+  it('a failed detached flight restores the sent draft and occurrences into an untouched composer', () => {
+    const m = new InputMachine()
+    m.dispatch({ type: 'draft-changed', draft: 'restore me' })
+    const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
+    expect(m.state.draft).toBe('')
+    const fx = m.dispatch({ type: 'sink-settled', attempt: effect.attempt, ok: false, message: 'boom' })
+    expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
+    expect(m.state.draft).toBe('restore me')
+    // A second settlement of the same attempt is a dropped stale event.
+    expect(m.dispatch({ type: 'sink-settled', attempt: effect.attempt, ok: false, message: 'again' })).toEqual([])
   })
 })
 

+ 3 - 2
packages/client/ui-conversation/tests/input-matrix.client.spec.tsx

@@ -114,8 +114,9 @@ describe('matrix row: plain', () => {
     expect(shell.snapshot.claim).toBeUndefined()
     fireEvent.keyDown(textarea, { key: 'Enter' })
     expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal))
-    expect(shell.snapshot.phase).toBe('submitting')
-    await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
+    // The detached default send never freezes the composer.
+    expect(shell.snapshot.phase).toBe('plain')
+    expect(shell.snapshot.draft).toBe('')
     expect(shell.snapshot.claim).toBeUndefined()
   })
 })

+ 13 - 7
packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts

@@ -96,9 +96,12 @@ describe('reference submission', () => {
     })
 
     shell.submit('queue')
-    expect(shell.snapshot.phase).toBe('submitting')
+    // Optimistic commit: the composer clears at enter and stays unlocked
+    // while the detached flight runs.
+    expect(shell.snapshot.phase).toBe('plain')
+    expect(shell.snapshot.draft).toBe('')
     await vi.waitFor(() => {
-      expect(shell.snapshot.phase).toBe('plain')
+      expect(shell.snapshot.draft).toBe('@Research ')
     })
     expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal))
     expect(shell.snapshot).toMatchObject({
@@ -111,10 +114,10 @@ describe('reference submission', () => {
     })
 
     shell.submit('queue')
+    expect(shell.snapshot.draft).toBe('')
     await vi.waitFor(() => {
-      expect(shell.snapshot.draft).toBe('')
+      expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal))
     })
-    expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal))
     expect(shell.snapshot.occurrences).toEqual([])
     expect(serializeReference).toHaveBeenCalledTimes(2)
   })
@@ -133,11 +136,12 @@ describe('reference submission', () => {
     })
     chip(shell)
     shell.submit()
+    // The serializer rejection restores the committed draft and chip into the
+    // still-untouched composer.
     await vi.waitFor(() => {
-      expect(shell.snapshot.phase).toBe('plain')
+      expect(shell.snapshot.draft).toBe('@Research ')
     })
     expect(sink).not.toHaveBeenCalled()
-    expect(shell.snapshot.draft).toBe('@Research ')
     expect(shell.snapshot.occurrences).toHaveLength(1)
     expect(shell.notices.getSnapshot()).toMatchObject({
       level: 'error',
@@ -161,7 +165,9 @@ describe('reference submission', () => {
     shell.dispose()
     expect(signal?.aborted).toBe(true)
     expect(shell.snapshot.phase).toBe('plain')
-    expect(shell.snapshot.draft).toBe('send this')
+    // The optimistic commit stands: disposal drops the settlement, so the
+    // sent draft is not restored into the dying composer.
+    expect(shell.snapshot.draft).toBe('')
   })
 
   it('retains a rejected default message without duplicating its prompt error notice', async () => {