fix: 稳定 steer 提交回显位置

This commit is contained in:
creatixchu 2026-08-27 17:25:36 +08:00
parent 5bb24c03f1
commit 53f5418a72
25 changed files with 205 additions and 48 deletions

View file

@ -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 .agents/notes/implemented/bug-fix/2026-08-27-steer-followup-image-delivery.md
2026-08-27-steer-followup-image-delivery.md: e5b3f516e45588a865c87a1d8188b50208fbff33
2026-08-27-steer-followup-image-delivery.zh.md: 37e55b7148841634e306b2a9d17f7f48b099c1eb
2026-08-27-steer-followup-image-delivery.md: 37160f19f561f060c6eb3f6b5b3360bba83b0e5c
2026-08-27-steer-followup-image-delivery.zh.md: b5c0467733e1551761bed417fd2da53d86b0b05e

View file

@ -6,7 +6,7 @@ English | [中文](2026-08-27-steer-followup-image-delivery.zh.md)
## Problem
Images submitted while an agent is running did not reliably reach the model context (#3186), for three independent reasons.
Images submitted while an agent is running did not reliably reach the model context or retain their intended browser placement (#3186), for four independent reasons.
First, a steer or follow-up spliced into a live driver latched no wake: the live driver was expected to claim it, but a turn that finished or failed between the splice and the claim exited without re-checking, stranding the accepted message until an unrelated waking send. Image admission widens this window because the Host awaits attachment normalization before `agent.steer()`/`agent.followup()` runs.
@ -14,6 +14,8 @@ Second, continuable-subagent follow-ups rejected images in the Client (`SUBAGENT
Third, the browser queue projection reduced a queued image to the text `[image]` even though the durable reference was already present and readable through the session attachment authorization.
Fourth, every local submission echo rendered at the Chat flow tail while the browser serialized image bytes. A direct steer therefore appeared as an ordinary chat message during the pre-admission wait, then moved to the pending-steering position when the Host queue snapshot arrived. Busy Queue sends had the same transition into QueueDock.
## Decision
**Closing-turn wake delivery.** `ReactLoopAgent` tracks the identities of waking sends still awaiting a claim (`pendingWakes`); claim and discard notifications prune the set. At a driver exit whose turn loop returned without throwing, a non-empty set re-wakes the driver, so a steer or follow-up that lost the race with a normally closing turn is claimed by a fresh turn. Cancellation and `agent/pre-step` rejection instead clear the set: accepted-but-unclaimed input parks until the next waking send, preserving the tested `cancel({ keepInbox: true })` semantics and keeping rejected claims from being re-offered to the rejecting policy. Injected context never enters the set. The turn-flow section of [docs/architecture.md](../../../../docs/architecture.md) records the delivery/parking rule.
@ -22,6 +24,8 @@ Third, the browser queue projection reduced a queued image to the text `[image]`
**Queue presentation.** The queue mirror's text preview excludes image blocks, and the queue dock renders each durable image part as a thumbnail resolved through `ctx.uiConversation.imageUrl` — the same session-authorized read the transcript uses. Editing queued image messages stays refused (#3072).
**Stable optimistic placement.** `PendingSubmission` records the surface selected when submission begins: `transcript` for an idle send, `queued` for a busy Queue send, and `steering` for a busy Steer send. Chat renders transcript and steering echoes on their respective surfaces, while QueueDock renders queued echoes with browser-owned image previews. The existing `rpcId` correlation suppresses the local echo in the same render that introduces the Host queue occurrence or durable user node. If the turn closes while images serialize and the Host places a requested steer in the next-turn queue, the later move from steering to QueueDock reflects the authoritative delivery decision.
## Alternatives considered
**Re-wake on every driver exit with pending input.** Rejected: it breaks the deliberate parking semantics of `cancel({ keepInbox: true })` and pre-step rejection, and a pre-commit `turn/start` failure would re-enter a hot loop because the failing turn never claims the message.
@ -34,8 +38,8 @@ Third, the browser queue projection reduced a queued image to the text `[image]`
## Testing
Agent-loop tests pin the closing-turn window deterministically (a `turn/end` listener queues the send as a microtask ahead of the driver's exit continuation) for steer, follow-up, and the inject non-delivery case. Host tests cover `mode: 'steer'` image admission; subagent control tests cover ordered admission, batch refusal, non-canonical base64, and the capability refusal mapping; continuation tests cover refusal without a partial message, capable delivery, and the routeless deferral. Client tests cover unstripped forwarding, the catalog-visible upload declaration, queue thumbnails (load, failure placeholder, unmount), and the image-free preview.
Agent-loop tests pin the closing-turn window deterministically (a `turn/end` listener queues the send as a microtask ahead of the driver's exit continuation) for steer, follow-up, and the inject non-delivery case. Host tests cover `mode: 'steer'` image admission; subagent control tests cover ordered admission, batch refusal, non-canonical base64, and the capability refusal mapping; continuation tests cover refusal without a partial message, capable delivery, and the routeless deferral. Client tests cover unstripped forwarding, the catalog-visible upload declaration, queue thumbnails (load, failure placeholder, unmount), the image-free preview, placement capture before serialization, local steering presentation, queued echo presentation, and `rpcId` handoff on both surfaces.
## Consequences
A steer or follow-up accepted during a turn's final microtasks is now delivered by a fresh turn instead of hanging in the inbox, while user cancellation still parks pending work — delivery after a stop remains an explicit next waking send. The subagent package now depends on `dsh-attachment` and reads `ctx.llm` optionally. Images persisted by a batch whose delivery is later refused stay as unreachable content-addressed objects under the existing retention rules. Queue thumbnails add one authorized attachment read per queued image, shared with the transcript cache.
A steer or follow-up accepted during a turn's final microtasks is delivered by a fresh turn instead of hanging in the inbox, while user cancellation still parks pending work — delivery after a stop remains an explicit next waking send. Slow image serialization leaves optimistic messages on their selected transcript, QueueDock, or pending-steering surface until the Host handoff. The subagent package depends on `dsh-attachment` and reads `ctx.llm` optionally. Images persisted by a batch whose delivery is later refused stay as unreachable content-addressed objects under the existing retention rules. Queue thumbnails add one authorized attachment read per queued image, shared with the transcript cache.

View file

@ -6,7 +6,7 @@ Status: implemented
## Problem
agent 运行期间提交的图片没有可靠进入模型上下文(#3186),原因有三个,彼此独立。
agent 运行期间提交的图片没有可靠进入模型上下文,也没有保持预期的浏览器显示位置(#3186),原因有四个,彼此独立。
第一,splice 进在线 driver 的 steer 或 follow-up 不会锁存唤醒:预期由在线 driver 自行认领,但轮次在 splice 与认领之间正常结束或失败时,退出路径不再复查,已接受的消息就滞留到下一次无关的唤醒发送。图片准入放大了这个窗口,因为 Host 在执行 `agent.steer()`/`agent.followup()` 之前要先等待附件规范化完成。
@ -14,6 +14,8 @@ agent 运行期间提交的图片没有可靠进入模型上下文(#3186),
第三,浏览器队列投影把已排队的图片折叠成文本 `[image]`,尽管持久化引用已经存在,并且可以通过会话附件授权读取。
第四,浏览器序列化图片字节期间,所有本地提交回显都位于 Chat 消息流末尾。直接 steer 会在准入前等待阶段显示为普通聊天消息,Host queue snapshot 到达后才移到 pending-steering 位置。繁忙时 Queue 发送也会发生同类跳动,最终进入 QueueDock。
## Decision
**轮次收尾期的唤醒投递。** `ReactLoopAgent` 用 `pendingWakes` 记录尚未被认领的唤醒发送的身份;认领与丢弃通知会移除对应条目。当 driver 的轮次循环无异常返回并退出时,集合非空就重新拉起 driver,输掉与正常收尾轮次竞态的 steer 或 follow-up 由新轮次认领。取消与 `agent/pre-step` 拒绝则清空该集合:已接受但未认领的输入停放到下一次唤醒发送,既保留了有测试保护的 `cancel({ keepInbox: true })` 语义,也避免把被拒绝的认领重新塞给同一个拒绝策略。注入的上下文从不进入该集合。投递与停放规则记录在 [docs/architecture.md](../../../../docs/architecture.zh.md) 的 turn-flow 一节。
@ -22,6 +24,8 @@ agent 运行期间提交的图片没有可靠进入模型上下文(#3186),
**队列展示。** 队列镜像的文本预览不再包含图片块,queue dock 把每个持久化图片部分渲染为缩略图,经 `ctx.uiConversation.imageUrl` 解析,与会话记录使用同一个会话授权读取。已排队图片消息的编辑仍然拒绝(#3072)。
**稳定的乐观显示位置。** `PendingSubmission` 记录提交开始时选定的区域:空闲发送是 `transcript`,繁忙时 Queue 发送是 `queued`,繁忙时 Steer 发送是 `steering`。Chat 分别在 transcript 与 steering 区域渲染对应回显,QueueDock 用浏览器持有的图片预览渲染 queued 回显。现有 `rpcId` 关联会在 Host queue occurrence 或持久化 user node 出现的同一次渲染中隐藏本地回显。如果图片序列化期间轮次关闭,Host 把请求的 steer 放入 next-turn queue,消息随后从 steering 移到 QueueDock,反映实际投递决定。
## Alternatives considered
**任何 driver 退出都在有滞留输入时重新拉起。** 拒绝:这破坏 `cancel({ keepInbox: true })` 与 pre-step 拒绝的刻意停放语义,而且 `turn/start` 提交前失败的轮次永远不会认领消息,会进入热循环。
@ -34,8 +38,8 @@ agent 运行期间提交的图片没有可靠进入模型上下文(#3186),
## Testing
agent-loop 测试确定性地钉住收尾窗口(`turn/end` 监听器把发送排为微任务,先于 driver 的退出续体执行),覆盖 steer、follow-up 与注入不投递。Host 测试覆盖 `mode: 'steer'` 的图片准入;subagent control 测试覆盖有序准入、整批拒绝、非规范 base64 与能力拒绝映射;continuation 测试覆盖拒绝时不留半条消息、能力通过时投递、无路由时的顺延。客户端测试覆盖不剥离的转发、目录可见的上传声明、队列缩略图(加载、失败占位、卸载)与不含图片的预览。
agent-loop 测试确定性地钉住收尾窗口(`turn/end` 监听器把发送排为微任务,先于 driver 的退出续体执行),覆盖 steer、follow-up 与注入不投递。Host 测试覆盖 `mode: 'steer'` 的图片准入;subagent control 测试覆盖有序准入、整批拒绝、非规范 base64 与能力拒绝映射;continuation 测试覆盖拒绝时不留半条消息、能力通过时投递、无路由时的顺延。客户端测试覆盖不剥离的转发、目录可见的上传声明、队列缩略图(加载、失败占位、卸载)、不含图片的预览、序列化前的位置捕获、steering 本地显示、queued 回显显示,以及两个区域的 `rpcId` 交接。
## Consequences
在轮次最后几个微任务里被接受的 steer 或 follow-up 现在由新轮次投递,而不是挂在 inbox 里;用户取消仍然停放待处理工作,停止之后的投递依旧需要一次显式的唤醒发送。subagent 包新增对 `dsh-attachment` 的依赖,并可选读取 `ctx.llm`。整批持久化后投递被拒绝的图片按现有保留规则保持为不可达的内容寻址对象。队列缩略图对每张排队图片增加一次授权附件读取,与会话记录缓存共享。
在轮次最后几个微任务里被接受的 steer 或 follow-up 由新轮次投递,不会挂在 inbox 里;用户取消仍然停放待处理工作,停止之后的投递依旧需要一次显式的唤醒发送。图片序列化较慢时,乐观消息停留在选定的 transcript、QueueDock 或 pending-steering 区域,直到与 Host 状态交接。subagent 包依赖 `dsh-attachment`,并可选读取 `ctx.llm`。整批持久化后投递被拒绝的图片按现有保留规则保持为不可达的内容寻址对象。队列缩略图对每张排队图片增加一次授权附件读取,与会话记录缓存共享。

View file

@ -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: dc04e595c9c4bf029ed427d5a1214802041c11c0
README.zh.md: b34c8ad018e4e2c0d95ac0b8aa1954742f9ecfa4
README.md: e4a4ed9918c0d469876da7db8d37c0e00554d5b2
README.zh.md: a9a20cc88873f56567a411459f27e9baf5132ff1

View file

@ -29,7 +29,7 @@ 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.
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. Each echo records its expected `transcript`, `queued`, or `steering` placement; this keeps slow serialization on the surface selected when submission began. The prompt's `requestId` is the correlation identity: the Host 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 replacement is ready), 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.
-----

View file

@ -29,7 +29,7 @@ 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 重建会话。
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。每条回显记录预期的 `transcript`、`queued` 或 `steering` 位置,使较慢的序列化过程始终显示在提交开始时选定的区域。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
-----

View file

@ -14,7 +14,9 @@ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { QueueAction, SessionRequestId } from '../../types.ts'
import type { ClientResult } from './result.ts'
import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts'
import type {
PendingSubmissionImage, PendingSubmissionPlacement, SessionSnapshot,
} from './snapshot.ts'
/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */
export type PromptContentPart =
@ -38,6 +40,8 @@ export type PendingSubmissionRetirement =
/** Input registering one local submission echo ahead of its prompt call. */
export interface BeginSubmissionInput {
/** Expected surface selected from the submission mode and running state. */
readonly placement: PendingSubmissionPlacement
/** Prompt text exactly as the upcoming prompt will send it. */
readonly text: string
/** Ordered image previews matching the upcoming prompt's image parts. */

View file

@ -30,6 +30,9 @@ export interface PendingSubmissionImage {
readonly height?: number
}
/** Client surface selected when a local submission begins. */
export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering'
/**
* One local prompt-submission echo: inserted synchronously when a submission
* begins, so the conversation can show the message before serialization,
@ -39,6 +42,8 @@ export interface PendingSubmissionImage {
export interface PendingSubmission {
/** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */
readonly requestId: SessionRequestId
/** Expected surface until the Host reports the admitted queue or durable occurrence. */
readonly placement: PendingSubmissionPlacement
/** Client wall-clock ms when the submission began. */
readonly time: number
/** Prompt text exactly as it will be sent (one text block). */

View file

@ -62,6 +62,7 @@ export type {
OpenState,
PendingSubmission,
PendingSubmissionImage,
PendingSubmissionPlacement,
PromptError,
QueuedMessage,
SessionSnapshot,

View file

@ -189,6 +189,7 @@ export class Session implements SessionFace {
const requestId = randomUUID() as SessionRequestId
this.pendingSubmissions = [...this.pendingSubmissions, {
requestId,
placement: input.placement,
time: Date.now(),
text: input.text,
images: input.images,

View file

@ -69,12 +69,14 @@ describe('beginSubmission', () => {
const { session } = makeSession()
expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false })
const handle = session.beginSubmission({
placement: 'transcript',
text: '你好',
images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
})
expect(session.getSnapshot().promptAttempted).toBe(true)
expect(session.getSnapshot().pendingSubmissions).toMatchObject([{
requestId: handle.requestId,
placement: 'transcript',
text: '你好',
images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
}])
@ -84,6 +86,7 @@ describe('beginSubmission', () => {
const { session } = makeSession()
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
placement: 'transcript',
text: '放弃',
images: [],
onRetire: retirement => retirements.push(retirement),
@ -101,6 +104,7 @@ describe('prompt-coupled retirement', () => {
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } }))
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
placement: 'transcript',
text: '失败的',
images: [],
onRetire: retirement => retirements.push(retirement),
@ -114,7 +118,7 @@ describe('prompt-coupled retirement', () => {
it('sends the echo identity as the prompt requestId', async () => {
const { api, session } = makeSession()
const handle = session.beginSubmission({ text: '带 id', images: [] })
const handle = session.beginSubmission({ placement: 'transcript', text: '带 id', images: [] })
await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId)
expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }])
})
@ -122,7 +126,7 @@ describe('prompt-coupled retirement', () => {
it('an unidentified prompt failure leaves registered echoes alone', async () => {
const { api, session } = makeSession()
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } }))
session.beginSubmission({ text: '还在', images: [] })
session.beginSubmission({ placement: 'transcript', text: '还在', images: [] })
await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
})
@ -135,6 +139,7 @@ describe('observed retirement', () => {
await session.open()
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
placement: 'transcript',
text: '发送',
images: [{ previewUrl: 'blob:p1' }],
onRetire: retirement => retirements.push(retirement),
@ -153,6 +158,7 @@ describe('observed retirement', () => {
const { session } = makeSession()
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
placement: 'queued',
text: '排队',
images: [{ previewUrl: 'blob:p1' }],
onRetire: retirement => retirements.push(retirement),
@ -168,7 +174,7 @@ describe('observed retirement', () => {
it('a full-window install (reconnect resync) retires echoes observed in the window', async () => {
const { api, session } = makeSession()
const handle = session.beginSubmission({ text: '重连', images: [] })
const handle = session.beginSubmission({ placement: 'transcript', text: '重连', images: [] })
api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)])))
await session.open()
await settleFrames()
@ -181,6 +187,7 @@ describe('observed retirement', () => {
await session.open()
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
placement: 'transcript',
text: '先观察',
images: [],
onRetire: retirement => retirements.push(retirement),
@ -197,6 +204,7 @@ describe('observed retirement', () => {
await session.open()
const retirements: PendingSubmissionRetirement[] = []
const handle = session.beginSubmission({
placement: 'transcript',
text: '同一请求',
images: [],
onRetire: retirement => retirements.push(retirement),
@ -221,7 +229,7 @@ describe('observed retirement', () => {
const { api, session } = makeSession()
api.onHistory = () => Promise.resolve(ok(historyValue([])))
await session.open()
const handle = session.beginSubmission({ text: '帧', images: [] })
const handle = session.beginSubmission({ placement: 'transcript', text: '帧', images: [] })
await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never })
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
expect(frames).toHaveLength(1)
@ -237,11 +245,13 @@ describe('disposal', () => {
await session.open()
const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = []
const observed = session.beginSubmission({
placement: 'transcript',
text: '已观察',
images: [],
onRetire: retirement => retirements.push({ text: '已观察', retirement }),
})
session.beginSubmission({
placement: 'transcript',
text: '未settle',
images: [],
onRetire: retirement => retirements.push({ text: '未settle', retirement }),

View file

@ -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: cd69eb7fdf704bb0e138e1f2aad88782e26aeeff
README.zh.md: 3e9d1ccab71f2516f92ec5402eac49baaaccdf77
README.md: 5e365593b054c6826de817e3e0d83734b1c8410d
README.zh.md: 0f1569ab877acd465f41baedb9a626e42eb29983

View file

@ -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 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.
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. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic.
## Table of Contents

View file

@ -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,不展开其成员。消息流尾部渲染 session 的本地提交回显(`SessionSnapshot.pendingSubmissions`),气泡与其最终的 durable user 节点一致;一旦某个 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此回显到 durable 的替换是原子的。
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,不展开其成员。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。
## 目录

View file

@ -268,7 +268,9 @@ export function ChatView({
const visibleSubmissions = useMemo(() => {
if (pendingSubmissions.length === 0) return pendingSubmissions
const observed = observedRpcIds(order, nodeStore, inbox)
return pendingSubmissions.filter(submission => !observed.has(submission.requestId))
return pendingSubmissions.filter(submission => (
submission.placement !== 'queued' && !observed.has(submission.requestId)
))
}, [pendingSubmissions, order, nodeStore, inbox])
const renderMessageImages = useCallback<RenderMessageImages>(
owner => renderSlot('conversation.message.images', { ...owner, loadImage }),

View file

@ -222,10 +222,10 @@ export function PendingSteeringBubble({ content, renderMessageImages, t }: {
}
/**
* Render one local submission echo with the exact visual language of the
* durable user node that replaces it: draft text plus object-URL previews,
* visible from the submit click until the durable `user/message` (or its
* queue occurrence) renders.
* Render one local transcript or steering submission echo with the same
* visual language and surface marker as the Host occurrence that replaces
* it: draft text plus object-URL previews, visible from the submit click
* until the durable `user/message` or steering occurrence renders.
* @param props - the session snapshot's pending submission and render seats.
* @returns the echoed user bubble.
*/
@ -254,6 +254,7 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }:
content={content}
previewImages={previewImages}
renderMessageImages={renderMessageImages}
pending={submission.placement === 'steering'}
echo
t={t}
actions={text => (

View file

@ -762,7 +762,10 @@ describe('ChatView', () => {
{ nodes: [assistant(1, 'working')] },
{
pendingSubmissions: [
{ requestId: 'req-1' as never, time: 5_000, text: '即发即显', images: [] },
{
requestId: 'req-1' as never, placement: 'transcript',
time: 5_000, text: '即发即显', images: [],
},
],
},
)
@ -791,18 +794,57 @@ describe('ChatView', () => {
expect(view.getAllByText('即发即显')).toHaveLength(1)
})
it('hides an echo once its queue occurrence carries the rpcId (running-turn submission)', () => {
it('renders a local steer echo as pending steering before Host image admission completes', () => {
const h = makeHarness(
{ nodes: [assistant(1, 'working')] },
{
running: true,
pendingSubmissions: [{
requestId: 'req-steer' as never,
placement: 'steering',
time: 5_500,
text: '带图纠偏',
images: [{ previewUrl: 'blob:steer-preview', name: 'steer.png' }],
}],
},
)
const view = render(<h.ChatView {...h.props} />)
const local = view.getByText('带图纠偏').closest('[data-submission-echo]')
expect(local?.hasAttribute('data-pending-steering')).toBe(true)
act(() => {
h.setSession({
queue: [{
id: 'steer-occurrence' as never,
messageId: 'steer-message' as never,
placement: 'steering',
rpcId: 'req-steer' as never,
content: [{ type: 'text', text: '带图纠偏' }],
preview: '带图纠偏',
text: '带图纠偏',
}],
})
})
expect(view.getAllByText('带图纠偏')).toHaveLength(1)
expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
expect(view.container.querySelector('[data-pending-steering]')).not.toBeNull()
})
it('keeps a queued echo out of the Chat flow before and after Host admission', () => {
const h = makeHarness(
{ nodes: [assistant(1, 'working')] },
{
running: true,
pendingSubmissions: [
{ requestId: 'req-q' as never, time: 6_000, text: '排队中', images: [] },
{
requestId: 'req-q' as never, placement: 'queued',
time: 6_000, text: '排队中', images: [],
},
],
},
)
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('排队中')).toBeTruthy()
expect(view.queryByText('排队中')).toBeNull()
act(() => {
h.setSession({
queue: [{
@ -816,8 +858,8 @@ describe('ChatView', () => {
}],
})
})
// The queued occurrence renders in the queue dock, not the flow; the
// flow-tail echo yields to it in the same snapshot.
// The queued occurrence and its local predecessor both belong to the
// queue dock, never the Chat flow.
expect(view.queryByText('排队中')).toBeNull()
})
@ -827,6 +869,7 @@ describe('ChatView', () => {
{
pendingSubmissions: [{
requestId: 'req-img' as never,
placement: 'transcript',
time: 7_000,
text: '',
images: [

View file

@ -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: 6e26991dcefa8eb922f3913feb3db4750258646a
README.zh.md: 99cacf47995552bc35e2414ee64283186f2076d2
README.md: cd410bbdb6ee403cf246962d9b8f3d17d1bc30ad
README.zh.md: a8ab2a1ea10698c1d191db6e75e222d4d19b5fb4

View file

@ -38,9 +38,9 @@ The package registers the optional-Session `conversation` shell, strict Session
View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell.
The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show each durable image part as a thumbnail resolved through the session image URL cache, while an edit exposes the literal sent text. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local image previews or durable image parts as thumbnails, while an edit exposes the literal sent text. Durable thumbnails resolve through the session image URL cache. 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. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id.
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 and records the surface selected from the current running state and delivery mode: idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint and encodes images through the browser's native `FileReader` data-URL path. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id.
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)).

View file

@ -38,9 +38,9 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。
常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并把每个持久化图片部分经会话图片 URL 缓存解析为缩略图展示,编辑态则展示字面发送文本。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并把本地图片预览或持久化图片部分显示为缩略图,编辑态则展示字面发送文本。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前注册 Session 提交回显(`session.beginSubmission`),让出一帧使回显在点击当帧渲染,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前注册 Session 提交回显(`session.beginSubmission`),并按当前运行状态与投递模式记录选定区域:空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。
普通 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))。

View file

@ -64,6 +64,14 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock
export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: QueueDockProps) {
const inbox = useSession(s => s.queue)
const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox])
const pendingSubmissions = useSession(s => s.pendingSubmissions)
const pendingQueue = useMemo(() => {
const admitted = new Set(queue.flatMap(row => row.rpcId === undefined ? [] : [row.rpcId]))
return pendingSubmissions.filter(submission => (
submission.placement === 'queued' && !admitted.has(submission.requestId)
))
}, [pendingSubmissions, queue])
const rowCount = queue.length + pendingQueue.length
const running = useSession(s => s.running)
const queueMutable = useSession(s => s.subagent === null)
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
@ -72,15 +80,15 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
const listId = useId()
useEffect(() => {
if (queue.length === 0 && !collapsed) setCollapsed(true)
if (rowCount === 0 && !collapsed) setCollapsed(true)
if (editing !== null && (!queueMutable || !queue.some(row => row.id === editing.id))) setEditing(null)
}, [collapsed, editing, queue, queueMutable])
}, [collapsed, editing, queue, queueMutable, rowCount])
if (queue.length === 0) return null
if (rowCount === 0) return null
const interactionActive = queueMutable && (editing !== null || busy !== null)
const expanded = !collapsed || interactionActive
const listVisible = queue.length === 1 || expanded
const listVisible = rowCount === 1 || expanded
const applyAction = async (
itemId: QueueItemId,
@ -111,7 +119,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
return (
<div className={css.dock} data-queue-dock="">
<div className={css.panel}>
{queue.length > 1 && (
{rowCount > 1 && (
<button
type="button"
className={css.header}
@ -121,7 +129,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
onClick={() => { setCollapsed(value => !value) }}
>
<span className={css.lead} aria-hidden><IconQueueOutline14 /></span>
<span className={css.count}>{t('queue.count', { n: queue.length })}</span>
<span className={css.count}>{t('queue.count', { n: rowCount })}</span>
<span className={css.chevron} aria-hidden>
{expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
</span>
@ -133,7 +141,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
return (
<li key={row.id} className={css.row}>
{/* Single-item strip has no count header, so the row itself carries the queue glyph. */}
{queue.length === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
{rowCount === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
{editing?.id === row.id
? (
<input
@ -258,6 +266,24 @@ 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>
))}
</ul>
</div>
</div>

View file

@ -207,7 +207,8 @@ 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')
}
if (session.getSnapshot().subagent !== null) {
const snapshot = session.getSnapshot()
if (snapshot.subagent !== null) {
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)
@ -218,6 +219,7 @@ export class ConversationController extends Service implements IConversation {
? undefined
: new Promise<PendingSubmissionRetirement>((resolve) => { finishRetirement = resolve })
const submission = session.beginSubmission({
placement: snapshot.running ? mode === 'steer' ? 'steering' : 'queued' : 'transcript',
text,
images: attachments.map(attachment => ({
previewUrl: attachment.previewUrl,

View file

@ -116,6 +116,32 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('renders a queued local echo in the dock and hands off by rpcId', () => {
const pending = {
...snapshotWith([]),
pendingSubmissions: [{
requestId: 'req-local-queue' as never,
placement: 'queued' as const,
time: 1,
text: '等待上传',
images: [{ previewUrl: 'blob:queue-preview', name: 'queue.png' }],
}],
}
const source = liveSession(pending)
const view = render(<QueueDock {...kitFor(pending)} useSession={source.useSession} />)
expect(view.getByText('等待上传').closest('[data-submission-echo]')).not.toBeNull()
expect(view.getByRole('img', { name: '排队消息图片' }).getAttribute('src')).toBe('blob:queue-preview')
act(() => {
source.push({
...pending,
queue: [{ ...row('accepted', '等待上传'), rpcId: 'req-local-queue' as never }],
})
})
expect(view.getAllByText('等待上传')).toHaveLength(1)
expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
})
it('leaves pending steering to the conversation flow', () => {
const steering = { ...row('s-1', 'interrupt'), placement: 'steering' as const }
const snap = snapshotWith([steering])

View file

@ -185,6 +185,7 @@ describe('sendSession submission echo', () => {
const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue')
// Synchronous: the echo is registered before any encoding starts.
expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({
placement: 'transcript',
text: '带图',
images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })],
}))
@ -211,6 +212,29 @@ describe('sendSession submission echo', () => {
await b.runtime.dispose()
})
it('captures the busy submission surface before image serialization', async () => {
const b = await echoBench()
try {
await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.running = true })
const session = b.runtime.sessions.binding('s1')!.session
await expect(b.root.sendSession(session, '立即纠偏', [], 'steer'))
.resolves.toEqual({ kind: 'success' })
expect(b.beginSubmission).toHaveBeenLastCalledWith(expect.objectContaining({
placement: 'steering',
text: '立即纠偏',
}))
await expect(b.root.sendSession(session, '稍后处理', [], 'queue'))
.resolves.toEqual({ kind: 'success' })
expect(b.beginSubmission).toHaveBeenLastCalledWith(expect.objectContaining({
placement: 'queued',
text: '稍后处理',
}))
} 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 {

View file

@ -444,7 +444,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'BeginSubmissionInput',
declaration: 'export interface BeginSubmissionInput {\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}',
declaration: 'export interface BeginSubmissionInput {\n readonly placement: PendingSubmissionPlacement;\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}',
},
{
name: 'BoundActions',
@ -608,12 +608,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PendingSubmission',
declaration: 'export interface PendingSubmission {\n readonly requestId: SessionRequestId;\n readonly time: number;\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n}',
declaration: 'export interface PendingSubmission {\n readonly requestId: SessionRequestId;\n readonly placement: PendingSubmissionPlacement;\n readonly time: number;\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n}',
},
{
name: 'PendingSubmissionImage',
declaration: 'export interface PendingSubmissionImage {\n readonly previewUrl: string;\n readonly name?: string;\n readonly width?: number;\n readonly height?: number;\n}',
},
{
name: 'PendingSubmissionPlacement',
declaration: 'export type PendingSubmissionPlacement = \'transcript\' | \'queued\' | \'steering\';',
},
{
name: 'PendingSubmissionRetirement',
declaration: 'export type PendingSubmissionRetirement = {\n readonly reason: \'observed\';\n readonly attachments: readonly ImageAttachmentRef[];\n} | {\n readonly reason: \'failed\';\n};',