diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml
new file mode 100644
index 0000000000..7a3d710fe7
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md
+2026-08-26-local-submission-echoes.md: 2151ebeb44f096e343aba88133495fbc1e743eb4
+2026-08-26-local-submission-echoes.zh.md: 052ca219164724d2bf687c3c728a7ea1581401cf
diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md
new file mode 100644
index 0000000000..2151ebeb44
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md
@@ -0,0 +1,37 @@
+# Agent Note: Local submission echoes over the prompt rpcId
+
+Status: implemented
+
+English | [中文](2026-08-26-local-submission-echoes.zh.md)
+
+## Problem
+
+A multi-image prompt spent seconds in client serialization plus host admission before its durable `user/message` existed, and the conversation showed nothing until then: the composer froze read-only, the message appeared only after the full pipeline, and the user could not tell whether the submission had started (#3003). The durable event cannot move earlier — Model-visible ⟺ logged requires the `user/message` to land only after every attachment persists — so the visible submission had to decouple from the durable one.
+
+## Decision
+
+**The Session object owns a client-local submission echo, correlated by the prompt's existing `requestId`/`rpcId`.** `session.beginSubmission` synchronously inserts `{requestId, text, images: previews}` into `SessionSnapshot.pendingSubmissions` and flips `promptAttempted`, before the caller serializes anything; the same `requestId` rides the prompt RPC. No new correlation id, no wire-type change, and no session-log change: the host already stamps the prompt's `requestId` into the durable user source as `rpcId`, and the queue projection now carries it as `SessionQueuedItem.rpcId` for prompts that land in the inbox instead of the log (running-turn submissions).
+
+**Retirement is observation-driven with a one-frame delay; display dedupe is render-time and declarative.** The Session marks an echo observed when a durable `user/message` or queue occurrence with its rpcId arrives (append, window install, or control frame) and removes it one animation frame later — after the conversation assembly's frame, which was scheduled first. ChatView independently hides any echo whose rpcId appears among rendered user/steering nodes or queue rows, so within every render exactly one of echo/durable is visible regardless of store update order. An identified prompt failure, `abandon()`, or disposal retires the echo immediately as failed; the first settlement wins.
+
+**The composer commits optimistically.** Enter clears the draft, occurrence table, and undo history in one machine transaction and keeps phase `plain`; the send runs as a detached attempt (concurrent sends allowed; the single frozen in-flight slot remains command-only). A failed settlement restores the sent draft, occurrences, and image ids only into a still-empty plain composer — content typed during the flight always wins. Draft images stay registered until the echo retires: failed → available for rail restore; observed → each hands its object URL to `HistoricalImageCache.seed` under the admitted reference (URL ownership and scope-bound revocation move to the cache) so the durable node renders without a byte round-trip or loading flash.
+
+Client image encoding switched from the synchronous chunked-`btoa` loop to `FileReader.readAsDataURL` (native encode). The browser→host transport still ships one base64 JSON envelope; that remaining #2885 transport work is out of scope here.
+
+## Consequences
+
+The submit click paints its message and docks the composer on the same frame, for text and image prompts alike, while admission timing is unchanged. The composer never freezes for default sends, so drafts can be typed and sent during a flight; the machine's `submitting` phase now occurs only for command submissions. A prompt whose RPC response is lost but whose admission succeeded converges through observation instead of double-posting. Echo previews pin the original image blobs until the durable bytes would be fetched anyway; seeded cache entries keep the original (not the normalized) rendition for the session scope's lifetime, which trades some memory for zero-flash replacement.
+
+## Verification
+
+Session client specs pin synchronous insertion, requestId threading, event/queue/window observation, frame-delayed removal, first-settlement-wins, abandon, and disposal. Machine and shell specs pin the optimistic commit, detached settlement, untouched-composer restore, and image-only rail restore. ChatView specs pin flow-tail rendering, node- and queue-keyed dedupe with the echo still in the snapshot, and preview handoff through the message-image slot. Host control specs pin the queue rpcId projection; cache specs pin seed adoption, exclusivity, and scope revocation. The connection fixture echoes `requestId`, so assembled web replays exercise the same retirement.
+
+## Alternatives considered
+
+**A new `clientSubmissionId` threaded through the wire and the user source.** Rejected: `requestId` already exists end-to-end (`user-rpc` source member), so a second id would duplicate the correlation and touch wire validation for nothing.
+
+**Retire the echo synchronously on event ingestion.** Rejected: the chat assembly publishes on an animation frame, so synchronous removal blanks the message for a frame. The steering queue mirror historically accepted that race; the echo path removes it via render-time dedupe plus the delayed retirement.
+
+**Render echoes through the conversation assembler as synthetic nodes.** Rejected: the assembler is driven by durable session events only, and a client-only node kind would widen the closed `ConversationNode` union into every target's `assertNever`; the `PartialAssistant`-style side-channel state matches the existing precedent.
+
+**Keep the composer frozen and only add the echo.** Rejected: the issue's acceptance requires consecutive and concurrent submissions, and a frozen composer reintroduces the perceived hang the echo exists to remove.
diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md
new file mode 100644
index 0000000000..052ca21916
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md
@@ -0,0 +1,37 @@
+# Agent Note:基于 prompt rpcId 的本地提交回显
+
+状态:implemented
+
+[English](2026-08-26-local-submission-echoes.md) | 中文
+
+## 问题
+
+多图 prompt 在客户端序列化加 host admission 上要花数秒,durable `user/message` 在此之前不存在,会话在此期间什么都不显示:composer 冻结为只读,消息在整条流水线结束后才出现,用户无法判断提交是否已经开始(#3003)。durable event 无法提前,Model-visible ⟺ logged 要求 `user/message` 只能在全部附件持久化后落盘,因此可见的提交必须与 durable 的提交解耦。
+
+## 决定
+
+**Session 对象持有客户端本地的提交回显,用 prompt 现有的 `requestId`/`rpcId` 关联。**`session.beginSubmission` 在调用方序列化任何内容之前,同步把 `{requestId, text, images: previews}` 写入 `SessionSnapshot.pendingSubmissions` 并翻转 `promptAttempted`;同一个 `requestId` 随 prompt RPC 发出。没有新关联 id,没有 wire 类型改动,也没有 session log 改动:host 本就把 prompt 的 `requestId` 写进 durable user source 的 `rpcId`,queue 投影现在把它作为 `SessionQueuedItem.rpcId` 携带,覆盖落进 inbox 而非 log 的 prompt(运行中 turn 的提交)。
+
+**退休由观察驱动并延迟一帧;显示去重是渲染期的声明式规则。**Session 在带其 rpcId 的 durable `user/message` 或 queue occurrence 到达时(append、窗口安装或 control frame)标记回显为已观察,并在一个动画帧之后移除,晚于先注册的会话组装帧。ChatView 独立地隐藏 rpcId 出现在已渲染 user/steering 节点或 queue 行中的回显,因此无论 store 更新顺序如何,每一次渲染中回显与 durable 恰有一个可见。带标识的 prompt 失败、`abandon()` 或销毁使回显立即按 failed 退休;先到的 settlement 生效。
+
+**Composer 乐观提交。**Enter 在一个 machine 事务里清空草稿、occurrence 表和撤销历史,phase 保持 `plain`;发送作为 detached attempt 运行(允许并发发送,唯一的冻结 in-flight 槽只留给命令)。失败的 settlement 只把已发送的草稿、occurrence 和图片 id 还原进仍为空的 plain composer,飞行期间输入的内容始终优先。草稿图片保持注册直到回显退休:failed 时可供 rail 还原;observed 时逐张把 object URL 通过 `HistoricalImageCache.seed` 挂到 admitted 引用名下(URL 所有权与随 scope 的回收移交缓存),durable 节点因此无需字节往返即可渲染,没有加载闪烁。
+
+客户端图片编码从同步分块 `btoa` 循环换成 `FileReader.readAsDataURL`(原生编码)。browser→host 传输仍是一个 base64 JSON 整包;#2885 剩余的传输改造不在本决定范围内。
+
+## 后果
+
+点击提交在当帧画出消息并让 composer 落底,文本与图片 prompt 一致,admission 时机不变。默认发送不再冻结 composer,飞行期间可以继续输入和发送;machine 的 `submitting` 阶段只在命令提交时出现。RPC 响应丢失但 admission 已成功的 prompt 通过观察收敛,不会重复发送。回显预览会固定原始图片 blob,直到 durable 字节本来也要被拉取为止;seed 进缓存的条目在 session scope 生命周期内保留原图而非归一化版本,用一些内存换零闪烁替换。
+
+## 验证
+
+Session client spec 钉住同步插入、requestId 透传、event/queue/窗口观察、延帧移除、先到 settlement 生效、abandon 与销毁。Machine 与 shell spec 钉住乐观提交、detached settlement、未触碰 composer 的还原和图片纯发送的 rail 还原。ChatView spec 钉住流尾渲染、回显仍在 snapshot 时按节点与队列的去重,以及经 message-image slot 的预览移交。Host control spec 钉住 queue rpcId 投影;缓存 spec 钉住 seed 的接管、排他与 scope 回收。connection fixture 回显 `requestId`,组装 web 回放具备同样的退休语义。
+
+## 考虑过的替代方案
+
+**新增 `clientSubmissionId` 贯穿 wire 与 user source。**否决:`requestId` 已端到端存在(`user-rpc` source 成员),第二个 id 会重复关联并平白触碰 wire 校验。
+
+**事件入库时同步移除回显。**否决:会话组装按动画帧发布,同步移除会让消息空一帧。steering 队列镜像历史上接受了这个竞态;回显路径用渲染期去重加延帧退休消除它。
+
+**把回显作为合成节点走会话 assembler。**否决:assembler 只由 durable session event 驱动,客户端专属的节点 kind 会把闭合的 `ConversationNode` 联合扩进每个 target 的 `assertNever`;`PartialAssistant` 式的旁路状态符合现有先例。
+
+**保持 composer 冻结,只加回显。**否决:issue 验收要求连续与并发提交,冻结的 composer 会重新引入回显本要消除的卡顿感。
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 3325cc86a4..4f66278d0d 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: 815276847f538f99352e0f0e55fc19af5da67470
-README.zh.md: 51bf5a98c62aaa3fcb2156c029416a4d26515f0b
+README.md: dc04e595c9c4bf029ed427d5a1214802041c11c0
+README.zh.md: b34c8ad018e4e2c0d95ac0b8aa1954742f9ecfa4
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index 815276847f..dc04e595c9 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -29,6 +29,8 @@ Each endpoint states its activation policy. List, search, attachment, history pa
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
+The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The prompt's `requestId` is the correlation identity — the Host already echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the transcript node is), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only — reload and reconnect rebuild the conversation from durable events alone.
+
-----
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index 51bf5a98c6..b34c8ad018 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -29,6 +29,8 @@ kind: "package-reference"
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
+Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。prompt 的 `requestId` 就是关联标识,Host 本就把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休(该延迟保证 transcript 节点可渲染之前回显仍在),带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存,刷新与重连只从 durable event 重建会话。
+
-----
diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts
index 9ce0c453a0..e3fe868c89 100644
--- a/packages/api/session-controller/tests/control-queue.host.spec.ts
+++ b/packages/api/session-controller/tests/control-queue.host.spec.ts
@@ -69,6 +69,30 @@ describe('Session control queue projection', () => {
await iterator.next()
})
+ it('projects the prompt rpcId from a user-rpc source and omits it elsewhere', async () => {
+ const { control, inbox } = await harness()
+ const identified = createUserMessage({
+ content: [{ type: 'text', text: 'browser prompt' }],
+ source: { kind: 'user', rpcId: 'req-42' as never },
+ })
+ inbox.append('next-turn', identified)
+ inbox.append('next-step', message('plain steering'))
+
+ const abort = new AbortController()
+ const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
+ const opened = await iterator.next()
+ if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline')
+ const items = opened.value.value.queues['queue-session' as SessionId] ?? []
+ expect(items).toMatchObject([
+ { id: identified.id, placement: 'queued', rpcId: 'req-42' },
+ { id: expect.anything(), placement: 'steering' },
+ ])
+ expect('rpcId' in (items[1] ?? {})).toBe(false)
+
+ abort.abort()
+ await iterator.next()
+ })
+
it('ignores inbox events without the exact live Agent session', async () => {
const { ctx, control, agent, inbox } = await harness()
const abort = new AbortController()
diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml
index 7378631ca9..5e0e50e06a 100644
--- a/packages/client/ui-attachment/README.i18n.yaml
+++ b/packages/client/ui-attachment/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md
-README.md: 3e9240f5f2f68dbc9f709ee7e720bb48053ba3b5
-README.zh.md: 0a450d2bc78f183847b945c73107c1466fb3ba0e
+README.md: 9fa03432b43686dd55af494640708a8bf0983f9a
+README.zh.md: 48e467280bfb83b2f4341f0e4c833b0b44cdce18
diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md
index 3e9240f5f2..9fa03432b4 100644
--- a/packages/client/ui-attachment/README.md
+++ b/packages/client/ui-attachment/README.md
@@ -54,7 +54,7 @@ The plugin waits for `conversation.input.attachments`, `conversation.message.ima
| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | Draft-image rail + drop overlay assembly |
| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Scrolling thumbnail rail, wheel translation, edge arrows |
| [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | Per-message gallery + lightbox assembly |
-| [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open |
+| [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open; local submission-echo previews render their object URL directly |
| [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | Document-level modal preview over the shared mask |
| [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | Pointer-inert drag invitation portal |
diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md
index 0a450d2bc7..48e467280b 100644
--- a/packages/client/ui-attachment/README.zh.md
+++ b/packages/client/ui-attachment/README.zh.md
@@ -54,7 +54,7 @@ kind: "package-reference"
| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | 草稿图片栏+拖放遮罩的组装 |
| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 滚动缩略图栏、滚轮转换、边缘箭头 |
| [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | 每消息画廊+灯箱的组装 |
-| [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开 |
+| [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开;本地提交回显预览直接显示其 object URL |
| [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | 铺在共享遮罩上的文档级模态预览 |
| [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | 不接收指针事件的拖拽邀请 portal |
diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx
index 5972beacdb..64d1eea37e 100644
--- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx
+++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx
@@ -152,6 +152,45 @@ describe('MessageImage', () => {
})
})
+describe('MessageImage preview arm', () => {
+ it('displays a local preview immediately, without the loader, sized by its probed dimensions', () => {
+ const load = vi.fn()
+ const view = render(
+ ,
+ )
+ expect(load).not.toHaveBeenCalled()
+ const img = view.getByAltText('echo.png') as HTMLImageElement
+ expect(img.src).toContain('blob:echo')
+ const frame = img.closest('button') as HTMLButtonElement
+ expect(frame.style.width).toBe('240px')
+ expect(frame.style.height).toBe('120px')
+ })
+
+ it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => {
+ const load = vi.fn()
+ const view = render(
+ ,
+ )
+ const img = view.getByAltText('图片') as HTMLImageElement
+ const frame = img.closest('button') as HTMLButtonElement
+ expect(frame.style.width).toBe('240px')
+ expect(frame.style.height).toBe('240px')
+ })
+
+ it('opens the lightbox from a preview thumbnail', () => {
+ const view = render(
+ ,
+ )
+ fireEvent.click(view.getByRole('button', { name: '图片,点击查看原图' }))
+ expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
+ })
+})
+
describe('ImageGallery', () => {
it('renders nothing without images and an aligned wrapping group with them', async () => {
const load = vi.fn().mockResolvedValue('blob:gallery')
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index dbcc134300..9146293925 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/packages/client/ui-chat/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: b44c061aa9af8f58dbbae212c72fa55e186e4564
-README.zh.md: b339fea3835f0be0e6e07ec93577c4f11c80c1b7
+README.md: a48413b7d6ef95d23ff854db125cb2757afd93da
+README.zh.md: e64d55f754392867820fd9b4d8fa2593aa84fc23
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index b44c061aa9..a48413b7d6 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
## Summary
-The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members.
+The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. The flow tail renders the session's local submission echoes (`SessionSnapshot.pendingSubmissions`) with the same bubble as their eventual durable user nodes, hidden per render once a user/steering node or queue occurrence carries the echo's prompt `rpcId`, so the echo-to-durable swap is atomic.
## Table of Contents
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index b339fea383..e64d55f754 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -8,7 +8,7 @@ kind: "package-reference"
## 概述
-Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。
+Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。消息流尾部渲染 session 的本地提交回显(`SessionSnapshot.pendingSubmissions`),气泡与其最终的 durable user 节点一致;一旦某个 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此回显到 durable 的替换是原子的。
## 目录
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index 094eeea06e..a0720a90fa 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -661,6 +661,98 @@ describe('ChatView', () => {
expect(view.container.querySelectorAll('[data-pending-steering]')).toHaveLength(1)
})
+ it('renders local submission echoes at the flow tail and swaps atomically with the durable node', () => {
+ const h = makeHarness(
+ { nodes: [assistant(1, 'working')] },
+ {
+ pendingSubmissions: [
+ { requestId: 'req-1' as never, time: 5_000, text: '即发即显', images: [] },
+ ],
+ },
+ )
+ const view = render()
+ expect(view.getByText('即发即显')).toBeTruthy()
+
+ // The durable node arrives while the echo is STILL in the session
+ // snapshot: the render-time rpcId dedupe keeps exactly one bubble.
+ act(() => {
+ h.setChat({
+ nodes: [
+ assistant(1, 'working'),
+ {
+ kind: 'user', seq: 2, time: 2_000,
+ content: [{ type: 'text', text: '即发即显' }] as never,
+ source: { kind: 'user', rpcId: 'req-1' } as never,
+ },
+ ],
+ })
+ })
+ expect(view.getAllByText('即发即显')).toHaveLength(1)
+
+ // The delayed snapshot retirement changes nothing visible.
+ act(() => { h.setSession({ pendingSubmissions: [] }) })
+ expect(view.getAllByText('即发即显')).toHaveLength(1)
+ })
+
+ it('hides an echo once its queue occurrence carries the rpcId (running-turn submission)', () => {
+ const h = makeHarness(
+ { nodes: [assistant(1, 'working')] },
+ {
+ running: true,
+ pendingSubmissions: [
+ { requestId: 'req-q' as never, time: 6_000, text: '排队中', images: [] },
+ ],
+ },
+ )
+ const view = render()
+ expect(view.getByText('排队中')).toBeTruthy()
+ act(() => {
+ h.setSession({
+ queue: [{
+ id: 'q-occurrence' as never,
+ messageId: 'q-message' as never,
+ placement: 'queued' as const,
+ rpcId: 'req-q' as never,
+ content: [{ type: 'text' as const, text: '排队中' }],
+ preview: '排队中',
+ text: '排队中',
+ }],
+ })
+ })
+ // The queued occurrence renders in the queue dock, not the flow; the
+ // flow-tail echo yields to it in the same snapshot.
+ expect(view.queryByText('排队中')).toBeNull()
+ })
+
+ it('an image echo renders its previews through the message-image slot', () => {
+ const h = makeHarness(
+ { nodes: [] },
+ {
+ pendingSubmissions: [{
+ requestId: 'req-img' as never,
+ time: 7_000,
+ text: '',
+ images: [
+ { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
+ { previewUrl: 'blob:echo-b' },
+ ],
+ }],
+ },
+ )
+ const baseRenderSlot = h.props.renderSlot
+ const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
+ if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never)
+ const images = (owner as { images: readonly unknown[] }).images
+ return
+ }) as unknown as ChatViewSlotProps['renderSlot']
+ const view = render()
+ const gallery = view.getByTestId('echo-images')
+ expect(gallery.getAttribute('data-count')).toBe('2')
+ expect(JSON.parse(gallery.getAttribute('data-first') ?? '{}')).toEqual({
+ preview: { url: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
+ })
+ })
+
it('animates only the latest unresolved model retry', () => {
const retryNode = retry(2)
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 122fcc3d5d..bf29a9bd0f 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 276f4126328ab26f610e8a59bc18e43283290296
-README.zh.md: 3b3e2135625e521bc4362de657d259222cb3d09b
+README.md: f0669403f90d2283a0ae521b569955978cdaf94a
+README.zh.md: 9341772e1148880a3caa4fa9443fa8a2cf7e9880
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 276f412632..f0669403f9 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -40,6 +40,8 @@ View selection is deterministic: a registered persisted selection wins, otherwis
The resident composer survives no-Session and Session transitions. The no-Session state keeps the same textarea mounted but inert while the Workspace picker connects a blank Session. Draft text is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
+Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) before serializing, yields one paint so the echo renders on the click's own frame, and encodes images through the browser's native `FileReader` data-URL path. A failed send restores the sent draft, references, and image ids only into a still-untouched empty composer; command submissions keep the frozen `submitting` phase. When an echo retires as observed, its draft previews hand their object URLs to the durable image cache (`seedImageUrl`) so the transcript node displays without a byte round-trip.
+
While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)).
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 3b3e213562..9341772e11 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -40,6 +40,8 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是
常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个 textarea 保持 inert,Workspace picker 连接 blank Session;草稿文本镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
+默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,飞行期间可以继续输入和继续发送。`sendSession` 在序列化之前注册 Session 提交回显(`session.beginSubmission`),让出一帧使回显在点击当帧渲染,图片经浏览器原生 `FileReader` data-URL 路径编码。发送失败只把已发送的草稿、引用和图片 id 还原进仍未被触碰的空 composer;命令提交保持冻结的 `submitting` 阶段。回显以 observed 退休时,草稿预览把 object URL 移交 durable 图片缓存(`seedImageUrl`),transcript 节点无需字节往返即可显示。
+
普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。
diff --git a/packages/client/ui-conversation/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts
index 71aa3d6018..7b44cd2016 100644
--- a/packages/client/ui-conversation/tests/historical-images.client.spec.ts
+++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts
@@ -25,4 +25,41 @@ describe('HistoricalImageCache', () => {
await expect(pending).rejects.toThrow('ui-conversation image scope was released before loading completed')
await runtime.dispose()
})
+
+ it('adopts a seeded URL, reuses it for later resolves, and revokes it with the Session scope', async () => {
+ const revoked: string[] = []
+ const originalRevoke = URL.revokeObjectURL
+ URL.revokeObjectURL = (url: string) => { revoked.push(url) }
+ try {
+ const runtime = await SlotTestRuntime.create()
+ const sessionId = await runtime.sessions.add({ id: 's1', session: {} })
+ const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions)
+ const attachment = {
+ attachmentId: AttachmentId('image-seeded'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
+ } as const
+
+ expect(cache.seed(sessionId, attachment, 'blob:seeded')).toBe(true)
+ // Ownership is exclusive: a second seed of the same reference refuses,
+ // and resolve() serves the adopted URL without a byte round-trip.
+ expect(cache.seed(sessionId, attachment, 'blob:duplicate')).toBe(false)
+ await expect(cache.resolve(sessionId, attachment)).resolves.toBe('blob:seeded')
+
+ await runtime.sessions.remove(sessionId)
+ await Promise.resolve()
+ expect(revoked).toContain('blob:seeded')
+ await runtime.dispose()
+ } finally {
+ URL.revokeObjectURL = originalRevoke
+ }
+ })
+
+ it('refuses to seed for an unknown session', async () => {
+ const runtime = await SlotTestRuntime.create()
+ const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions)
+ const attachment = {
+ attachmentId: AttachmentId('image-unknown'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
+ } as const
+ expect(cache.seed('missing' as never, attachment, 'blob:orphan')).toBe(false)
+ await runtime.dispose()
+ })
})
diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
index 4633d07e20..8950cf5945 100644
--- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
+++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
@@ -131,6 +131,183 @@ describe('ConversationController', () => {
})
})
+describe('sendSession submission echo', () => {
+ /** Bench with an observable beginSubmission on the session face. */
+ async function echoBench() {
+ const b = await bench()
+ const retire: { onRetire?: (retirement: unknown) => void } = {}
+ const abandon = vi.fn()
+ const beginSubmission = vi.fn((input: { onRetire?: (retirement: unknown) => void }) => {
+ retire.onRetire = input.onRetire
+ return { requestId: 'req-echo' as never, abandon }
+ })
+ await b.runtime.sessions.updateSessionSnapshot('s1', () => {})
+ const face = b.runtime.sessions.binding('s1')!.session as unknown as Record
+ face['beginSubmission'] = beginSubmission
+ const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:echo-1')
+ const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
+ const restore = () => {
+ created.mockRestore()
+ revoked.mockRestore()
+ }
+ return { ...b, beginSubmission, abandon, retire, revoked, restore }
+ }
+
+ it('registers the echo before serialization and prompts with its identity', async () => {
+ const b = await echoBench()
+ try {
+ const [attachment] = b.root.createDraftImages([
+ new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }),
+ ])
+ const session = b.runtime.sessions.binding('s1')!.session
+ const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue')
+ // Synchronous: the echo is registered before any encoding starts.
+ expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({
+ text: '带图',
+ images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })],
+ }))
+ expect(b.prompt).not.toHaveBeenCalled()
+ await expect(sending).resolves.toEqual({ kind: 'success' })
+ expect(b.prompt).toHaveBeenCalledWith(
+ [
+ { type: 'image', mediaType: 'image/png', data: expect.any(String), name: 'a.png' },
+ { type: 'text', text: '带图' },
+ ],
+ 'queue',
+ undefined,
+ 'req-echo',
+ )
+ // The draft stays registered until the echo's observed retirement.
+ expect(b.root.draftImages([attachment!.id])).toHaveLength(1)
+ b.retire.onRetire?.({ reason: 'observed', attachments: [] })
+ expect(b.root.draftImages([attachment!.id])).toEqual([])
+ expect(b.revoked).toHaveBeenCalledWith('blob:echo-1')
+ } finally {
+ b.restore()
+ }
+ await b.runtime.dispose()
+ })
+
+ it('hands the preview URL to the image cache on observed retirement instead of revoking it', async () => {
+ const b = await echoBench()
+ try {
+ const seedImageUrl = vi.fn(() => true)
+ b.runtime.ctx.provide('uiConversation')
+ b.runtime.ctx.set('uiConversation', { seedImageUrl })
+ const [attachment] = b.root.createDraftImages([
+ new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }),
+ ])
+ const session = b.runtime.sessions.binding('s1')!.session
+ await b.root.sendSession(session, '', [attachment!.id], 'queue')
+ const ref = { attachmentId: 'att-1' }
+ b.retire.onRetire?.({ reason: 'observed', attachments: [ref] })
+ expect(seedImageUrl).toHaveBeenCalledWith('s1', ref, 'blob:echo-1')
+ expect(b.root.draftImages([attachment!.id])).toEqual([])
+ expect(b.revoked).not.toHaveBeenCalled()
+ // Failed retirement keeps nothing to do; a second retire of released ids is a no-op.
+ b.retire.onRetire?.({ reason: 'observed', attachments: [ref] })
+ } finally {
+ b.restore()
+ }
+ await b.runtime.dispose()
+ })
+
+ it('keeps the drafts registered when the echo retires as failed (composer restore path)', async () => {
+ const b = await echoBench()
+ try {
+ b.prompt.mockResolvedValueOnce({
+ ok: false, error: { code: 'attachment-error', message: 'nope', details: {} },
+ } as never)
+ const [attachment] = b.root.createDraftImages([
+ new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }),
+ ])
+ const session = b.runtime.sessions.binding('s1')!.session
+ await expect(b.root.sendSession(session, '失败', [attachment!.id], 'queue'))
+ .resolves.toEqual({ kind: 'error' })
+ b.retire.onRetire?.({ reason: 'failed' })
+ expect(b.root.draftImages([attachment!.id])).toHaveLength(1)
+ expect(b.revoked).not.toHaveBeenCalled()
+ } finally {
+ b.restore()
+ }
+ await b.runtime.dispose()
+ })
+
+ it('abandons the echo when encoding fails before the prompt', async () => {
+ const b = await echoBench()
+ class FailingReader {
+ onload: (() => void) | null = null
+ onerror: (() => void) | null = null
+ error = new Error('read failed')
+ readAsDataURL(): void {
+ queueMicrotask(() => this.onerror?.())
+ }
+ }
+ vi.stubGlobal('FileReader', FailingReader)
+ try {
+ const [attachment] = b.root.createDraftImages([
+ new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }),
+ ])
+ const session = b.runtime.sessions.binding('s1')!.session
+ await expect(b.root.sendSession(session, 'x', [attachment!.id], 'queue'))
+ .rejects.toThrow('read failed')
+ expect(b.abandon).toHaveBeenCalledOnce()
+ expect(b.prompt).not.toHaveBeenCalled()
+ } finally {
+ vi.unstubAllGlobals()
+ b.restore()
+ }
+ await b.runtime.dispose()
+ })
+
+ it('yields through the macrotask fallback where no frame clock exists', async () => {
+ const b = await echoBench()
+ vi.stubGlobal('requestAnimationFrame', undefined)
+ try {
+ const session = b.runtime.sessions.binding('s1')!.session
+ await expect(b.root.sendSession(session, '纯文本', [], 'queue')).resolves.toEqual({ kind: 'success' })
+ expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: '纯文本' }], 'queue', undefined, 'req-echo')
+ } finally {
+ vi.unstubAllGlobals()
+ b.restore()
+ }
+ await b.runtime.dispose()
+ })
+})
+
+describe('draft image dimension probe', () => {
+ it('fills intrinsic dimensions from the header probe and skips runtimes without Image', async () => {
+ const b = await bench()
+ const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:probe')
+ class InstantImage {
+ onload: (() => void) | null = null
+ naturalWidth = 0
+ naturalHeight = 0
+ set src(_value: string) {
+ this.naturalWidth = 640
+ this.naturalHeight = 480
+ this.onload?.()
+ }
+ }
+ vi.stubGlobal('Image', InstantImage)
+ try {
+ const [probed] = b.root.createDraftImages([
+ new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }),
+ ])
+ expect(probed).toMatchObject({ width: 640, height: 480 })
+ vi.stubGlobal('Image', undefined)
+ const [unprobed] = b.root.createDraftImages([
+ new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }),
+ ])
+ expect(unprobed?.width).toBeUndefined()
+ } finally {
+ vi.unstubAllGlobals()
+ created.mockRestore()
+ }
+ await b.runtime.dispose()
+ })
+})
+
describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
const row = (id: string): QueuedMessage => ({
id: id as never,